From 6501448d0bbf54f64a8028268cc454bdc0ff21a0 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Fri, 6 Nov 2026 13:01:18 +0100 Subject: [PATCH 1/3] build(docs): move the site to Zensical Material for MkDocs reaches end of life on 2027-05-05 and its maintainers now build Zensical, so the docs site moves there before that date. Zensical reads the same Markdown and builds the same pages, with instant navigation, a Copy as Markdown button and an llms.txt index. It silently drops the nav and absolute-link validation MkDocs ran, so the Docs workflow now runs site-check.py on the built site to keep those checks. --- .github/dependabot.yml | 13 +- .github/scripts/site-check.py | 233 ++++++++++++++++++++++++++++++++++ .github/workflows/docs.yml | 61 +++++---- .gitignore | 2 +- docs/CI-CD.md | 2 +- docs/requirements-docs.txt | 3 +- docs/stylesheets/theme.css | 15 ++- mkdocs.yml | 159 ----------------------- zensical.toml | 123 ++++++++++++++++++ 9 files changed, 422 insertions(+), 189 deletions(-) create mode 100755 .github/scripts/site-check.py delete mode 100644 mkdocs.yml create mode 100644 zensical.toml diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ff2edff7..df87a7af 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,5 +1,5 @@ -# Dependency updates (docs/CI-CD.md CI-13): once a week per ecosystem, minor and patch updates in -# one pull request, each major in a pull request of its own. Every ignore says why on the line +# Dependency updates (docs/CI-CD.md CI-13): once a week per ecosystem (the docs pin once a month), +# minor and patch updates in one pull request, each major in a pull request of its own. Every ignore says why on the line # above it; tests/dependabot.test.ts fails on an ignore without a reason. # # Merge these with an explicit subject and body (`gh pr merge --squash --subject ... --body ...`): @@ -46,3 +46,12 @@ updates: groups: actions-minor-and-patch: update-types: [minor, patch] + + # The docs site's one pin (zensical). Monthly, not weekly: Zensical releases every week or two, + # and the strict build and site check in the Docs workflow prove each bump. + - package-ecosystem: "pip" + directory: "/docs" + schedule: + interval: "monthly" + commit-message: + prefix: "build(docs)" diff --git a/.github/scripts/site-check.py b/.github/scripts/site-check.py new file mode 100755 index 00000000..31a8df51 --- /dev/null +++ b/.github/scripts/site-check.py @@ -0,0 +1,233 @@ +# Copied from the new-repo templates into every docs repo. Repos run different ruff +# settings, so their formatter leaves this file as it is: lint it, do not reformat it. +# fmt: off +"""Post-build check of a Zensical site: the rules `zensical build --strict` does not enforce. + +usage: python3 -I .github/scripts/site-check.py [zensical.toml] + +Run from the repository root (the workflow does): the config is read from ./zensical.toml +unless its path is given. No config file is an error, never a skipped pass. + +Exit 1 on any of these, each printed as one ERROR line: + - a link or image whose target is not in the built site: a nav entry whose page does not + exist, a missing image, any other dangling href or src (Zensical validates Markdown page + links and anchors; it does not validate images, nav entries or raw HTML links). A redirect + stub's own links (canonical and body) are checked the same way; + - an href ending in .md: a correct page link is rewritten to a directory URL, so a .md left + in the HTML is a raw-HTML or unprocessed link, and it opens the llmstxt Markdown copy; + - a root-absolute link (/getting-started/) outside the site's own path prefix: it breaks + under https://geiserx.github.io// (MkDocs' `absolute_links: warn`, which Zensical + ignores); + - a page that no navigation entry links to: a stray file under docs/ published at a public + URL that nothing reaches (MkDocs' `nav.omitted_files: warn`, which Zensical ignores); + - a .py, .pyc or __pycache__ entry in the site: the awesome lists keep their Markdown + extension under docs/hooks/, and Zensical publishes every file not in exclude_docs; + - an awesome list's count token left in a page (``, `n:categorias`, + `n:retirados`): the Markdown extension that fills them in did not run. Only those three + names: CashPilot writes other `` comments of its own in its pages; + - a raw , <textarea>, <xmp>, <plaintext>, <noembed> or <noframes> tag inside a page's + article: a browser's HTML parser treats everything after it as text, so the rest of the + page disappears (Markdown passes `<title>` written in prose through as a real tag; + Telegram-Archive shipped that way); + - a "Copy as Markdown" button (data-md-copy-url) whose Markdown copy does not exist: the + llmstxt plugin writes the copies, and only for pages its `sections` globs match. +And, from zensical.toml (Zensical drops what it does not know without a word; under MkDocs +strict an unknown plugin aborted the build): + - a plugin name Zensical 0.1.0 does not know (`redirect` for `redirects`, `llmstxtt`): the + plugin's whole output silently vanishes otherwise; + - a theme feature name the Zensical UI 0.0.41 does not know (the list below is what its + templates and bundle test for; `search.suggest` and `search.share` are not in it); + - `theme.variant` other than "modern" or "classic" (anything else is classic, silently), + and a "modern" config whose index.html does not load the modern stylesheet; + - a `redirect_maps` entry whose stub was not written; + - a `[project.plugins.llmstxt]` table with no site/llms.txt, or `content.action.copy` in + the features with a page that carries no copy button. +Stdlib only (tomllib needs Python 3.11+), so it runs under `python -I` on any runner. +""" + +import re +import sys +import tomllib +from pathlib import Path +from urllib.parse import unquote, urlparse + +SWALLOW = re.compile(r"<(title|textarea|xmp|plaintext|noembed|noframes)\b", re.IGNORECASE) +TOKEN = re.compile(r"<!--\s*n:(?:proyectos|categorias|retirados)\s*-->") +ARTICLE = re.compile(r"<article\b.*?</article>", re.DOTALL) +ATTR = re.compile(r'\s(?:href|src)="([^"]*)"') +COPY = re.compile(r'\sdata-md-copy-url="([^"]*)"') +NAV = re.compile(r'<a\s+href="([^"]*)"\s+class="md-(?:nav|tabs)__link') +STUB = "<title>Redirecting..." + +# Keys of _PLUGIN_UNSUPPORTED_OPTIONS in zensical/config.py (0.1.0, same set as 0.0.74): every other name is +# dropped by `_convert_plugins` without a message. `material/` prefixes and `social/`, +# `privacy/`, `optimize/` instance names canonicalise the same way Zensical does. +KNOWN_PLUGINS = { + "api-autonav", "autorefs", "awesome-nav", "blog", "callouts", "exclude", "gh-admonitions", + "glightbox", "include-markdown", "literate-nav", "llmstxt", "macros", "markdown-exec", + "markdownextradata", "meta", "mike", "minify", "mkdocs-audio", "mkdocs-autoapi", + "mkdocs-nav-weight", "mkdocs-video", "mkdocstrings", "offline", "optimize", "privacy", + "redirects", "rss", "search", "social", "table-reader", "tags", +} +# Feature names the shipped UI tests for (zensical_ui 0.0.41, read from its templates and +# bundle.*.min.js). Regenerate after a bump with: +# grep -rhoE '"[a-z]+(\.[a-z]+)+" in features' /zensical_ui/templates | sort -u +# grep -oE '"(navigation|content|search|toc|header|announce)\.[a-z.]+"' .../bundle.*.min.js | sort -u +KNOWN_FEATURES = { + "announce.dismiss", "content.action.copy", "content.action.edit", "content.action.view", + "content.code.annotate", "content.code.copy", "content.code.select", + "content.footnote.tooltips", "content.lazy", "content.tabs.link", "content.tooltips", + "header.autohide", "navigation.expand", "navigation.footer", "navigation.indexes", + "navigation.instant", "navigation.instant.prefetch", "navigation.instant.preview", + "navigation.instant.progress", "navigation.path", "navigation.prune", "navigation.sections", + "navigation.tabs", "navigation.tabs.sticky", "navigation.top", "navigation.tracking", + "search.highlight", "toc.follow", "toc.integrate", +} + + +def body_urls(body: str) -> set[str]: + return set(ATTR.findall(body)) + + +def canonical_plugin(name: str) -> str: + name = name.removeprefix("material/") + return name.split("/", 1)[0] if name.startswith(("social/", "privacy/", "optimize/")) else name + + +def read_config(path: Path, errors: set[str]) -> dict: + if not path.is_file(): + errors.add(f"{path}: config file not found (run from the repository root, or pass its path)") + return {} + try: + project = tomllib.loads(path.read_text(encoding="utf-8")).get("project") + except (tomllib.TOMLDecodeError, UnicodeDecodeError) as e: + errors.add(f"{path}: not valid TOML: {e}") + return {} + # Every config check reads [project]: without it they would all pass by skipping. + if not isinstance(project, dict) or not project: + errors.add(f"{path}: no [project] table, so no config check can run") + return {} + return project + + +def main(site_dir: str, config_path: str) -> int: + site = Path(site_dir).resolve() + index = site / "index.html" + if not index.is_file(): + print(f"ERROR {site_dir}: no index.html (did the build run?)") + return 1 + m = re.search(r' in the page body hides everything after it in a browser") + for tok in TOKEN.findall(art): + errors.add(f"{rel}: build token {tok} was not filled in (extension not loaded?)") + # holds from the + # llmstxt plugin, a legitimate .md href; the .md rule looks at the body only. + body = text.split("", 1)[-1] + for url in ATTR.findall(text): + t = resolve(page, url) + if t == "ABS": + errors.add(f"{rel}: absolute link {url} (outside {prefix})") + elif t is not None and not t.is_relative_to(site): + # ../LICENSE from the top page: the file exists in the checkout, not on Pages. + errors.add(f"{rel}: {url} leaves the built site (resolves to {t})") + elif t is not None and not t.exists(): + shown = t.relative_to(site) if t.is_relative_to(site) else t + errors.add(f"{rel}: {url} -> missing {shown}") + elif t is not None and unquote(urlparse(url).path).endswith(".md") and url in body_urls(body): + errors.add(f"{rel}: {url} links a Markdown source, not a page URL (raw HTML link?)") + copies = COPY.findall(text) + for url in copies: + t = resolve(page, url) + if isinstance(t, Path) and not t.is_file(): + errors.add(f"{rel}: Copy as Markdown points at {url}, which was not written (llmstxt sections?)") + if rel.name == "index.html" and not stub and not copies \ + and "content.action.copy" in ((cfg.get("theme") or {}).get("features") or []): + errors.add(f"{rel}: content.action.copy is on but the page has no Copy as Markdown button") + for url in NAV.findall(text): + t = resolve(page, url) + if isinstance(t, Path): + nav_targets.add(t) + + for page in pages: + if page.resolve() not in nav_targets: + errors.add(f"{page.relative_to(site)}: page is in no navigation entry (stray page)") + + if cfg: + plugins = cfg.get("plugins") or {} + for name in plugins: + if canonical_plugin(name) not in KNOWN_PLUGINS: + errors.add(f"zensical.toml: plugin {name!r} is unknown to Zensical and silently ignored") + theme = cfg.get("theme") or {} + for f in theme.get("features") or []: + if f not in KNOWN_FEATURES: + errors.add(f"zensical.toml: feature {f!r} is unknown to the Zensical UI and silently ignored") + variant = theme.get("variant", "classic") + if variant not in ("modern", "classic"): + errors.add(f"zensical.toml: theme.variant {variant!r} is not modern or classic (Zensical falls back to classic silently)") + elif variant == "modern" and "assets/stylesheets/modern/" not in index.read_text(errors="replace"): + errors.add("index.html does not load the modern stylesheet although theme.variant is modern") + # The offline plugin turns directory URLs off (zensical/config.py, + # _apply_offline_plugin), so a redirect stub becomes old.html there. + offline = plugins.get("offline", False) + offline_on = "offline" in plugins and (offline or {}).get("enabled", True) is not False + directory_urls = cfg.get("use_directory_urls", True) and not offline_on + for src in (plugins.get("redirects") or {}).get("redirect_maps") or {}: + s = src.removesuffix(".md") + # A README page is its folder's index: sub/README.md -> sub/index.html in every mode. + if s == "README" or s.endswith("/README"): + s = s[: -len("README")] + "index" + if not directory_urls or s == "index" or s.endswith("/index"): + stub = site / (s + ".html") + else: + stub = site / s / "index.html" + if not stub.is_file() or STUB not in stub.read_text(errors="replace"): + errors.add(f"redirect_maps {src!r}: no redirect stub at {stub.relative_to(site)}") + if "llmstxt" in plugins and not (site / "llms.txt").is_file(): + errors.add("llmstxt plugin is configured but site/llms.txt was not written") + + for e in sorted(errors): + print("ERROR", e) + print(f"site-check: {len(pages)} pages, {len(errors)} errors, prefix {prefix}") + return 1 if errors else 0 + + +if __name__ == "__main__": + if len(sys.argv) not in (2, 3): + sys.exit("usage: site-check.py [zensical.toml]") + sys.exit(main(sys.argv[1], sys.argv[2] if len(sys.argv) == 3 else "zensical.toml")) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 1fe049b1..70f1ef6f 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -2,31 +2,31 @@ name: Docs # Copy to .github/workflows/docs.yml. Before the first run, set the repository's # Pages source to "GitHub Actions": Settings -> Pages -> Source, or -# `gh api -X POST repos/GeiserX//pages -f build_type=workflow` for a repo -# with no Pages yet. For a repo already publishing from a branch the API is a -# PUT, and its docs list `source.branch` and `source.path` as required, so the -# bare `-f build_type=workflow` PUT may 422: try it on CashPilot first and -# record which form worked. A deploy while the source is still a branch is -# expected to fail; that is the test that the switch happened. +# `gh api -X PUT repos/GeiserX//pages -f build_type=workflow` (HTTP 204; +# the API keeps reporting the old source branch afterwards, which is cosmetic). +# A repo with no Pages at all uses `-X POST` with the same field. A repo +# already on the artifact flow needs no change. # # Never for a repo whose gh-pages branch holds a Helm chart index (index.yaml, -# artifacthub-repo.yml): Pumperly, LynxPrompt, genieacs-container, -# duplicacy-container. Those use docs-pages-helm.yml instead. +# artifacthub-repo.yml): Pumperly, LynxPrompt, genieacs-container. Those use +# docs-pages-helm.yml instead. # -# Pull requests that touch the docs run the strict build, so a broken link, a -# missing page or an unreachable page fails the check instead of reaching the -# site. A push to main deploys, and only after the same build has passed. +# Pull requests that touch the docs run the strict build and the site check, +# so a broken link, a missing page or an unreachable page fails the check +# instead of reaching the site. A push to main deploys, and only after the +# same build has passed. # -# The site is published as a Pages artifact, never with `mkdocs gh-deploy`: -# no gh-pages branch, no `contents: write`, and one artifact is the whole site. +# The site is published as a Pages artifact: no gh-pages branch, no +# `contents: write`, and one artifact is the whole site. on: push: branches: - main paths: - 'docs/**' - - 'mkdocs.yml' + - 'zensical.toml' - '.github/workflows/docs.yml' + - '.github/scripts/site-check.py' # Add every file a page includes with pymdownx.snippets (for example # docker-compose.yml, .env.example): a change to it must rebuild the # site, and a rename must fail the build. @@ -36,9 +36,11 @@ on: pull_request: workflow_dispatch: -# One run at a time per ref, in push order, never cancelled. Locking only the -# deploy job is not enough: an older push whose build finishes later would -# deploy last and publish a stale site. Pull requests get their own group. +# One run at a time per ref, never cancelled. GitHub keeps one running and one +# pending run per group and a newer push replaces the pending one, so the last +# deploy is the newest push; it does not promise strict push order beyond that. +# Locking only the deploy job is not enough: an older push whose build finishes +# later could deploy last and publish a stale site. Pull requests get their own group. concurrency: group: pages-${{ github.ref }} cancel-in-progress: false @@ -55,32 +57,45 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - # This job never pushes; a persisted token is readable by pip and every MkDocs plugin. + # This job never pushes; a persisted token is readable by pip and by the build. persist-credentials: false - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 with: python-version: '3.13' + # pip's download cache only. Zensical's own build cache (.cache/) is + # never kept between runs: its docs advise against it on CI, and + # --clean below starts from nothing anyway. cache: pip cache-dependency-path: docs/requirements-docs.txt - name: Install dependencies run: pip install -r docs/requirements-docs.txt - # mkdocs.yml sets `strict: true`; the flag here keeps the build strict - # even if someone removes that line. + # zensical.toml sets `strict = true`; the flag here keeps the build strict + # even if someone removes that line. `python -m zensical`, never the bare + # `zensical` command: it puts the repo root on sys.path, which the awesome + # lists' Markdown extension needs, and every repo builds the same way. - name: Build - run: mkdocs build --strict + run: python -m zensical build --clean --strict + # What the strict build does not check: a page in no nav entry, a nav + # entry for a missing file, a missing image, a /rooted link, a raw + # in a page. Each one was proven to turn this step red (validate-zensical.sh). + - name: Check the built site + run: python -I .github/scripts/site-check.py site + + # Push to main only: workflow_dispatch can be run on any branch, and the + # push filter above does not apply to it. Same line as docs-pages-helm.yml. - name: Upload the site - if: github.event_name != 'pull_request' + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: path: site deploy: # Runs on push to main only, after the build job has passed. - if: github.event_name != 'pull_request' + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' needs: build runs-on: ubuntu-latest permissions: diff --git a/.gitignore b/.gitignore index f6f95c94..1053db8c 100644 --- a/.gitignore +++ b/.gitignore @@ -21,5 +21,5 @@ artifacts/ # Beads: bead text lives in the Dolt tracker only, never in git .beads/*.jsonl -# mkdocs build output +# docs build output (zensical) /site/ diff --git a/docs/CI-CD.md b/docs/CI-CD.md index a62a2f31..1103b5e5 100644 --- a/docs/CI-CD.md +++ b/docs/CI-CD.md @@ -96,7 +96,7 @@ CI-3 (squash only) is now part of CI-1. CI-6 (remove the leftover workflow) and | # | Item | P | From | Acceptance | Today | |---|---|---|---|---|---| -| CI-13 | Dependabot, grouped and weekly | P1 | audit (disabled); Minutes groups by ecosystem | `.github/dependabot.yml` for `bun`, `cargo` and `github-actions`: minor and patch grouped in one PR per ecosystem per week, majors alone, each ignore with its reason. Dependabot alerts and security updates turned on. Squash-merged with an explicit subject and body so no trailer is added | has: `.github/dependabot.yml` (ignores `@types/bun`, which moves with `.bun-version`, and `electrobun`, which moves with the Hutch pins), checked by `tests/dependabot.test.ts`; alerts and security updates on since 2026-09-25 | +| CI-13 | Dependabot, grouped and weekly | P1 | audit (disabled); Minutes groups by ecosystem | `.github/dependabot.yml` for `bun`, `cargo` and `github-actions`: minor and patch grouped in one PR per ecosystem per week, majors alone, each ignore with its reason. Dependabot alerts and security updates turned on. Squash-merged with an explicit subject and body so no trailer is added | has: `.github/dependabot.yml` (ignores `@types/bun`, which moves with `.bun-version`, and `electrobun`, which moves with the Hutch pins), checked by `tests/dependabot.test.ts`; a monthly `pip` entry for `/docs` bumps the docs site's `zensical` pin; alerts and security updates on since 2026-09-25 | | CI-14 | Audits beyond alerts | P2 | alerts miss advisories that `audit` finds | Nightly `bun audit` and `cargo audit`; a high or critical finding fails the night | missing | | CI-15 | CodeQL | P1 | audit (no code scanning) | Code scanning default setup on, first analysis visible under the Security tab | has: default setup on since 2026-09-25 (actions, JavaScript and TypeScript, Rust); the first analysis ran green and opened 13 medium alerts (12 `js/prototype-polluting-assignment` in `src/main/asr/live-worker.ts`, one `js/bad-code-sanitization` in a test), all closed by #42; none open | | CI-16 | Workflow lint and security scan | P1 | Minutes (actionlint), anarlog (zizmor); public repo | actionlint and zizmor run in `lint` on any `.github/` change; zizmor findings at medium or above fail. zizmor's `unpinned-uses` audit runs with a hash-pin policy for every action, so an action referenced by tag fails; `dangerous-triggers` fails on `pull_request_target`; `artipacked` requires `persist-credentials: false` on every checkout that does not push. One more line in `lint` fails on `secrets.` referenced by any job a `pull_request` event can run. A job may read secrets only when its `if:` limits it to a tag push or `workflow_dispatch`, so a packaging dry run on a pull request builds unsigned. Positive controls: a scratch workflow using `actions/checkout@v4` by tag fails `lint`, and so does a job that reads `secrets.` with a `pull_request` trigger and no such `if:` | partial: SHA pins and no `pull_request_target`; nothing enforces them. The release workflow reads every secret behind `github.event_name == 'push'` or `!= 'pull_request'`, which `tests/release.test.ts` checks | diff --git a/docs/requirements-docs.txt b/docs/requirements-docs.txt index 42c6096f..f01d4c2f 100644 --- a/docs/requirements-docs.txt +++ b/docs/requirements-docs.txt @@ -1,2 +1 @@ -mkdocs-material==9.7.7 -mkdocs-redirects==1.2.2 +zensical==0.1.0 diff --git a/docs/stylesheets/theme.css b/docs/stylesheets/theme.css index 2f66efc7..f7dc8b7a 100644 --- a/docs/stylesheets/theme.css +++ b/docs/stylesheets/theme.css @@ -45,12 +45,18 @@ * scroll. Styling both would change every page title too. */ .md-header__topic:first-child .md-ellipsis { - color: #f2f3f5; font-weight: 700; letter-spacing: 0.04em; text-transform: lowercase; } +/* That colour only on the dark scheme: Zensical's modern header takes the page + background, so on the light scheme it is white and a light name would vanish. + There the name keeps the default text colour. */ +[data-md-color-scheme="slate"] .md-header__topic:first-child .md-ellipsis { + color: #f2f3f5; +} + /* Keep the mark and the name close, so they read as one unit. */ .md-header__button.md-logo { padding-right: 0.2rem; @@ -184,3 +190,10 @@ grid-template-columns: 1fr; } } + +/* The logo is the #f2f3f5 mark alone, which vanishes on the light scheme's + white header. There it sits on the app icon's plate, as in the Dock. */ +[data-md-color-scheme="default"] .md-header__button.md-logo img { + background: linear-gradient(#212327, #0e1014); + border-radius: 22%; +} diff --git a/mkdocs.yml b/mkdocs.yml deleted file mode 100644 index a2f61d5f..00000000 --- a/mkdocs.yml +++ /dev/null @@ -1,159 +0,0 @@ -site_name: akou -site_url: https://geiserx.github.io/akou/ -site_description: Record your calls on your own computer, transcribe them on the machine, and ask about a call while it runs. -repo_url: https://github.com/GeiserX/akou -repo_name: GeiserX/akou -edit_uri: edit/main/docs/ - -# A warning is a broken link, a missing page or a page nobody can reach. All of -# them fail the build, locally and in CI, so nobody has to remember --strict. -strict: true - -theme: - name: material - language: en # es for a Spanish repo: it changes the UI strings, not the content. - logo: images/logo.svg - favicon: images/icon.svg - # Dark first: the first palette entry is the default, and the banners are dark. - palette: - - scheme: slate - primary: custom - accent: custom - toggle: - icon: material/brightness-4 - name: Switch to light mode - - scheme: default - primary: custom - accent: custom - toggle: - icon: material/brightness-7 - name: Switch to dark mode - features: - - navigation.tabs - - navigation.sections - - navigation.expand - - navigation.top - - content.code.copy - - content.action.edit - - search.highlight - - search.suggest - - toc.follow - icon: - repo: fontawesome/brands/github - -# MkDocs reports most link problems as `info`, which --strict ignores. Every -# line here promotes one of them to a warning so the build fails on it: -# nav.omitted_files a Markdown file under docs/ that is in no nav entry and -# not in exclude_docs (a page nobody can reach, published -# at a public URL anyway); -# nav.not_found a nav entry whose file does not exist; -# links.not_found a relative link to a page or file that does not exist; -# links.anchors a link to a heading that does not exist; -# links.absolute_links a link written as /path/ (breaks under the /akou/ -# prefix of the site); -# links.unrecognized_links a relative link that leaves docs/ (../README.md). -# Repo files are linked by absolute GitHub URL instead. -validation: - nav: - omitted_files: warn - not_found: warn - absolute_links: warn - links: - not_found: warn - anchors: warn - absolute_links: warn - unrecognized_links: warn - -plugins: - - search - - redirects: - redirect_maps: - install.md: getting-started.md - -markdown_extensions: - - admonition - - pymdownx.details - - pymdownx.superfences: - custom_fences: - - name: mermaid - class: mermaid - format: !!python/name:pymdownx.superfences.fence_code_format - - pymdownx.tabbed: - alternate_style: true - - pymdownx.highlight: - anchor_linenums: true - - pymdownx.inlinehilite - # base_path includes the repository root, so a page can include a file that - # ships (docker-compose.yml, .env.example) word for word with - # `--8<-- "docker-compose.yml"`. A copied example drifts from the real file; - # an included one cannot. check_paths makes a missing include fail the build - # instead of rendering an empty block. - - pymdownx.snippets: - base_path: - - . - - docs - check_paths: true - - pymdownx.mark - - attr_list - - md_in_html - # Renders :material-xxx: shortcodes as icons. The grid cards on index.md use - # them; without this they print as literal text. - - pymdownx.emoji: - emoji_index: !!python/name:material.extensions.emoji.twemoji - emoji_generator: !!python/name:material.extensions.emoji.to_svg - - tables - - toc: - permalink: true - -extra: - social: - - icon: fontawesome/brands/github - link: https://github.com/GeiserX/akou - - icon: fontawesome/brands/docker - link: https://hub.docker.com/r/drumsergio/akou - -extra_css: - - stylesheets/theme.css - -# Files under docs/ that are not pages. MkDocs publishes every Markdown file in -# docs/ whether or not it is in the nav, so anything that is not a page must be -# listed here. The patterns follow the docs/ contract: a page is a lowercase -# kebab-case .md file; anything with a capital letter (ROADMAP.md, CHANGELOG.md, -# GOAL.md, DESIGN.md) is a repo-facing note, and design/, research/ and notes/ -# hold material read on GitHub. Add a line for any other per-repo note. -exclude_docs: | - [A-Z]*.md - design/ - research/ - notes/ - requirements-docs.txt - # akou: test records, design specs and mockups, and the API file are read on GitHub. - gates/ - ux/ - api/ - -# One entry per canonical page, in the contract's order. Delete the lines for -# pages the repo does not have. A repo-specific page goes after the canonical -# one it extends (a `Metrics` page after Usage, a `Security` page after -# Configuration). The nav title must equal the page's H1. -nav: - - Home: index.md - - Get started: - - Getting started: getting-started.md - - Usage: usage.md - - Dictation: dictation.md - - Agents and the command line: agents.md - - Configuration: - - Configuration: configuration.md - - Providers: providers.md - - Hand-off to your knowledge system: knowledge-handoff.md - - Operations: - - Server mode: server.md - - GPUs and presets: server-hardware.md - - Troubleshooting: troubleshooting.md - - Reference: - - How it works: how-it-works.md - - Command-line reference: cli.md - - MCP tools: reference/mcp.md - - FAQ: faq.md - - Development: development.md diff --git a/zensical.toml b/zensical.toml new file mode 100644 index 00000000..1b1fc6e6 --- /dev/null +++ b/zensical.toml @@ -0,0 +1,123 @@ +# Generated from mkdocs.yml by mkdocs-to-zensical.py; the why of every block is in +# new-repo/templates/zensical.toml. Edit this file, not mkdocs.yml, which is gone. + +[project] +site_name = "akou" +site_url = "https://geiserx.github.io/akou/" +site_description = "Record your calls on your own computer, transcribe them on the machine, and ask about a call while it runs." +repo_url = "https://github.com/GeiserX/akou" +repo_name = "GeiserX/akou" +edit_uri = "edit/main/docs/" +strict = true +extra_css = ["stylesheets/theme.css"] +exclude_docs = """ +[A-Z]*.md +design/ +research/ +notes/ +requirements-docs.txt +# akou: test records, design specs and mockups, and the API file are read on GitHub. +gates/ +ux/ +api/ +""" +nav = [ + { Home = "index.md" }, + { "Get started" = [ + { "Getting started" = "getting-started.md" }, + { Usage = "usage.md" }, + { Dictation = "dictation.md" }, + { "Agents and the command line" = "agents.md" }, + ] }, + { Configuration = [ + { Configuration = "configuration.md" }, + { Providers = "providers.md" }, + { "Hand-off to your knowledge system" = "knowledge-handoff.md" }, + ] }, + { Operations = [ + { "Server mode" = "server.md" }, + { "GPUs and presets" = "server-hardware.md" }, + { Troubleshooting = "troubleshooting.md" }, + ] }, + { Reference = [ + { "How it works" = "how-it-works.md" }, + { "Command-line reference" = "cli.md" }, + { "MCP tools" = "reference/mcp.md" }, + { FAQ = "faq.md" }, + { Development = "development.md" }, + ] }, +] + +[project.validation] +invalid_links = true +invalid_link_anchors = true + +[project.theme] +language = "en" +variant = "modern" +logo = "images/logo.svg" +favicon = "images/icon.svg" +font = false +features = [ + "navigation.tabs", + "navigation.sections", + "navigation.expand", + "navigation.top", + "content.code.copy", + "content.action.edit", + "search.highlight", + "toc.follow", + "navigation.instant", + "navigation.instant.progress", + "content.action.copy", +] +icon.repo = "fontawesome/brands/github" + +[[project.theme.palette]] +scheme = "slate" +primary = "custom" +accent = "custom" +toggle.icon = "lucide/sun" +toggle.name = "Switch to light mode" + +[[project.theme.palette]] +scheme = "default" +primary = "custom" +accent = "custom" +toggle.icon = "lucide/moon" +toggle.name = "Switch to dark mode" + +[[project.extra.social]] +icon = "fontawesome/brands/github" +link = "https://github.com/GeiserX/akou" + +[[project.extra.social]] +icon = "fontawesome/brands/docker" +link = "https://hub.docker.com/r/drumsergio/akou" + +[project.plugins.search] + +[project.plugins.redirects.redirect_maps] +"install.md" = "getting-started.md" + +[project.plugins.llmstxt.sections] +Documentation = ["*.md", "*/*.md"] + +[project.markdown_extensions] +admonition = {} +"pymdownx.details" = {} +"pymdownx.superfences".custom_fences = [ + { name = "mermaid", class = "mermaid", format = "pymdownx.superfences.fence_code_format" }, +] +"pymdownx.tabbed".alternate_style = true +"pymdownx.highlight".anchor_linenums = true +"pymdownx.inlinehilite" = {} +"pymdownx.snippets".base_path = [".", "docs"] +"pymdownx.snippets".check_paths = true +"pymdownx.mark" = {} +attr_list = {} +md_in_html = {} +"pymdownx.emoji".emoji_index = "zensical.extensions.emoji.twemoji" +"pymdownx.emoji".emoji_generator = "zensical.extensions.emoji.to_svg" +tables = {} +toc.permalink = true From ef96e06173bd36db99e371e35674a771a7f3a9ca Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:08:21 +0100 Subject: [PATCH 2/3] fix(docs): check single-quoted links, same-site URLs and #fragments in the site check The shared post-build check read href/src with a double-quote regex, so a single-quoted or unquoted raw HTML link to a missing file passed, and text in inline code or scripts was read as links. It now parses the HTML. It also checks full URLs into the site itself, every #fragment against the target page's ids, a copy button pointing outside the site, and it fails when there is no site_url or canonical link to take the path prefix from. --- .github/scripts/site-check.py | 206 +++++++++++++++++++++++++--------- .github/workflows/docs.yml | 5 +- 2 files changed, 155 insertions(+), 56 deletions(-) diff --git a/.github/scripts/site-check.py b/.github/scripts/site-check.py index 31a8df51..1f2efc1f 100755 --- a/.github/scripts/site-check.py +++ b/.github/scripts/site-check.py @@ -10,14 +10,19 @@ Exit 1 on any of these, each printed as one ERROR line: - a link or image whose target is not in the built site: a nav entry whose page does not - exist, a missing image, any other dangling href or src (Zensical validates Markdown page - links and anchors; it does not validate images, nav entries or raw HTML links). A redirect - stub's own links (canonical and body) are checked the same way; + exist, a missing image, any other dangling href or src, in any quoting and with HTML + entities decoded (Zensical validates Markdown page links; it does not validate images, + nav entries or raw HTML links). A full URL into the site itself + (https://geiserx.github.io/<repo>/...) is checked the same way. A redirect stub's own + links (canonical and body) are checked too; + - a link whose #fragment is not an id on the target page, including links that come in + through a snippet include (Zensical 0.1.0 does not check those; MkDocs strict did); - an href ending in .md: a correct page link is rewritten to a directory URL, so a .md left in the HTML is a raw-HTML or unprocessed link, and it opens the llmstxt Markdown copy; - a root-absolute link (/getting-started/) outside the site's own path prefix: it breaks under https://geiserx.github.io/<repo>/ (MkDocs' `absolute_links: warn`, which Zensical - ignores); + ignores). The prefix comes from site_url, else the home page's canonical link; with + neither, that is an error rather than a check that cannot fire; - a page that no navigation entry links to: a stray file under docs/ published at a public URL that nothing reaches (MkDocs' `nav.omitted_files: warn`, which Zensical ignores); - a .py, .pyc or __pycache__ entry in the site: the awesome lists keep their Markdown @@ -25,12 +30,12 @@ - an awesome list's count token left in a page (`<!-- n:proyectos -->`, `n:categorias`, `n:retirados`): the Markdown extension that fills them in did not run. Only those three names: CashPilot writes other `<!-- n:... -->` comments of its own in its pages; - - a raw <title>, <textarea>, <xmp>, <plaintext>, <noembed> or <noframes> tag inside a page's - article: a browser's HTML parser treats everything after it as text, so the rest of the - page disappears (Markdown passes `<title>` written in prose through as a real tag; - Telegram-Archive shipped that way); - - a "Copy as Markdown" button (data-md-copy-url) whose Markdown copy does not exist: the - llmstxt plugin writes the copies, and only for pages its `sections` globs match. + - a raw <title>, or an unclosed <textarea>, <xmp>, <noembed>, <noframes>, or any + <plaintext>, in a page's article outside inline SVG and MathML: a browser's HTML parser + treats everything after it as text, so the rest of the page disappears (Markdown passes + `<title>` written in prose through as a real tag; Telegram-Archive shipped that way); + - a "Copy as Markdown" button (data-md-copy-url) whose Markdown copy does not exist in the + site: the llmstxt plugin writes the copies, and only for pages its `sections` globs match. And, from zensical.toml (Zensical drops what it does not know without a word; under MkDocs strict an unknown plugin aborted the build): - a plugin name Zensical 0.1.0 does not know (`redirect` for `redirects`, `llmstxtt`): the @@ -40,23 +45,25 @@ - `theme.variant` other than "modern" or "classic" (anything else is classic, silently), and a "modern" config whose index.html does not load the modern stylesheet; - a `redirect_maps` entry whose stub was not written; - - a `[project.plugins.llmstxt]` table with no site/llms.txt, or `content.action.copy` in - the features with a page that carries no copy button. + - an enabled llmstxt plugin with no site/llms.txt, or `content.action.copy` in the + features with a page that carries no copy button. +Plugin keys are read the way Zensical reads them (`material/redirects` is `redirects`), and +a plugin with `enabled = false` is treated as off. With directory URLs off (the offline +plugin turns them off) every .html file is a page. Stdlib only (tomllib needs Python 3.11+), so it runs under `python -I` on any runner. """ import re import sys import tomllib +from html.parser import HTMLParser from pathlib import Path from urllib.parse import unquote, urlparse SWALLOW = re.compile(r"<(title|textarea|xmp|plaintext|noembed|noframes)\b", re.IGNORECASE) +FOREIGN = re.compile(r"<(svg|math)\b.*?</\1\s*>", re.IGNORECASE | re.DOTALL) TOKEN = re.compile(r"<!--\s*n:(?:proyectos|categorias|retirados)\s*-->") ARTICLE = re.compile(r"<article\b.*?</article>", re.DOTALL) -ATTR = re.compile(r'\s(?:href|src)="([^"]*)"') -COPY = re.compile(r'\sdata-md-copy-url="([^"]*)"') -NAV = re.compile(r'<a\s+href="([^"]*)"\s+class="md-(?:nav|tabs)__link') STUB = "<title>Redirecting..." # Keys of _PLUGIN_UNSUPPORTED_OPTIONS in zensical/config.py (0.1.0, same set as 0.0.74): every other name is @@ -85,16 +92,84 @@ } -def body_urls(body: str) -> set[str]: - return set(ATTR.findall(body)) + + +class Page(HTMLParser): + """The parts of one built HTML file the checks read, from a real HTML parser: any + quoting, entities decoded, nothing taken from