]> git.ipfire.org Git - thirdparty/fastapi/fastapi.git/commitdiff
🔧 Migrate docs from MkDocs to Zensical (#15563)
authorSebastián Ramírez <tiangolo@gmail.com>
Tue, 19 May 2026 17:40:41 +0000 (19:40 +0200)
committerGitHub <noreply@github.com>
Tue, 19 May 2026 17:40:41 +0000 (19:40 +0200)
35 files changed:
.github/workflows/build-docs.yml
.gitignore
docs/de/docs/index.md
docs/de/mkdocs.yml [deleted file]
docs/en/docs/contributing.md
docs/en/docs/external-links.md
docs/en/docs/fastapi-people.md
docs/en/docs/index.md
docs/en/mkdocs.env.yml [deleted file]
docs/en/mkdocs.yml
docs/en/overrides/partials/copyright.html
docs/es/docs/index.md
docs/es/mkdocs.yml [deleted file]
docs/fr/docs/index.md
docs/fr/mkdocs.yml [deleted file]
docs/ja/docs/index.md
docs/ja/mkdocs.yml [deleted file]
docs/ko/docs/index.md
docs/ko/mkdocs.yml [deleted file]
docs/pt/docs/index.md
docs/pt/mkdocs.yml [deleted file]
docs/ru/docs/index.md
docs/ru/mkdocs.yml [deleted file]
docs/tr/docs/index.md
docs/tr/mkdocs.yml [deleted file]
docs/uk/docs/index.md
docs/uk/mkdocs.yml [deleted file]
docs/zh-hant/docs/index.md
docs/zh-hant/mkdocs.yml [deleted file]
docs/zh/docs/index.md
docs/zh/mkdocs.yml [deleted file]
pyproject.toml
scripts/docs.py
scripts/mkdocs_hooks.py [deleted file]
uv.lock

index d8e5d9e94387481098774a06342c6703645d91f2..f30ea3bef921acf45ea2ef6fb0587b054bd6284e 100644 (file)
@@ -34,14 +34,13 @@ jobs:
             - docs_src/**
             - pyproject.toml
             - uv.lock
-            - mkdocs.yml
-            - mkdocs.env.yml
             - .github/workflows/build-docs.yml
             - .github/workflows/deploy-docs.yml
-            - scripts/mkdocs_hooks.py
+            - scripts/docs.py
   langs:
     needs:
       - changes
+    if: ${{ needs.changes.outputs.docs == 'true' }}
     runs-on: ubuntu-latest
     outputs:
       langs: ${{ steps.show-langs.outputs.langs }}
@@ -103,21 +102,28 @@ jobs:
         run: uv run ./scripts/docs.py update-languages
       - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
         with:
-          key: mkdocs-cards-${{ matrix.lang }}-${{ github.ref }}
-          path: docs/${{ matrix.lang }}/.cache
+          key: zensical-${{ matrix.lang }}-${{ github.ref }}
+          path: site_zensical_src/${{ matrix.lang }}/.cache
       - name: Build Docs
         run: | # zizmor: ignore[template-injection] - comes from trusted source
           uv run ./scripts/docs.py build-lang ${{ matrix.lang }}
       - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
         with:
           name: docs-site-${{ matrix.lang }}
-          path: ./site/**
+          # English owns root static assets. Translated pages reference /img, /css,
+          # and /js, so omit duplicated language-local copies from artifacts.
+          path: |
+            ./site/**
+            !./site/${{ matrix.lang }}/img/**
+            !./site/${{ matrix.lang }}/css/**
+            !./site/${{ matrix.lang }}/js/**
           include-hidden-files: true
 
   # https://github.com/marketplace/actions/alls-green#why
   docs-all-green:  # This job does nothing and is only used for the branch protection
     if: always()
     needs:
+      - langs
       - build-docs
     runs-on: ubuntu-latest
     steps:
@@ -125,4 +131,4 @@ jobs:
         uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # v1.2.2
         with:
           jobs: ${{ toJSON(needs) }}
-          allowed-skips: build-docs
+          allowed-skips: langs, build-docs
index 243cdb93a560fdb1ae641a064634b87430687036..2c0d859ad7da16c3e1f6a99a9b121d2c2d9c412a 100644 (file)
@@ -7,6 +7,7 @@ __pycache__
 htmlcov
 dist
 site
+site_zensical_src
 .coverage*
 coverage.xml
 .netlify
index c71391432af56dde375752312497b3b86219d94a..d557554a121826f618cf64ed7955bda6787f2d66 100644 (file)
@@ -1,3 +1,8 @@
+---
+include_yaml:
+  sponsors: data/sponsors.yml
+---
+
 # FastAPI { #fastapi }
 
 <style>
diff --git a/docs/de/mkdocs.yml b/docs/de/mkdocs.yml
deleted file mode 100644 (file)
index de18856..0000000
+++ /dev/null
@@ -1 +0,0 @@
-INHERIT: ../en/mkdocs.yml
index fe7318f6cd6908b9c3f6530e7f96a0d0ddab7aba..d22fa09e584ed9d63d5322930e22b11b8b43bde1 100644 (file)
@@ -100,10 +100,10 @@ Go into the language directory, for the main docs in English it's at `docs/en/`:
 $ cd docs/en/
 ```
 
-Then run `mkdocs` in that directory:
+Then run `zensical` in that directory:
 
 ```console
-$ mkdocs serve --dev-addr 127.0.0.1:8008
+$ zensical serve --dev-addr 127.0.0.1:8008
 ```
 
 ///
@@ -129,7 +129,7 @@ Completion will take effect once you restart the terminal.
 
 ### Docs Structure
 
-The documentation uses [MkDocs](https://www.mkdocs.org/).
+The documentation uses [Zensical](https://zensical.org).
 
 And there are extra tools/scripts in place to handle translations in `./scripts/docs.py`.
 
index e92c881f4237216d5fa8e0435855bce241f0d4d3..e1614e818f32df704fab612bb00ddb13697b47e6 100644 (file)
@@ -1,3 +1,8 @@
+---
+include_yaml:
+  topic_repos: data/topic_repos.yml
+---
+
 # External Links
 
 **FastAPI** has a great community constantly growing.
index 28e0ce3d699170800c77072bcf2533a87bd3f98e..ad32966e57a3b8cd71183e22e9728f8c4137e3df 100644 (file)
@@ -1,6 +1,16 @@
 ---
 hide:
   - navigation
+
+include_yaml:
+  github_sponsors: data/github_sponsors.yml
+  people: data/people.yml
+  contributors: data/contributors.yml
+  translation_reviewers: data/translation_reviewers.yml
+  skip_users: data/skip_users.yml
+  members: data/members.yml
+  sponsors_badge: data/sponsors_badge.yml
+  sponsors: data/sponsors.yml
 ---
 
 # FastAPI People
index 149e44a044a5cfac18f8172bfea1bfe95fb76734..0aeee755ecffe48025b369d64ebedd866935b926 100644 (file)
@@ -1,3 +1,8 @@
+---
+include_yaml:
+  sponsors: data/sponsors.yml
+---
+
 # FastAPI { #fastapi }
 
 <style>
diff --git a/docs/en/mkdocs.env.yml b/docs/en/mkdocs.env.yml
deleted file mode 100644 (file)
index c5f6e07..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-# Define this here and not in the main mkdocs.yml file because that one is auto
-# updated and written, and the script would remove the env var
-markdown_extensions:
-  pymdownx.highlight:
-    linenums: !ENV [LINENUMS, false]
index bb67bca917e41a50dc949bd5301a29ccf96319ab..b2918cbb673236c2cbf5e2d1b3ef5d2572fdaf75 100644 (file)
@@ -1,10 +1,10 @@
-INHERIT: ../en/mkdocs.env.yml
 site_name: FastAPI
 site_description: FastAPI framework, high performance, easy to learn, fast to code, ready for production
 site_url: https://fastapi.tiangolo.com/
 theme:
+  variant: classic
   name: material
-  custom_dir: ../en/overrides
+  custom_dir: overrides
   palette:
   - media: (prefers-color-scheme)
     toggle:
@@ -45,38 +45,13 @@ theme:
   - search.suggest
   - toc.follow
   icon:
-    repo: fontawesome/brands/github-alt
+    repo: octicons/mark-github-24
   logo: img/icon-white.svg
   favicon: img/favicon.png
   language: en
 repo_name: fastapi/fastapi
 repo_url: https://github.com/fastapi/fastapi
 plugins:
-  social:
-    cards_layout_options:
-      logo: ../en/docs/img/icon-white.svg
-  typeset: null
-  search: null
-  macros:
-    include_yaml:
-    - github_sponsors: ../en/data/github_sponsors.yml
-    - people: ../en/data/people.yml
-    - contributors: ../en/data/contributors.yml
-    - translators: ../en/data/translators.yml
-    - translation_reviewers: ../en/data/translation_reviewers.yml
-    - skip_users: ../en/data/skip_users.yml
-    - members: ../en/data/members.yml
-    - sponsors_badge: ../en/data/sponsors_badge.yml
-    - sponsors: ../en/data/sponsors.yml
-    - topic_repos: ../en/data/topic_repos.yml
-  redirects:
-    redirect_maps:
-      deployment/deta.md: deployment/cloud.md
-      advanced/graphql.md: how-to/graphql.md
-      advanced/custom-request-and-route.md: how-to/custom-request-and-route.md
-      advanced/conditional-openapi.md: how-to/conditional-openapi.md
-      advanced/extending-openapi.md: how-to/extending-openapi.md
-      advanced/testing-database.md: how-to/testing-database.md
   mkdocstrings:
     handlers:
       python:
@@ -102,13 +77,13 @@ plugins:
 nav:
 - FastAPI: index.md
 - features.md
-- Learn:
+- "":
   - learn/index.md
   - python-types.md
   - async.md
   - environment-variables.md
   - virtual-environments.md
-  - Tutorial - User Guide:
+  - "":
     - tutorial/index.md
     - tutorial/first-steps.md
     - tutorial/path-params.md
@@ -137,14 +112,14 @@ nav:
     - tutorial/path-operation-configuration.md
     - tutorial/encoder.md
     - tutorial/body-updates.md
-    - Dependencies:
+    - "":
       - tutorial/dependencies/index.md
       - tutorial/dependencies/classes-as-dependencies.md
       - tutorial/dependencies/sub-dependencies.md
       - tutorial/dependencies/dependencies-in-path-operation-decorators.md
       - tutorial/dependencies/global-dependencies.md
       - tutorial/dependencies/dependencies-with-yield.md
-    - Security:
+    - "":
       - tutorial/security/index.md
       - tutorial/security/first-steps.md
       - tutorial/security/get-current-user.md
@@ -161,7 +136,7 @@ nav:
     - tutorial/static-files.md
     - tutorial/testing.md
     - tutorial/debugging.md
-  - Advanced User Guide:
+  - "":
     - advanced/index.md
     - advanced/stream-data.md
     - advanced/path-operation-advanced-configuration.md
@@ -173,7 +148,7 @@ nav:
     - advanced/response-headers.md
     - advanced/response-change-status-code.md
     - advanced/advanced-dependencies.md
-    - Advanced Security:
+    - "":
       - advanced/security/index.md
       - advanced/security/oauth2-scopes.md
       - advanced/security/http-basic-auth.md
@@ -199,7 +174,7 @@ nav:
     - advanced/strict-content-type.md
   - fastapi-cli.md
   - editor-support.md
-  - Deployment:
+  - "":
     - deployment/index.md
     - deployment/versions.md
     - deployment/fastapicloud.md
@@ -209,7 +184,7 @@ nav:
     - deployment/cloud.md
     - deployment/server-workers.md
     - deployment/docker.md
-  - How To - Recipes:
+  - "":
     - how-to/index.md
     - how-to/general.md
     - how-to/migrate-from-pydantic-v1-to-pydantic-v2.md
@@ -222,7 +197,7 @@ nav:
     - how-to/configure-swagger-ui.md
     - how-to/testing-database.md
     - how-to/authentication-error-status-code.md
-- Reference (Code API):
+- "":
   - reference/index.md
   - reference/fastapi.md
   - reference/parameters.md
@@ -238,7 +213,7 @@ nav:
   - reference/response.md
   - reference/responses.md
   - reference/middleware.md
-  - OpenAPI:
+  - "":
     - reference/openapi/index.md
     - reference/openapi/docs.md
     - reference/openapi/models.md
@@ -248,7 +223,7 @@ nav:
   - reference/templating.md
   - reference/testclient.md
 - fastapi-people.md
-- Resources:
+- "":
   - resources/index.md
   - help-fastapi.md
   - contributing.md
@@ -256,7 +231,7 @@ nav:
   - external-links.md
   - newsletter.md
   - management-tasks.md
-- About:
+- "":
   - about/index.md
   - alternatives.md
   - history-design-future.md
@@ -264,10 +239,7 @@ nav:
   - management.md
 - release-notes.md
 markdown_extensions:
-  material.extensions.preview:
-    targets:
-      include:
-      - '*'
+  zensical.extensions.macros: null
   abbr: null
   attr_list: null
   footnotes: null
@@ -312,16 +284,16 @@ markdown_extensions:
   markdown_include_variants: null
 extra:
   social:
-  - icon: fontawesome/brands/github-alt
+  - icon: octicons/mark-github-24
     link: https://github.com/fastapi/fastapi
   - icon: fontawesome/brands/discord
     link: https://discord.gg/VQjSZaeJmf
-  - icon: fontawesome/brands/twitter
+  - icon: fontawesome/brands/x-twitter
     link: https://x.com/fastapi
+  - icon: fontawesome/brands/bluesky
+    link: https://bsky.app/profile/fastapi.tiangolo.com
   - icon: fontawesome/brands/linkedin
     link: https://www.linkedin.com/company/fastapi
-  - icon: fontawesome/solid/globe
-    link: https://tiangolo.com
   alternate:
   - link: /
     name: en - English
@@ -354,5 +326,5 @@ extra_javascript:
 - js/termynal.js
 - js/custom.js
 - js/init_kapa_widget.js
-hooks:
-- ../../scripts/mkdocs_hooks.py
+validation:
+  unresolved_references: false
index dcca89abe30e15b47b4004acd2e0e3dbb08c5e9d..9c9b60f68025ef079e1376e24e7ea3f981ad3cbd 100644 (file)
@@ -4,8 +4,8 @@
     </div>
     {% if not config.extra.generator == false %}
     Made with
-    <a href="https://squidfunk.github.io/mkdocs-material/" target="_blank" rel="noopener">
-        Material for MkDocs
+    <a href="https://zensical.org" target="_blank" rel="noopener">
+        Zensical
     </a>
     {% endif %}
 </div>
index bcae23914ab7589bd2f0a37efd4353408085f3de..1217c4c6faa66db71bac48bd122d96eeba848f2c 100644 (file)
@@ -1,3 +1,8 @@
+---
+include_yaml:
+  sponsors: data/sponsors.yml
+---
+
 # FastAPI { #fastapi }
 
 <style>
diff --git a/docs/es/mkdocs.yml b/docs/es/mkdocs.yml
deleted file mode 100644 (file)
index de18856..0000000
+++ /dev/null
@@ -1 +0,0 @@
-INHERIT: ../en/mkdocs.yml
index a351071f07a80d52131b7944dfa3a99e4faad7e2..4c5bea3e4c37a9c6badcb1cb933d510b70e08a78 100644 (file)
@@ -1,3 +1,8 @@
+---
+include_yaml:
+  sponsors: data/sponsors.yml
+---
+
 # FastAPI { #fastapi }
 
 <style>
diff --git a/docs/fr/mkdocs.yml b/docs/fr/mkdocs.yml
deleted file mode 100644 (file)
index de18856..0000000
+++ /dev/null
@@ -1 +0,0 @@
-INHERIT: ../en/mkdocs.yml
index 2068874eb276f60761fca4494c985949e3c06528..ac4d1242ed0a9fe1b7016993e360fb26a9bc1985 100644 (file)
@@ -1,3 +1,8 @@
+---
+include_yaml:
+  sponsors: data/sponsors.yml
+---
+
 # FastAPI { #fastapi }
 
 <style>
diff --git a/docs/ja/mkdocs.yml b/docs/ja/mkdocs.yml
deleted file mode 100644 (file)
index de18856..0000000
+++ /dev/null
@@ -1 +0,0 @@
-INHERIT: ../en/mkdocs.yml
index 719fae8219e2c4f133c22636eba453eee3074a86..0dd0bef59eda30f4bb2641fbd662f742a8befc3a 100644 (file)
@@ -1,3 +1,8 @@
+---
+include_yaml:
+  sponsors: data/sponsors.yml
+---
+
 # FastAPI { #fastapi }
 
 <style>
diff --git a/docs/ko/mkdocs.yml b/docs/ko/mkdocs.yml
deleted file mode 100644 (file)
index de18856..0000000
+++ /dev/null
@@ -1 +0,0 @@
-INHERIT: ../en/mkdocs.yml
index a982657d869aa9a0542bc990c8c35ec2ec7abd57..6f54cd6dc3954545e1c96b4dcb44318c4a44ff3c 100644 (file)
@@ -1,3 +1,8 @@
+---
+include_yaml:
+  sponsors: data/sponsors.yml
+---
+
 # FastAPI { #fastapi }
 
 <style>
diff --git a/docs/pt/mkdocs.yml b/docs/pt/mkdocs.yml
deleted file mode 100644 (file)
index de18856..0000000
+++ /dev/null
@@ -1 +0,0 @@
-INHERIT: ../en/mkdocs.yml
index 10184990b26beefaa7af613e8dcfd7c95354b5f5..015b9769ef7584584bd2a2593606a926e47409bc 100644 (file)
@@ -1,3 +1,8 @@
+---
+include_yaml:
+  sponsors: data/sponsors.yml
+---
+
 # FastAPI { #fastapi }
 
 <style>
diff --git a/docs/ru/mkdocs.yml b/docs/ru/mkdocs.yml
deleted file mode 100644 (file)
index de18856..0000000
+++ /dev/null
@@ -1 +0,0 @@
-INHERIT: ../en/mkdocs.yml
index 4ad59fa0a9f155eb966b4dd691e64dfe1702cc2b..f6101be0d374a01e2b7629224745f779dfa5c8cc 100644 (file)
@@ -1,3 +1,8 @@
+---
+include_yaml:
+  sponsors: data/sponsors.yml
+---
+
 # FastAPI { #fastapi }
 
 <style>
diff --git a/docs/tr/mkdocs.yml b/docs/tr/mkdocs.yml
deleted file mode 100644 (file)
index de18856..0000000
+++ /dev/null
@@ -1 +0,0 @@
-INHERIT: ../en/mkdocs.yml
index 213cf8db4a3a1f880079dc2b418b5765c533039a..2b770ff39902ad7b643fbb24e808c22425a48f96 100644 (file)
@@ -1,3 +1,8 @@
+---
+include_yaml:
+  sponsors: data/sponsors.yml
+---
+
 # FastAPI { #fastapi }
 
 <style>
diff --git a/docs/uk/mkdocs.yml b/docs/uk/mkdocs.yml
deleted file mode 100644 (file)
index de18856..0000000
+++ /dev/null
@@ -1 +0,0 @@
-INHERIT: ../en/mkdocs.yml
index 60122a74ed790f57c914a88abc02a49b82412f11..09974e59dc5f86a8c83dea08be2f0ccdadc32b99 100644 (file)
@@ -1,3 +1,8 @@
+---
+include_yaml:
+  sponsors: data/sponsors.yml
+---
+
 # FastAPI { #fastapi }
 
 <style>
diff --git a/docs/zh-hant/mkdocs.yml b/docs/zh-hant/mkdocs.yml
deleted file mode 100644 (file)
index de18856..0000000
+++ /dev/null
@@ -1 +0,0 @@
-INHERIT: ../en/mkdocs.yml
index c1d1c1f9b396f75c80ea13e264ead4fcf6312f18..f89d0a653181c9dc88de85c2a6999cf59f1186f9 100644 (file)
@@ -1,3 +1,8 @@
+---
+include_yaml:
+  sponsors: data/sponsors.yml
+---
+
 # FastAPI { #fastapi }
 
 <style>
diff --git a/docs/zh/mkdocs.yml b/docs/zh/mkdocs.yml
deleted file mode 100644 (file)
index de18856..0000000
+++ /dev/null
@@ -1 +0,0 @@
-INHERIT: ../en/mkdocs.yml
index 8b066c125e75e6cf6ffdffd0feb66c889e16f886..4bac4757b4c30e80166efc768b3fc59541aeb0c4 100644 (file)
@@ -132,21 +132,17 @@ docs = [
     { include-group = "docs-tests" },
     "black >=25.1.0",
     "cairosvg >=2.8.2",
-    # for MkDocs live reload
-    "click==8.2.1",
     "griffe-typingdoc >=0.3.0",
     "griffe-warnings-deprecated >=1.1.0",
     "jieba >=0.42.1",
     "markdown-include-variants >=0.0.8",
     "mdx-include >=1.4.1,<2.0.0",
-    "mkdocs-macros-plugin >=1.5.0",
-    "mkdocs-material >=9.7.0",
-    "mkdocs-redirects >=1.2.1,<1.3.0",
-    "mkdocstrings[python] >=0.30.1",
+    "mkdocstrings[python] >=1.0.3",
     "pillow >=11.3.0",
     "python-slugify >=8.0.4",
     "pyyaml >=5.3.1,<7.0.0",
     "typer >=0.21.1",
+    "zensical >=0.0.42",
 ]
 docs-tests = [
     "httpx >=0.23.0,<1.0.0",
index c36f976d5f8d6f55fe30857877674d8e992cc687..a273cab2f870837ba9ce34ddcbf9b8877b1b6c91 100644 (file)
@@ -10,7 +10,6 @@ from multiprocessing import Pool
 from pathlib import Path
 from typing import Any
 
-import mkdocs.utils
 import typer
 import yaml
 from jinja2 import Template
@@ -39,10 +38,6 @@ app = typer.Typer()
 
 mkdocs_name = "mkdocs.yml"
 
-missing_translation_snippet = """
-{!../../docs/missing-translation.md!}
-"""
-
 non_translated_sections = (
     f"reference{os.sep}",
     "release-notes.md",
@@ -58,7 +53,7 @@ docs_path = Path("docs")
 en_docs_path = Path("docs/en")
 en_config_path: Path = en_docs_path / mkdocs_name
 site_path = Path("site").absolute()
-build_site_path = Path("site_build").absolute()
+zensical_src_path = Path("site_zensical_src").absolute()
 
 header_pattern = re.compile(r"^(#{1,6}) (.+?)(?:\s*\{\s*(#.*)\s*\})?\s*$")
 header_with_permalink_pattern = re.compile(r"^(#{1,6}) (.+?)(\s*\{\s*#.*\s*\})\s*$")
@@ -105,7 +100,7 @@ def slugify(text: str) -> str:
 
 
 def get_en_config() -> dict[str, Any]:
-    return mkdocs.utils.yaml_load(en_config_path.read_text(encoding="utf-8"))
+    return yaml.unsafe_load(en_config_path.read_text(encoding="utf-8"))
 
 
 def get_lang_paths() -> list[Path]:
@@ -142,8 +137,6 @@ def new_lang(lang: str = typer.Argument(..., callback=lang_callback)):
         typer.echo(f"The language was already created: {lang}")
         raise typer.Abort()
     new_path.mkdir()
-    new_config_path: Path = Path(new_path) / mkdocs_name
-    new_config_path.write_text("INHERIT: ../en/mkdocs.yml\n", encoding="utf-8")
     new_llm_prompt_path: Path = new_path / "llm-prompt.md"
     new_llm_prompt_path.write_text("", encoding="utf-8")
     print(f"Successfully initialized: {new_path}")
@@ -159,29 +152,158 @@ def build_lang(
     """
     Build the docs for a language.
     """
-    lang_path: Path = Path("docs") / lang
-    if not lang_path.is_dir():
+    build_zensical_lang_to_stage(lang)
+    copy_zensical_stage_to_site(lang)
+    typer.secho(f"Successfully built docs for: {lang}", color=typer.colors.GREEN)
+
+
+def split_markdown_header(markdown: str) -> tuple[str, str]:
+    prefix = ""
+    if markdown.startswith("---\n"):
+        front_matter_end = markdown.find("\n---\n", 4)
+        if front_matter_end != -1:
+            front_matter_end += len("\n---\n")
+            prefix = markdown[:front_matter_end]
+            markdown = markdown[front_matter_end:]
+    if markdown.startswith("#"):
+        header, separator, body = markdown.partition("\n\n")
+        if separator:
+            return f"{prefix}{header}", body
+    if prefix:
+        return prefix.rstrip("\n"), markdown
+    return "", markdown
+
+
+def add_markdown_notice(markdown: str, notice: str) -> str:
+    header, body = split_markdown_header(markdown)
+    if header:
+        return f"{header}\n\n{notice}\n\n{body}"
+    return f"{notice}\n\n{body}"
+
+
+def is_non_translated_path(path: Path) -> bool:
+    src_path = path.as_posix()
+    return any(src_path.startswith(section) for section in non_translated_sections)
+
+
+def get_en_url(path: Path) -> str:
+    url_path = path.with_suffix("").as_posix()
+    if url_path.endswith("/index"):
+        url_path = url_path.removesuffix("index")
+    elif url_path != "index":
+        url_path = f"{url_path}/"
+    else:
+        url_path = ""
+    return f"https://fastapi.tiangolo.com/{url_path}"
+
+
+def get_zensical_theme_language(lang: str) -> str:
+    if lang == "zh-hant":
+        return "zh-Hant"
+    return lang
+
+
+def stage_zensical_docs(lang: str) -> Path:
+    lang_docs_path = docs_path / lang / "docs"
+    if not lang_docs_path.is_dir():
         typer.echo(f"The language translation doesn't seem to exist yet: {lang}")
         raise typer.Abort()
-    typer.echo(f"Building docs for: {lang}")
-    build_site_dist_path = build_site_path / lang
+
+    en_docs_source_path = en_docs_path / "docs"
+    staged_docs_src_path = zensical_src_path / "docs_src"
+    if not staged_docs_src_path.exists():
+        shutil.copytree(Path("docs_src"), staged_docs_src_path, dirs_exist_ok=True)
+    lang_stage_path = zensical_src_path / lang
+    staged_docs_path = lang_stage_path / "content"
+    shutil.rmtree(lang_stage_path, ignore_errors=True)
+    shutil.copytree(en_docs_source_path, staged_docs_path)
+
+    missing_translation = (docs_path / "missing-translation.md").read_text(
+        encoding="utf-8"
+    )
+    translation_banner_path = lang_docs_path / "translation-banner.md"
+    if not translation_banner_path.is_file():
+        translation_banner_path = en_docs_source_path / "translation-banner.md"
+    translation_banner = translation_banner_path.read_text(encoding="utf-8")
+
+    if lang != "en":
+        for staged_file in staged_docs_path.rglob("*.md"):
+            relative_path = staged_file.relative_to(staged_docs_path)
+            translated_file = lang_docs_path / relative_path
+            if translated_file.is_file():
+                markdown = translated_file.read_text(encoding="utf-8")
+                if relative_path.name == "translation-banner.md":
+                    staged_file.write_text(markdown, encoding="utf-8")
+                    continue
+                en_url = get_en_url(relative_path)
+                banner = translation_banner.replace("ENGLISH_VERSION_URL", en_url)
+                staged_file.write_text(
+                    add_markdown_notice(markdown, banner), encoding="utf-8"
+                )
+            elif not is_non_translated_path(relative_path):
+                markdown = staged_file.read_text(encoding="utf-8")
+                staged_file.write_text(
+                    add_markdown_notice(markdown, missing_translation),
+                    encoding="utf-8",
+                )
+
+    shutil.copytree(en_docs_path / "data", lang_stage_path / "data")
+    shutil.copytree(en_docs_path / "overrides", lang_stage_path / "overrides")
+
+    config = get_updated_config_content()
+    config["docs_dir"] = "content"
+    config["site_dir"] = "site"
+    if lang == "en":
+        config["site_url"] = "https://fastapi.tiangolo.com/"
+    else:
+        config["site_url"] = f"https://fastapi.tiangolo.com/{lang}/"
+    config.setdefault("theme", {})
+    config["theme"]["language"] = get_zensical_theme_language(lang)
+    if lang != "en":
+        # The root English build owns shared static assets; translated builds should
+        # reference those root paths instead of emitting language-local copies.
+        if "logo" in config["theme"]:
+            config["theme"]["logo"] = "/" + config["theme"]["logo"].lstrip("/")
+        if "favicon" in config["theme"]:
+            config["theme"]["favicon"] = "/" + config["theme"]["favicon"].lstrip("/")
+        config["extra_css"] = ["/" + path.lstrip("/") for path in config["extra_css"]]
+        config["extra_javascript"] = [
+            "/" + path.lstrip("/") for path in config["extra_javascript"]
+        ]
+    config_path = lang_stage_path / mkdocs_name
+    config_path.write_text(
+        yaml.dump(config, sort_keys=False, width=200, allow_unicode=True),
+        encoding="utf-8",
+    )
+    return config_path
+
+
+def build_zensical_config(config_path: Path) -> None:
+    subprocess.run(
+        ["zensical", "build", "--config-file", config_path.name],
+        check=True,
+        cwd=config_path.parent,
+    )
+
+
+def build_zensical_lang_to_stage(lang: str) -> Path:
+    typer.echo(f"Building Zensical docs for: {lang}")
+    config_path = stage_zensical_docs(lang)
+    config = yaml.unsafe_load(config_path.read_text(encoding="utf-8"))
+    build_site_dist_path = config_path.parent / config["site_dir"]
+    shutil.rmtree(build_site_dist_path, ignore_errors=True)
+    build_zensical_config(config_path)
+    return build_site_dist_path
+
+
+def copy_zensical_stage_to_site(lang: str) -> None:
+    build_site_dist_path = zensical_src_path / lang / "site"
     if lang == "en":
         dist_path = site_path
-        # Don't remove en dist_path as it might already contain other languages.
-        # When running build_all(), that function already removes site_path.
-        # All this is only relevant locally, on GitHub Actions all this is done through
-        # artifacts and multiple workflows, so it doesn't matter if directories are
-        # removed or not.
     else:
         dist_path = site_path / lang
         shutil.rmtree(dist_path, ignore_errors=True)
-    current_dir = os.getcwd()
-    os.chdir(lang_path)
-    shutil.rmtree(build_site_dist_path, ignore_errors=True)
-    subprocess.run(["mkdocs", "build", "--site-dir", build_site_dist_path], check=True)
     shutil.copytree(build_site_dist_path, dist_path, dirs_exist_ok=True)
-    os.chdir(current_dir)
-    typer.secho(f"Successfully built docs for: {lang}", color=typer.colors.GREEN)
 
 
 index_sponsors_template = """
@@ -223,7 +345,7 @@ def generate_readme_content() -> str:
     match_start = re.search(r"<!-- sponsors -->", content)
     match_end = re.search(r"<!-- /sponsors -->", content)
     sponsors_data_path = en_docs_path / "data" / "sponsors.yml"
-    sponsors = mkdocs.utils.yaml_load(sponsors_data_path.read_text(encoding="utf-8"))
+    sponsors = yaml.safe_load(sponsors_data_path.read_text(encoding="utf-8"))
     if not (match_start and match_end):
         raise RuntimeError("Couldn't auto-generate sponsors section")
     if not match_pre:
@@ -265,27 +387,33 @@ def generate_readme() -> None:
 @app.command()
 def build_all() -> None:
     """
-    Build mkdocs site for en, and then build each language inside, end result is located
-    at directory ./site/ with each language inside.
+    Build the full translated docs site into ./site/.
     """
     update_languages()
     shutil.rmtree(site_path, ignore_errors=True)
+    shutil.rmtree(zensical_src_path, ignore_errors=True)
+    shutil.copytree(Path("docs_src"), zensical_src_path / "docs_src")
     langs = [
         lang.name
         for lang in get_lang_paths()
         if (lang.is_dir() and lang.name in SUPPORTED_LANGS)
     ]
-    cpu_count = os.cpu_count() or 1
-    process_pool_size = cpu_count * 4
+    process_pool_size = min(4, len(langs), os.cpu_count() or 1)
     typer.echo(f"Using process pool size: {process_pool_size}")
     with Pool(process_pool_size) as p:
-        p.map(build_lang, langs)
+        p.map(build_zensical_lang_to_stage, langs)
+    if "en" in langs:
+        copy_zensical_stage_to_site("en")
+    for lang in langs:
+        if lang != "en":
+            copy_zensical_stage_to_site(lang)
+    typer.secho("Successfully built all docs", color=typer.colors.GREEN)
 
 
 @app.command()
 def update_languages() -> None:
     """
-    Update the mkdocs.yml file Languages section including all the available languages.
+    Update the docs config Languages section including all the available languages.
     """
     old_config = get_en_config()
     updated_config = get_updated_config_content()
@@ -305,7 +433,7 @@ def serve() -> None:
     """
     A quick server to preview a built site with translations.
 
-    For development, prefer the command live (or just mkdocs serve).
+    For development, prefer the command live.
 
     This is here only to preview a site with translations already built.
 
@@ -323,31 +451,21 @@ def serve() -> None:
 
 
 @app.command()
-def live(
-    lang: str = typer.Argument(
-        None, callback=lang_callback, autocompletion=complete_existing_lang
-    ),
-    dirty: bool = False,
-) -> None:
+def live() -> None:
     """
-    Serve with livereload a docs site for a specific language.
-
-    This only shows the actual translated files, not the placeholders created with
-    build-all.
-
-    Takes an optional LANG argument with the name of the language to serve, by default
-    en.
+    Serve the English docs with livereload from the source files.
     """
-    # Enable line numbers during local development to make it easier to highlight
-    if lang is None:
-        lang = "en"
-    lang_path: Path = docs_path / lang
-    # Enable line numbers during local development to make it easier to highlight
-    args = ["mkdocs", "serve", "--dev-addr", "127.0.0.1:8008"]
-    if dirty:
-        args.append("--dirty")
     subprocess.run(
-        args, env={**os.environ, "LINENUMS": "true"}, cwd=lang_path, check=True
+        [
+            "zensical",
+            "serve",
+            "--config-file",
+            mkdocs_name,
+            "--dev-addr",
+            "127.0.0.1:8008",
+        ],
+        cwd=en_docs_path,
+        check=True,
     )
 
 
@@ -358,7 +476,7 @@ def get_updated_config_content() -> dict[str, Any]:
     # Language names sourced from https://quickref.me/iso-639-1
     # Contributors may wish to update or change these, e.g. to fix capitalization.
     language_names_path = Path(__file__).parent / "../docs/language_names.yml"
-    local_language_names: dict[str, str] = mkdocs.utils.yaml_load(
+    local_language_names: dict[str, str] = yaml.safe_load(
         language_names_path.read_text(encoding="utf-8")
     )
     for lang_path in get_lang_paths():
diff --git a/scripts/mkdocs_hooks.py b/scripts/mkdocs_hooks.py
deleted file mode 100644 (file)
index 9700695..0000000
+++ /dev/null
@@ -1,182 +0,0 @@
-from functools import lru_cache
-from pathlib import Path
-from typing import Any
-
-import material
-from mkdocs.config.defaults import MkDocsConfig
-from mkdocs.structure.files import File, Files
-from mkdocs.structure.nav import Link, Navigation, Section
-from mkdocs.structure.pages import Page
-
-non_translated_sections = [
-    "reference/",
-    "release-notes.md",
-    "fastapi-people.md",
-    "external-links.md",
-    "newsletter.md",
-    "management-tasks.md",
-    "management.md",
-]
-
-
-@lru_cache
-def get_missing_translation_content(docs_dir: str) -> str:
-    docs_dir_path = Path(docs_dir)
-    missing_translation_path = docs_dir_path.parent.parent / "missing-translation.md"
-    return missing_translation_path.read_text(encoding="utf-8")
-
-
-@lru_cache
-def get_translation_banner_content(docs_dir: str) -> str:
-    docs_dir_path = Path(docs_dir)
-    translation_banner_path = docs_dir_path / "translation-banner.md"
-    if not translation_banner_path.is_file():
-        translation_banner_path = (
-            docs_dir_path.parent.parent / "en" / "docs" / "translation-banner.md"
-        )
-    return translation_banner_path.read_text(encoding="utf-8")
-
-
-@lru_cache
-def get_mkdocs_material_langs() -> list[str]:
-    material_path = Path(material.__file__).parent
-    material_langs_path = material_path / "templates" / "partials" / "languages"
-    langs = [file.stem for file in material_langs_path.glob("*.html")]
-    return langs
-
-
-class EnFile(File):
-    pass
-
-
-def on_config(config: MkDocsConfig, **kwargs: Any) -> MkDocsConfig:
-    available_langs = get_mkdocs_material_langs()
-    dir_path = Path(config.docs_dir)
-    lang = dir_path.parent.name
-    if lang in available_langs:
-        config.theme["language"] = lang
-    if not (config.site_url or "").endswith(f"{lang}/") and lang != "en":
-        config.site_url = f"{config.site_url}{lang}/"
-    return config
-
-
-def resolve_file(*, item: str, files: Files, config: MkDocsConfig) -> None:
-    item_path = Path(config.docs_dir) / item
-    if not item_path.is_file():
-        en_src_dir = (Path(config.docs_dir) / "../../en/docs").resolve()
-        potential_path = en_src_dir / item
-        if potential_path.is_file():
-            files.append(
-                EnFile(
-                    path=item,
-                    src_dir=str(en_src_dir),
-                    dest_dir=config.site_dir,
-                    use_directory_urls=config.use_directory_urls,
-                )
-            )
-
-
-def resolve_files(*, items: list[Any], files: Files, config: MkDocsConfig) -> None:
-    for item in items:
-        if isinstance(item, str):
-            resolve_file(item=item, files=files, config=config)
-        elif isinstance(item, dict):
-            assert len(item) == 1
-            values = list(item.values())
-            if not values:
-                continue
-            if isinstance(values[0], str):
-                resolve_file(item=values[0], files=files, config=config)
-            elif isinstance(values[0], list):
-                resolve_files(items=values[0], files=files, config=config)
-            else:
-                raise ValueError(f"Unexpected value: {values}")
-
-
-def on_files(files: Files, *, config: MkDocsConfig) -> Files:
-    resolve_files(items=config.nav or [], files=files, config=config)
-    if "logo" in config.theme:
-        resolve_file(item=config.theme["logo"], files=files, config=config)
-    if "favicon" in config.theme:
-        resolve_file(item=config.theme["favicon"], files=files, config=config)
-    resolve_files(items=config.extra_css, files=files, config=config)
-    resolve_files(items=config.extra_javascript, files=files, config=config)
-    return files
-
-
-def generate_renamed_section_items(
-    items: list[Page | Section | Link], *, config: MkDocsConfig
-) -> list[Page | Section | Link]:
-    new_items: list[Page | Section | Link] = []
-    for item in items:
-        if isinstance(item, Section):
-            new_title = item.title
-            new_children = generate_renamed_section_items(item.children, config=config)
-            first_child = new_children[0]
-            if isinstance(first_child, Page):
-                if first_child.file.src_path.endswith("index.md"):
-                    # Read the source so that the title is parsed and available
-                    first_child.read_source(config=config)
-                    new_title = first_child.title or new_title
-            # Creating a new section makes it render it collapsed by default
-            # no idea why, so, let's just modify the existing one
-            # new_section = Section(title=new_title, children=new_children)
-            item.title = new_title.split("{ #")[0]
-            item.children = new_children
-            new_items.append(item)
-        else:
-            new_items.append(item)
-    return new_items
-
-
-def on_nav(
-    nav: Navigation, *, config: MkDocsConfig, files: Files, **kwargs: Any
-) -> Navigation:
-    new_items = generate_renamed_section_items(nav.items, config=config)
-    return Navigation(items=new_items, pages=nav.pages)
-
-
-def on_pre_page(page: Page, *, config: MkDocsConfig, files: Files) -> Page:
-    return page
-
-
-def on_page_markdown(
-    markdown: str, *, page: Page, config: MkDocsConfig, files: Files
-) -> str:
-    # Set metadata["social"]["cards_layout_options"]["title"] to clean title (without
-    # permalink)
-    title = page.title
-    clean_title = title.split("{ #")[0]
-    if clean_title:
-        page.meta.setdefault("social", {})
-        page.meta["social"].setdefault("cards_layout_options", {})
-        page.meta["social"]["cards_layout_options"]["title"] = clean_title
-
-    if isinstance(page.file, EnFile):
-        for excluded_section in non_translated_sections:
-            if page.file.src_path.startswith(excluded_section):
-                return markdown
-        missing_translation_content = get_missing_translation_content(config.docs_dir)
-        header = ""
-        body = markdown
-        if markdown.startswith("#"):
-            header, _, body = markdown.partition("\n\n")
-        return f"{header}\n\n{missing_translation_content}\n\n{body}"
-
-    docs_dir_path = Path(config.docs_dir)
-    en_docs_dir_path = docs_dir_path.parent.parent / "en/docs"
-
-    if docs_dir_path == en_docs_dir_path:
-        return markdown
-
-    # For translated pages add translation banner
-    translation_banner_content = get_translation_banner_content(config.docs_dir)
-    en_url = "https://fastapi.tiangolo.com/" + page.url.lstrip("/")
-    translation_banner_content = translation_banner_content.replace(
-        "ENGLISH_VERSION_URL", en_url
-    )
-    header = ""
-    body = markdown
-    if markdown.startswith("#"):
-        header, _, body = markdown.partition("\n\n")
-    return f"{header}\n\n{translation_banner_content}\n\n{body}"
diff --git a/uv.lock b/uv.lock
index 72407ae03d66ea338be945e3cb1bc7b3d9a22b15..effcdba998bd90eb9b503bae8407696d9eb26e8d 100644 (file)
--- a/uv.lock
+++ b/uv.lock
@@ -336,15 +336,6 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/57/2f/55fca558f925a51db046e5b929deb317ddb05afed74b22d89f4eca578980/authlib-1.6.11-py2.py3-none-any.whl", hash = "sha256:c8687a9a26451c51a34a06fa17bb97cb15bba46a6a626755e2d7f50da8bff3e3", size = 244469, upload-time = "2026-04-16T07:22:48.413Z" },
 ]
 
-[[package]]
-name = "babel"
-version = "2.18.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" },
-]
-
 [[package]]
 name = "backports-tarfile"
 version = "1.2.0"
@@ -354,20 +345,6 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" },
 ]
 
-[[package]]
-name = "backrefs"
-version = "6.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/86/e3/bb3a439d5cb255c4774724810ad8073830fac9c9dee123555820c1bcc806/backrefs-6.1.tar.gz", hash = "sha256:3bba1749aafe1db9b915f00e0dd166cba613b6f788ffd63060ac3485dc9be231", size = 7011962, upload-time = "2025-11-15T14:52:08.323Z" }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/3b/ee/c216d52f58ea75b5e1841022bbae24438b19834a29b163cb32aa3a2a7c6e/backrefs-6.1-py310-none-any.whl", hash = "sha256:2a2ccb96302337ce61ee4717ceacfbf26ba4efb1d55af86564b8bbaeda39cac1", size = 381059, upload-time = "2025-11-15T14:51:59.758Z" },
-    { url = "https://files.pythonhosted.org/packages/e6/9a/8da246d988ded941da96c7ed945d63e94a445637eaad985a0ed88787cb89/backrefs-6.1-py311-none-any.whl", hash = "sha256:e82bba3875ee4430f4de4b6db19429a27275d95a5f3773c57e9e18abc23fd2b7", size = 392854, upload-time = "2025-11-15T14:52:01.194Z" },
-    { url = "https://files.pythonhosted.org/packages/37/c9/fd117a6f9300c62bbc33bc337fd2b3c6bfe28b6e9701de336b52d7a797ad/backrefs-6.1-py312-none-any.whl", hash = "sha256:c64698c8d2269343d88947c0735cb4b78745bd3ba590e10313fbf3f78c34da5a", size = 398770, upload-time = "2025-11-15T14:52:02.584Z" },
-    { url = "https://files.pythonhosted.org/packages/eb/95/7118e935b0b0bd3f94dfec2d852fd4e4f4f9757bdb49850519acd245cd3a/backrefs-6.1-py313-none-any.whl", hash = "sha256:4c9d3dc1e2e558965202c012304f33d4e0e477e1c103663fd2c3cc9bb18b0d05", size = 400726, upload-time = "2025-11-15T14:52:04.093Z" },
-    { url = "https://files.pythonhosted.org/packages/1d/72/6296bad135bfafd3254ae3648cd152980a424bd6fed64a101af00cc7ba31/backrefs-6.1-py314-none-any.whl", hash = "sha256:13eafbc9ccd5222e9c1f0bec563e6d2a6d21514962f11e7fc79872fd56cbc853", size = 412584, upload-time = "2025-11-15T14:52:05.233Z" },
-    { url = "https://files.pythonhosted.org/packages/02/e3/a4fa1946722c4c7b063cc25043a12d9ce9b4323777f89643be74cef2993c/backrefs-6.1-py39-none-any.whl", hash = "sha256:a9e99b8a4867852cad177a6430e31b0f6e495d65f8c6c134b68c14c3c95bf4b0", size = 381058, upload-time = "2025-11-15T14:52:06.698Z" },
-]
-
 [[package]]
 name = "beartype"
 version = "0.22.9"
@@ -959,6 +936,15 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/1c/7c/996760c30f1302704af57c66ff2d723f7d656d0d0b93563b5528a51484bb/cyclopts-4.5.1-py3-none-any.whl", hash = "sha256:0642c93601e554ca6b7b9abd81093847ea4448b2616280f2a0952416574e8c7a", size = 199807, upload-time = "2026-01-25T15:23:55.219Z" },
 ]
 
+[[package]]
+name = "deepmerge"
+version = "2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a8/3a/b0ba594708f1ad0bc735884b3ad854d3ca3bdc1d741e56e40bbda6263499/deepmerge-2.0.tar.gz", hash = "sha256:5c3d86081fbebd04dd5de03626a0607b809a98fb6ccba5770b62466fe940ff20", size = 19890, upload-time = "2024-08-30T05:31:50.308Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/2d/82/e5d2c1c67d19841e9edc74954c827444ae826978499bde3dfc1d007c8c11/deepmerge-2.0-py3-none-any.whl", hash = "sha256:6de9ce507115cff0bed95ff0ce9ecc31088ef50cbdf09bc90a09349a318b3d00", size = 13475, upload-time = "2024-08-30T05:31:48.659Z" },
+]
+
 [[package]]
 name = "defusedxml"
 version = "0.7.1"
@@ -1117,7 +1103,6 @@ dev = [
     { name = "anyio", extra = ["trio"] },
     { name = "black" },
     { name = "cairosvg" },
-    { name = "click" },
     { name = "coverage", extra = ["toml"] },
     { name = "dirty-equals" },
     { name = "flask" },
@@ -1129,9 +1114,6 @@ dev = [
     { name = "jieba" },
     { name = "markdown-include-variants" },
     { name = "mdx-include" },
-    { name = "mkdocs-macros-plugin" },
-    { name = "mkdocs-material" },
-    { name = "mkdocs-redirects" },
     { name = "mkdocstrings", extra = ["python"] },
     { name = "mypy" },
     { name = "pillow" },
@@ -1154,27 +1136,25 @@ dev = [
     { name = "strawberry-graphql" },
     { name = "ty" },
     { name = "typer" },
+    { name = "zensical" },
     { name = "zizmor" },
 ]
 docs = [
     { name = "black" },
     { name = "cairosvg" },
-    { name = "click" },
     { name = "griffe-typingdoc" },
     { name = "griffe-warnings-deprecated" },
     { name = "httpx" },
     { name = "jieba" },
     { name = "markdown-include-variants" },
     { name = "mdx-include" },
-    { name = "mkdocs-macros-plugin" },
-    { name = "mkdocs-material" },
-    { name = "mkdocs-redirects" },
     { name = "mkdocstrings", extra = ["python"] },
     { name = "pillow" },
     { name = "python-slugify" },
     { name = "pyyaml" },
     { name = "ruff" },
     { name = "typer" },
+    { name = "zensical" },
 ]
 docs-tests = [
     { name = "httpx" },
@@ -1260,7 +1240,6 @@ dev = [
     { name = "anyio", extras = ["trio"], specifier = ">=3.2.1,<5.0.0" },
     { name = "black", specifier = ">=25.1.0" },
     { name = "cairosvg", specifier = ">=2.8.2" },
-    { name = "click", specifier = "==8.2.1" },
     { name = "coverage", extras = ["toml"], specifier = ">=7.13,<8.0" },
     { name = "dirty-equals", specifier = ">=0.9.0" },
     { name = "flask", specifier = ">=3.0.0,<4.0.0" },
@@ -1272,10 +1251,7 @@ dev = [
     { name = "jieba", specifier = ">=0.42.1" },
     { name = "markdown-include-variants", specifier = ">=0.0.8" },
     { name = "mdx-include", specifier = ">=1.4.1,<2.0.0" },
-    { name = "mkdocs-macros-plugin", specifier = ">=1.5.0" },
-    { name = "mkdocs-material", specifier = ">=9.7.0" },
-    { name = "mkdocs-redirects", specifier = ">=1.2.1,<1.3.0" },
-    { name = "mkdocstrings", extras = ["python"], specifier = ">=0.30.1" },
+    { name = "mkdocstrings", extras = ["python"], specifier = ">=1.0.3" },
     { name = "mypy", specifier = ">=1.14.1" },
     { name = "pillow", specifier = ">=11.3.0" },
     { name = "playwright", specifier = ">=1.57.0" },
@@ -1297,27 +1273,25 @@ dev = [
     { name = "strawberry-graphql", specifier = ">=0.200.0,<1.0.0" },
     { name = "ty", specifier = ">=0.0.25" },
     { name = "typer", specifier = ">=0.21.1" },
+    { name = "zensical", specifier = ">=0.0.42" },
     { name = "zizmor", specifier = ">=1.23.1" },
 ]
 docs = [
     { name = "black", specifier = ">=25.1.0" },
     { name = "cairosvg", specifier = ">=2.8.2" },
-    { name = "click", specifier = "==8.2.1" },
     { name = "griffe-typingdoc", specifier = ">=0.3.0" },
     { name = "griffe-warnings-deprecated", specifier = ">=1.1.0" },
     { name = "httpx", specifier = ">=0.23.0,<1.0.0" },
     { name = "jieba", specifier = ">=0.42.1" },
     { name = "markdown-include-variants", specifier = ">=0.0.8" },
     { name = "mdx-include", specifier = ">=1.4.1,<2.0.0" },
-    { name = "mkdocs-macros-plugin", specifier = ">=1.5.0" },
-    { name = "mkdocs-material", specifier = ">=9.7.0" },
-    { name = "mkdocs-redirects", specifier = ">=1.2.1,<1.3.0" },
-    { name = "mkdocstrings", extras = ["python"], specifier = ">=0.30.1" },
+    { name = "mkdocstrings", extras = ["python"], specifier = ">=1.0.3" },
     { name = "pillow", specifier = ">=11.3.0" },
     { name = "python-slugify", specifier = ">=8.0.4" },
     { name = "pyyaml", specifier = ">=5.3.1,<7.0.0" },
     { name = "ruff", specifier = ">=0.14.14" },
     { name = "typer", specifier = ">=0.21.1" },
+    { name = "zensical", specifier = ">=0.0.42" },
 ]
 docs-tests = [
     { name = "httpx", specifier = ">=0.23.0,<1.0.0" },
@@ -2074,15 +2048,6 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/cb/44/870d44b30e1dcfb6a65932e3e1506c103a8a5aea9103c337e7a53180322c/hf_xet-1.2.0-cp37-abi3-win_amd64.whl", hash = "sha256:e6584a52253f72c9f52f9e549d5895ca7a471608495c4ecaa6cc73dba2b24d69", size = 2905735, upload-time = "2025-10-24T19:04:35.928Z" },
 ]
 
-[[package]]
-name = "hjson"
-version = "3.1.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/82/e5/0b56d723a76ca67abadbf7fb71609fb0ea7e6926e94fcca6c65a85b36a0e/hjson-3.1.0.tar.gz", hash = "sha256:55af475a27cf83a7969c808399d7bccdec8fb836a07ddbd574587593b9cdcf75", size = 40541, upload-time = "2022-08-13T02:53:01.919Z" }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/1f/7f/13cd798d180af4bf4c0ceddeefba2b864a63c71645abc0308b768d67bb81/hjson-3.1.0-py3-none-any.whl", hash = "sha256:65713cdcf13214fb554eb8b4ef803419733f4f5e551047c9b711098ab7186b89", size = 54018, upload-time = "2022-08-13T02:52:59.899Z" },
-]
-
 [[package]]
 name = "httpcore"
 version = "1.0.9"
@@ -2851,73 +2816,9 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521, upload-time = "2023-11-20T17:51:08.587Z" },
 ]
 
-[[package]]
-name = "mkdocs-macros-plugin"
-version = "1.5.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
-    { name = "hjson" },
-    { name = "jinja2" },
-    { name = "mkdocs" },
-    { name = "packaging" },
-    { name = "pathspec" },
-    { name = "python-dateutil" },
-    { name = "pyyaml" },
-    { name = "requests" },
-    { name = "super-collections" },
-    { name = "termcolor" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/92/15/e6a44839841ebc9c5872fa0e6fad1c3757424e4fe026093b68e9f386d136/mkdocs_macros_plugin-1.5.0.tar.gz", hash = "sha256:12aa45ce7ecb7a445c66b9f649f3dd05e9b92e8af6bc65e4acd91d26f878c01f", size = 37730, upload-time = "2025-11-13T08:08:55.545Z" }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/51/62/9fffba5bb9ed3d31a932ad35038ba9483d59850256ee0fea7f1187173983/mkdocs_macros_plugin-1.5.0-py3-none-any.whl", hash = "sha256:c10fabd812bf50f9170609d0ed518e54f1f0e12c334ac29141723a83c881dd6f", size = 44626, upload-time = "2025-11-13T08:08:53.878Z" },
-]
-
-[[package]]
-name = "mkdocs-material"
-version = "9.7.6"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
-    { name = "babel" },
-    { name = "backrefs" },
-    { name = "colorama" },
-    { name = "jinja2" },
-    { name = "markdown" },
-    { name = "mkdocs" },
-    { name = "mkdocs-material-extensions" },
-    { name = "paginate" },
-    { name = "pygments" },
-    { name = "pymdown-extensions" },
-    { name = "requests" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/45/29/6d2bcf41ae40802c4beda2432396fff97b8456fb496371d1bc7aad6512ec/mkdocs_material-9.7.6.tar.gz", hash = "sha256:00bdde50574f776d328b1862fe65daeaf581ec309bd150f7bff345a098c64a69", size = 4097959, upload-time = "2026-03-19T15:41:58.161Z" }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/2c/01/bc663630c510822c95c47a66af9fa7a443c295b47d5f041e5e6ae62ef659/mkdocs_material-9.7.6-py3-none-any.whl", hash = "sha256:71b84353921b8ea1ba84fe11c50912cc512da8fe0881038fcc9a0761c0e635ba", size = 9305470, upload-time = "2026-03-19T15:41:55.217Z" },
-]
-
-[[package]]
-name = "mkdocs-material-extensions"
-version = "1.3.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" },
-]
-
-[[package]]
-name = "mkdocs-redirects"
-version = "1.2.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
-    { name = "mkdocs" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/f1/a8/6d44a6cf07e969c7420cb36ab287b0669da636a2044de38a7d2208d5a758/mkdocs_redirects-1.2.2.tar.gz", hash = "sha256:3094981b42ffab29313c2c1b8ac3969861109f58b2dd58c45fc81cd44bfa0095", size = 7162, upload-time = "2024-11-07T14:57:21.109Z" }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/c4/ec/38443b1f2a3821bbcb24e46cd8ba979154417794d54baf949fefde1c2146/mkdocs_redirects-1.2.2-py3-none-any.whl", hash = "sha256:7dbfa5647b79a3589da4401403d69494bd1f4ad03b9c15136720367e1f340ed5", size = 6142, upload-time = "2024-11-07T14:57:19.143Z" },
-]
-
 [[package]]
 name = "mkdocstrings"
-version = "1.0.2"
+version = "1.0.4"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "jinja2" },
@@ -2927,9 +2828,9 @@ dependencies = [
     { name = "mkdocs-autorefs" },
     { name = "pymdown-extensions" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/63/4d/1ca8a9432579184599714aaeb36591414cc3d3bfd9d494f6db540c995ae4/mkdocstrings-1.0.2.tar.gz", hash = "sha256:48edd0ccbcb9e30a3121684e165261a9d6af4d63385fc4f39a54a49ac3b32ea8", size = 101048, upload-time = "2026-01-24T15:57:25.735Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/1d/5d/f888d4d3eb31359b327bc9b17a212d6ef03fe0b0682fbb3fc2cb849fb12b/mkdocstrings-1.0.4.tar.gz", hash = "sha256:3969a6515b77db65fd097b53c1b7aa4ae840bd71a2ee62a6a3e89503446d7172", size = 100088, upload-time = "2026-04-15T09:16:53.376Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/57/32/407a9a5fdd7d8ecb4af8d830b9bcdf47ea68f916869b3f44bac31f081250/mkdocstrings-1.0.2-py3-none-any.whl", hash = "sha256:41897815a8026c3634fe5d51472c3a569f92ded0ad8c7a640550873eea3b6817", size = 35443, upload-time = "2026-01-24T15:57:23.933Z" },
+    { url = "https://files.pythonhosted.org/packages/6e/94/be70f8ee9c45f2f62b39a1f0e9303bc20e138a8f3b8e50ffd89498e177e1/mkdocstrings-1.0.4-py3-none-any.whl", hash = "sha256:63464b4b29053514f32a1dbbf604e52876d5e638111b0c295ab7ed3cac73ca9b", size = 35560, upload-time = "2026-04-15T09:16:51.436Z" },
 ]
 
 [package.optional-dependencies]
@@ -3352,15 +3253,6 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
 ]
 
-[[package]]
-name = "paginate"
-version = "0.5.7"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" },
-]
-
 [[package]]
 name = "pathable"
 version = "0.4.4"
@@ -5159,18 +5051,6 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/be/25/13773a2944cc5975d44db58233b3610ddc88d4be49e6576adf7ed4b62250/strawberry_graphql-0.314.3-py3-none-any.whl", hash = "sha256:4ef4442cea79014487acd7a0d1a2ce55c9d2a42dcd34a307d4c01f2ab477ecfa", size = 324471, upload-time = "2026-04-08T18:04:44.088Z" },
 ]
 
-[[package]]
-name = "super-collections"
-version = "0.6.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
-    { name = "hjson" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/e0/de/a0c3d1244912c260638f0f925e190e493ccea37ecaea9bbad7c14413b803/super_collections-0.6.2.tar.gz", hash = "sha256:0c8d8abacd9fad2c7c1c715f036c29f5db213f8cac65f24d45ecba12b4da187a", size = 31315, upload-time = "2025-09-30T00:37:08.067Z" }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/17/43/47c7cf84b3bd74a8631b02d47db356656bb8dff6f2e61a4c749963814d0d/super_collections-0.6.2-py3-none-any.whl", hash = "sha256:291b74d26299e9051d69ad9d89e61b07b6646f86a57a2f5ab3063d206eee9c56", size = 16173, upload-time = "2025-09-30T00:37:07.104Z" },
-]
-
 [[package]]
 name = "temporalio"
 version = "1.26.0"
@@ -6024,6 +5904,36 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" },
 ]
 
+[[package]]
+name = "zensical"
+version = "0.0.42"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "click" },
+    { name = "deepmerge" },
+    { name = "jinja2" },
+    { name = "markdown" },
+    { name = "pygments" },
+    { name = "pymdown-extensions" },
+    { name = "pyyaml" },
+    { name = "tomli" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7a/dd/04e89ab92aed1ef9e36c76ef095fb587ffcbe4162aa7f3fe6d63aafade4a/zensical-0.0.42.tar.gz", hash = "sha256:cc346b833868a59412fe8d8498a152be90be9f3d8fb87e1f1a1c2e1146cbae1b", size = 3931093, upload-time = "2026-05-15T10:22:45.354Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/fb/19/2ca4e52769307959f7485d4c5da7b24787339787c1cbc371885cef448e50/zensical-0.0.42-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:bffd7a34b570fa3ccadf1d23babb0f7c4851c6b626e4fc8ed9f21c2eaae85968", size = 12705326, upload-time = "2026-05-15T10:22:07.905Z" },
+    { url = "https://files.pythonhosted.org/packages/2c/82/0832b0d2c0c2800174141d5519a017105d3dace9194e2c29730e7a676adf/zensical-0.0.42-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:ee1a79789f9462ef44a4b6ebbfc8b5bf4b2447607da8bc5b35bc9c4ce4ea2370", size = 12568663, upload-time = "2026-05-15T10:22:11.072Z" },
+    { url = "https://files.pythonhosted.org/packages/ac/87/272b3998322958ca38f09323d2347cb121dfc851477c36962b71319242a5/zensical-0.0.42-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e9a5d508ce8d1b07d8417f0623be476f6b37d445ab4356481a71e613a7979d6", size = 12948460, upload-time = "2026-05-15T10:22:13.792Z" },
+    { url = "https://files.pythonhosted.org/packages/ae/1b/e5f153401f162f48cae2d58e96b95fd39ba5bd1728fb5881a60e502f4e1d/zensical-0.0.42-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9fbc0951a676e48afe7df3a9b2a30958dcf9c426ed2480972d3c04d6de485ba3", size = 12913460, upload-time = "2026-05-15T10:22:16.791Z" },
+    { url = "https://files.pythonhosted.org/packages/9b/4f/5186b4204bdfdf132851b7515a37b9602bfc153fb601db5fb244339bae52/zensical-0.0.42-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8f0e96e53f39b9e4b929a25d9df70bd7fa8217166a854e2c8f3185983dd01500", size = 13276704, upload-time = "2026-05-15T10:22:19.819Z" },
+    { url = "https://files.pythonhosted.org/packages/f2/df/b57b5fcc631ac7a4b4c6834d8cf0b88d3fca37c9db42fc6bbf9f097200ed/zensical-0.0.42-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7d586e57436d603e88acd856864f99f0771aef24bf6560b2de238417bd3817c", size = 12987069, upload-time = "2026-05-15T10:22:22.537Z" },
+    { url = "https://files.pythonhosted.org/packages/a3/3a/b326a44a065d98e89b472645ad33037201e3385340c2e6e35627b18ab3fa/zensical-0.0.42-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3c026f023330d67f986a94b68ffd36dc5066882e697e1125c37308d8d684135c", size = 13124195, upload-time = "2026-05-15T10:22:25.543Z" },
+    { url = "https://files.pythonhosted.org/packages/1b/1e/823740a662e357a8826dc8eeb87e06705e64219b2774430bc555f7c53d57/zensical-0.0.42-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:e5908bc09cf5c1c50c9504241e37f89955daf3e89ba1b9d71c17972578b24804", size = 13182981, upload-time = "2026-05-15T10:22:28.89Z" },
+    { url = "https://files.pythonhosted.org/packages/80/6d/9fe261267ac36a7d57051d790022408e9043bc925c9ad21971a1e5b6c3e8/zensical-0.0.42-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:c0bf96b55f0a44e8716bcb334a16380ed56772b555145da775a7d8ac8678cb6f", size = 13332666, upload-time = "2026-05-15T10:22:32.249Z" },
+    { url = "https://files.pythonhosted.org/packages/9b/57/9b0e4f131a7ad15cf1aca081748ea7336c084fb8e16be202a6bed32f595c/zensical-0.0.42-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:47cd99583738a8ab03fac4080741275c56e741a06dc8edfb541f4c1649a5ae69", size = 13270817, upload-time = "2026-05-15T10:22:35.388Z" },
+    { url = "https://files.pythonhosted.org/packages/bb/fd/bdb85cc444e4146e8970a22e48a903bfed5bf83276ad7d755caa415dda64/zensical-0.0.42-cp310-abi3-win32.whl", hash = "sha256:83090e53fba061967ecb3dff81500b1900f288bae108bf54084a2aeb6648ebd0", size = 12256227, upload-time = "2026-05-15T10:22:38.869Z" },
+    { url = "https://files.pythonhosted.org/packages/e0/b9/09d1f735c8e6d3eb61d176ed5ebcf658b65b126d7d4bbc03a7d366a1e17d/zensical-0.0.42-cp310-abi3-win_amd64.whl", hash = "sha256:2e4304e103f9cd5c637045bbae1ff29de3009ab01b16e99c2fd6d4bbceb7a3ee", size = 12486598, upload-time = "2026-05-15T10:22:42.158Z" },
+]
+
 [[package]]
 name = "zipp"
 version = "3.23.0"