dabla commented on code in PR #74536: URL: https://github.com/apache/airflow/pull/74536#discussion_r4306469982
########## devel-common/src/sphinx_exts/airflow_llms_txt.py: ########## @@ -0,0 +1,533 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. +""" +Publish Markdown copies of each page and an ``llms.txt`` index for LLMs and coding agents. + +`sphinx-llm <https://github.com/NVIDIA/sphinx-llm>`__ does the heavy lifting: it runs a second +Sphinx build with a Markdown builder, writes ``page.html.md`` next to every ``page.html`` and +adds ``<link rel="alternate" type="text/markdown">`` to each HTML page. This extension adds what +Airflow docs need on top of it: + +* Markdown handlers for nodes the stock translator drops or misrenders: ``mermaid`` diagrams, + code-block captions, keyword-only ``*`` markers in API signatures and toctree captions. +* License-header comments are removed, so they do not open every page. +* ``exampleinclude`` headers link to the full example file on GitHub at the release tag, since + the excerpts leave out imports. +* ``llms.txt`` is rewritten: sections follow the root toctree captions, every link is absolute, + and descriptions come from each page's first paragraph rather than its first line of text. +* ``llms-full.txt`` holds the same pages in the same order, each headed by its URL, with links + made absolute so they still work once the pages share one file. +""" + +from __future__ import annotations + +import posixpath +import re +from collections.abc import Iterator +from pathlib import Path +from typing import TYPE_CHECKING, Any, cast +from urllib.parse import urljoin, urlsplit, urlunsplit + +from docutils import nodes +from sphinx import addnodes +from sphinx.errors import ExtensionError +from sphinx.util import logging +from sphinx.util.matching import Matcher +from sphinx_llm.markdown_builder import SphinxLlmMarkdownBuilder, SphinxLlmMarkdownTranslator +from sphinx_markdown_builder.contexts import IndentContext, SubContext, SubContextParams, TableContext + +from sphinx_exts.redirects import iter_redirects + +if TYPE_CHECKING: + from sphinx.application import Sphinx + from sphinx.config import Config + from sphinx.domains.std import StandardDomain + from sphinx.environment import BuildEnvironment + +logger = logging.getLogger(__name__) + +MARKDOWN_BUILDER = SphinxLlmMarkdownBuilder.name +# Pages whose first paragraph is shorter than this get no description rather than a fragment. +MIN_DESCRIPTION_LENGTH = 20 +MAX_DESCRIPTION_LENGTH = 240 +_SENTENCE_END = re.compile(r"(?<=[.!?])\s+(?=[A-Z`])") +# Paragraphs under these are not the page's own opening prose. +_NOT_PROSE = ( + nodes.Admonition, + nodes.comment, + nodes.compound, + nodes.list_item, + nodes.system_message, + nodes.table, + nodes.topic, + addnodes.desc, +) +# llms.txt is built from the HTML build's doctrees, where Sphinx has already curled the quotes. +# The files are served without a charset, so keep them ASCII (see SMARTQUOTES_EXCLUDES). +_TYPOGRAPHY = str.maketrans( + { + "\u2018": "'", + "\u2019": "'", + "\u201c": '"', + "\u201d": '"', + "\u2013": "-", + "\u2014": "-", + "\u2026": "...", + "\u00a0": " ", + } +) +_INLINE_CODE = re.compile(r"(`[^`]*`)") +_MARKDOWN_LINK = re.compile(r"(\]\()([^)\s]+)(\))") + + +class CompactTableContext(TableContext): + """ + A pipe table without the column padding. + + ``tabulate`` pads every cell to the widest one in its column, which is pure whitespace to a + model reading the file: about a tenth of ``llms-full.txt`` before this. + """ + + def make(self) -> str: + ctx = SubContext() + prefix = self.internal_context.make() + if prefix: + ctx.add(prefix) + rows = [self.make_row(row) for row in [*self.headers, *self.body]] + if rows: + width = max(len(row) for row in rows) + lines = [ + "| " + + " | ".join(cell.replace("|", r"\|") for cell in [*row, *[""] * (width - len(row))]) + + " |" + for row in rows + ] + lines.insert(1, "|" + "---|" * width) + ctx.add("\n".join(lines), prefix_eol=2) + return ctx.make() + + +class AirflowMarkdownTranslator(SphinxLlmMarkdownTranslator): + """Render the Sphinx nodes Airflow docs use that sphinx-markdown-builder skips or misrenders.""" + + def _push_box(self, title: str) -> None: + # Admonitions become a quoted block that starts with their label, rather than an + # ``#### NOTE`` heading that breaks the heading order and never says where the note ends. + self._push_context(IndentContext("> ", empty=True, params=SubContextParams(2, 2))) + self.add(f"**{title.capitalize()}:**", suffix_eol=2) + + def _fetch_ref_uri(self, node: nodes.reference) -> str: + # A link to a section on the same page (```Confidence`_``) has a refid but no refuri and + # no ``internal`` flag, so the base translators leave its target empty. + if node.get("refid") and not node.get("refuri"): + return self.builder.link_token(self.builder.current_doc_name, node["refid"]) + return super()._fetch_ref_uri(node) + + def visit_meta(self, node: nodes.Element) -> None: + raise nodes.SkipNode + + def visit_toctree(self, node: addnodes.toctree) -> None: + # Hidden toctrees can reach the writer unresolved; like HTML, show nothing for them. + # Visible ones are resolved into link lists before this, so any left are unexpected. + if not node.get("hidden"): + super().unknown_visit(node) + raise nodes.SkipNode + + def visit_table(self, node: nodes.Element) -> None: + self._push_context(CompactTableContext(params=SubContextParams(2, 1))) + + def depart_table(self, node: nodes.Element) -> None: + self._pop_context(node) + + def visit_mermaid(self, node: nodes.Element) -> None: + self.add(f"```mermaid\n{node['code'].strip()}\n```", prefix_eol=2, suffix_eol=2) + raise nodes.SkipNode + + def visit_caption(self, node: nodes.Element) -> None: + self.add(f"**{node.astext()}**", prefix_eol=2, suffix_eol=1) + raise nodes.SkipNode + + # ``abbreviation`` wraps the bare ``*`` that marks keyword-only parameters in signatures. + def visit_abbreviation(self, node: nodes.Element) -> None: + pass + + def depart_abbreviation(self, node: nodes.Element) -> None: + pass + + def visit_title(self, node: nodes.Element) -> None: + # Render navigation captions in bold rather than as section headings. + parent = node.parent + if (isinstance(parent, addnodes.compact_paragraph) and parent.get("toctree")) or ( + isinstance(parent, nodes.compound) and "toctree-wrapper" in parent.get("classes", []) + ): + self.add(f"**{node.astext()}**", prefix_eol=2, suffix_eol=1) + raise nodes.SkipNode + + super().visit_title(node) + + def depart_title(self, node: nodes.Element) -> None: + self._pop_context(node) + + +def _escape_link_text(text: str) -> str: + return text.replace("[", r"\[").replace("]", r"\]") + + +def _inline_markdown(node: nodes.Node, env: BuildEnvironment) -> str: + """Flatten a title or paragraph to one line of Markdown, keeping inline code as code.""" + parts: list[str] = [] + for child in node.children: + if isinstance(child, addnodes.pending_xref) and not child.get("refexplicit"): + parts.append(_xref_title(child, env) or _inline_markdown(child, env)) + elif isinstance(child, nodes.literal): + parts.append(f"`{child.astext()}`") + elif isinstance(child, nodes.Text): + parts.append(child.astext()) + elif isinstance(child, nodes.Element): + parts.append(_inline_markdown(child, env)) + return re.sub(r"\s+", " ", "".join(parts)).strip().translate(_TYPOGRAPHY) + + +def _xref_title(xref: addnodes.pending_xref, env: BuildEnvironment) -> str | None: + """ + Return the title a ``:doc:`` or ``:ref:`` without explicit text resolves to. + + Doctrees read back from the environment are unresolved, so such a reference still holds its + raw target (``operators/llm``) rather than the page or section title the HTML shows. + """ + target = xref.get("reftarget", "") + if xref.get("reftype") == "doc": + refdoc = xref.get("refdoc", "") + docname = ( + target.lstrip("/") + if target.startswith("/") + else posixpath.join(posixpath.dirname(refdoc), target) + ) + title = env.titles.get(posixpath.normpath(docname)) + return _inline_markdown(title, env) if title else None + if xref.get("reftype") == "ref": + domain = cast("StandardDomain", env.get_domain("std")) + label = domain.labels.get(target.lower()) + return label[2] if label else None + return None + + +def _separate_markdown_doctrees(app: Sphinx, config: Config) -> None: + """Give the Markdown build its own doctree cache.""" + # sphinx-llm's sequential build shares the HTML cache, which can reuse + # incompatible doctrees or overwrite them. Set a separate cache before + # Sphinx creates the environment. + + if app.tags.has("sphinx_llm_markdown"): + app.doctreedir = Path(app.outdir) / ".doctrees" + + +def _is_markdown_build(app: Sphinx) -> bool: + return app.builder is not None and app.builder.name == MARKDOWN_BUILDER + + +def _link_example_sources(app: Sphinx, doctree: nodes.document) -> None: + """ + Replace each ``exampleinclude`` header with a link to the whole example file. + + Runs before ``exampleinclude``'s own ``doctree-read`` handler, which would otherwise turn the + header into a bare module path plus a "View Source" button that has no Markdown equivalent. + """ + if not _is_markdown_build(app): + return + # Imported by bare name, as conf.py loads it: another import path gives a different class. + from exampleinclude import ExampleHeader + + repo_root = Path(app.config.llms_txt_repo_root).resolve() + source_url = app.config.llms_txt_source_url.rstrip("/") + for header in list(doctree.findall(ExampleHeader)): + path = Path(header["filename"]).resolve() + if not source_url or not path.is_relative_to(repo_root): + header.parent.remove(header) + continue + repo_path = path.relative_to(repo_root).as_posix() + paragraph = nodes.paragraph() + paragraph += nodes.Text("Excerpt from ") + link = nodes.reference("", "", refuri=f"{source_url}/{repo_path}", internal=False) + link += nodes.literal(repo_path, repo_path) + paragraph += link + paragraph += nodes.Text(":") + header.replace_self(paragraph) + + +def _clean_markdown_doctree(app: Sphinx, doctree: nodes.document, docname: str) -> None: + """Remove RST comments and normalize version, GitHub source and site-root links for Markdown.""" + + if not _is_markdown_build(app): + return + site_root = urljoin(app.config.llms_txt_site_url, "/") + release_url = app.config.llms_txt_release_source_url + source_url = app.config.llms_txt_source_url + for reference in doctree.findall(nodes.reference): + refuri = reference.get("refuri", "") + if "|version|" in refuri: + refuri = reference["refuri"] = refuri.replace("|version|", app.config.version) + if release_url and source_url and release_url != source_url: + for kind in ("blob", "tree"): + prefix = release_url.replace("/blob/", f"/{kind}/") + if refuri.startswith(prefix): + refuri = reference["refuri"] = ( + source_url.replace("/blob/", f"/{kind}/") + refuri[len(prefix) :] + ) + # Links into other packages' docs are site-root relative, which means nothing to a model + # that got the page without its URL. + if refuri.startswith("/") and not refuri.startswith("//"): + reference["refuri"] = urljoin(site_root, refuri) + for comment in list(doctree.findall(nodes.comment)): + comment.parent.remove(comment) + for quote in list(doctree.findall(nodes.block_quote)): + if not quote.children: + quote.parent.remove(quote) + + +def _page_description(env: BuildEnvironment, docname: str) -> str: + """Return the page's meta description or the opening sentences of its first paragraph.""" + doctree = env.get_doctree(docname) + for meta in doctree.findall(nodes.meta): + if meta.get("name") == "description" and meta.get("content"): + return meta["content"] + for paragraph in doctree.findall(nodes.paragraph): + # ``example-header`` is the file path ``exampleinclude`` puts above a code excerpt. + if "example-header" in paragraph["classes"] or any( + isinstance(ancestor, _NOT_PROSE) for ancestor in _ancestors(paragraph) + ): + continue + text = _inline_markdown(paragraph, env) + if len(text) >= MIN_DESCRIPTION_LENGTH: + return _summarize(text) + return "" + + +def _summarize(text: str) -> str: + """Return whole sentences of ``text`` up to the length cap, cutting the first one if it is longer.""" + description = "" + for sentence in _SENTENCE_END.split(text): + if description and len(description) + len(sentence) + 1 > MAX_DESCRIPTION_LENGTH: + break + description = f"{description} {sentence}".strip() + if len(description) > MAX_DESCRIPTION_LENGTH: + description = description[:MAX_DESCRIPTION_LENGTH].rsplit(" ", 1)[0] + " ..." + # A paragraph that introduces a list or code block ends on a colon. + return re.sub(r":$", ".", description) + + +def _ancestors(node: nodes.Node) -> Iterator[nodes.Element]: + parent = node.parent + while parent is not None: + yield parent + parent = parent.parent + + +def _page_title(env: BuildEnvironment, docname: str, explicit_title: str | None) -> str: + if explicit_title: + return explicit_title + title_node = env.titles.get(docname) + return _inline_markdown(title_node, env) if title_node else docname + + +def _toctree_docs(env: BuildEnvironment, docname: str, seen: set[str]) -> list[tuple[str, str | None]]: + """Return ``docname`` and everything below it in toctree order, each once.""" + if docname in seen: + return [] + seen.add(docname) + docs: list[tuple[str, str | None]] = [(docname, None)] + for child in env.toctree_includes.get(docname, []): + docs.extend(_toctree_docs(env, child, seen)) + return docs + + +def _write_markdown_redirects(app: Sphinx) -> None: + """Write Markdown stubs for redirected documentation pages.""" + outdir = Path(app.outdir) + + for from_path, to_path in iter_redirects(app): + # Preserve Markdown belonging to an actual source document. + docname = from_path.removesuffix(".html") + if docname in app.env.found_docs: + continue + + target = urlsplit(to_path) + resolved_path = posixpath.normpath(posixpath.join(posixpath.dirname(from_path), target.path)) + + # Only change links within this package to Markdown. + # Other packages and external sites might only publish HTML. + if ( + not target.scheme + and not target.netloc + and not target.path.startswith("/") + and resolved_path != ".." + and not resolved_path.startswith("../") + and target.path.endswith(".html") + ): + target = target._replace(path=f"{target.path}.md") + + destination = urlunsplit(target) + output = outdir / f"{from_path}.md" + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text( + f"# Page moved\n\nThis page has moved to [the new page](<{destination}>).\n", + encoding="utf-8", + ) + + +def _check_markdown_coverage(outdir: Path) -> None: + """Fail if an eligible HTML page has no Markdown copy.""" + excluded = {"genindex.html", "search.html", "py-modindex.html"} + missing = [] + + for page in sorted(outdir.rglob("*.html")): + relative = page.relative_to(outdir) + if "_modules" in relative.parts or page.name in excluded: + continue + if not page.with_name(page.name + ".md").is_file(): + missing.append(relative.as_posix()) + + if missing: + raise ExtensionError( + "HTML pages missing Markdown copies:\n" + "\n".join(f" {page}" for page in missing) + ) + + +def _write_llms_txt(app: Sphinx, exception: Exception | None) -> None: + """ + Replace sphinx-llm's flat ``llms.txt`` with one grouped by the root toctree captions. + + Runs after sphinx-llm has copied the Markdown pages into the HTML output (priority 101). + """ + if exception is not None or app.builder.name != "html" or not app.config.llms_txt_enabled: + return + env = app.env + outdir = Path(app.outdir) + + _write_markdown_redirects(app) + _check_markdown_coverage(outdir) Review Comment: **[nit]** When the whole Markdown build fails, this error buries the cause. When sphinx-llm's Markdown subprocess exits non-zero, `combine_builds` logs an error and returns without copying anything (sphinx-llm 1.1.0, `txt.py`). The coverage check then raises `HTML pages missing Markdown copies:` followed by every page of the package, about 230 lines for common.ai. The subprocess's own error is somewhere above that list. Failing the build is right; the PoC's warning-and-return let it pass with no Markdown at all. Consider a separate, short error when the root page has no `.html.md`, for example "the Markdown build produced no pages; see the sphinx-llm errors above". Keep the per-page list for the partial case it is meant for. ########## devel-common/src/sphinx_exts/airflow_llms_txt.py: ########## @@ -0,0 +1,533 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. +""" +Publish Markdown copies of each page and an ``llms.txt`` index for LLMs and coding agents. + +`sphinx-llm <https://github.com/NVIDIA/sphinx-llm>`__ does the heavy lifting: it runs a second +Sphinx build with a Markdown builder, writes ``page.html.md`` next to every ``page.html`` and +adds ``<link rel="alternate" type="text/markdown">`` to each HTML page. This extension adds what +Airflow docs need on top of it: + +* Markdown handlers for nodes the stock translator drops or misrenders: ``mermaid`` diagrams, + code-block captions, keyword-only ``*`` markers in API signatures and toctree captions. +* License-header comments are removed, so they do not open every page. +* ``exampleinclude`` headers link to the full example file on GitHub at the release tag, since + the excerpts leave out imports. +* ``llms.txt`` is rewritten: sections follow the root toctree captions, every link is absolute, + and descriptions come from each page's first paragraph rather than its first line of text. +* ``llms-full.txt`` holds the same pages in the same order, each headed by its URL, with links + made absolute so they still work once the pages share one file. +""" + +from __future__ import annotations + +import posixpath +import re +from collections.abc import Iterator +from pathlib import Path +from typing import TYPE_CHECKING, Any, cast +from urllib.parse import urljoin, urlsplit, urlunsplit + +from docutils import nodes +from sphinx import addnodes +from sphinx.errors import ExtensionError +from sphinx.util import logging +from sphinx.util.matching import Matcher +from sphinx_llm.markdown_builder import SphinxLlmMarkdownBuilder, SphinxLlmMarkdownTranslator +from sphinx_markdown_builder.contexts import IndentContext, SubContext, SubContextParams, TableContext + +from sphinx_exts.redirects import iter_redirects + +if TYPE_CHECKING: + from sphinx.application import Sphinx + from sphinx.config import Config + from sphinx.domains.std import StandardDomain + from sphinx.environment import BuildEnvironment + +logger = logging.getLogger(__name__) + +MARKDOWN_BUILDER = SphinxLlmMarkdownBuilder.name +# Pages whose first paragraph is shorter than this get no description rather than a fragment. +MIN_DESCRIPTION_LENGTH = 20 +MAX_DESCRIPTION_LENGTH = 240 +_SENTENCE_END = re.compile(r"(?<=[.!?])\s+(?=[A-Z`])") +# Paragraphs under these are not the page's own opening prose. +_NOT_PROSE = ( + nodes.Admonition, + nodes.comment, + nodes.compound, + nodes.list_item, + nodes.system_message, + nodes.table, + nodes.topic, + addnodes.desc, +) +# llms.txt is built from the HTML build's doctrees, where Sphinx has already curled the quotes. +# The files are served without a charset, so keep them ASCII (see SMARTQUOTES_EXCLUDES). +_TYPOGRAPHY = str.maketrans( + { + "\u2018": "'", + "\u2019": "'", + "\u201c": '"', + "\u201d": '"', + "\u2013": "-", + "\u2014": "-", + "\u2026": "...", + "\u00a0": " ", + } +) +_INLINE_CODE = re.compile(r"(`[^`]*`)") +_MARKDOWN_LINK = re.compile(r"(\]\()([^)\s]+)(\))") + + +class CompactTableContext(TableContext): + """ + A pipe table without the column padding. + + ``tabulate`` pads every cell to the widest one in its column, which is pure whitespace to a + model reading the file: about a tenth of ``llms-full.txt`` before this. + """ + + def make(self) -> str: + ctx = SubContext() + prefix = self.internal_context.make() + if prefix: + ctx.add(prefix) + rows = [self.make_row(row) for row in [*self.headers, *self.body]] + if rows: + width = max(len(row) for row in rows) + lines = [ + "| " + + " | ".join(cell.replace("|", r"\|") for cell in [*row, *[""] * (width - len(row))]) + + " |" + for row in rows + ] + lines.insert(1, "|" + "---|" * width) + ctx.add("\n".join(lines), prefix_eol=2) + return ctx.make() + + +class AirflowMarkdownTranslator(SphinxLlmMarkdownTranslator): + """Render the Sphinx nodes Airflow docs use that sphinx-markdown-builder skips or misrenders.""" + + def _push_box(self, title: str) -> None: + # Admonitions become a quoted block that starts with their label, rather than an + # ``#### NOTE`` heading that breaks the heading order and never says where the note ends. + self._push_context(IndentContext("> ", empty=True, params=SubContextParams(2, 2))) + self.add(f"**{title.capitalize()}:**", suffix_eol=2) + + def _fetch_ref_uri(self, node: nodes.reference) -> str: + # A link to a section on the same page (```Confidence`_``) has a refid but no refuri and + # no ``internal`` flag, so the base translators leave its target empty. + if node.get("refid") and not node.get("refuri"): + return self.builder.link_token(self.builder.current_doc_name, node["refid"]) + return super()._fetch_ref_uri(node) + + def visit_meta(self, node: nodes.Element) -> None: + raise nodes.SkipNode + + def visit_toctree(self, node: addnodes.toctree) -> None: + # Hidden toctrees can reach the writer unresolved; like HTML, show nothing for them. + # Visible ones are resolved into link lists before this, so any left are unexpected. + if not node.get("hidden"): + super().unknown_visit(node) + raise nodes.SkipNode + + def visit_table(self, node: nodes.Element) -> None: + self._push_context(CompactTableContext(params=SubContextParams(2, 1))) + + def depart_table(self, node: nodes.Element) -> None: + self._pop_context(node) + + def visit_mermaid(self, node: nodes.Element) -> None: + self.add(f"```mermaid\n{node['code'].strip()}\n```", prefix_eol=2, suffix_eol=2) + raise nodes.SkipNode + + def visit_caption(self, node: nodes.Element) -> None: + self.add(f"**{node.astext()}**", prefix_eol=2, suffix_eol=1) + raise nodes.SkipNode + + # ``abbreviation`` wraps the bare ``*`` that marks keyword-only parameters in signatures. + def visit_abbreviation(self, node: nodes.Element) -> None: + pass + + def depart_abbreviation(self, node: nodes.Element) -> None: + pass + + def visit_title(self, node: nodes.Element) -> None: + # Render navigation captions in bold rather than as section headings. + parent = node.parent + if (isinstance(parent, addnodes.compact_paragraph) and parent.get("toctree")) or ( + isinstance(parent, nodes.compound) and "toctree-wrapper" in parent.get("classes", []) + ): + self.add(f"**{node.astext()}**", prefix_eol=2, suffix_eol=1) + raise nodes.SkipNode + + super().visit_title(node) + + def depart_title(self, node: nodes.Element) -> None: + self._pop_context(node) + + +def _escape_link_text(text: str) -> str: + return text.replace("[", r"\[").replace("]", r"\]") + + +def _inline_markdown(node: nodes.Node, env: BuildEnvironment) -> str: + """Flatten a title or paragraph to one line of Markdown, keeping inline code as code.""" + parts: list[str] = [] + for child in node.children: + if isinstance(child, addnodes.pending_xref) and not child.get("refexplicit"): + parts.append(_xref_title(child, env) or _inline_markdown(child, env)) + elif isinstance(child, nodes.literal): + parts.append(f"`{child.astext()}`") + elif isinstance(child, nodes.Text): + parts.append(child.astext()) + elif isinstance(child, nodes.Element): + parts.append(_inline_markdown(child, env)) + return re.sub(r"\s+", " ", "".join(parts)).strip().translate(_TYPOGRAPHY) + + +def _xref_title(xref: addnodes.pending_xref, env: BuildEnvironment) -> str | None: + """ + Return the title a ``:doc:`` or ``:ref:`` without explicit text resolves to. + + Doctrees read back from the environment are unresolved, so such a reference still holds its + raw target (``operators/llm``) rather than the page or section title the HTML shows. + """ + target = xref.get("reftarget", "") + if xref.get("reftype") == "doc": + refdoc = xref.get("refdoc", "") + docname = ( + target.lstrip("/") + if target.startswith("/") + else posixpath.join(posixpath.dirname(refdoc), target) + ) + title = env.titles.get(posixpath.normpath(docname)) + return _inline_markdown(title, env) if title else None + if xref.get("reftype") == "ref": + domain = cast("StandardDomain", env.get_domain("std")) + label = domain.labels.get(target.lower()) + return label[2] if label else None + return None + + +def _separate_markdown_doctrees(app: Sphinx, config: Config) -> None: + """Give the Markdown build its own doctree cache.""" Review Comment: **[nit]** The same applies here and to `_clean_markdown_doctree` (L273). The PoC's docstrings carried facts that these replacements no longer state: - `_separate_markdown_doctrees`: the new cache sits inside sphinx-llm's temporary `_markdown_build` directory, which is deleted after the copy. So the Markdown build never reuses a doctree. That is the intended behaviour, and it also means this function goes away once the sphinx-llm bug is fixed upstream. - `_clean_markdown_doctree`: `|version|` in a URL is not an RST substitution. `extra_provider_files_with_substitutions` only rewrites the built HTML files, which is why the Markdown build has to do the same on the doctree. These are workarounds for library behaviour, and that is exactly what comments are for. Please restore them. ########## uv.lock: ########## @@ -1,5 +1,5 @@ version = 1 -revision = 5 +revision = 3 Review Comment: **[warning]** The lock file carries about 560 lines of churn that this change doesn't need. The diff is +299/−267: - the `revision` field goes from 5 to 3; - `[options.exclude-newer-package]` is reordered; - resolution markers change on packages unrelated to this PR. When I regenerated it from `main`'s lock with only this PR's `pyproject.toml`, the result was +33/−1: the two new packages and the devel-common `docs` entries. `uv lock --check` passes either way, so this is not a correctness problem. It is a conflict magnet and hides the real change from reviewers. Please reset `uv.lock` to `main` and run `uv lock` again, ideally with a uv recent enough to keep `revision = 5`. ########## devel-common/src/docs/utils/example_source.py: ########## @@ -0,0 +1,43 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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 annotations + +import subprocess + +from docs.utils.conf_constants import AIRFLOW_REPO_ROOT_PATH + + +def example_source_ref(tag: str) -> str: + """Return HEAD if a remote-tracking ref or tag contains it; otherwise return the release tag.""" Review Comment: **[nit]** The move dropped the reason for the rule. The PoC's docstring explained why the function picks a ref: - `main` docs describe examples that the last release tag doesn't have yet. - The docs publish workflow cherry-picks fixes onto the tag, and that creates a HEAD that doesn't exist on GitHub. The new one-liner only repeats what the code does. In six months, nobody reading this will know why a local-only HEAD falls back to the tag. Please bring the reason back, either in the docstring or as a comment above the `for-each-ref` call. A comment that earns its place explains a nuance the code cannot show, such as a workaround or why the obvious approach is wrong ([PEP 8, *Comments*](https://peps.python.org/pep-0008/#comments)). (see also comments [2] and [4]) ########## devel-common/src/sphinx_exts/redirects.py: ########## @@ -28,67 +28,61 @@ log = logging.getLogger(__name__) -def generate_redirects(app): - """Generate redirects files.""" +def iter_redirects(app): + """Yield source and resolved destination paths for redirects.""" redirect_file_path = os.path.join(app.srcdir, app.config.redirects_file) if not os.path.exists(redirect_file_path): - log.info("Could not found the redirect file: %s", redirect_file_path) + log.info("Could not find the redirect file: %s", redirect_file_path) return in_suffix = next(iter(app.config.source_suffix.keys())) - if not isinstance(app.builder, builders.StandaloneHTMLBuilder): - return - with open(redirect_file_path) as redirects: for line in redirects: - # Skip empty line - if not line.strip(): - continue - - # Skip comments - if line.startswith("#"): + if not line.strip() or line.startswith("#"): continue - # Split line into the original path `from_path` and where the URL should redirect to `to_path` from_path, _, to_path = line.rstrip().partition(" ") log.debug("Redirecting '%s' to '%s'", from_path, to_path) - # in_suffix is often ".rst" from_path = from_path.replace(in_suffix, ".html") to_path = to_path.replace(in_suffix, ".html") - to_path_prefix = f"..{os.path.sep}" * (len(from_path.split(os.path.sep)) - 1) - # The redirect path needs to move back to the root of the apache-airflow docs directory - # or the root of the docs directory altogether for provider distributions. + # Preserve the existing handling of provider redirect paths. Review Comment: **[nit]** "Preserve the existing handling of provider redirect paths" replaces a comment that explained the `..` prefixes: they climb back to the root of the apache-airflow docs, or to the docs root for provider distributions. The new comment talks about the refactor, not the code, so the next reader can't tell why `"providers" in to_path` adds one extra `..`. Please restore the explanation. Dropping the always-true `"../" and` while keeping the behaviour is a good cleanup, and `test_iter_redirects` now pins that behaviour. ########## devel-common/tests/unit/sphinx_exts/test_airflow_llms_txt.py: ########## @@ -0,0 +1,640 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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 annotations + +import io +from pathlib import Path +from types import SimpleNamespace + +import pytest +from docutils import nodes +from sphinx import addnodes +from sphinx.application import Sphinx +from sphinx.errors import ExtensionError + +from sphinx_exts import airflow_llms_txt + + +def make_app(tmp_path, *, found_docs=()): + source = tmp_path / "source" + output = tmp_path / "output" + source.mkdir() + output.mkdir() + (source / "redirects.txt").write_text("", encoding="utf-8") + + return SimpleNamespace( + srcdir=source, + outdir=output, + builder=SimpleNamespace(name="html"), + env=SimpleNamespace(found_docs=set(found_docs)), + config=SimpleNamespace( + redirects_file="redirects.txt", + source_suffix={".rst": "restructuredtext"}, + llms_txt_enabled=True, + root_doc="index", + ), + ) + + [email protected]( + ("source", "target", "expected"), + [ + pytest.param( + "old.rst", + "new.rst", + "new.html.md", + id="normal", + ), + pytest.param( + "hooks/index.rst", + "concepts.rst", + "../concepts.html.md", + id="nested", + ), + pytest.param( + "old.rst", + "new.rst#section", + "new.html.md#section", + id="anchor", + ), + pytest.param( + "old.rst", + "new.rst?view=full#section", + "new.html.md?view=full#section", + id="query-and-anchor", + ), + pytest.param( + "old.rst", + "../other/new.rst", + "../other/new.html", + id="outside-package", + ), + pytest.param( + "old.rst", + "https://example.org/new.html", + "https://example.org/new.html", + id="external", + ), + ], +) +def test_write_markdown_redirects(tmp_path, source, target, expected): + app = make_app(tmp_path) + (app.srcdir / "redirects.txt").write_text(f"{source} {target}\n", encoding="utf-8") + + airflow_llms_txt._write_markdown_redirects(app) + + output = app.outdir / source.replace(".rst", ".html.md") + assert output.read_text(encoding="utf-8") == ( + f"# Page moved\n\nThis page has moved to [the new page](<{expected}>).\n" + ) + + +def test_redirect_preserves_actual_document(tmp_path): + app = make_app(tmp_path, found_docs={"old"}) + (app.srcdir / "redirects.txt").write_text("old.rst new.rst\n", encoding="utf-8") + output = app.outdir / "old.html.md" + output.write_text("# Actual documentation\n", encoding="utf-8") + + airflow_llms_txt._write_markdown_redirects(app) + + assert output.read_text(encoding="utf-8") == "# Actual documentation\n" + + +def test_redirect_updates_existing_stub(tmp_path): + app = make_app(tmp_path) + (app.srcdir / "redirects.txt").write_text("old.rst new.rst\n", encoding="utf-8") + output = app.outdir / "old.html.md" + output.write_text("Old redirect destination", encoding="utf-8") + + airflow_llms_txt._write_markdown_redirects(app) + + assert "(<new.html.md>)" in output.read_text(encoding="utf-8") + assert "Old redirect destination" not in output.read_text(encoding="utf-8") + + +def test_complete_markdown_coverage(tmp_path): + for name in ("index.html", "operators/llm.html"): + page = tmp_path / name + page.parent.mkdir(parents=True, exist_ok=True) + page.write_text("HTML", encoding="utf-8") + page.with_name(page.name + ".md").write_text("# Documentation", encoding="utf-8") + + airflow_llms_txt._check_markdown_coverage(tmp_path) + + +def test_missing_markdown_lists_all_missing_pages(tmp_path): + for name in ("index.html", "operators/llm.html"): + page = tmp_path / name + page.parent.mkdir(parents=True, exist_ok=True) + page.write_text("HTML", encoding="utf-8") + + with pytest.raises(ExtensionError) as exc: + airflow_llms_txt._check_markdown_coverage(tmp_path) + + assert str(exc.value) == ("HTML pages missing Markdown copies:\n index.html\n operators/llm.html") + + [email protected]( + "name", + [ + "genindex.html", + "search.html", + "py-modindex.html", + "_modules/index.html", + "_modules/airflow/providers/example.html", + ], +) +def test_coverage_excludes_special_pages(tmp_path, name): + page = tmp_path / name + page.parent.mkdir(parents=True, exist_ok=True) + page.write_text("HTML", encoding="utf-8") + + airflow_llms_txt._check_markdown_coverage(tmp_path) + + +def test_missing_root_markdown_fails_build(tmp_path): + app = make_app(tmp_path, found_docs={"index"}) + (app.outdir / "index.html").write_text("HTML", encoding="utf-8") + + with pytest.raises(ExtensionError, match="index.html"): + airflow_llms_txt._write_llms_txt(app, None) + + [email protected]( + ("builder", "enabled", "exception"), + [ + pytest.param("html", True, RuntimeError("Build failed"), id="failed-build"), + pytest.param("html", False, None, id="disabled"), + pytest.param( + airflow_llms_txt.MARKDOWN_BUILDER, + True, + None, + id="markdown-build", + ), + ], +) +def test_index_generation_skipped(tmp_path, builder, enabled, exception): + app = make_app(tmp_path) + app.builder.name = builder + app.config.llms_txt_enabled = enabled + (app.outdir / "index.html").write_text("HTML", encoding="utf-8") + + airflow_llms_txt._write_llms_txt(app, exception) + + assert not (app.outdir / "llms.txt").exists() + assert not (app.outdir / "llms-full.txt").exists() + + +def test_indexes_follow_navigation_and_use_versioned_urls(tmp_path): + names = {"index", "guide", "second", "changelog", "commits"} + app = make_app(tmp_path, found_docs=names) + site_url = "https://airflow.apache.org/docs/apache-airflow-providers-common-ai/0.10.0" + + root = nodes.document("", "") + navigation = addnodes.toctree() + navigation["caption"] = "Guides" + navigation["entries"] = [ + ("Getting started", "guide"), + (None, "second"), + (None, "changelog"), + (None, "commits"), + ] + root += navigation + + doctrees = {"index": root} + for name in names - {"index"}: + tree = nodes.document("", "") + tree += nodes.paragraph("", f"This is the documentation description for {name}.") + doctrees[name] = tree + + app.env.titles = {name: nodes.title("", name.capitalize()) for name in names} + app.env.toctree_includes = {} + app.env.get_doctree = doctrees.__getitem__ + + app.config.llms_txt_site_url = site_url + "/" + app.config.llms_txt_optional_docs = ["changelog"] + app.config.llms_txt_skip_docs = ["commits"] + app.config.project = "Common AI" + app.config.release = "0.10.0" + app.config.llms_txt_description = "Common AI documentation." + app.config.llms_txt_intro = "Install the Common AI provider." + app.config.llms_txt_root_description = "" + + for name in names: + (app.outdir / f"{name}.html").write_text("HTML", encoding="utf-8") + content = f"# {name.capitalize()}\n" + if name == "guide": + content += "[Next](second.html.md)\n" + (app.outdir / f"{name}.html.md").write_text(content, encoding="utf-8") + + airflow_llms_txt._write_llms_txt(app, None) + + index = (app.outdir / "llms.txt").read_text(encoding="utf-8") + full = (app.outdir / "llms-full.txt").read_text(encoding="utf-8") + + assert index.startswith("# Common AI 0.10.0\n") + assert "Install the Common AI provider." in index + assert "## Guides" in index + assert ( + f"- [Getting started]({site_url}/guide.html.md): This is the documentation description for guide." + ) in index + assert index.index("/guide.html.md") < index.index("/second.html.md") + + optional = index.split("## Optional\n", 1)[1] + assert f"- [Changelog]({site_url}/changelog.html.md)" in optional + assert f"[llms-full.txt]({site_url}/llms-full.txt)" in optional + assert "/commits.html.md" not in index + + assert full.index("Source: " + site_url + "/index.html.md") < full.index( + "Source: " + site_url + "/guide.html.md" + ) + assert full.index("Source: " + site_url + "/guide.html.md") < full.index( + "Source: " + site_url + "/second.html.md" + ) + assert f"[Next]({site_url}/second.html.md)" in full + assert "Source: " + site_url + "/changelog.html.md" not in full + assert "Source: " + site_url + "/commits.html.md" not in full + + [email protected]( + ("inside_repo", "source_url", "builder"), + [ + pytest.param( + True, "https://github.com/apache/airflow/blob/abc", airflow_llms_txt.MARKDOWN_BUILDER, id="linked" + ), + pytest.param( + False, + "https://github.com/apache/airflow/blob/abc", + airflow_llms_txt.MARKDOWN_BUILDER, + id="outside-repo", + ), + pytest.param(True, "", airflow_llms_txt.MARKDOWN_BUILDER, id="no-source-url"), + pytest.param(True, "https://github.com/apache/airflow/blob/abc", "html", id="html-unchanged"), + ], +) +def test_example_source_links(tmp_path, monkeypatch, inside_repo, source_url, builder): + monkeypatch.syspath_prepend(str(Path(airflow_llms_txt.__file__).parent)) + + from exampleinclude import ExampleHeader + + repo = tmp_path / "repo" + example = ( + repo / "providers/common/ai/tests/system/example.py" + if inside_repo + else tmp_path / "outside/example.py" + ) + tree = nodes.document("", "") + header = ExampleHeader() + header["filename"] = str(example) + tree += header + + app = SimpleNamespace( + builder=SimpleNamespace(name=builder), + config=SimpleNamespace( + llms_txt_repo_root=str(repo), + llms_txt_source_url=source_url + "/" if source_url else "", + ), + ) + + airflow_llms_txt._link_example_sources(app, tree) + + if builder == "html": + assert tree.children == [header] + elif not inside_repo or not source_url: + assert not tree.children + else: + assert not list(tree.findall(ExampleHeader)) + references = list(tree.findall(nodes.reference)) + assert len(references) == 1 + assert references[0]["refuri"] == (source_url + "/providers/common/ai/tests/system/example.py") + assert references[0].astext() == ("providers/common/ai/tests/system/example.py") + assert tree.astext() == ("Excerpt from providers/common/ai/tests/system/example.py:") + + [email protected]( + ("url", "expected"), + [ + pytest.param( + "https://example.org/docs/|version|/guide", + "https://example.org/docs/0.10.0/guide", + id="version", + ), + pytest.param( + "https://github.com/apache/airflow/blob/release-tag/example.py", + "https://github.com/apache/airflow/blob/head-sha/example.py", + id="github-file", + ), + pytest.param( + "https://github.com/apache/airflow/tree/release-tag/examples", + "https://github.com/apache/airflow/tree/head-sha/examples", + id="github-directory", + ), + pytest.param( + "/docs/apache-airflow/stable/index.html", + "https://airflow.apache.org/docs/apache-airflow/stable/index.html", + id="site-root", + ), + pytest.param( + "//example.org/page", + "//example.org/page", + id="protocol-relative-unchanged", + ), + pytest.param( + "concepts.html.md#section", + "concepts.html.md#section", + id="relative-unchanged", + ), + pytest.param( + "https://example.org/page", + "https://example.org/page", + id="external-unchanged", + ), + ], +) +def test_clean_markdown_link_targets(url, expected): + tree = nodes.document("", "") + reference = nodes.reference("", "Link", refuri=url) + tree += reference + + app = SimpleNamespace( + builder=SimpleNamespace(name=airflow_llms_txt.MARKDOWN_BUILDER), + config=SimpleNamespace( + version="0.10.0", + llms_txt_site_url=("https://airflow.apache.org/docs/common-ai/0.10.0/"), + llms_txt_release_source_url=("https://github.com/apache/airflow/blob/release-tag/"), + llms_txt_source_url=("https://github.com/apache/airflow/blob/head-sha/"), + ), + ) + + airflow_llms_txt._clean_markdown_doctree(app, tree, "index") + + assert reference["refuri"] == expected + + +def test_clean_markdown_removes_comments_and_empty_quotes(): + tree = nodes.document("", "") + tree += nodes.comment("", "License header") + tree += nodes.block_quote() + populated_quote = nodes.block_quote() + populated_quote += nodes.paragraph("", "Keep this quotation.") + tree += populated_quote + + app = SimpleNamespace( + builder=SimpleNamespace(name=airflow_llms_txt.MARKDOWN_BUILDER), + config=SimpleNamespace( + llms_txt_site_url="https://airflow.apache.org/", + llms_txt_release_source_url="", + llms_txt_source_url="", + ), + ) + + airflow_llms_txt._clean_markdown_doctree(app, tree, "index") + + assert not list(tree.findall(nodes.comment)) + assert list(tree.findall(nodes.block_quote)) == [populated_quote] + assert tree.astext() == "Keep this quotation." + + +def test_clean_doctree_leaves_html_unchanged(): + tree = nodes.document("", "") + comment = nodes.comment("", "License header") + reference = nodes.reference("", "Link", refuri="/docs/|version|/") + tree += comment + tree += reference + app = SimpleNamespace(builder=SimpleNamespace(name="html")) + + airflow_llms_txt._clean_markdown_doctree(app, tree, "index") + + assert comment in tree.children + assert reference["refuri"] == "/docs/|version|/" + + [email protected]( + ("markdown", "expected"), + [ + pytest.param( + "[Next](other.html.md#section)", + "[Next](https://example.org/docs/other.html.md#section)", + id="relative-link", + ), + pytest.param( + "[Parent](../index.html.md)", + "[Parent](https://example.org/index.html.md)", + id="parent-link", + ), + pytest.param( + "[Section](#setup)", + "[Section](https://example.org/docs/page.html.md#setup)", + id="same-page-link", + ), + pytest.param( + "[External](https://other.example/page)", + "[External](https://other.example/page)", + id="external-link", + ), + pytest.param( + "`[Example](other.html.md)` [Next](other.html.md)", + "`[Example](other.html.md)` [Next](https://example.org/docs/other.html.md)", + id="inline-code", + ), + pytest.param( + "```python\n[Example](other.html.md)\n```\n[Next](other.html.md)", + "```python\n[Example](other.html.md)\n```\n[Next](https://example.org/docs/other.html.md)", + id="fenced-code", + ), + ], +) +def test_absolute_links(markdown, expected): + assert airflow_llms_txt._absolute_links(markdown, "https://example.org/docs/page.html.md") == expected + + +def test_navigation_handles_nested_pages_duplicates_and_cycles(): + env = SimpleNamespace( + toctree_includes={ + "index": ["guide", "second", "guide"], + "guide": ["child", "second"], + "child": ["index"], + } + ) + seen = set() + + assert airflow_llms_txt._toctree_docs(env, "index", seen) == [ + ("index", None), + ("guide", None), + ("child", None), + ("second", None), + ] + assert airflow_llms_txt._toctree_docs(env, "guide", seen) == [] + + [email protected]("markdown_build", [True, False]) +def test_markdown_doctree_cache_is_separate(tmp_path, markdown_build): + original = tmp_path / "html-doctrees" + app = SimpleNamespace( + outdir=tmp_path / "markdown-output", + doctreedir=original, + tags=SimpleNamespace(has=lambda tag: markdown_build and tag == "sphinx_llm_markdown"), + ) + + airflow_llms_txt._separate_markdown_doctrees(app, SimpleNamespace()) + + expected = app.outdir / ".doctrees" if markdown_build else original + assert app.doctreedir == expected + + [email protected] +def rendered_markdown(tmp_path, monkeypatch): + source = tmp_path / "source" + output = tmp_path / "output" + source.mkdir() + + # The extension imports redirects and exampleinclude by bare module name. + extension_dir = Path(airflow_llms_txt.__file__).parent + source_root = extension_dir.parent + + (source / "conf.py").write_text( + "import sys\n" + f"sys.path.insert(0, {str(source_root)!r})\n" + f"sys.path.insert(0, {str(extension_dir)!r})\n" + "extensions = [\n" + ' "redirects",\n' + ' "sphinx_exts.airflow_llms_txt",\n' + ' "sphinxcontrib.mermaid",\n' + "]\n" + 'root_doc = "index"\n' + 'redirects_file = "redirects.txt"\n' + "llms_txt_enabled = True\n" + 'llms_txt_suffix_mode = "append"\n' + "llms_txt_build_parallel = False\n", + encoding="utf-8", + ) + (source / "redirects.txt").write_text("", encoding="utf-8") + (source / "index.rst").write_text( + """\ +Rendering +========= + +.. _setup-section: + +Setup +----- + +See `Setup`_. + +.. note:: + + Important configuration advice. + +Following paragraph outside the note. + +.. list-table:: + :header-rows: 1 + + * - Name + - Value + * - Short + - A much longer value + +.. code-block:: python + :caption: Example code + + print("hello") + +.. mermaid:: + + graph TD + A --> B + +.. py:function:: configure(*, model_id) + + Configure a model. + +.. toctree:: + :caption: Further reading + + child +""", + encoding="utf-8", + ) + (source / "child.rst").write_text( + "Child page\n==========\n\nChild documentation.\n", + encoding="utf-8", + ) + + warnings = io.StringIO() + app = Sphinx( + srcdir=str(source), + confdir=str(source), + outdir=str(output), + doctreedir=str(tmp_path / "doctrees"), + buildername="html", + status=io.StringIO(), + warning=warnings, + freshenv=True, + ) + app.build(force_all=True) + + assert app.statuscode == 0, warnings.getvalue() + pages = list(output.rglob("index*.md")) + assert len(pages) == 1 + return pages[0].read_text(encoding="utf-8") + + +def test_rendered_mermaid(rendered_markdown): + assert "```mermaid\n" in rendered_markdown + assert "graph TD\n" in rendered_markdown + assert "A --> B" in rendered_markdown + + +def test_rendered_code_caption_and_sample(rendered_markdown): + assert "**Example code**" in rendered_markdown + assert 'print("hello")' in rendered_markdown + assert rendered_markdown.index("**Example code**") < (rendered_markdown.index('print("hello")')) + + +def test_rendered_keyword_only_marker(rendered_markdown): + signature = next(line for line in rendered_markdown.splitlines() if "configure(" in line) + assert "*" in signature + assert "model_id" in signature + + +def test_rendered_toctree_caption(rendered_markdown): + assert "**Further reading**" in rendered_markdown + assert "[Child page]" in rendered_markdown + assert "# Further reading" not in rendered_markdown + + +def test_rendered_admonition_has_clear_boundaries(rendered_markdown): + lines = rendered_markdown.splitlines() + assert any(line.startswith(">") and "**Note:**" in line for line in lines) + assert any(line.startswith(">") and "Important configuration advice." in line for line in lines) + following = next(line for line in lines if "Following paragraph outside the note." in line) + assert not following.startswith(">") + + +def test_rendered_table_is_compact(rendered_markdown): + table_lines = [line.strip() for line in rendered_markdown.splitlines() if line.strip().startswith("|")] + assert "| Name | Value |" in table_lines + assert "|---|---|" in table_lines + assert "| Short | A much longer value |" in table_lines + + +def test_rendered_same_page_link(rendered_markdown): + assert "[Setup]()" not in rendered_markdown + assert "[Setup]" in rendered_markdown + assert "#setup-section" in rendered_markdown or "#setup" in rendered_markdown Review Comment: **[nit]** This assertion can't tell the two anchors apart. `"#setup" in rendered_markdown` is true whenever `"#setup-section"` is, so the `or` accepts either anchor. It would also pass on any other text that contains `#setup`. Please assert the exact link the translator renders, for example `"[Setup](#setup-section)"` or whatever the build produces. That way the test fails if `_fetch_ref_uri` stops resolving the `refid`. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
