Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,7 @@ trim_trailing_whitespace = true

[*.{yml,yaml}]
indent_size = 2

# Two trailing spaces are a hard line break in the converted pages.
[docs/**.md]
trim_trailing_whitespace = false
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,7 @@ AGENTS.md export-ignore

# source user guide
user_guide_src/ export-ignore
docs/ export-ignore
mkdocs.yml export-ignore
.nojekyll export-ignore
phpdoc.dist.xml export-ignore
48 changes: 46 additions & 2 deletions .github/workflows/test-userguide.yml
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
# When a Pull Request is opened that modifies
# the User Guide source, build the User Guide
# with Sphinx and let the contributor know of
# any errors.
# with Sphinx or MkDocs and let the contributor
# know of any errors.
name: Test User Guide

on:
pull_request:
paths:
- 'user_guide_src/**'
- 'docs/**'
- 'mkdocs.yml'
- 'admin/anchors.py'
- 'admin/llms.py'
- 'admin/update-docs-nav.php'
- '.github/workflows/test-userguide.yml'

concurrency:
Expand Down Expand Up @@ -42,3 +47,42 @@ jobs:
- name: Build Docs with Sphinx
run: make html SPHINXOPTS="-W --keep-going -w /tmp/sphinx-log"
working-directory: user_guide_src

markdown_check:
name: Check Markdown User Guide
runs-on: ubuntu-24.04

steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Setup PHP
uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0
with:
php-version: '8.2'
coverage: none

- name: Setup Python
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: '3.12'

- name: Install MkDocs
run: |
python -m pip install --upgrade pip
pip install -r docs/requirements.txt

- name: Check that every page is in the nav
run: php admin/update-docs-nav.php --check

- name: Build Docs with MkDocs
env:
NO_MKDOCS_2_WARNING: '1'
run: mkdocs build --strict

- name: Write the llms files and the anchor aliases
run: |
python admin/llms.py
python admin/anchors.py
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ php_errors.log
# User Guide Temp Files
#-------------------------
/user_guide_src/build/*
/site/
/venv/
__pycache__/

#-------------------------
# Test Files
Expand Down
42 changes: 42 additions & 0 deletions admin/anchors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""Adds the legacy anchor ids listed in the docs' .anchors.json to the headings of the built site."""

from __future__ import annotations

import html
import json
import re
import sys
from pathlib import Path

from llms import load


def main(config_path: Path) -> None:
config = load(config_path)
base = config_path.parent
docs = base / config.get("docs_dir", "docs")
site = base / config.get("site_dir", "site")
aliases = json.loads((docs / ".anchors.json").read_text(encoding="utf-8"))

for page, headings in aliases.items():
target = site / (page.removesuffix(".md") + ".html")

if not target.is_file():
print(f"WARNING - {page} has aliases but was not built", file=sys.stderr)
continue

content = target.read_text(encoding="utf-8")

for heading, ids in headings.items():
opening = re.compile(rf'(<h[1-6]\b[^>]*\bid="{re.escape(html.escape(heading))}"[^>]*>)')
anchors = "".join(f'<a id="{html.escape(id_)}"></a>' for id_ in ids)
content, count = opening.subn(lambda match: match.group(1) + anchors, content, count=1)

if count == 0:
print(f"WARNING - {page} has no heading #{heading} for its aliases", file=sys.stderr)

target.write_text(content, encoding="utf-8")


if __name__ == "__main__":
main(Path(sys.argv[1] if len(sys.argv) > 1 else "mkdocs.yml"))
95 changes: 95 additions & 0 deletions admin/llms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""Writes llms.txt, llms-full.txt and each page's Markdown beside its HTML in the built site."""

from __future__ import annotations

import re
import sys
from pathlib import Path

import yaml


class Loader(yaml.SafeLoader):
pass


Loader.add_multi_constructor("!", lambda loader, suffix, node: None)


def load(path: Path) -> dict:
return yaml.load(path.read_text(encoding="utf-8"), Loader=Loader) or {}


def title_of(markdown: Path) -> str:
fenced = False

for line in markdown.read_text(encoding="utf-8").splitlines():
if line.startswith("```"):
fenced = not fenced
elif not fenced and (match := re.match(r"# (.+?)\s*#*\s*$", line)):
return match.group(1)

return markdown.stem


def index_lines(items: list, docs: Path, depth: int = 0) -> list[str]:
indent = " " * max(depth - 1, 0)
lines: list[str] = []

for item in items:
title, target = next(iter(item.items())) if isinstance(item, dict) else (None, item)

if isinstance(target, list):
lines += ["", f"## {title}", ""] if depth == 0 else [f"{indent}- {title}"]
lines += index_lines(target, docs, depth + 1)
elif isinstance(target, str) and target.endswith(".md"):
lines.append(f"{indent}- [{title or title_of(docs / target)}]({target})")

return lines


def nav_pages(items: list) -> list[str]:
pages: list[str] = []

for item in items:
target = next(iter(item.values())) if isinstance(item, dict) else item

if isinstance(target, list):
pages += nav_pages(target)
elif isinstance(target, str) and target.endswith(".md"):
pages.append(target)

return pages


def main(config_path: Path) -> None:
config = load(config_path)
base = config_path.parent
nav = config.get("nav") or load(base / config["INHERIT"]).get("nav", [])
docs = base / config.get("docs_dir", "docs")
site = base / config.get("site_dir", "site")

in_nav = nav_pages(nav)
outside_nav = sorted(
path.relative_to(docs).as_posix() for path in docs.rglob("*.md") if path.relative_to(docs).as_posix() not in in_nav
)
pages = in_nav + outside_nav

for page in pages:
(site / page).write_text((docs / page).read_text(encoding="utf-8"), encoding="utf-8")

index = [f"# {config['site_name']}", "", f"> {config['site_description']}", "", *index_lines(nav, docs)]

if outside_nav:
index += ["", "## Other pages", ""]
index += [f"- [{title_of(docs / page)}]({page})" for page in outside_nav]

(site / "llms.txt").write_text("\n".join(index) + "\n", encoding="utf-8")
(site / "llms-full.txt").write_text(
"\n\n".join((docs / page).read_text(encoding="utf-8").strip() for page in pages) + "\n",
encoding="utf-8",
)


if __name__ == "__main__":
main(Path(sys.argv[1] if len(sys.argv) > 1 else "mkdocs.yml"))
Loading
Loading