#[A-Za-z0-9_.-]+)?",
+ lambda match: version_doc_link(
+ version,
+ _LinkMatch(match.group("prefix"), match.group("category"), match.group("anchor")),
+ ),
+ value,
+ )
+
+
+class _LinkMatch:
+ def __init__(self, prefix: str | None, category: str, fragment: str | None) -> None:
+ self.values = {"prefix": prefix, "category": category, "fragment": fragment}
+
+ def group(self, name: str) -> str | None:
+ return self.values[name]
+
+
+def render_docstring(docstring: griffe.Docstring | None, version: str) -> str:
+ if not docstring or not docstring.value.strip():
+ return "No public documentation was provided in this release."
+ raw = rewrite_absolute_links(docstring.value.strip(), version)
+ raw = re.sub(r"<(https?://[^>]+)>", r"[\1](\1)", raw)
+ raw = re.sub(r"^Throws:$", "Raises:", raw, flags=re.MULTILINE)
+ if not re.search(
+ r"^(?:Args|Arguments|Parameters|Raises|Returns|Yields|Examples):$",
+ raw,
+ re.MULTILINE,
+ ):
+ return raw
+
+ sections = griffe.Docstring(raw, parent=docstring.parent).parse(
+ griffe.Parser.google, warnings=False
+ )
+ rendered: list[str] = []
+ for section in sections:
+ if isinstance(section, griffe.DocstringSectionText):
+ rendered.append(section.value)
+ elif isinstance(section, griffe.DocstringSectionParameters):
+ rows = [
+ (
+ f"`{parameter.name}`",
+ f"`{parameter.annotation}`",
+ parameter.description,
+ )
+ for parameter in section.value
+ ]
+ rendered.extend(
+ [
+ "Arguments
",
+ markdown_table(("Argument", "Type", "Description"), rows),
+ ]
+ )
+ elif isinstance(section, griffe.DocstringSectionRaises):
+ rows = [(f"`{error.annotation}`", error.description) for error in section.value]
+ rendered.extend(["Errors
", markdown_table(("Error", "When"), rows)])
+ elif isinstance(section, griffe.DocstringSectionReturns):
+ rows = [(f"`{item.annotation}`", item.description) for item in section.value]
+ rendered.extend(["#### Returns", markdown_table(("Type", "Description"), rows)])
+ elif isinstance(section, griffe.DocstringSectionExamples):
+ rendered.extend(["#### Examples", *(value for _, value in section.value)])
+ return "\n\n".join(item for item in rendered if item)
+
+
+def member_signature(name: str, member: Any) -> str:
+ if isinstance(member, (griffe.Class, griffe.Function)):
+ try:
+ return str(member.signature())
+ except (AttributeError, TypeError, ValueError):
+ return name
+ annotation = getattr(member, "annotation", None)
+ value = getattr(member, "value", None)
+ if annotation and value is not None:
+ return f"{name}: {annotation} = {value}"
+ if annotation:
+ return f"{name}: {annotation}"
+ return f"{name} = {value}" if value is not None else name
+
+
+def member_kind(member: Any) -> str:
+ if isinstance(member, griffe.Class):
+ return "Class"
+ if isinstance(member, griffe.Function):
+ return "Function"
+ return "Value"
+
+
+def render_member(
+ package: griffe.Module,
+ anchor: str,
+ nested_anchors: Sequence[str],
+ version: str,
+ *,
+ include_heading: bool,
+) -> str:
+ try:
+ member = package.get_member(anchor)
+ except (KeyError, griffe.AliasResolutionError) as error:
+ raise RuntimeError(f"Could not resolve historical API anchor {anchor}") from error
+ if isinstance(member, griffe.Alias):
+ member = member.target
+ name = anchor.rsplit(".", 1)[-1]
+ content = [f'']
+ if include_heading:
+ content.extend([f"### {name}", ""])
+ content.extend(
+ [
+ f"**{member_kind(member)}**",
+ "",
+ "```python",
+ member_signature(name, member),
+ "```",
+ "",
+ '',
+ "",
+ render_docstring(getattr(member, "docstring", None), version),
+ "",
+ "
",
+ ]
+ )
+ for nested_anchor in nested_anchors:
+ try:
+ nested = package.get_member(nested_anchor)
+ except (KeyError, griffe.AliasResolutionError):
+ content.extend(["", f''])
+ continue
+ if isinstance(nested, griffe.Alias):
+ nested = nested.target
+ nested_name = nested_anchor.rsplit(".", 1)[-1]
+ content.extend(
+ [
+ "",
+ f'',
+ f"#### {nested_name}",
+ "",
+ "```python",
+ member_signature(nested_name, nested),
+ "```",
+ "",
+ render_docstring(getattr(nested, "docstring", None), version),
+ ]
+ )
+ return "\n".join(content)
+
+
+def directive_content(
+ package: griffe.Module,
+ target: str,
+ anchors: Sequence[str],
+ version: str,
+) -> str:
+ try:
+ target_member = package.get_member(target)
+ except (KeyError, griffe.AliasResolutionError) as error:
+ raise RuntimeError(f"Could not resolve mkdocstrings target {target}") from error
+ if isinstance(target_member, griffe.Alias):
+ target_member = target_member.target
+ if not isinstance(target_member, griffe.Module):
+ nested = [anchor for anchor in anchors if anchor.startswith(f"{target}.")]
+ return render_member(package, target, nested, version, include_heading=False)
+
+ prefix = f"{target}."
+ top_level: list[str] = []
+ for anchor in anchors:
+ if not anchor.startswith(prefix):
+ continue
+ remainder = anchor[len(prefix) :]
+ if "." not in remainder:
+ top_level.append(anchor)
+ rendered = []
+ for anchor in top_level:
+ nested = [candidate for candidate in anchors if candidate.startswith(f"{anchor}.")]
+ rendered.append(render_member(package, anchor, nested, version, include_heading=True))
+ return "\n\n".join(rendered)
+
+
+def replace_directives(
+ source: str,
+ package: griffe.Module,
+ anchors: Sequence[str],
+ version: str,
+) -> str:
+ lines = source.splitlines()
+ output: list[str] = []
+ index = 0
+ while index < len(lines):
+ match = DIRECTIVE_RE.match(lines[index])
+ if not match:
+ output.append(lines[index])
+ index += 1
+ continue
+ target = match.group("target")
+ output.append(directive_content(package, target, anchors, version))
+ index += 1
+ while index < len(lines) and (not lines[index].strip() or lines[index].startswith(" ")):
+ index += 1
+ return "\n".join(output)
+
+
+def convert_tabs(source: str) -> tuple[str, bool]:
+ lines = source.splitlines()
+ output: list[str] = []
+ index = 0
+ found = False
+ while index < len(lines):
+ header = TAB_HEADER_RE.match(lines[index])
+ if not header:
+ output.append(lines[index])
+ index += 1
+ continue
+ found = True
+ tabs: list[tuple[str, list[str]]] = []
+ while index < len(lines):
+ header = TAB_HEADER_RE.match(lines[index])
+ if not header:
+ break
+ label = header.group("label")
+ index += 1
+ body: list[str] = []
+ while index < len(lines):
+ if TAB_HEADER_RE.match(lines[index]):
+ break
+ if not lines[index].strip():
+ body.append("")
+ index += 1
+ continue
+ if lines[index].startswith(" "):
+ body.append(lines[index][4:])
+ index += 1
+ continue
+ break
+ while body and not body[-1].strip():
+ body.pop()
+ tabs.append((label, body))
+ while index < len(lines) and not lines[index].strip():
+ index += 1
+ if index >= len(lines) or not TAB_HEADER_RE.match(lines[index]):
+ break
+ output.append("")
+ for tab_index, (label, body) in enumerate(tabs):
+ value = re.sub(r"[^a-z0-9]+", "-", label.lower()).strip("-") or f"tab-{tab_index + 1}"
+ output.extend(
+ [
+ f"",
+ "",
+ *body,
+ "",
+ "",
+ ]
+ )
+ output.append("")
+ return "\n".join(output), found
+
+
+def convert_admonitions(source: str) -> str:
+ lines = source.splitlines()
+ output: list[str] = []
+ index = 0
+ while index < len(lines):
+ match = ADMONITION_RE.match(lines[index])
+ if not match:
+ output.append(lines[index])
+ index += 1
+ continue
+ kind = "details" if match.group("marker") == "???" else match.group("kind")
+ title = match.group("title")
+ opening = f":::{kind}"
+ if title:
+ opening += f"[{title}]"
+ output.append(opening)
+ index += 1
+ while index < len(lines) and (not lines[index].strip() or lines[index].startswith(" ")):
+ output.append(lines[index][4:] if lines[index].startswith(" ") else "")
+ index += 1
+ output.append(":::")
+ return "\n".join(output)
+
+
+def rewrite_markdown(source: str, version: str, docs_hash: str) -> str:
+ source = re.sub(
+ r"(?P[A-Za-z0-9_./-]+)\.md(?P#[A-Za-z0-9_.-]+)?",
+ r"\g\g",
+ source,
+ )
+ source = source.replace("./docs_src/img/", f"/img/legacy/{docs_hash}/")
+ source = source.replace("./img/", f"/img/legacy/{docs_hash}/")
+ source = source.replace("../img/", f"/img/legacy/{docs_hash}/")
+ source = rewrite_absolute_links(source, version)
+ source = re.sub(r"^\s*\{[.:#][^}]+\}\s*$", "", source, flags=re.MULTILINE)
+ return source
+
+
+def generated_notice(entry: dict[str, Any]) -> str:
+ return (
+ f""
+ )
+
+
+def convert_page(
+ source: str,
+ entry: dict[str, Any],
+ package: griffe.Module,
+ page_inventory: dict[str, Any],
+) -> str:
+ source = replace_directives(
+ source,
+ package,
+ page_inventory.get("api_anchors", []),
+ entry["version"],
+ )
+ source, has_tabs = convert_tabs(source)
+ source = convert_admonitions(source)
+ source = rewrite_markdown(
+ source,
+ entry["version"],
+ entry["documentation_source_tree_hash"],
+ )
+ header = [generated_notice(entry)]
+ if has_tabs:
+ header.extend(
+ [
+ "import Tabs from '@theme/Tabs';",
+ "import TabItem from '@theme/TabItem';",
+ ]
+ )
+ return "\n".join([*header, "", source.strip(), ""])
+
+
+def source_file_for_route(route: str) -> str:
+ return "index.md" if route == "/" else f"{route.lstrip('/')}.md"
+
+
+def output_file_for_route(output_root: Path, version: str, route: str) -> Path:
+ relative = "index.mdx" if route == "/" else f"{route.lstrip('/')}.mdx"
+ return output_root / "versioned_docs" / f"version-{version}" / relative
+
+
+def sidebar_for(entry: dict[str, Any]) -> dict[str, Any]:
+ route_set = set(entry["expected_routes"])
+ usage_order = [
+ "/usage/installation",
+ "/usage/compatibility",
+ "/usage/using_blocks",
+ "/usage/sending_messages",
+ "/usage/cookbook",
+ "/usage/migration",
+ "/usage/troubleshooting",
+ ]
+ reference_order = [f"/reference/{page}" for page in REFERENCE_PAGES]
+ items: list[dict[str, Any]] = [{"type": "doc", "id": "index", "label": "Welcome"}]
+ items.append(
+ {
+ "type": "category",
+ "label": "Usage",
+ "collapsed": False,
+ "items": [
+ {
+ "type": "doc",
+ "id": route.lstrip("/"),
+ "label": PAGE_TITLES[route.lstrip("/")],
+ }
+ for route in usage_order
+ if route in route_set
+ ],
+ }
+ )
+ items.append(
+ {
+ "type": "category",
+ "label": "Reference",
+ "collapsed": False,
+ "items": [
+ {
+ "type": "doc",
+ "id": route.lstrip("/"),
+ "label": PAGE_TITLES[route.lstrip("/")],
+ }
+ for route in reference_order
+ if route in route_set
+ ],
+ }
+ )
+ if "/contributing" in route_set:
+ items.append({"type": "doc", "id": "contributing", "label": "Contributing"})
+ return {"docs": items}
+
+
+def generate_version(
+ entry: dict[str, Any],
+ output_root: Path,
+ *,
+ source_archive: Path | None = None,
+) -> None:
+ inventory = json.loads((LEGACY_ROOT / entry["inventory"]).read_text())
+ pages_by_route = {page["route"]: page for page in inventory["pages"]}
+ version_docs = output_root / "versioned_docs" / f"version-{entry['version']}"
+ if version_docs.exists():
+ shutil.rmtree(version_docs)
+ version_docs.mkdir(parents=True)
+
+ with tempfile.TemporaryDirectory(prefix=f"slackblocks-{entry['tag']}-") as temporary:
+ extracted = Path(temporary)
+ extract_release(entry, extracted, source_archive)
+ package = griffe.load(extracted / "slackblocks", submodules=True)
+ docs_source = extracted / "docs_src"
+ for route in entry["expected_routes"]:
+ source_path = docs_source / source_file_for_route(route)
+ if not source_path.is_file():
+ raise RuntimeError(
+ f"{entry['tag']} is missing {source_path.relative_to(extracted)}"
+ )
+ output_path = output_file_for_route(output_root, entry["version"], route)
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ output_path.write_text(
+ convert_page(source_path.read_text(), entry, package, pages_by_route[route])
+ )
+
+ source_assets = docs_source / "img"
+ if source_assets.is_dir():
+ asset_root = (
+ output_root / "static" / "img" / "legacy" / entry["documentation_source_tree_hash"]
+ )
+ if asset_root.exists():
+ shutil.rmtree(asset_root)
+ shutil.copytree(source_assets, asset_root)
+
+ sidebar_path = output_root / "versioned_sidebars" / f"version-{entry['version']}-sidebars.json"
+ sidebar_path.parent.mkdir(parents=True, exist_ok=True)
+ sidebar_path.write_text(json.dumps(sidebar_for(entry), indent=2) + "\n")
+
+
+def hash_files(paths: Iterable[Path], relative_to: Path) -> str:
+ digest = hashlib.sha256()
+ files: list[Path] = []
+ for path in paths:
+ if path.is_dir():
+ files.extend(item for item in path.rglob("*") if item.is_file())
+ elif path.is_file():
+ files.append(path)
+ for path in sorted(set(files), key=lambda item: item.relative_to(relative_to).as_posix()):
+ relative = path.relative_to(relative_to).as_posix().encode()
+ digest.update(len(relative).to_bytes(4, "big"))
+ digest.update(relative)
+ content = path.read_bytes()
+ digest.update(len(content).to_bytes(8, "big"))
+ digest.update(content)
+ return digest.hexdigest()
+
+
+def snapshot_hash(output_root: Path, version: str) -> str:
+ return hash_files(
+ [
+ output_root / "versioned_docs" / f"version-{version}",
+ output_root / "versioned_sidebars" / f"version-{version}-sidebars.json",
+ ],
+ output_root,
+ )
+
+
+def write_registered_versions(manifest: dict[str, Any]) -> None:
+ registered = [
+ entry["version"]
+ for entry in manifest["versions"]
+ if entry.get("generated_snapshot_tree_hash")
+ ]
+ VERSIONS_PATH.write_text(json.dumps(registered, indent=2) + "\n")
+
+
+def update_snapshots(entries: Sequence[dict[str, Any]], manifest: dict[str, Any]) -> None:
+ for entry in entries:
+ generate_version(entry, DOCS_ROOT)
+ entry["generated_snapshot_tree_hash"] = snapshot_hash(DOCS_ROOT, entry["version"])
+ MANIFEST_PATH.write_text(json.dumps(manifest, indent=2) + "\n")
+ write_registered_versions(manifest)
+
+
+def compare_trees(expected: Path, actual: Path) -> None:
+ expected_files = {
+ path.relative_to(expected): path.read_bytes()
+ for path in expected.rglob("*")
+ if path.is_file()
+ }
+ actual_files = {
+ path.relative_to(actual): path.read_bytes() for path in actual.rglob("*") if path.is_file()
+ }
+ if expected_files != actual_files:
+ missing = sorted(str(path) for path in expected_files.keys() - actual_files.keys())
+ extra = sorted(str(path) for path in actual_files.keys() - expected_files.keys())
+ changed = sorted(
+ str(path)
+ for path in expected_files.keys() & actual_files.keys()
+ if expected_files[path] != actual_files[path]
+ )
+ raise RuntimeError(
+ f"Generated legacy tree differs; missing={missing}, extra={extra}, changed={changed}"
+ )
+
+
+def check_snapshots(entries: Sequence[dict[str, Any]]) -> None:
+ for entry in entries:
+ expected_hash = entry.get("generated_snapshot_tree_hash")
+ if not expected_hash:
+ raise RuntimeError(f"{entry['tag']} has not been generated and registered")
+ actual_hash = snapshot_hash(DOCS_ROOT, entry["version"])
+ if actual_hash != expected_hash:
+ raise RuntimeError(
+ f"{entry['tag']} snapshot hash is {actual_hash}, expected {expected_hash}"
+ )
+ with tempfile.TemporaryDirectory(prefix=f"check-{entry['tag']}-") as temporary:
+ generated = Path(temporary)
+ generate_version(entry, generated)
+ compare_trees(
+ generated / "versioned_docs" / f"version-{entry['version']}",
+ DOCS_ROOT / "versioned_docs" / f"version-{entry['version']}",
+ )
+ generated_sidebar = (
+ generated / "versioned_sidebars" / f"version-{entry['version']}-sidebars.json"
+ )
+ actual_sidebar = (
+ DOCS_ROOT / "versioned_sidebars" / f"version-{entry['version']}-sidebars.json"
+ )
+ if generated_sidebar.read_bytes() != actual_sidebar.read_bytes():
+ raise RuntimeError(f"{entry['tag']} sidebar is not reproducible")
+
+
+def check_fixture(manifest: dict[str, Any]) -> None:
+ entry = version_entry(manifest, "1.0.0")
+ if not FIXTURE_ARCHIVE.is_file():
+ raise RuntimeError(f"Missing fixture archive: {FIXTURE_ARCHIVE}")
+ with (
+ tempfile.TemporaryDirectory(prefix="legacy-fixture-a-") as first_temp,
+ tempfile.TemporaryDirectory(prefix="legacy-fixture-b-") as second_temp,
+ ):
+ first = Path(first_temp)
+ second = Path(second_temp)
+ generate_version(entry, first, source_archive=FIXTURE_ARCHIVE)
+ generate_version(entry, second, source_archive=FIXTURE_ARCHIVE)
+ compare_trees(first, second)
+ print(f"v1.0.0 deterministic fixture hash: {snapshot_hash(first, '1.0.0')}")
+
+
+def validate_manifest(manifest: dict[str, Any]) -> None:
+ versions = [entry["version"] for entry in manifest["versions"]]
+ if len(versions) != 22:
+ raise RuntimeError(f"Expected 22 published versions, found {len(versions)}")
+ if "1.0.4" in versions or "1.2.1" in versions:
+ raise RuntimeError("Unpublished v1.0.4 or v1.2.1 leaked into the manifest")
+ route_count = sum(len(entry["expected_routes"]) for entry in manifest["versions"])
+ if route_count != 294:
+ raise RuntimeError(f"Expected 294 canonical routes, found {route_count}")
+ for entry in manifest["versions"]:
+ if entry["language_availability"] != ["python"]:
+ raise RuntimeError(f"{entry['tag']} must remain Python-only")
+ inventory = json.loads((LEGACY_ROOT / entry["inventory"]).read_text())
+ if inventory["gh_pages_commit"] != manifest["migration"]["gh_pages_commit"]:
+ raise RuntimeError(f"{entry['tag']} inventory uses a different gh-pages commit")
+ inventory_routes = [page["route"] for page in inventory["pages"]]
+ if inventory_routes != entry["expected_routes"]:
+ raise RuntimeError(f"{entry['tag']} route inventory does not match the manifest")
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser()
+ subparsers = parser.add_subparsers(dest="command", required=True)
+ inventory = subparsers.add_parser("inventory", help="Refresh manifest and frozen inventories")
+ inventory.add_argument("--gh-pages-ref", default="origin/gh-pages")
+ subparsers.add_parser("fixture", help="Verify deterministic v1.0.0 fixture generation")
+ generate = subparsers.add_parser("generate", help="Write immutable historical snapshots")
+ generate.add_argument("--versions", nargs="*")
+ check = subparsers.add_parser("check", help="Verify manifests and checked-in snapshots")
+ check.add_argument("--versions", nargs="*")
+ return parser.parse_args()
+
+
+def main() -> None:
+ args = parse_args()
+ if args.command == "inventory":
+ manifest = refresh_manifest(args.gh_pages_ref)
+ validate_manifest(manifest)
+ print(
+ f"Recorded {len(manifest['versions'])} versions and "
+ f"{sum(len(entry['expected_routes']) for entry in manifest['versions'])} routes"
+ )
+ return
+
+ manifest = load_manifest()
+ validate_manifest(manifest)
+ if args.command == "fixture":
+ check_fixture(manifest)
+ return
+ entries = selected_entries(manifest, args.versions)
+ if args.command == "generate":
+ update_snapshots(entries, manifest)
+ print(f"Generated {len(entries)} immutable historical snapshots")
+ return
+ check_snapshots(entries)
+ print(f"Verified {len(entries)} immutable historical snapshots")
+
+
+if __name__ == "__main__":
+ main()