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" {#([^&]+)}") # 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()