| # 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) |