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'