| # Lint as: python3 |
| # pylint: disable=g-direct-third-party-import |
| # 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. |
| """A tool for converting .html/.md(x) docs to valid .mdx files.""" |
| |
| import os |
| import re |
| import sys |
| |
| from absl import app |
| from absl import flags |
| import markdownify |
| from scripts.docs import clr_converter |
| from scripts.docs import mdx_fixes |
| |
| |
| FLAGS = flags.FLAGS |
| |
| flags.DEFINE_string( |
| "in_dir", |
| None, |
| "Absolute path of the input directory (where .html and .md(x) files " |
| "should be read from).", |
| ) |
| flags.DEFINE_string( |
| "out_dir", |
| None, |
| "Absolute path of the output directory (where .mdx files should be" |
| " written to).", |
| ) |
| flags.mark_flag_as_required("in_dir") |
| flags.mark_flag_as_required("out_dir") |
| |
| |
| _TEMPLATE_RE = re.compile(r"^\{%.+$\n", re.MULTILINE) |
| _TAG_RE = re.compile(r"\s?\{:[^}]+\}") |
| _METADATA_PATTERN = re.compile( |
| "^((Project|Book):.+\n)", re.MULTILINE |
| ) |
| _HTML_COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL) |
| # Flag docs wrap the anchor link inside <code>, which markdownify drops. |
| # Move the link outside <code> so it survives conversion to MDX definition |
| # lists. |
| _CODE_FLAG_LINK_RE = re.compile( |
| r'<code(?:\s[^>]*)?><a href="(#[^"]+)">(.*?)</a>(.*?)</code>', |
| re.DOTALL, |
| ) |
| # Definition-list flag terms that should expose a copyable deep-link anchor. |
| _FLAG_TERM_LINK_RE = re.compile( |
| r"^\[`([^`]+)`\]\(#((?:[^)]*-)?flag--[^)]+)\)", |
| ) |
| _HEADING_TAG_RE = re.compile( |
| r"^([^\n]*)<h([1-6])([^>]*)>(.*?)</h\2>", |
| re.DOTALL | re.IGNORECASE | re.MULTILINE, |
| ) |
| _HEADING_ID_ATTR_RE = re.compile(r"""\bid=(["'])([^"']+)\1""") |
| # See docstrings of _format_table_cell() and () |
| # for an explanation. |
| _TAGS_TO_FLATTEN_RE = re.compile(r"(?:</?(?:pre|code)[^>]*>)+") |
| |
| # In prose (outside code/pre blocks), these characters must be converted to |
| # HTML entities so they don't look like JSX or JavaScript blocks to MDX parsers. |
| _REPLACED_JS_CHARACTERS = { |
| "{": "{", |
| "}": "}", |
| "$": "$", |
| } |
| |
| _REPLACED_CODE_CHARACTERS = { |
| "<": "<", |
| ">": ">", |
| **_REPLACED_JS_CHARACTERS, |
| } |
| |
| _CONFIGURATION_HTML_BAD_LINE = re.compile( |
| r"^<p>Use this to distinguish different configurations for the same" |
| r" target.+$", |
| re.MULTILINE, |
| ) |
| |
| |
| def _escape_chars(text, replacements): |
| """Escapes characters in a string. |
| |
| Args: |
| text: str; string that needs characters escaped. |
| replacements: dict[str, str]; a dictionary mapping characters to escape with |
| their replacements. |
| |
| Returns: |
| The escaped version of `text`. |
| """ |
| for c in replacements: |
| text = text.replace(c, replacements[c]) |
| return text |
| |
| |
| # Table cells containing these elements cannot be represented as plain markdown |
| # table cell text. Preserve their inner HTML so MDX renders them correctly. |
| _COMPLEX_CELL_TAGS = frozenset(["ul", "ol", "table", "pre"]) |
| |
| |
| def _cell_has_complex_content(cell): |
| """Returns True if a table cell contains content that needs HTML preservation.""" |
| return cell.find(list(_COMPLEX_CELL_TAGS)) is not None |
| |
| |
| def _cell_inner_html(cell): |
| """Returns the raw inner HTML of a table cell.""" |
| return "".join(str(child) for child in cell.children).strip() |
| |
| |
| def _format_table_cell(cell, content): |
| """Formats table cell content as a markdown table cell. |
| |
| Markdownify converts elements bottom-up. |
| For table cells we discard the already converted tag and instead |
| manually "convert" the inner HTML, which can be very complex. |
| |
| A better approach would be this: |
| For every tag <foo> that can produce multi-line Markdown output:r |
| If convert_foo is called for a nested table, it should preserve <foo> |
| tags and return the (already converted) content as a single line. |
| Otherwise it just delegates to the super class implementation. |
| However, this cannot be implemented since parent_tags is a set and not a |
| dict or multiset, so we cannot distinguish a simple table from a |
| nested table. |
| |
| Args: |
| cell: The BeautifulSoup table cell element (`td` or `th`). |
| content: str; the raw inner HTML content of the table cell. |
| |
| Returns: |
| The formatted single-line Markdown table cell string. |
| """ |
| colspan = 1 |
| if "colspan" in cell.attrs and cell["colspan"].isdigit(): |
| colspan = max(1, min(1000, int(cell["colspan"]))) |
| # Content = raw HTML, i.e. it can contain tags such as <code> or <pre> |
| # with forbidden characters in their values (such as curly braces). |
| # Consequently, we need to escape them here. |
| return f" {_convert_html_to_single_line_md(content)}{' |' * colspan}" |
| |
| |
| def _convert_html_to_single_line_md(content): |
| """Converts 'bad' tags (<pre>, <code>) and fits the result into a single line. |
| |
| These tags are 'bad' since they can contain reserved chars such as curly |
| braces, |
| which lead to syntax errors if they appear outside of fenced Markdown code |
| blocks. |
| |
| Args: |
| content: str; the raw HTML content to convert. |
| |
| Returns: |
| A single-line Markdown/HTML string with `<pre>` and `<code>` tags flattened. |
| """ |
| # Convert <pre> and <code> to single fenced code blocks. |
| no_bad_tags = _TAGS_TO_FLATTEN_RE.sub("`", content) |
| |
| # Escape special characters outside of fenced code blocks. |
| parts = no_bad_tags.split("`") |
| for i in range(0, len(parts), 2): |
| parts[i] = _escape_chars(parts[i], _REPLACED_JS_CHARACTERS) |
| escaped = "`".join(parts) |
| |
| # Remove line breaks. |
| raw_lines = [l.strip() for l in escaped.split("\n")] |
| return " ".join([l for l in raw_lines if l]) |
| |
| |
| class AcornSafeMarkdownConverter(markdownify.MarkdownConverter): |
| """Custom converter that produces Acorn-parsable MDX output.""" |
| |
| def convert_td(self, el, text, parent_tags): |
| if _cell_has_complex_content(el): |
| return _format_table_cell(el, _cell_inner_html(el)) |
| return super().convert_td(el, text, parent_tags) |
| |
| def convert_th(self, el, text, parent_tags): |
| if _cell_has_complex_content(el): |
| return _format_table_cell(el, _cell_inner_html(el)) |
| return super().convert_th(el, text, parent_tags) |
| |
| def convert_code(self, node, text, parent_tags): |
| """Normalize whitespace in inline code before converting. |
| |
| Args: |
| node: The HTML element being converted. |
| text: The text content within the code tag. |
| parent_tags: A list of parent tag names. |
| |
| Returns: |
| The converted markdown string. |
| """ |
| if "pre" not in parent_tags: |
| # Multi-line <code> elements in the source HTML cause acorn parse errors |
| # when curly braces span line boundaries. Collapsing whitespace first |
| # lets the standard backtick conversion handle them on a single line. |
| text = " ".join(text.split()) |
| |
| return super().convert_code(node, text, parent_tags) |
| |
| def escape(self, text, parent_tags): |
| """Custom escape handling.""" |
| if not text: |
| return text |
| escaped = super().escape(text, parent_tags) |
| # Unescape underscores that are in the middle of words. |
| escaped = re.sub(r"(\w)\\_(\w)", r"\1_\2", escaped) |
| # Fenced and inline code blocks are already safe from MDX parsing. |
| if "pre" in parent_tags or "code" in parent_tags: |
| return escaped |
| return _escape_chars(escaped, _REPLACED_CODE_CHARACTERS) |
| |
| |
| def _convert_directory(root_dir, mdx_dir): |
| """Converts all .html and .md(x) files to .mdx files. |
| |
| Args: |
| root_dir: str; full path of the directory with .html/.md(x) files (input). |
| mdx_dir: str; full path of the directory where .mdx files should be |
| created (output). |
| """ |
| for curr_dir, _, files in os.walk(root_dir): |
| rel = os.path.relpath(curr_dir, start=root_dir) |
| dest_dir = os.path.join(mdx_dir, rel) |
| os.makedirs(dest_dir, exist_ok=True) |
| |
| for fname in files: |
| basename, ext = os.path.splitext(fname) |
| if ext not in (".html", ".md", ".mdx"): |
| continue |
| |
| src = os.path.join(curr_dir, fname) |
| dest = os.path.join(dest_dir, f"{basename}.mdx") |
| |
| _convert_file(src, dest) |
| |
| |
| def _convert_file(src, dest): |
| with open(src, "rt") as f: |
| content = f.read() |
| |
| with open(dest, "wt") as f: |
| f.write(_transform(src, content)) |
| |
| |
| def _transform(path, content): |
| """Transforms the content of an HTML or Markdown file into valid MDX.""" |
| content = _pre_markdown_transforms(content) |
| if path.endswith(".html"): |
| if os.path.basename(path) == "command-line-reference.html": |
| md = clr_converter.convert(content) |
| else: |
| if path.endswith("configuration.html"): |
| fixed_content = _fix_configuration_dot_html(content) |
| elif path.endswith("bzl.html"): |
| fixed_content = _fix_bzl_dot_html(content) |
| else: |
| fixed_content = content |
| |
| md = _html2md(fixed_content) |
| else: |
| md = content |
| return _post_markdown_transforms(md) |
| |
| |
| def _fix_configuration_dot_html(content): |
| """Fixes malformed HTML in rules/lib/builtins/configurations.html.""" |
| |
| def fix(m): |
| return f"{m.group(0).replace('.', '.</li>')}</ul></p>" |
| |
| return _CONFIGURATION_HTML_BAD_LINE.sub(fix, content) |
| |
| |
| def _fix_bzl_dot_html(content): |
| """Fixes malformed HTML link in rules/lib/globals/bzl.html. |
| |
| There is only a single instance of this bug, so it doesn't |
| make sense to implement a general solution. |
| |
| Args: |
| content: str; the HTML content to be fixed. |
| Returns: |
| The fixed HTML content, as string. |
| """ |
| href = "../globals/workspace#register_execution_platforms" |
| return content.replace(f'"{href}>', f'"{href}">') |
| |
| |
| def _html2md(content): |
| # HTML content needs a few extra transforms. |
| content = _move_flag_links_outside_code(content) |
| return AcornSafeMarkdownConverter(heading_style="ATX").convert(content) |
| |
| |
| def _pre_markdown_transforms(content): |
| """Transforms applied to all sources before any markdown conversion. |
| |
| Args: |
| content: str; content of an HTML or .md file. |
| |
| Returns: |
| The file with invalid content removed. |
| """ |
| no_tags = _TAG_RE.sub("", content) |
| no_comments = _HTML_COMMENT_RE.sub("", no_tags) |
| # Remove Project: and Book: lines |
| no_metadata = _METADATA_PATTERN.sub("", no_comments, count=2).lstrip() |
| no_templates = _TEMPLATE_RE.sub("", no_metadata) |
| return _convert_heading_ids_to_mdx_anchors(no_templates) |
| |
| |
| def _move_flag_links_outside_code(content): |
| """Moves in-code flag anchor links outside of <code> tags. |
| |
| HtmlUtils.getUsageHtml() renders flags as |
| <code><a href="#flag--name">--name</a>...</code>. Markdownify discards links |
| nested inside inline code, so restructure the HTML before conversion. |
| |
| Args: |
| content: str; HTML content before markdown conversion. |
| |
| Returns: |
| Content with flag links moved outside of <code> tags. |
| """ |
| return _CODE_FLAG_LINK_RE.sub( |
| r'<a href="\1"><code>\2\3</code></a>', |
| content, |
| ) |
| |
| |
| 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(2)) |
| attrs = match.group(3) |
| text = match.group(4).strip() |
| id_match = _HEADING_ID_ATTR_RE.search(attrs) |
| if not id_match: |
| return match.group(0) |
| # Hack: do not add anchor if heading has non-empty prefix (e.g. list tag) |
| line_prefix = match.group(1).strip() |
| heading_id = id_match.group(2) |
| anchor = "" if line_prefix else f" {{#{heading_id}}}" |
| return f"{'#' * level} {text}{anchor}" |
| |
| return _HEADING_TAG_RE.sub(repl, content) |
| |
| |
| def _post_markdown_transforms(content): |
| """Transforms applied to all sources after any markdown conversion. |
| |
| Args: |
| content: str; content of a converted .mdx file. |
| |
| Returns: |
| The content as fully valid .mdx. |
| """ |
| return _add_flag_anchor_targets(mdx_fixes.apply(content)) |
| |
| |
| def _add_flag_anchor_targets(content): |
| """Inserts explicit anchor targets for copyable per-flag deep links. |
| |
| After markdown conversion, flag terms look like |
| [`--flag_name`](#flag--flag_name). Mintlify needs an element with a matching |
| id attribute for those links (and copied URLs) to resolve. |
| |
| Args: |
| content: str; MDX content after markdown conversion. |
| |
| Returns: |
| Content with <a id="..."></a> targets inserted before each flag term. |
| """ |
| seen_anchor_ids = set() |
| lines = [] |
| for line in content.split("\n"): |
| match = _FLAG_TERM_LINK_RE.match(line) |
| if match: |
| anchor_id = match.group(2) |
| if anchor_id not in seen_anchor_ids: |
| seen_anchor_ids.add(anchor_id) |
| lines.append(f'<a id="{anchor_id}"></a>') |
| lines.append("") |
| lines.append(line) |
| return "\n".join(lines) |
| |
| |
| def _fail(msg): |
| print(msg, file=sys.stderr) |
| exit(1) |
| |
| |
| def main(unused_argv): |
| if not os.path.isdir(FLAGS.in_dir): |
| _fail(f"{FLAGS.in_dir} is not a directory") |
| if not os.path.isdir(FLAGS.out_dir): |
| _fail(f"{FLAGS.out_dir} is not a directory") |
| |
| _convert_directory(FLAGS.in_dir, FLAGS.out_dir) |
| |
| |
| if __name__ == "__main__": |
| FLAGS(sys.argv) |
| app.run(main) |