| # 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. |
| """Converts command-line-reference HTML to MDX with ParamField components.""" |
| |
| import dataclasses |
| import html |
| import html.parser |
| import re |
| |
| |
| @dataclasses.dataclass |
| class _Option: |
| anchor_id: str = "" |
| flag_name: str = "" |
| value_type: str = "" |
| abbreviation: str = "" |
| default: str = "" |
| allow_multiple: bool = False |
| is_deprecated: bool = False |
| help_html: str = "" |
| expansion_flags: list[str] = dataclasses.field(default_factory=list) |
| tags_html: str = "" |
| |
| |
| @dataclasses.dataclass |
| class _Section: |
| heading: str = "" |
| anchor: str = "" |
| inherits_html: str = "" |
| categories: list[str] = dataclasses.field(default_factory=list) |
| |
| |
| @dataclasses.dataclass |
| class _Category: |
| description: str = "" |
| options: list[str] = dataclasses.field(default_factory=list) |
| |
| |
| @dataclasses.dataclass |
| class _TagTable: |
| heading: str = "" |
| rows: list[str] = dataclasses.field(default_factory=list) |
| |
| |
| _REPLACED_JS_CHARACTERS = { |
| "{": "{", |
| "}": "}", |
| } |
| |
| _REPLACED_CODE_CHARACTERS = { |
| "<": "<", |
| ">": ">", |
| **_REPLACED_JS_CHARACTERS, |
| } |
| |
| |
| def _escape_mdx(text): |
| for c, replacement in _REPLACED_JS_CHARACTERS.items(): |
| text = text.replace(c, replacement) |
| return text |
| |
| |
| def _escape_mdx_code(text): |
| # Escape $ so MDX doesn't parse it as a LaTeX math delimiter or, |
| # combined with {, as a JS template expression. |
| text = text.replace("$", "$") |
| for c, replacement in _REPLACED_CODE_CHARACTERS.items(): |
| text = text.replace(c, replacement) |
| return text |
| |
| |
| class _CLRParser(html.parser.HTMLParser): |
| """Parses the command-line-reference HTML into structured data.""" |
| |
| def __init__(self): |
| super().__init__() |
| self.prefix_html = "" |
| self.commands_table_rows = [] |
| self.sections = [] |
| self.tag_tables = [] |
| |
| self._state = "PREFIX" |
| self._depth = 0 |
| self._tag_stack = [] |
| |
| self._current_section = None |
| self._current_category = None |
| self._current_option = None |
| self._current_tag_table = None |
| |
| self._in_dt = False |
| self._in_dd = False |
| self._in_h2 = False |
| self._in_h3 = False |
| self._in_commands_table = False |
| self._in_tag_table = False |
| self._in_table_row = False |
| self._table_cells = [] |
| self._current_cell_html = "" |
| self._in_table_cell = False |
| self._in_dl = False |
| self._dl_text_buffer = "" |
| self._inherits_buffer = "" |
| |
| self._dt_html = "" |
| self._dd_html = "" |
| self._heading_text = "" |
| self._heading_anchor = "" |
| |
| self._prefix_parts = [] |
| self._saw_commands_heading = False |
| |
| def handle_starttag(self, tag, attrs): |
| attrs_dict = dict(attrs) |
| |
| if self._state == "PREFIX": |
| if tag == "h2": |
| self._in_h2 = True |
| self._heading_text = "" |
| return |
| self._prefix_parts.append(self._rebuild_tag(tag, attrs)) |
| return |
| |
| if self._in_dt: |
| self._dt_html += self._rebuild_tag(tag, attrs) |
| return |
| |
| if self._in_dd: |
| self._dd_html += self._rebuild_tag(tag, attrs) |
| return |
| |
| if self._in_table_cell: |
| self._current_cell_html += self._rebuild_tag(tag, attrs) |
| return |
| |
| if tag == "h2": |
| self._in_h2 = True |
| self._heading_text = "" |
| self._heading_anchor = "" |
| return |
| |
| if tag == "h3": |
| self._in_h3 = True |
| self._heading_text = "" |
| return |
| |
| if tag == "a" and self._in_h2: |
| name = attrs_dict.get("name", "") |
| if name: |
| self._heading_anchor = name |
| return |
| |
| if tag == "table": |
| if self._in_h3 or (self._current_tag_table is not None): |
| self._in_tag_table = True |
| elif self._state == "COMMANDS_TABLE": |
| self._in_commands_table = True |
| return |
| |
| if tag == "tr" and (self._in_commands_table or self._in_tag_table): |
| self._in_table_row = True |
| self._table_cells = [] |
| return |
| |
| if tag == "td" and self._in_table_row: |
| self._in_table_cell = True |
| self._current_cell_html = "" |
| cell_id = attrs_dict.get("id", "") |
| if cell_id: |
| self._current_cell_html += f'<span id="{cell_id}">' |
| return |
| |
| if tag == "p" and self._current_section and not self._in_dl: |
| self._state = "INHERITS_P" |
| self._inherits_buffer = "" |
| return |
| |
| if tag == "a" and self._state == "INHERITS_P": |
| self._inherits_buffer += self._rebuild_tag(tag, attrs) |
| return |
| |
| if tag == "dl": |
| self._in_dl = True |
| self._dl_text_buffer = "" |
| return |
| |
| if tag == "dt": |
| self._in_dt = True |
| self._dt_html = "" |
| anchor_id = attrs_dict.get("id", "") |
| self._current_option = _Option(anchor_id=anchor_id) |
| return |
| |
| if tag == "dd": |
| self._in_dd = True |
| self._dd_html = "" |
| return |
| |
| if tag == "p" and self._in_dl and not self._in_dt and not self._in_dd: |
| self._in_dd = True |
| self._dd_html = "<p>" |
| return |
| |
| def handle_endtag(self, tag): |
| if self._state == "PREFIX": |
| if tag == "h2" and self._in_h2: |
| self._in_h2 = False |
| if self._heading_text.strip() == "Commands": |
| self.prefix_html = "".join(self._prefix_parts) |
| self._state = "COMMANDS_TABLE" |
| else: |
| self._prefix_parts.append(f"<h2>{self._heading_text}</h2>") |
| return |
| self._prefix_parts.append(f"</{tag}>") |
| return |
| |
| if self._in_dt: |
| if tag == "dt": |
| self._in_dt = False |
| self._parse_dt(self._dt_html) |
| return |
| self._dt_html += f"</{tag}>" |
| return |
| |
| if self._in_dd: |
| if tag == "dd": |
| self._in_dd = False |
| self._parse_dd(self._dd_html) |
| if self._current_option and self._current_category: |
| self._current_category.options.append(self._current_option) |
| self._current_option = None |
| return |
| self._dd_html += f"</{tag}>" |
| return |
| |
| if self._in_table_cell: |
| if tag == "td": |
| self._in_table_cell = False |
| self._table_cells.append(self._current_cell_html) |
| return |
| self._current_cell_html += f"</{tag}>" |
| return |
| |
| if tag == "tr" and self._in_table_row: |
| self._in_table_row = False |
| if self._in_commands_table: |
| self.commands_table_rows.append(self._table_cells) |
| elif self._in_tag_table and self._current_tag_table: |
| self._current_tag_table.rows.append(self._table_cells) |
| return |
| |
| if tag == "a" and self._state == "INHERITS_P": |
| self._inherits_buffer += "</a>" |
| return |
| |
| if tag == "p" and self._state == "INHERITS_P": |
| if self._current_section: |
| self._current_section.inherits_html = self._inherits_buffer |
| self._state = "BODY" |
| return |
| |
| if tag == "table": |
| if self._in_commands_table: |
| self._in_commands_table = False |
| self._state = "BODY" |
| elif self._in_tag_table: |
| self._in_tag_table = False |
| if self._current_tag_table: |
| self.tag_tables.append(self._current_tag_table) |
| self._current_tag_table = None |
| return |
| |
| if tag == "h2" and self._in_h2: |
| self._in_h2 = False |
| self._finish_current_section() |
| self._current_section = _Section( |
| heading=self._heading_text.strip(), |
| anchor=self._heading_anchor, |
| ) |
| return |
| |
| if tag == "h3" and self._in_h3: |
| self._in_h3 = False |
| heading = self._heading_text.strip() |
| if "Option" in heading and "Tag" in heading: |
| self._current_tag_table = _TagTable(heading=heading) |
| return |
| |
| if tag == "dl": |
| self._in_dl = False |
| if self._current_category: |
| if self._current_section: |
| self._current_section.categories.append(self._current_category) |
| self._current_category = None |
| return |
| |
| if tag == "p" and self._in_dl and not self._in_dt and not self._in_dd: |
| pass |
| |
| def handle_data(self, data): |
| if self._state == "PREFIX": |
| if self._in_h2: |
| self._heading_text += data |
| else: |
| self._prefix_parts.append(html.escape(data).replace("'", "'")) |
| return |
| |
| if self._in_h2 or self._in_h3: |
| self._heading_text += data |
| return |
| |
| if self._in_dt: |
| self._dt_html += html.escape(data) |
| return |
| |
| if self._in_dd: |
| self._dd_html += html.escape(data) |
| return |
| |
| if self._in_table_cell: |
| self._current_cell_html += html.escape(data) |
| return |
| |
| if self._in_dl and not self._in_dt and not self._in_dd: |
| stripped = data.strip().rstrip(":") |
| if stripped: |
| self._current_category = _Category(description=html.unescape(stripped)) |
| return |
| |
| if self._state == "INHERITS_P": |
| self._inherits_buffer += html.escape(data) |
| return |
| |
| def handle_entityref(self, name): |
| text = f"&{name};" |
| if self._in_dt: |
| self._dt_html += text |
| elif self._in_dd: |
| self._dd_html += text |
| elif self._in_table_cell: |
| self._current_cell_html += text |
| elif self._state == "INHERITS_P": |
| self._inherits_buffer += text |
| elif self._state == "PREFIX" and not self._in_h2: |
| self._prefix_parts.append(text) |
| |
| def handle_charref(self, name): |
| text = f"&#{name};" |
| if self._in_dt: |
| self._dt_html += text |
| elif self._in_dd: |
| self._dd_html += text |
| elif self._in_table_cell: |
| self._current_cell_html += text |
| |
| def close(self): |
| super().close() |
| self._finish_current_section() |
| |
| def _finish_current_section(self): |
| if self._current_category and self._current_section: |
| self._current_section.categories.append(self._current_category) |
| self._current_category = None |
| if self._current_section: |
| self.sections.append(self._current_section) |
| self._current_section = None |
| |
| def _rebuild_tag(self, tag, attrs): |
| parts = [tag] |
| for k, v in attrs: |
| if v is None: |
| parts.append(k) |
| else: |
| parts.append(f'{k}="{v}"') |
| return "<" + " ".join(parts) + ">" |
| |
| def _parse_dt(self, dt_html): |
| """Parses the content of a <dt> tag.""" |
| opt = self._current_option |
| if not opt: |
| return |
| |
| flag_match = re.search(r"<a[^>]*>([^<]+)</a>", dt_html) |
| if flag_match: |
| opt.flag_name = flag_match.group(1) |
| |
| code_match = re.search(r"<code[^>]*>(.*?)</code>", dt_html, re.DOTALL) |
| if code_match: |
| code_content = code_match.group(1) |
| after_a = re.split(r"</a>", code_content, maxsplit=1) |
| if len(after_a) > 1: |
| value_part = after_a[1].strip() |
| if value_part.startswith("="): |
| raw_type = html.unescape(value_part[1:]).strip() |
| if raw_type.startswith("<") and raw_type.endswith(">"): |
| raw_type = raw_type[1:-1] |
| opt.value_type = raw_type |
| |
| after_code = re.split(r"</code>\s*", dt_html, maxsplit=1) |
| if len(after_code) > 1: |
| remainder = after_code[1] |
| |
| abbrev_match = re.search(r"\[<code>(-\w)</code>\]", remainder) |
| if abbrev_match: |
| opt.abbreviation = abbrev_match.group(1) |
| remainder = remainder[abbrev_match.end() :] |
| |
| remainder = remainder.strip() |
| if "multiple uses are accumulated" in remainder: |
| opt.allow_multiple = True |
| elif remainder.startswith("default:"): |
| default_val = remainder[len("default:") :].strip() |
| default_val = html.unescape(default_val).strip('"') |
| opt.default = default_val |
| |
| if not opt.value_type and not opt.flag_name.startswith("--[no]"): |
| opt.value_type = "void" |
| elif not opt.value_type and opt.flag_name.startswith("--[no]"): |
| opt.value_type = "boolean" |
| |
| def _parse_dd(self, dd_html): |
| """Parses the content of a <dd> tag.""" |
| opt = self._current_option |
| if not opt: |
| return |
| |
| parts = re.split(r"<p>", dd_html) |
| help_parts = [] |
| for part in parts: |
| part_text = part.strip() |
| if not part_text: |
| continue |
| if part_text.startswith("Expands to:"): |
| exp_flags = re.findall(r"<code><a[^>]*>([^<]+)</a></code>", part_text) |
| opt.expansion_flags = exp_flags |
| elif part_text.startswith("Tags:"): |
| opt.tags_html = "<p>" + part_text |
| if "deprecated" in part_text.lower(): |
| if "metadata_tag_DEPRECATED" in part_text: |
| opt.is_deprecated = True |
| else: |
| help_parts.append(part_text.removesuffix("</p>")) |
| |
| opt.help_html = "\n".join(help_parts) |
| |
| |
| def _escape_mdx_outside_code(text): |
| """Escapes MDX-special characters outside of code spans and fenced blocks.""" |
| # First split on fenced code blocks (``` ... ```), then within non-fenced |
| # segments split on inline backtick spans. |
| fenced_parts = re.split(r"(```[^`]*```)", text, flags=re.DOTALL) |
| result = [] |
| for fi, fenced_part in enumerate(fenced_parts): |
| if fi % 2 == 1: |
| result.append(fenced_part) |
| continue |
| inline_parts = re.split(r"(`[^`]*`)", fenced_part) |
| for ii, inline_part in enumerate(inline_parts): |
| if ii % 2 == 0: |
| inline_part = _escape_mdx_code(inline_part) |
| result.append(inline_part) |
| return "".join(result) |
| |
| |
| _PRE_CODE_RE = re.compile(r"<pre><code>(.*?)</code></pre>", re.DOTALL) |
| |
| |
| def _convert_pre_code_block(m): |
| content = html.unescape(m.group(1)).strip("\n") |
| return f"\n\n```\n{content}\n```\n\n" |
| |
| |
| _ERRATA = [ |
| ("`rewrite, allow, block'", "`rewrite, allow, block`"), |
| ] |
| |
| |
| def _apply_known_errata(text): |
| for old, new in _ERRATA: |
| text = text.replace(old, new) |
| return text |
| |
| |
| def _html_to_simple_md(html_content): |
| """Minimal HTML-to-markdown for option help text.""" |
| text = html_content |
| |
| # Fenced code blocks before inline code so <pre><code> isn't consumed |
| # by the inline pattern. Content is unescaped but not MDX-escaped since |
| # fenced blocks are literal in MDX. |
| text = _PRE_CODE_RE.sub(_convert_pre_code_block, text) |
| |
| text = re.sub(r'<a\s+href="([^"]*)"[^>]*>([^<]*)</a>', r"[\2](\1)", text) |
| text = re.sub(r"<code>([^<]*)</code>", lambda m: "`" + m.group(1) + "`", text) |
| text = re.sub(r"<em>([^<]*)</em>", r"*\1*", text) |
| text = re.sub(r"<strong>([^<]*)</strong>", r"**\1**", text) |
| text = re.sub(r"<br\s*/?>", "\n", text) |
| |
| text = re.sub(r"<li>\s*", "- ", text) |
| text = re.sub(r"</li>", "", text) |
| text = re.sub(r"</?[uo]l>", "", text) |
| |
| text = re.sub(r"<p>", "\n\n", text) |
| text = re.sub(r"</p>", "", text) |
| |
| text = re.sub(r"<[^>]+>", "", text) |
| |
| text = html.unescape(text) |
| text = _apply_known_errata(text) |
| text = _escape_mdx_outside_code(text) |
| text = re.sub(r"\n{3,}", "\n\n", text) |
| return text.strip() |
| |
| |
| def _escape_attr(value): |
| """Escapes a string for use inside a JSX attribute value.""" |
| escaped = value.replace("&", "&") |
| escaped = escaped.replace('"', """) |
| escaped = escaped.replace("<", "<") |
| escaped = escaped.replace(">", ">") |
| escaped = escaped.replace("{", "{") |
| escaped = escaped.replace("}", "}") |
| return escaped |
| |
| |
| def _render_option(opt): |
| """Renders a single option as a ParamField component.""" |
| attrs = [f'path="{_escape_attr(opt.flag_name)}"'] |
| |
| if opt.value_type: |
| attrs.append(f'type="{_escape_attr(opt.value_type)}"') |
| |
| if opt.default and not opt.allow_multiple: |
| attrs.append(f'default="{_escape_attr(opt.default)}"') |
| |
| if opt.is_deprecated: |
| attrs.append("deprecated") |
| |
| attr_str = " ".join(attrs) |
| lines = [f"<ParamField {attr_str}>"] |
| |
| if opt.abbreviation: |
| lines.append(f" Short form: `{opt.abbreviation}`") |
| lines.append("") |
| |
| help_md = _html_to_simple_md(opt.help_html) |
| if help_md: |
| for line in help_md.split("\n"): |
| lines.append(f" {line}" if line.strip() else "") |
| |
| if opt.allow_multiple: |
| lines.append("") |
| lines.append(" *May be used multiple times; values are accumulated.*") |
| |
| if opt.expansion_flags: |
| lines.append("") |
| lines.append(" Expands to:") |
| for flag in opt.expansion_flags: |
| lines.append(f" - `{_escape_mdx(flag)}`") |
| |
| if opt.tags_html: |
| tags_md = _render_tags(opt.tags_html) |
| if tags_md: |
| lines.append("") |
| lines.append(f" {tags_md}") |
| |
| lines.append("</ParamField>") |
| return "\n".join(lines) |
| |
| |
| def _render_tags(tags_html): |
| """Converts tags HTML to markdown.""" |
| tag_links = re.findall( |
| r'<a\s+href="([^"]*)"><code>([^<]*)</code></a>', tags_html |
| ) |
| if not tag_links: |
| return "" |
| parts = [f"[`{name}`]({href})" for href, name in tag_links] |
| return "Tags: " + ", ".join(parts) |
| |
| |
| def _render_commands_table(rows): |
| """Renders the commands table as markdown.""" |
| lines = [ |
| "## Commands", |
| "", |
| "| | |", |
| "| --- | --- |", |
| ] |
| for cells in rows: |
| if len(cells) >= 2: |
| cmd_md = re.sub( |
| r'<a\s+href="([^"]*)"><code>([^<]*)</code></a>', |
| lambda m: f"[`{m.group(2)}`]({m.group(1)})", |
| cells[0], |
| ) |
| desc = html.unescape(re.sub(r"<[^>]+>", "", cells[1])) |
| lines.append(f"| {cmd_md} | {_escape_mdx(desc)} |") |
| return "\n".join(lines) |
| |
| |
| def _render_tag_table(tag_table): |
| """Renders a tag description table as markdown.""" |
| lines = [ |
| f"### {tag_table.heading}", |
| "", |
| "| | |", |
| "| --- | --- |", |
| ] |
| for cells in tag_table.rows: |
| if len(cells) >= 2: |
| name_cell = cells[0] |
| span_match = re.search(r'<span id="([^"]*)">', name_cell) |
| code_match = re.search(r"<code>([^<]*)</code>", name_cell) |
| if span_match and code_match: |
| tag_id = span_match.group(1) |
| tag_name = code_match.group(1) |
| name_md = f'<span id="{tag_id}">`{tag_name}`</span>' |
| else: |
| name_md = re.sub(r"<[^>]+>", "", name_cell) |
| desc = html.unescape(re.sub(r"<[^>]+>", "", cells[1])) |
| lines.append(f"| {name_md} | {_escape_mdx(desc)} |") |
| return "\n".join(lines) |
| |
| |
| def _render_section(section): |
| """Renders a section heading, inherits info, and all options.""" |
| lines = [f"## {section.heading}"] |
| if section.anchor: |
| lines[0] = f"## {section.heading} {{#{section.anchor}}}" |
| |
| if section.inherits_html: |
| inherits_md = re.sub( |
| r'<a\s+href="([^"]*)">([^<]*)</a>', |
| lambda m: f"[{m.group(2)}]({m.group(1)})", |
| section.inherits_html, |
| ) |
| inherits_md = re.sub(r"<[^>]+>", "", inherits_md).strip() |
| if inherits_md: |
| lines.append("") |
| lines.append(inherits_md) |
| |
| for cat in section.categories: |
| lines.append("") |
| if cat.description: |
| lines.append(cat.description) |
| lines.append("") |
| for opt in cat.options: |
| lines.append(_render_option(opt)) |
| lines.append("") |
| |
| return "\n".join(lines) |
| |
| |
| def convert(html_content): |
| """Converts command-line-reference HTML to MDX with ParamField components. |
| |
| Args: |
| html_content: str; the full HTML content of command-line-reference.html. |
| |
| Returns: |
| The MDX content with ParamField components. |
| """ |
| parser = _CLRParser() |
| parser.feed(html_content) |
| parser.close() |
| |
| parts = [] |
| |
| parts.append(_render_commands_table(parser.commands_table_rows)) |
| parts.append("") |
| |
| for section in parser.sections: |
| parts.append(_render_section(section)) |
| parts.append("") |
| |
| for tag_table in parser.tag_tables: |
| parts.append(_render_tag_table(tag_table)) |
| parts.append("") |
| |
| return "\n".join(parts) |