facelessuser: Additionally a bug with our check for special strings was also fixed. Doctypes were accidentally omitted from our special string check.
diff --git a/docs/src/markdown/about/changelog.md b/docs/src/markdown/about/changelog.md
index e224461..f16ded2 100644
--- a/docs/src/markdown/about/changelog.md
+++ b/docs/src/markdown/about/changelog.md
@@ -1,8 +1,10 @@
 # Changelog
 
-<!-- ## Next
+## 1.9.2
 
-- **FIX**: Shortcut last descendant calculation if possible for performance. -->
+- **FIX**: Shortcut last descendant calculation if possible for performance.
+- **FIX**: Fix issue where `Doctype` strings can be mistaken for a normal text node.
+- **FIX**: A top level tag is not a `:root` tag if it has sibling text nodes or tag nodes. This is an issue that mostly manifests when using the `html.parser` as it will allow multiple root nodes.
 
 ## 1.9.1
 
diff --git a/soupsieve/css_match.py b/soupsieve/css_match.py
index 648bb76..799cd81 100644
--- a/soupsieve/css_match.py
+++ b/soupsieve/css_match.py
@@ -113,11 +113,11 @@ class Document(object):
         return isinstance(obj, bs4.Declaration)
 
     @staticmethod
-    def is_cdata(obj):  # pragma: no cover
+    def is_cdata(obj):
         """Is CDATA."""
 
         import bs4
-        return isinstance(obj, bs4.Declaration)
+        return isinstance(obj, bs4.CData)
 
     @staticmethod
     def is_processing_instruction(obj):  # pragma: no cover
@@ -138,7 +138,7 @@ class Document(object):
         """Is special string."""
 
         import bs4
-        return isinstance(obj, (bs4.Comment, bs4.Declaration, bs4.CData, bs4.ProcessingInstruction))
+        return isinstance(obj, (bs4.Comment, bs4.Declaration, bs4.CData, bs4.ProcessingInstruction, bs4.Doctype))
 
     @classmethod
     def is_content_string(cls, obj):
@@ -254,20 +254,20 @@ class Document(object):
         return el.prefix
 
     @classmethod
-    def get_next_tag(cls, el):
+    def get_next(cls, el, tags=True):
         """Get next sibling tag."""
 
         sibling = el.next_sibling
-        while not cls.is_tag(sibling) and sibling is not None:
+        while tags and not cls.is_tag(sibling) and sibling is not None:
             sibling = sibling.next_sibling
         return sibling
 
     @classmethod
-    def get_previous_tag(cls, el):
+    def get_previous(cls, el, tags=True):
         """Get previous sibling tag."""
 
         sibling = el.previous_sibling
-        while not cls.is_tag(sibling) and sibling is not None:
+        while tags and not cls.is_tag(sibling) and sibling is not None:
             sibling = sibling.previous_sibling
         return sibling
 
@@ -663,12 +663,12 @@ class CSSMatch(Document, object):
             if parent:
                 found = self.match_selectors(parent, relation)
         elif relation[0].rel_type == REL_SIBLING:
-            sibling = self.get_previous_tag(el)
+            sibling = self.get_previous(el)
             while not found and sibling:
                 found = self.match_selectors(sibling, relation)
-                sibling = self.get_previous_tag(sibling)
+                sibling = self.get_previous(sibling)
         elif relation[0].rel_type == REL_CLOSE_SIBLING:
-            sibling = self.get_previous_tag(el)
+            sibling = self.get_previous(el)
             if sibling and self.is_tag(sibling):
                 found = self.match_selectors(sibling, relation)
         return found
@@ -693,12 +693,12 @@ class CSSMatch(Document, object):
         elif relation[0].rel_type == REL_HAS_CLOSE_PARENT:
             found = self.match_future_child(el, relation)
         elif relation[0].rel_type == REL_HAS_SIBLING:
-            sibling = self.get_next_tag(el)
+            sibling = self.get_next(el)
             while not found and sibling:
                 found = self.match_selectors(sibling, relation)
