"""Small MkDocs hooks used by the CATLASS documentation site."""
from __future__ import annotations
import os
import re
from pathlib import Path
from urllib.parse import quote, unquote, urlsplit, urlunsplit
import yaml
from mkdocs.plugins import event_priority
from mkdocs.structure.files import File, InclusionLevel
_MARKDOWN_LINK = re.compile(
r"(?P<prefix>\]\(\s*<?)"
r"(?P<target>(?:\.\./)+[^)\s>]+)"
r"(?P<suffix>>?(?:\s+(?:\"[^\"]*\"|'[^']*'|\([^)]*\)))?\s*\))"
)
_PAGE_NAV_TITLES = {
"zh": {"README.md": "主页", "FAQ.md": "FAQ"},
"en": {"README.md": "Home", "FAQ.md": "FAQ"},
}
_TOP_LEVEL_ORDER = {
"README.md": 0,
"1_Practice": 10,
"2_Design": 20,
"3_API": 30,
"4_CATLASS_DSL": 40,
"0x_new_versions": 50,
"FAQ.md": 100,
}
_ASSETS_PATH = Path("assets")
_DSL_DOCS_PATH = Path("python/tla_dsl/docs")
_DSL_SITE_SECTION = Path("4_CATLASS_DSL")
_DSL_GENERATED_BY = "mkdocs_hooks.dsl_docs"
_DSL_DEFAULT_ORDER = 90
def _asset_sources(assets_root: Path):
"""Yield centralized assets and their public paths."""
for source in assets_root.rglob("*"):
if source.is_file():
yield _ASSETS_PATH / source.relative_to(assets_root), source
def _dsl_sources(dsl_root: Path, locale: str):
"""Yield canonical DSL Markdown sources and their virtual site paths."""
locale_root = dsl_root / locale
if not locale_root.is_dir():
return
for source in locale_root.rglob("*.md"):
virtual_source = _DSL_SITE_SECTION / source.relative_to(locale_root)
yield virtual_source, source
def _file_destination(path: Path, config, output_prefix: Path) -> Path:
"""Return MkDocs' normal HTML destination with a locale prefix."""
probe = File(
path.as_posix(),
src_dir=None,
dest_dir=config.site_dir,
use_directory_urls=config.use_directory_urls,
)
return output_prefix / probe.dest_uri
@event_priority(-200)
def on_files(files, config, **kwargs):
"""Expose centralized assets and canonical DSL docs in each locale build."""
assets_root = Path(config.docs_dir) / _ASSETS_PATH
i18n = config.plugins["i18n"]
locale = i18n.current_language
default_locale = i18n.default_language
output_prefix = Path() if locale == default_locale else Path(locale)
if assets_root.is_dir():
for file in list(files):
if not file.abs_src_path:
continue
try:
Path(file.abs_src_path).resolve().relative_to(assets_root.resolve())
except ValueError:
continue
files.remove(file)
for public_path, source in _asset_sources(assets_root):
virtual_source = Path(locale) / public_path
destination = output_prefix / public_path
asset = File(
virtual_source.as_posix(),
src_dir=None,
dest_dir=config.site_dir,
use_directory_urls=config.use_directory_urls,
dest_uri=destination.as_posix(),
)
asset.abs_src_path = str(source)
asset.generated_by = "mkdocs_hooks"
files.append(asset)
project_root = Path(config.config_file_path).parent.resolve()
dsl_root = project_root / _DSL_DOCS_PATH
for virtual_source, source in _dsl_sources(dsl_root, locale):
destination = _file_destination(virtual_source, config, output_prefix)
localized_source = Path(locale) / virtual_source
dsl_file = File(
localized_source.as_posix(),
src_dir=None,
dest_dir=config.site_dir,
use_directory_urls=config.use_directory_urls,
dest_uri=destination.as_posix(),
inclusion=InclusionLevel.INCLUDED,
)
dsl_file.abs_src_path = str(source)
dsl_file.generated_by = _DSL_GENERATED_BY
dsl_file.alternates = {locale: dsl_file}
dsl_file.locale = locale
dsl_file.locale_alternate_of = locale
dsl_file.localization = locale
dsl_file.norm_src_uri = virtual_source.as_posix()
files.append(dsl_file)
return files
def _source_url(target: str, page, config) -> str:
"""Map a link that escapes docs_dir to the corresponding repository URL."""
parts = urlsplit(target)
if parts.scheme or parts.netloc:
return target
project_root = Path(config.config_file_path).parent.resolve()
docs_root = Path(config.docs_dir).resolve()
source_file = Path(page.file.abs_src_path)
destination = (source_file.parent / unquote(parts.path)).resolve()
dsl_root = project_root / _DSL_DOCS_PATH
try:
source_file.resolve().relative_to(dsl_root)
source_is_dsl_doc = True
except ValueError:
source_is_dsl_doc = False
try:
dsl_relative = destination.relative_to(dsl_root)
except ValueError:
dsl_relative = None
if dsl_relative is not None:
if source_is_dsl_doc:
return target
relative_parts = dsl_relative.parts
if relative_parts and relative_parts[0] in _PAGE_NAV_TITLES:
relative_parts = relative_parts[1:]
virtual_target = _DSL_SITE_SECTION.joinpath(*relative_parts)
page_parts = Path(page.file.src_uri).parts
if page_parts and page_parts[0] in _PAGE_NAV_TITLES:
page_parts = page_parts[1:]
page_parent = Path(*page_parts).parent
mapped_path = Path(os.path.relpath(virtual_target, page_parent)).as_posix()
return urlunsplit(("", "", mapped_path, parts.query, parts.fragment))
try:
docs_relative = destination.relative_to(docs_root)
except ValueError:
docs_relative = None
if docs_relative is not None:
if not source_is_dsl_doc:
return target
relative_parts = docs_relative.parts
if relative_parts and relative_parts[0] in _PAGE_NAV_TITLES:
relative_parts = relative_parts[1:]
virtual_target = Path(*relative_parts)
page_parts = Path(page.file.src_uri).parts
if page_parts and page_parts[0] in _PAGE_NAV_TITLES:
page_parts = page_parts[1:]
page_parent = Path(*page_parts).parent
mapped_path = Path(os.path.relpath(virtual_target, page_parent)).as_posix()
return urlunsplit(("", "", mapped_path, parts.query, parts.fragment))
try:
relative_path = destination.relative_to(project_root)
except ValueError:
return target
if not destination.exists():
return target
branch = config.extra.get("source_branch", "master")
view = "tree" if destination.is_dir() else "blob"
repository_url = config.repo_url.rstrip("/")
repository_path = quote(relative_path.as_posix(), safe="/")
mapped_path = f"{repository_url}/{view}/{branch}/{repository_path}"
return urlunsplit(("", "", mapped_path, parts.query, parts.fragment))
def on_page_markdown(markdown, page, config, **kwargs):
"""Rewrite repository-relative source links before MkDocs validates them."""
def replace(match: re.Match[str]) -> str:
target = _source_url(match.group("target"), page, config)
return f"{match.group('prefix')}{target}{match.group('suffix')}"
return _MARKDOWN_LINK.sub(replace, markdown)
def _nav_items(items):
"""Yield every item in a MkDocs navigation tree."""
for item in items:
yield item
children = getattr(item, "children", None)
if children:
yield from _nav_items(children)
def _nav_source(item) -> str:
"""Return a navigation item's first source path, without its locale prefix."""
current = item
while getattr(current, "file", None) is None:
children = getattr(current, "children", None)
if not children:
return ""
current = children[0]
parts = Path(current.file.src_uri).parts
if parts and parts[0] in _PAGE_NAV_TITLES:
parts = parts[1:]
return "/".join(parts)
def _top_level_sort_key(item):
source = _nav_source(item)
first_part = source.split("/", 1)[0]
return (_TOP_LEVEL_ORDER.get(first_part, 90), source)
def _dsl_relative_source(item) -> str:
source = _nav_source(item)
prefix = f"{_DSL_SITE_SECTION.as_posix()}/"
return source.removeprefix(prefix) if source.startswith(prefix) else ""
def _frontmatter(path: Path) -> dict:
"""Read a Markdown file's YAML frontmatter, if present."""
try:
text = path.read_text(encoding="utf-8")
except OSError:
return {}
if not text.startswith("---"):
return {}
end = text.find("\n---", 3)
if end == -1:
return {}
try:
data = yaml.safe_load(text[3:end])
except Exception:
return {}
return data if isinstance(data, dict) else {}
def _markdown_title(path: Path) -> str | None:
"""Return the first ATX H1 heading of a Markdown file (doc-driven title)."""
try:
text = path.read_text(encoding="utf-8")
except OSError:
return None
if text.startswith("---"):
end = text.find("\n---", 3)
if end != -1:
text = text[end + 4 :]
for line in text.splitlines():
if line.startswith("# "):
return line[2:].strip()
return None
def _dsl_apply_title(item) -> None:
"""Set a DSL nav item's title from its own H1 (pages) or index.md H1 (sections)."""
file = getattr(item, "file", None)
if file is not None and file.abs_src_path:
title = _markdown_title(Path(file.abs_src_path))
if title:
item.title = title
return
for child in getattr(item, "children", []) or []:
child_file = getattr(child, "file", None)
if child_file and Path(child_file.src_uri).name == "index.md":
title = _markdown_title(Path(child_file.abs_src_path))
if title:
item.title = title
return
def _dsl_nav_order(item) -> int:
"""Return the nav ordering for a DSL nav item from its frontmatter."""
file = getattr(item, "file", None)
if file is None or not file.abs_src_path:
for child in getattr(item, "children", []) or []:
child_file = getattr(child, "file", None)
if child_file and Path(child_file.src_uri).name == "index.md":
return _dsl_nav_order(child)
return _DSL_DEFAULT_ORDER
meta = _frontmatter(Path(file.abs_src_path))
order = meta.get("nav_order")
return order if isinstance(order, (int, float)) else _DSL_DEFAULT_ORDER
def _polish_dsl_nav(item, locale: str) -> None:
"""Order the DSL section and apply doc-driven titles to pages and sections."""
children = getattr(item, "children", None)
if not children:
return
if _nav_source(item).startswith(f"{_DSL_SITE_SECTION.as_posix()}/"):
children.sort(key=lambda child: (_dsl_nav_order(child), _nav_source(child)))
for child in children:
_dsl_apply_title(child)
for child in children:
_polish_dsl_nav(child, locale)
def on_page_context(context, page, config, nav, **kwargs):
"""Polish top-level tabs while retaining fully automatic page discovery."""
locale = getattr(page.file, "locale", "zh")
titles = _PAGE_NAV_TITLES.get(locale, {})
for item in _nav_items(nav.items):
file = getattr(item, "file", None)
if file and Path(file.src_uri).name in titles:
item.title = titles[Path(file.src_uri).name]
for item in nav.items:
if _nav_source(item).startswith(f"{_DSL_SITE_SECTION.as_posix()}/"):
item.title = "CATLASS DSL"
_polish_dsl_nav(item, locale)
if page.file.generated_by == _DSL_GENERATED_BY and page.file.abs_src_path:
project_root = Path(config.config_file_path).parent.resolve()
source_path = Path(page.file.abs_src_path).resolve().relative_to(project_root)
branch = config.extra.get("source_branch", "master")
repository_path = quote(source_path.as_posix(), safe="/")
page.edit_url = f"{config.repo_url.rstrip('/')}/edit/{branch}/{repository_path}"
nav.items.sort(key=_top_level_sort_key)
return context