From a6aa8d913178361f427a9e8496055d8e5fc4a961 Mon Sep 17 00:00:00 2001 From: parashardhapola Date: Tue, 27 Oct 2026 07:50:13 +0100 Subject: [PATCH] Unify API navigation and complete documentation metadata --- docs/build_docs.py | 413 +++++++++++++++--- .../api/_templates/docs-navigation.html | 5 + .../reference/api/_templates/layout.html | 29 ++ docs/source/reference/api/conf.py | 9 +- tests/test_documentation.py | 330 ++++++++++++++ 5 files changed, 733 insertions(+), 53 deletions(-) create mode 100644 docs/source/reference/api/_templates/docs-navigation.html create mode 100644 docs/source/reference/api/_templates/layout.html diff --git a/docs/build_docs.py b/docs/build_docs.py index 0fa25c9..6d896f2 100644 --- a/docs/build_docs.py +++ b/docs/build_docs.py @@ -21,8 +21,12 @@ import shutil import subprocess import zlib +from html import escape, unescape +from html.parser import HTMLParser from pathlib import Path from typing import Any +from urllib.parse import urljoin, urlsplit +from xml.etree import ElementTree import page_index @@ -31,6 +35,7 @@ BUILD = DOCS / "build" SITE = BUILD / "site" API = BUILD / "api" +PUBLIC_URL = "https://docs.nygen.io/CytePool" def code_fingerprint(notebook: dict[str, Any]) -> str: @@ -253,6 +258,37 @@ def stage_site() -> Path: notebook = json.loads(path.read_text()) check_execution(notebook, page) check_figures(notebook, page) + markdown = next( + cell for cell in notebook["cells"] if cell["cell_type"] == "markdown" + ) + prose = _text(markdown["source"]) + frontmatter = re.match(r"\A---\n(.*?)\n---\n", prose, re.DOTALL) + metadata = (yaml.safe_load(frontmatter[1]) or {}) if frontmatter else {} + if not metadata.get("description"): + body = prose[frontmatter.end() :] if frontmatter else prose + introduction = re.sub(r"(?m)^(?:#+\s.*|\([^)]+\)=)\s*$", "", body).strip() + summary = re.split(r"\n\s*\n", introduction, maxsplit=1)[0] + summary = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", summary) + summary = re.sub(r"\[@[^\]]+\]|\{[\w:]+\}", "", summary) + summary = " ".join(summary.translate(str.maketrans("", "", "`*")).split()) + summary = re.sub(r"\s+([,.;:!?])", r"\1", summary) + if not summary: + raise ValueError(f"No introductory paragraph in {page}") + sentences = re.split(r"(?<=[.!?])\s+", summary) + description = sentences.pop(0) + for sentence in sentences: + if len(description) + len(sentence) + 1 > 300: + break + description += " " + sentence + if len(description) > 300: + description = description[:297].rsplit(" ", 1)[0].rstrip(".,;:") + "…" + metadata["description"] = description + markdown["source"] = ( + "---\n" + + yaml.safe_dump(metadata, sort_keys=False) + + "---\n\n" + + body.lstrip() + ) for cell in _code_cells(notebook): descriptions = cell.get("metadata", {}).get("figure_descriptions", []) for output, description in zip(_figures(cell), descriptions, strict=True): @@ -262,6 +298,185 @@ def stage_site() -> Path: return SITE +def navigation_head_html() -> str: + return """ + +""" + + +def html_fragment(contents: str, class_name: str) -> str: + class Fragment(HTMLParser): + def __init__(self) -> None: + super().__init__() + self.lines = [0, *(match.end() for match in re.finditer("\n", contents))] + self.depth = 0 + self.start = 0 + self.matches: list[str] = [] + + def position(self) -> int: + line, column = self.getpos() + return self.lines[line - 1] + column + + def handle_starttag( + self, tag: str, attrs: list[tuple[str, str | None]] + ) -> None: + if self.depth: + self.depth += tag not in { + "area", + "base", + "br", + "col", + "embed", + "hr", + "img", + "input", + "link", + "meta", + "param", + "source", + "track", + "wbr", + } + elif class_name in (dict(attrs).get("class") or "").split(): + self.start = self.position() + self.depth = 1 + + def handle_startendtag( + self, tag: str, attrs: list[tuple[str, str | None]] + ) -> None: + pass + + def handle_endtag(self, tag: str) -> None: + if not self.depth: + return + self.depth -= 1 + if not self.depth: + end = contents.index(">", self.position()) + 1 + self.matches.append(contents[self.start : end]) + + parser = Fragment() + parser.feed(contents) + if parser.depth or len(parser.matches) != 1: + raise ValueError(f"Expected one complete .{class_name} element") + return parser.matches[0] + + +def include_api_navigation(site: Path) -> None: + import yaml + + base_url = os.environ.get("BASE_URL", "").rstrip("/") + source = DOCS / "build" / "api" / "index.html" + navigation = html_fragment(source.read_text(), "docs-api-tree") + links = [] + for href, title in re.findall( + r']*\bhref="([^"]+)"[^>]*>(.*?)', navigation, re.DOTALL + ): + href = unescape(href) + if urlsplit(href).scheme or href.startswith("//") or not href.endswith(".html"): + raise ValueError(f"Expected an API page link in {source}: {href}") + links.append( + { + "title": " ".join(unescape(re.sub(r"<[^>]+>", "", title)).split()), + "url": urljoin(f"{base_url}/api/", href), + "open_in_same_tab": True, + } + ) + if not links: + raise ValueError(f"No API navigation links in {source}") + config = yaml.safe_load((site / "myst.yml").read_text()) + entries = list(config["project"]["toc"]) + matches = [] + while entries: + entry = entries.pop() + if entry.get("url") == f"{base_url}/api/index.html": + matches.append(entry) + entries.extend(entry.get("children", [])) + if len(matches) != 1: + raise ValueError(f"Expected one API reference entry in {site / 'myst.yml'}") + matches[0]["children"] = links + (site / "myst.yml").write_text(yaml.safe_dump(config, sort_keys=False)) + + +def integrate_api_navigation(html: Path) -> None: + def static_classes(match: re.Match[str]) -> str: + classes = [ + token + for token in match[1].split() + if ( + token.startswith("myst-") + or token in {"collapsible-content", "sr-only", "hide-mac", "show-mac"} + ) + and token not in {"myst-toc-item-exact", "myst-search-bar-disabled"} + ] + if "myst-search-bar" in classes: + classes.append("search-button__button") + if "myst-top-nav-menu-button" in classes: + classes.extend(("sidebar-toggle", "primary-toggle")) + return f'class="{" ".join(classes)}"' + + home = (html / "index.html").read_text() + header = html_fragment(home, "myst-top-nav-bar") + header = re.sub(r"]*>.*?", "", header, flags=re.DOTALL) + header = re.sub(r'\bclass="([^"]*)"', static_classes, header) + header, count = re.subn( + r']*\bclass="[^"]*\bmyst-search-bar\b[^"]*"[^>]*>', + lambda match: ( + re.sub( + r' (?:aria-expanded|data-state|aria-label|aria-controls)="[^"]*"', + "", + match[0][:-1], + ) + + ' aria-label="Search documentation" aria-controls="pst-search-dialog">' + ), + header, + ) + if count != 1: + raise ValueError("Expected one MyST search button in the guide header") + navigation = html_fragment(home, "myst-primary-sidebar-toc") + navigation = re.sub(r' aria-current="[^"]*"', "", navigation) + navigation = re.sub(r'\bclass="([^"]*)"', static_classes, navigation) + base_url = os.environ.get("BASE_URL", "").rstrip("/") + for page in (html / "api").glob("*.html"): + contents = page.read_text() + route = f"{base_url}/{page.relative_to(html).as_posix()}" + + def mark_current(match: re.Match[str]) -> str: + tag = match[0] + href = re.search(r'\bhref="([^"]+)"', tag) + return ( + tag[:-1] + ' aria-current="page">' + if href and unescape(href[1]) == route + else tag + ) + + current_navigation = re.sub(r"]*>", mark_current, navigation) + contents = contents.replace( + html_fragment(contents, "docs-header-placeholder"), header + ) + contents = contents.replace( + html_fragment(contents, "docs-api-tree"), current_navigation + ) + if contents.count("") != 1: + raise ValueError(f"Expected one HTML head in {page}") + page.write_text( + contents.replace("", navigation_head_html() + "", 1) + ) + + def link_shared_brand_assets(html: Path) -> None: import yaml @@ -355,29 +570,47 @@ def prepare_site_template(site: Path) -> None: check=True, ) replacements = { - "build/index.js": ( - 'Pje.default.useState(i);(0,Pje.useEffect)(()=>{o.state==="idle"' - "&&s(tzt([t],e,r).includes(t.id))}", - 'Pje.default.useState(!0);(0,Pje.useEffect)(()=>{o.state==="idle"&&s(!0)}', - ), - "public/build/_shared/chunk-LS4ALTVG.js": ( - '_0.default.useState(o);(0,_0.useEffect)(()=>{i.state==="idle"' - "&&a(DS([t],e,r).includes(t.id))}", - '_0.default.useState(!0);(0,_0.useEffect)(()=>{i.state==="idle"&&a(!0)}', - ), + "build/index.js": [ + ( + 'Pje.default.useState(i);(0,Pje.useEffect)(()=>{o.state==="idle"' + "&&s(tzt([t],e,r).includes(t.id))}", + 'Pje.default.useState(!0);(0,Pje.useEffect)(()=>{o.state==="idle"&&s(!0)}', + ), + ( + 'title:o!=null&&o.title?`${o.title}${a?` - ${a}`:""}`:a', + 'title:o!=null&&o.title?o.title===a?`${a} documentation`:`${o.title}${a?` - ${a}`:""}`:a', + ), + ], + "public/build/_shared/chunk-LS4ALTVG.js": [ + ( + '_0.default.useState(o);(0,_0.useEffect)(()=>{i.state==="idle"' + "&&a(DS([t],e,r).includes(t.id))}", + '_0.default.useState(!0);(0,_0.useEffect)(()=>{i.state==="idle"&&a(!0)}', + ), + ], + "public/build/_shared/chunk-WGW5MSUV.js": [ + ( + 'title:m!=null&&m.title?`${m.title}${g?` - ${g}`:""}`:g', + 'title:m!=null&&m.title?m.title===g?`${g} documentation`:`${m.title}${g?` - ${g}`:""}`:g', + ), + ], } fingerprint = hashlib.sha256( json.dumps(replacements, sort_keys=True).encode() ).hexdigest()[:12] - for filename, (original, expanded) in replacements.items(): + for filename, rules in replacements.items(): path = template / filename if not path.is_file() and filename.startswith("public/"): - path = path.with_stem(f"{path.stem}-{fingerprint}") - content = path.read_text() - if content.count(original) == 1 and expanded not in content: - path.write_text(content.replace(original, expanded)) - elif original in content or content.count(expanded) != 1: - raise ValueError(f"Unexpected navigation component in {path}") + matches = list(path.parent.glob(f"{path.stem}-*.js")) + if len(matches) != 1: + raise ValueError(f"Expected one cached theme module for {path}") + path = matches[0] + for original, replacement in rules: + content = path.read_text() + if content.count(original) == 1 and replacement not in content: + path.write_text(content.replace(original, replacement)) + elif original in content or content.count(replacement) != 1: + raise ValueError(f"Unexpected theme component in {path}") modules = list((template / "public" / "build").rglob("*.js")) filenames = { path.name: f"{path.stem}-{fingerprint}.js" @@ -400,18 +633,113 @@ def prepare_site_template(site: Path) -> None: path.rename(path.with_name(filenames[path.name])) -def set_crawler_origin(html: Path) -> None: - sitemap = html / "sitemap.xml" - origin = re.search(r"(https?://[^<]+)", sitemap.read_text()) - if origin is None: - raise ValueError(f"Expected a sitemap URL in {sitemap}") - for name in ("sitemap.xml", "robots.txt"): - path = html / name - path.write_text( - path.read_text().replace( - origin[1].rstrip("/"), "https://docs.nygen.io/CytePool" - ) - ) +def update_page_metadata(contents: str, canonical: str) -> str: + class Description(HTMLParser): + def __init__(self) -> None: + super().__init__() + self.values: list[str] = [] + + def handle_starttag( + self, tag: str, attrs: list[tuple[str, str | None]] + ) -> None: + attributes = dict(attrs) + if tag == "meta" and attributes.get("name") == "description": + self.values.append(attributes.get("content") or "") + + if contents.count("") != 1: + raise ValueError(f"Expected one HTML head for {canonical}") + head, body = contents.split("", 1) + titles = re.findall(r"(.*?)", head, re.DOTALL) + description = Description() + description.feed(head) + if len(titles) != 1 or len(description.values) != 1 or not description.values[0]: + raise ValueError(f"Expected a title and description for {canonical}") + title = unescape(titles[0]) + tool = PUBLIC_URL.rsplit("/", 1)[-1] + parts = re.split(r" (?:-|—) ", title) + if len(parts) == 2 and ( + parts[0] == parts[1] + or canonical == f"{PUBLIC_URL}/api/index.html" + and parts[1] == f"{tool} API reference" + ): + title = parts[0] + if canonical == f"{PUBLIC_URL}/" and title == tool: + title = f"{tool} documentation" + if ( + canonical == f"{PUBLIC_URL}/api/{tool.lower()}.html" + and title == f"{tool} API reference" + ): + title = f"{tool} Python API" + image = f"https://docs.nygen.io/assets/brand/logo/{tool.lower()}-dark.png" + metadata = { + "og:title": title, + "og:description": description.values[0], + "og:type": "website", + "og:url": canonical, + "og:image": image, + "og:image:type": "image/png", + "og:image:width": "512", + "og:image:height": "512", + "og:image:alt": f"{tool} logo", + "twitter:card": "summary", + "twitter:image": image, + "twitter:image:alt": f"{tool} logo", + } + + def remove_managed(match: re.Match[str]) -> str: + key = re.search(r"""\b(?:name|property)=["']([^"']+)["']""", match[0]) + return "" if key and key[1] in metadata else match[0] + + head = re.sub(r"]*>", remove_managed, head) + head = re.sub( + r".*?", + lambda _: f"{escape(title)}", + head, + count=1, + flags=re.DOTALL, + ) + for name, value in metadata.items(): + attribute = "property" if name.startswith("og:") else "name" + head += f'' + return head + "" + body + + +def write_discovery_metadata(html: Path) -> None: + base_url = os.environ.get("BASE_URL", "").rstrip("/") + references = json.loads((html / "myst.xref.json").read_text())["references"] + routes = { + reference["url"].removeprefix(base_url).rstrip("/") + "/" + for reference in references + if reference["kind"] == "page" + } + if not routes: + raise ValueError(f"No documentation pages in {html / 'myst.xref.json'}") + sources = sorted((html / "api" / "_sources").glob("*.md")) + if not sources: + raise ValueError(f"No API source pages in {html / 'api' / '_sources'}") + routes.update(f"/api/{source.stem}.html" for source in sources) + sitemap = ElementTree.Element( + "urlset", xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" + ) + for route in sorted(routes): + page = html / route.lstrip("/") + if route.endswith("/"): + page /= "index.html" + canonical = f"{PUBLIC_URL}{route}" + contents = update_page_metadata(page.read_text(), canonical) + head, body = contents.split("", 1) + head = re.sub(r"""]*\brel=["']canonical["'])[^>]*>""", "", head) + head += f'' + page.write_text(head + "" + body) + entry = ElementTree.SubElement(sitemap, "url") + ElementTree.SubElement(entry, "loc").text = canonical + ElementTree.indent(sitemap) + ElementTree.ElementTree(sitemap).write( + html / "sitemap.xml", encoding="utf-8", xml_declaration=True + ) + (html / "robots.txt").write_text( + f"User-agent: *\nAllow: /\n\nSitemap: {PUBLIC_URL}/sitemap.xml\n" + ) def main() -> None: @@ -421,6 +749,7 @@ def main() -> None: site = stage_site() except ValueError as error: raise SystemExit(str(error)) from None + include_api_navigation(site) prepare_site_template(site) subprocess.run(["myst", "build", "--html", "--strict"], cwd=site, check=True) html = site / "_build" / "html" @@ -430,28 +759,7 @@ def main() -> None: ) except ValueError as error: raise SystemExit(str(error)) from None - script_url = "https://docs.nygen.io/assets/brand/docs.js" - head = ( - '' - ) - head += """""" - head += f'' + head = navigation_head_html() for page in html.rglob("*.html"): contents = page.read_text() if contents.count("") != 1: @@ -464,7 +772,8 @@ def main() -> None: dirs_exist_ok=True, ) link_shared_brand_assets(html) - set_crawler_origin(html) + integrate_api_navigation(html) + write_discovery_metadata(html) print(f"Documentation written to {html}") diff --git a/docs/source/reference/api/_templates/docs-navigation.html b/docs/source/reference/api/_templates/docs-navigation.html new file mode 100644 index 0000000..4c7d26a --- /dev/null +++ b/docs/source/reference/api/_templates/docs-navigation.html @@ -0,0 +1,5 @@ +
+ +
diff --git a/docs/source/reference/api/_templates/layout.html b/docs/source/reference/api/_templates/layout.html new file mode 100644 index 0000000..3f76d10 --- /dev/null +++ b/docs/source/reference/api/_templates/layout.html @@ -0,0 +1,29 @@ +{% extends "sphinx_book_theme/layout.html" %} + +{% block extrahead %} +{{ super() }} +{% if pagename == "search" %}{% endif %} +{% endblock %} + +{% block css %} + +{{ super() }} +{% endblock %} + +{% block body_tag %} + +{% endblock %} + +{% block docs_navbar %} +
+ +
+{% endblock %} diff --git a/docs/source/reference/api/conf.py b/docs/source/reference/api/conf.py index 64250e3..c995812 100644 --- a/docs/source/reference/api/conf.py +++ b/docs/source/reference/api/conf.py @@ -40,15 +40,22 @@ myst_all_links_external = True html_theme = "sphinx_book_theme" +templates_path = ["_templates"] +html_sidebars = {"**": ["docs-navigation.html"]} html_css_files = ["https://docs.nygen.io/assets/brand/docs.css"] html_logo = "https://docs.nygen.io/assets/brand/logo/cytepool.svg" html_favicon = "https://docs.nygen.io/assets/brand/logo/cytepool.ico" html_title = "CytePool API reference" +html_baseurl = "https://docs.nygen.io/CytePool/api/" html_theme_options = { "logo": {"text": "CytePool", "alt_text": "CytePool"}, "repository_url": "https://github.com/NygenAnalytics/CytePool", "path_to_docs": "docs/source/reference/api", - "use_repository_button": True, + "article_header_start": [], + "article_header_end": ["toggle-secondary-sidebar.html"], + "show_prev_next": False, + "footer_content_items": ["extra-footer.html"], + "extra_footer": 'Nygen documentation home', "show_navbar_depth": 2, "navigation_with_keys": False, } diff --git a/tests/test_documentation.py b/tests/test_documentation.py index 89ee9f5..7944744 100644 --- a/tests/test_documentation.py +++ b/tests/test_documentation.py @@ -24,9 +24,11 @@ from collections import defaultdict from collections.abc import Callable, Iterator from functools import cache +from html.parser import HTMLParser from pathlib import Path from types import ModuleType from typing import Any +from xml.etree import ElementTree import numpy as np import pytest @@ -1565,3 +1567,331 @@ def test_the_capability_matrix_agrees_with_the_validation_summary() -> None: assert problems == [] # Every script of the Validation page has a row. assert sorted(set(summary) - {_script(row[4]) for row in matrix}) == [] + + +@pytest.mark.parametrize("base_url", ["", "/CytePool"]) +def test_api_pages_join_the_staged_guide_navigation( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, base_url: str +) -> None: + yaml = pytest.importorskip("yaml") + + api = tmp_path / "docs" / "build" / "api" + api.mkdir(parents=True) + (api / "index.html").write_text( + '" + ) + site = tmp_path / "site" + site.mkdir() + config = { + "project": { + "toc": [ + {"file": "index.md"}, + { + "title": "Reference", + "children": [ + {"url": f"{base_url}/api/index.html", "title": "API reference"} + ], + }, + ] + } + } + (site / "myst.yml").write_text(yaml.safe_dump(config)) + monkeypatch.setattr(build_docs, "DOCS", api.parents[1]) + monkeypatch.setenv("BASE_URL", base_url) + + build_docs.include_api_navigation(site) + + toc = yaml.safe_load((site / "myst.yml").read_text())["project"]["toc"] + assert toc[0] == {"file": "index.md"} + assert toc[1]["children"][0]["children"] == [ + { + "title": "Cubes API reference", + "url": f"{base_url}/api/cubes.html", + "open_in_same_tab": True, + }, + { + "title": "Import & export", + "url": f"{base_url}/api/import_export.html", + "open_in_same_tab": True, + }, + ] + + +@pytest.mark.parametrize("base_url", ["", "/CytePool"]) +def test_api_pages_keep_the_guide_header_navigation_and_existing_content( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, base_url: str +) -> None: + pytest.importorskip("yaml") + + monkeypatch.setenv("BASE_URL", base_url) + html = tmp_path / "html" + api = html / "api" + api.mkdir(parents=True) + header = ( + '' + ) + navigation = ( + '" + ) + (html / "index.html").write_text( + f"{header}{navigation}" + ) + article = '' + for name in ("index.html", "cubes.html", "search.html", "genindex.html"): + (api / name).write_text( + '
' + '
' + '
' + '' + f"
{article}" + ) + (api / "objects.inv").write_bytes(b"existing Sphinx inventory") + static = api / "_static" + static.mkdir() + (static / "webpack-macros.html").write_text("{% macro assets() %}{% endmacro %}") + + build_docs.integrate_api_navigation(html) + + for page in api.glob("*.html"): + contents = page.read_text() + copied_header = build_docs.html_fragment(contents, "myst-top-nav-bar") + copied_navigation = build_docs.html_fragment( + contents, "myst-primary-sidebar-toc" + ) + assert 'class="myst-search-bar search-button__button"' in copied_header + assert ( + 'class="myst-top-nav-menu-button sidebar-toggle primary-toggle"' + in copied_header + ) + assert "myst-search-bar-disabled" not in copied_header + assert 'aria-label="Search documentation"' in copied_header + assert 'aria-controls="pst-search-dialog"' in copied_header + assert "aria-expanded" not in copied_header + assert "data-state" not in copied_header + assert "]*aria-current="page"[^>]*>', copied_navigation) + if page.name in {"index.html", "cubes.html"}: + assert len(selected) == 1 + assert f'href="{base_url}/api/{page.name}"' in selected[0] + else: + assert not selected + assert article in contents + assert contents.count('src="https://docs.nygen.io/assets/brand/docs.js"') == 1 + assert "nygen-docs-sidebar:" in contents + assert "fonts/inter/latin.woff2" in contents + assert (api / "objects.inv").read_bytes() == b"existing Sphinx inventory" + assert ( + static / "webpack-macros.html" + ).read_text() == "{% macro assets() %}{% endmacro %}" + + +def test_html_fragment_rejects_missing_or_duplicate_navigation() -> None: + pytest.importorskip("yaml") + + for contents in ( + "", + '', + ): + with pytest.raises(ValueError, match="Expected one complete .menu element"): + build_docs.html_fragment(contents, "menu") + + +@pytest.mark.parametrize( + ("route", "title", "expected"), + [ + ("/", "CytePool - CytePool", "CytePool documentation"), + ("/quickstart/", "Quick start - CytePool", "Quick start - CytePool"), + ( + "/api/index.html", + "CytePool Python API reference — CytePool API reference", + "CytePool Python API reference", + ), + ( + "/api/cytepool.html", + "CytePool API reference — CytePool API reference", + "CytePool Python API", + ), + ], +) +def test_page_metadata_preserves_authored_content_and_completes_link_previews( + route: str, title: str, expected: str +) -> None: + + class Metadata(HTMLParser): + def __init__(self) -> None: + super().__init__() + self.tags: list[dict[str, str | None]] = [] + + def handle_starttag( + self, tag: str, attrs: list[tuple[str, str | None]] + ) -> None: + if tag == "meta": + self.tags.append(dict(attrs)) + + body = "

Keep this heading

Keep SVG title" + source = ( + f"{title}" + '' + '' + '' + '' + f"{body}" + ) + canonical = build_docs.PUBLIC_URL + route + + actual = build_docs.update_page_metadata(source, canonical) + + head = actual.split("")[0] + parser = Metadata() + parser.feed(head) + keys = [tag.get("property", tag.get("name")) for tag in parser.tags] + assert len(keys) == len(set(keys)) + metadata = dict(zip(keys, (tag["content"] for tag in parser.tags), strict=True)) + assert re.findall(r"(.*?)", head) == [expected] + assert metadata["description"] == 'Read RNA & ATAC "together".' + assert metadata["og:description"] == metadata["description"] + assert metadata["og:title"] == expected + assert metadata["og:type"] == "website" + assert metadata["og:url"] == canonical + assert ( + metadata["og:image"] + == "https://docs.nygen.io/assets/brand/logo/cytepool-dark.png" + ) + assert metadata["og:image:type"] == "image/png" + assert metadata["og:image:width"] == metadata["og:image:height"] == "512" + assert metadata["og:image:alt"] == "CytePool logo" + assert metadata["twitter:card"] == "summary" + assert metadata["twitter:image"] == metadata["og:image"] + assert metadata["twitter:image:alt"] == metadata["og:image:alt"] + assert metadata["robots"] == "noindex,follow" + assert actual.endswith(body + "") + assert build_docs.update_page_metadata(actual, canonical) == actual + + +def test_page_metadata_rejects_missing_authored_description() -> None: + + with pytest.raises(ValueError, match="Expected a title and description"): + build_docs.update_page_metadata( + "Guide", + build_docs.PUBLIC_URL + "/guide/", + ) + + +@pytest.mark.parametrize("authored", [False, True]) +def test_staging_adds_introductory_descriptions_without_changing_source_or_code( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, authored: bool +) -> None: + yaml = pytest.importorskip("yaml") + source = tmp_path / "source" + source.mkdir() + prose = "# Example\n\nCompare **sample groups** with [tables](index.md).\n\n## Results\n" + if authored: + prose = "---\ndescription: Keep the authored summary.\n---\n\n" + prose + notebook = _executed_notebook() + notebook["cells"][0]["source"] = prose + saved = json.dumps(notebook) + (source / "example.ipynb").write_text(saved) + (source / "myst.yml").write_text( + yaml.safe_dump({"project": {"toc": [{"file": "example.ipynb"}]}}) + ) + monkeypatch.setattr(build_docs, "SOURCE", source) + monkeypatch.setattr(build_docs, "BUILD", tmp_path / "build") + monkeypatch.setattr(build_docs, "SITE", tmp_path / "build" / "site") + + staged = json.loads((build_docs.stage_site() / "example.ipynb").read_text()) + + frontmatter = re.match(r"\A---\n(.*?)\n---\n", staged["cells"][0]["source"], re.S) + assert frontmatter + assert yaml.safe_load(frontmatter[1])["description"] == ( + "Keep the authored summary." + if authored + else "Compare sample groups with tables." + ) + assert build_docs.code_fingerprint(staged) == build_docs.code_fingerprint(notebook) + assert (source / "example.ipynb").read_text() == saved + assert prose.split("# Example", 1)[1] in staged["cells"][0]["source"] + + +@pytest.mark.parametrize("base_url", ["", "/CytePool"]) +def test_discovery_covers_guides_and_authored_api_with_one_canonical( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, base_url: str +) -> None: + html = tmp_path / "html" + (html / "api" / "_sources").mkdir(parents=True) + body = '

Unchanged title

API symbol' + routes = ("/", "/guide/", "/api/index.html", "/api/cubes.html") + for route in routes: + path = html / route.lstrip("/") + if route.endswith("/"): + path /= "index.html" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + "CytePool - CytePool" + '' + '' + '' + f"{body}" + ) + if route.startswith("/api/"): + (html / "api" / "_sources" / f"{path.stem}.md").write_text("# API\n") + auxiliary = "Useful index or search" + for name in ("search.html", "genindex.html", "py-modindex.html"): + (html / "api" / name).write_text(auxiliary) + references = [ + {"kind": "page", "url": f"{base_url}/", "data": "/index.json"}, + {"kind": "page", "url": f"{base_url}/guide", "data": "/guide.json"}, + {"kind": "page", "url": f"{base_url}/guide/", "data": "/guide.json"}, + ] + (html / "myst.xref.json").write_text(json.dumps({"references": references})) + monkeypatch.setenv("BASE_URL", base_url) + + build_docs.write_discovery_metadata(html) + build_docs.write_discovery_metadata(html) + + sitemap = ElementTree.parse(html / "sitemap.xml") + locations = [node.text for node in sitemap.findall(".//{*}loc")] + assert sorted(locations) == sorted( + build_docs.PUBLIC_URL + route for route in routes + ) + for route in routes: + path = html / route.lstrip("/") + if route.endswith("/"): + path /= "index.html" + contents = path.read_text() + assert re.findall(r'") + assert 'content="An authored description."' in contents + assert ( + 'content="https://docs.nygen.io/assets/brand/logo/cytepool-dark.png"' + in contents + ) + for name in ("search.html", "genindex.html", "py-modindex.html"): + assert (html / "api" / name).read_text() == auxiliary + assert ( + (html / "robots.txt") + .read_text() + .endswith("Sitemap: https://docs.nygen.io/CytePool/sitemap.xml\n") + )