-                sibling = self.get_next_tag(sibling)
+                sibling = self.get_next(sibling)
         elif relation[0].rel_type == REL_HAS_CLOSE_SIBLING:
-            sibling = self.get_next_tag(el)
+            sibling = self.get_next(el)
             if sibling and self.is_tag(sibling):
                 found = self.match_selectors(sibling, relation)
         return found
@@ -739,7 +739,28 @@ class CSSMatch(Document, object):
     def match_root(self, el):
         """Match element as root."""
 
-        return self.is_root(el)
+        is_root = self.is_root(el)
+        if is_root:
+            sibling = self.get_previous(el, tags=False)
+            while is_root and sibling is not None:
+                if (
+                    self.is_tag(sibling) or (self.is_content_string(sibling) and sibling.strip()) or
+                    self.is_cdata(sibling)
+                ):
+                    is_root = False
+                else:
+                    sibling = self.get_previous(sibling, tags=False)
+        if is_root:
+            sibling = self.get_next(el, tags=False)
+            while is_root and sibling is not None:
+                if (
+                    self.is_tag(sibling) or (self.is_content_string(sibling) and sibling.strip()) or
+                    self.is_cdata(sibling)
+                ):
+                    is_root = False
+                else:
+                    sibling = self.get_next(sibling, tags=False)
+        return is_root
 
     def match_scope(self, el):
         """Match element as scope."""

html.parser and :root issue
So `html.parser` doesn't care if your HTML is sound or not. When using the `html.parser` in BeautifulSoup, you can give it HTML code, and it will gladly parse it as a document that has multiple root nodes.  So when we use `:root`, each root node is counted as `:root`. This is probably not proper.

Most parsers will never allow a multi-root document to exist. Both `lxml` and `html5lib` go out of their ways to ensure this doesn't happen, so this is very specific to `html.parser`. So here's the question, do we ensure that root is always the `html` element for HTML parsers? Or do we still just look for a root, even if it isn't `html`, and just verify that it is the only one? I assume, if for whatever reason, we deem their is no proper root, we should probably just never allow `:root` to match.
diff --git a/tests/test_level3/test_root.py b/tests/test_level3/test_root.py
index df587a4..87583de 100644
--- a/tests/test_level3/test_root.py
+++ b/tests/test_level3/test_root.py
@@ -118,6 +118,82 @@ class TestRoot(util.TestCase):
             ids.append(el['id'])
         self.assertEqual(sorted(ids), sorted(['div2']))
 
+    def test_no_root_double_tag(self):
+        """Test when there is no root due to double root tags."""
+
+        markup = """
+        <div id="1"></div>
+        <div id="2"></div>
+        """
+
+        soup = self.soup(markup, 'html.parser')
+        self.assertEqual(soup.select(':root'), [])
+
+    def test_no_root_text(self):
+        """Test when there is no root due to HTML text."""
+
+        markup = """
+        text
+        <div id="1"></div>
+        """
+
+        soup = self.soup(markup, 'html.parser')
+        self.assertEqual(soup.select(':root'), [])
+
+    def test_no_root_cdata(self):
+        """Test when there is no root due to CDATA and tag."""
+
+        markup = """
+        <![CDATA[test]]>
+        <div id="1"></div>
+        """
+
+        soup = self.soup(markup, 'html.parser')
+        self.assertEqual(soup.select(':root'), [])
+
+    def test_root_whitespace(self):
+        """Test when there is root and white space."""
+
+        markup = """
+
+        <div id="1"></div>
+        """
+
+        ids = []
+        soup = self.soup(markup, 'html.parser')
+        for el in soup.select(':root'):
+            ids.append(el['id'])
+        self.assertEqual(sorted(ids), sorted(['1']))
+
+    def test_root_preprocess(self):
+        """Test when there is root and pre-processing statement."""
+
+        markup = """
+        <?php ?>
+        <div id="1"></div>
+        """
+
+        ids = []
+        soup = self.soup(markup, 'html.parser')
+        for el in soup.select(':root'):
+            ids.append(el['id'])
+        self.assertEqual(sorted(ids), sorted(['1']))
+
+    def test_root_doctype(self):
+        """Test when there is root and doc type."""
+
+        markup = """
+        <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"
+        "http://www.w3.org/TR/html4/strict.dtd">
+        <div id="1"></div>
+        """
+
+        ids = []
+        soup = self.soup(markup, 'html.parser')
+        for el in soup.select(':root'):
+            ids.append(el['id'])
+        self.assertEqual(sorted(ids), sorted(['1']))
+
 
 class TestRootQuirks(TestRoot):
     """Test root selectors with quirks."""

