site: Preserve heading anchor IDs in docs2mdx conversion (https://github.com/bazelbuild/bazel/pull/30704)

## Summary

Reference doc pages like [common-definitions](https://bazel.build/versions/9.1.0/reference/be/common-definitions) expose a Contents section with links such as `#common-attributes`, but those anchors are broken on the published site.

The source Velocity template `common-definitions.vm` renders headings with explicit ids:

```html
<h2 id='common-attributes'>Attributes common to all build rules</h2>
```

After `docs2mdx.py` converts the generated HTML to MDX, the heading becomes plain markdown with no anchor:

```markdown
## Attributes common to all build rules
```

This PR updates `docs2mdx.py` to convert HTML headings with `id` attributes to MDX anchor syntax (`## Title {#id}`) before markdownify runs, and restores anchors after markdownify escapes curly braces in heading text.

Fixes bazelbuild/bazel#30617

## Test plan

### Unit tests
- [x] `bazel test //scripts/docs:rewriter_test`
- [x] `bazel test //scripts/docs:docs2mdx_test` — single/double-quoted ids, extra attributes, h3 headings

### Mintlify preview
Preview: https://bazel-pr-30704.mintlify.app/

| Check | URL | Expected |
|-------|-----|----------|
| [ ] Anchor resolves | `/reference/be/common-definitions#common-attributes` | Page scrolls to "Attributes common to all build rules" |
| [ ] Contents TOC link | `/reference/be/common-definitions` → click `#common-attributes` in Contents | Same section |
| [ ] Other anchors | `#typical-attributes`, `#common-attributes-tests` | Resolve correctly |

- [x] Preview deployed (bazel-docs bot comment)
- [ ] Anchor deep links verified in preview (requires reference doc regen in preview build)

### Post-merge
- [ ] Regenerate reference docs via `bazel build --config=docs //src/main/java/com/google/devtools/build/lib:gen_mdx_reference_docs`

Closes #30704.

PiperOrigin-RevId: 972587453
Change-Id: Ibfef830fce4525d746aff992bd990bab3f501eac
diff --git a/scripts/docs/BUILD b/scripts/docs/BUILD
index 4db9bb0..ab0f412 100644
--- a/scripts/docs/BUILD
+++ b/scripts/docs/BUILD
@@ -111,12 +111,9 @@
     ],
 )
 