pip install -e .
parse_log_pytest
requirements.txt
pytest pytest-cov coverage lxml html5lib mypy
apt-get update apt-get install -y gcc
3.9
requirements/project.txt requirements/tests.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
beautifulsoup4==4.13.3
coverage==7.8.0
exceptiongroup==1.2.2
html5lib==1.1
iniconfig==2.1.0
lxml==5.3.1
mypy==1.15.0
mypy-extensions==1.0.0
packaging==24.2
pluggy==1.5.0
pytest==8.3.5
pytest-cov==6.0.0
six==1.17.0
-e git+https://github.com/facelessuser/soupsieve.git@79a03f2410ba63fb10ffc1ee5a5255bfa774dac8#egg=soupsieve
tomli==2.2.1
typing_extensions==4.13.0
webencodings==0.5.1

name: soupsieve
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - pip=25.0=py39h06a4308_0
  - python=3.9.21=he870216_1
  - readline=8.2=h5eee18b_0
  - setuptools=75.8.0=py39h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - tzdata=2025a=h04d1e81_0
  - wheel=0.45.1=py39h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - beautifulsoup4==4.13.3
      - coverage==7.8.0
      - exceptiongroup==1.2.2
      - html5lib==1.1
      - iniconfig==2.1.0
      - lxml==5.3.1
      - mypy==1.15.0
      - mypy-extensions==1.0.0
      - packaging==24.2
      - pluggy==1.5.0
      - pytest==8.3.5
      - pytest-cov==6.0.0
      - six==1.17.0
      - tomli==2.2.1
      - typing-extensions==4.13.0
      - webencodings==0.5.1
prefix: /opt/conda/envs/soupsieve

tests/test_level3/test_root.py::TestRoot::test_no_root_cdata tests/test_level3/test_root.py::TestRoot::test_no_root_double_tag tests/test_level3/test_root.py::TestRoot::test_no_root_text tests/test_level3/test_root.py::TestRootQuirks::test_no_root_cdata tests/test_level3/test_root.py::TestRootQuirks::test_no_root_double_tag tests/test_level3/test_root.py::TestRootQuirks::test_no_root_text
tests/test_level3/test_root.py::TestRoot::test_iframe tests/test_level3/test_root.py::TestRoot::test_no_iframe tests/test_level3/test_root.py::TestRoot::test_root tests/test_level3/test_root.py::TestRoot::test_root_complex tests/test_level3/test_root.py::TestRoot::test_root_doctype tests/test_level3/test_root.py::TestRoot::test_root_iframe tests/test_level3/test_root.py::TestRoot::test_root_preprocess tests/test_level3/test_root.py::TestRoot::test_root_whitespace tests/test_level3/test_root.py::TestRootQuirks::test_iframe tests/test_level3/test_root.py::TestRootQuirks::test_no_iframe tests/test_level3/test_root.py::TestRootQuirks::test_root tests/test_level3/test_root.py::TestRootQuirks::test_root_complex tests/test_level3/test_root.py::TestRootQuirks::test_root_doctype tests/test_level3/test_root.py::TestRootQuirks::test_root_iframe tests/test_level3/test_root.py::TestRootQuirks::test_root_preprocess tests/test_level3/test_root.py::TestRootQuirks::test_root_whitespace
soupsieve/css_match.py docs/src/markdown/about/changelog.md
soupsieve/css_match.py docs/src/markdown/about/changelog.md