-py_binary(
-    name = "docs2mdx",
+py_library(
+    name = "docs2mdx_lib",
     srcs = ["docs2mdx.py"],
-    visibility = [
-        "//src/main/java/com/google/devtools/build/lib:__pkg__",
-    ],
     deps = [
         ":clr_converter",
         "//third_party/py/abseil",
@@ -124,6 +121,27 @@
     ],
 )
 
+py_binary(
+    name = "docs2mdx",
+    srcs = ["docs2mdx.py"],
+    main = "docs2mdx.py",
+    visibility = [
+        "//src/main/java/com/google/devtools/build/lib:__pkg__",
+    ],
+    deps = [
+        ":docs2mdx_lib",
+    ],
+)
+
+py_test(
+    name = "docs2mdx_test",
+    srcs = ["docs2mdx_test.py"],
+    deps = [
+        ":docs2mdx_lib",
+        "//third_party/py/abseil",
+    ],
+)
+
 filegroup(
     name = "srcs",
     srcs = glob(["**"]),
diff --git a/scripts/docs/docs2mdx.py b/scripts/docs/docs2mdx.py
index 30f5d2b..41500dc 100644
--- a/scripts/docs/docs2mdx.py
+++ b/scripts/docs/docs2mdx.py
@@ -56,6 +56,11 @@
 _HTML_PRE_PATTERN = re.compile(r"(?:<pre>)(.*?)(?:</pre>)")
 _HTML_STYLE_PATTERN = re.compile(r"^</?style>", re.MULTILINE)
 _MD_FRONT_MATTER_PATTERN = re.compile(r"^---", re.MULTILINE)
+_HEADING_TAG_RE = re.compile(
+    r"<h([1-6])([^>]*)>(.*?)</h\1>", re.DOTALL | re.IGNORECASE
+)
+_HEADING_ID_ATTR_RE = re.compile(r"""\bid=(["'])([^"']+)\1""")
+_ESCAPED_HEADING_ANCHOR_RE = re.compile(r" &lcub;#([^&]+)&rcub;")
 
 # Across code blocks and similar pre-formatted blocks, these
 # characters must be converted to HTML entities so they don't
@@ -185,13 +190,41 @@
   # Remove Project: and Book: lines
   no_metadata = _METADATA_PATTERN.sub("", no_comments, count=2).lstrip()
   no_templates = _TEMPLATE_RE.sub("", no_metadata)
+  with_heading_anchors = _convert_heading_ids_to_mdx_anchors(no_templates)
   return _HTML_PRE_PATTERN.sub(
       _escape_chars_in_pre_blocks,
-      no_templates,
+      with_heading_anchors,
       re.DOTALL,
   )
 
 
+def _convert_heading_ids_to_mdx_anchors(content):
+  """Converts HTML headings with id attributes to MDX anchor syntax.
+
+  Example: <h2 id='foo'>Title</h2> -> ## Title {#foo}
+
+  Headings without an id attribute are left unchanged for markdownify.
+
+  Args:
+    content: str; HTML content before markdown conversion.
+
+  Returns:
+    Content with id-bearing headings converted to MDX anchor syntax.
+  """
+
+  def repl(match):
+    level = int(match.group(1))
+    attrs = match.group(2)
+    text = match.group(3).strip()
+    id_match = _HEADING_ID_ATTR_RE.search(attrs)
+    if not id_match:
+      return match.group(0)
+    heading_id = id_match.group(2)
+    return f"{'#' * level} {text} {{#{heading_id}}}"
+
+  return _HEADING_TAG_RE.sub(repl, content)
+
+
 def _post_markdown_transforms(content):
   """Transforms applied to all sources after any markdown conversion.
 
@@ -211,7 +244,13 @@
       else _HEADING_RE.sub(_fix_title_heading, no_trailing_whitespaces, count=1)
   )
   front_matter_first = _remove_anything_before_front_matter(fixed_headings)
-  return _remove_style_sections(front_matter_first)
+  no_styles = _remove_style_sections(front_matter_first)
+  return _restore_heading_anchors(no_styles)
+
+
+def _restore_heading_anchors(content):
+  """Restores MDX heading anchors escaped during markdown conversion."""
+  return _ESCAPED_HEADING_ANCHOR_RE.sub(r" {#\1}", content)
 
 
 def _remove_trailing_whitespaces(content):
diff --git a/scripts/docs/docs2mdx_test.py b/scripts/docs/docs2mdx_test.py
new file mode 100644
index 0000000..e0da32d
--- /dev/null
+++ b/scripts/docs/docs2mdx_test.py
@@ -0,0 +1,70 @@
+# Copyright 2026 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import absolute_import
+from __future__ import division
+from __future__ import print_function
+
+import unittest
+
+from absl.testing import parameterized
+from scripts.docs import docs2mdx
+
+
+class Docs2MdxHeadingAnchorTest(parameterized.TestCase):
+
+  @parameterized.named_parameters(
+      (
+          "single_quotes",
+          (
+              "<h2 id='common-attributes'>Attributes common to all build"
+              " rules</h2>"
+          ),
+          "## Attributes common to all build rules {#common-attributes}",
+      ),
+      (
+          "double_quotes",
+          (
+              '<h2 id="typical-attributes">Typical attributes defined by most'
+              " build rules</h2>"
+          ),
+          (
+              "## Typical attributes defined by most build rules"
+              " {#typical-attributes}"
+          ),
+      ),
+      (
+          "extra_attributes",
+          '<h2 id="cc_binary" class="deprecated">\n    cc_binary\n  </h2>',
+          "## cc_binary {#cc_binary}",
+      ),
+      (
+          "h3_heading",
+          '<h3 id="package_args">Arguments</h3>',
+          "### Arguments {#package_args}",
+      ),
+  )
+  def test_heading_id_preserved(self, html, expected_heading):
+    result = docs2mdx._transform("test.html", html)
+    self.assertIn(expected_heading, result)
+
+  def test_heading_without_id_has_no_anchor(self):
+    html = "<h2>Rules</h2>"
+    result = docs2mdx._transform("test.html", html)
+    self.assertIn("## Rules", result)
+    self.assertNotIn("{#", result)
+
+
+if __name__ == "__main__":
+  unittest.main()