Validation and Publishing

This content was generated with the assistance of AI and should be verified against the official documentation of each tool before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page was written against Zensical 0.0.72 (released 2026-10-23, still marked alpha on PyPI) and checked on 2026-10-26. It covers the two things that sit between writing Markdown and having a public site: the build-time checks that find broken links, and the CI workflows that build the site and hand it to GitHub Pages or GitLab Pages. The running example is the documentation site of Pocket Units, the small units-conversion library with a C# and a Python implementation. The validation examples were run with Zensical 0.0.72 in a scratch project; the GitHub and GitLab workflows were not executed here because they need a hosted repository, so they are shown as documented and adapted.

What validation does and why

Pages get renamed and moved, and a link that used to work then silently points nowhere. Zensical scans every Markdown file of the documentation directory at build time, resolves both inline links and reference-style links against the pages and headings it knows about, and prints a warning for each link it cannot resolve. A warning carries the file name, the line and column, and a short message, drawn as a source excerpt with the offending text underlined.

By default a warning does not stop the build: the site is still written and the command exits with status 0. To make warnings fail the build, enable strict mode (see Strict builds). The official documentation also says that the checks are being reimplemented on top of a new Python Markdown parser, which already powers the in-editor link checking of Zensical Studio and which the team plans to publish as open source and integrate into Zensical. Until then the checks described below are the ones available.

The project.validation table

Validation is configured in the [project.validation] table of zensical.toml. The same keys exist under validation: in a mkdocs.yml, because Zensical reads both formats. Link validation is on by default, so a project without the table already gets the first two checks.

Key Default What it reports

invalid_links

on

A link whose target page does not exist.

invalid_link_anchors

on

A link to an anchor (a heading identifier, for example guide.md#install) that does not exist in the target page.

unresolved_references

off

A link or image reference such as [text][id] with no matching definition.

unresolved_footnotes

off

A footnote reference with no matching definition.

unused_definitions

off

A link definition that nothing references.

unused_footnotes

off

A footnote definition that nothing references.

shadowed_definitions

off

A link definition declared more than once.

shadowed_footnotes

off

A footnote definition declared more than once.

The documentation lists the first two as on and the other six as off in its reference configuration. It describes the last six checks as deprecated in their current form: they work in most cases, but they have edge cases the current approach cannot cover, and the team intends to replace them with functionally equivalent checks once the new parser is integrated. They remain usable, so enable them knowingly and expect their output to change.

zensical.toml
[project]
site_name = "Pocket Units"
site_url = "https://example.org/pocket-units/"

[project.validation]
invalid_links = true
invalid_link_anchors = true
unresolved_references = false
unresolved_footnotes = false
unused_definitions = false
unused_footnotes = false
shadowed_definitions = false
shadowed_footnotes = false

To switch every check off, set validation = false directly under [project] instead of using the table. To keep one check and drop another, set only the key you want to change; the example below turns off anchor checking and leaves page checking on.

zensical.toml
[project]
site_name = "Pocket Units"
site_url = "https://example.org/pocket-units/"

[project.validation]
invalid_link_anchors = false

Seeing the warnings

The scratch project below has a home page with two bad links, one to a page that does not exist and one to a heading that does not exist, plus two good ones (a link to the other page and a link to a heading on the same page).

docs/index.md
# Pocket Units

Convert lengths. See [API](api.md), the [guide](guide.md) and the [missing page](non-existent.md).

Jump to [a missing section](guide.md#no-such-section) or [this one](#local).

## Local

This is not a \[link](https://example.com).

Running zensical build --clean printed the following (the real output is coloured in a terminal; colour codes are removed here):

Build started
Warning: page does not exist
   ╭─[ index.md:3:82 ]
   │
 3 │ Convert lengths. See [API](api.md), the [guide](guide.md) and the [missing page](non-existent.md).
   │                                                                                  ───────┬───────
   │                                                                                         ╰───────── page does not exist
───╯
Warning: anchor does not exist
   ╭─[ index.md:5:38 ]
   │
 5 │ Jump to [a missing section](guide.md#no-such-section) or [this one](#local).
   │                                      ───────┬───────
   │                                             ╰───────── anchor does not exist
───╯
2 issues found
Build finished in 0.31s

The build exited with status 0 and wrote the site to site. Three things to note from this run: the same-page link #local and the cross-page link guide.md#guide were accepted, the escaped bracket in the last line of the file produced no warning, and the summary line counts the warnings.

Validation treats every bracketed phrase as an intended link or footnote. When you want literal brackets, escape the opening one with a backslash, as in \[text]; the closing bracket needs no escape. The official page recommends doing this even when no matching definition exists today, because adding a definition later would otherwise turn the text into a link by accident.

With unresolved_references and unused_definitions switched on in the scratch project, a page that contained a dangling reference [unresolved reference][id] and an unused definition [unused]: https://example.com produced these warnings, alongside the two link warnings above (5 issues found in total):

Warning: unresolved link reference
Warning: unresolved autoref
Warning: unused link definition

The documentation names only the first and third of these messages. The extra "unresolved autoref" warning appeared for the same dangling reference in this version; it shows that the deprecated checks can report one problem more than once, which is consistent with the caveat about their edge cases.

Strict builds

Strict mode makes the build fail when any validation warning was reported. Enable it for one run with --strict (short form -s), or for every run with strict = true in the [project] table.

zensical build --clean --strict
zensical.toml
[project]
site_name = "Pocket Units"
site_url = "https://example.org/pocket-units/"
strict = true

The build first reports every warning, then aborts and exits with status 1, which is what a CI system needs to stop a deployment. On the scratch project with the two bad links, zensical build --strict ended with these lines (strict = true in zensical.toml behaved the same):

2 issues found
Traceback (most recent call last):
  ...
RuntimeError: Aborted because --strict flag is set

In 0.0.72 the abort message is shown inside a Python traceback rather than as a plain error line; the documentation shows the shorter text "Aborted because --strict flag is set". The exit status was 1 in the run above. The site directory was also written in that run, so a failed strict build does not guarantee an empty output directory: let the CI job fail before the upload step instead of relying on the directory being absent. After fixing the two links, zensical build --strict printed "No issues found" and exited with status 0.

The command-line options of zensical build in this version are:

Option Short Effect

--config-file PATH

-f

Use a configuration file other than the one found in the working directory.

--clean

-c

Clear the build cache before building.

--strict

-s

Abort the build when warnings were reported.

--help

Show the help text.

The documentation recommends --clean when a build produces unexpected output, particularly after upgrading Zensical, because the build cache can keep stale state; the generated CI workflows use it on every run.

From push to published site

Both hosting workflows below follow the same sequence: check out the repository, install Python and Zensical, build, and give the contents of the site directory to the host.

flowchart LR A[Push to default branch] --> B[Check out repository] B --> C[Install Python and Zensical] C --> D["zensical build --clean --strict"] D -->|warnings| E[Job fails, nothing published] D -->|no warnings| F[Upload site directory] F --> G[Deploy to Pages]

Figure: the CI pipeline of a Zensical site with strict validation as the gate before publishing.

GitHub Pages with GitHub Actions

The repository must have GitHub Pages configured to publish from GitHub Actions (Settings, Pages, Source). The command zensical new already writes this workflow to .github/workflows/docs.yml; otherwise create the file yourself. The listing below is the workflow from the official publishing page, with one change: --strict is added to the build step so that a broken link stops the deployment. The official version runs zensical build --clean.

File .github/workflows/docs.yml
name: Documentation
on:
  push:
    branches:
      - master
      - main
permissions:
  contents: read
  pages: write
  id-token: write
jobs:
  deploy:
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/configure-pages@v6
      - uses: actions/checkout@v7
      - uses: actions/setup-python@v6
        with:
          python-version: 3.x
      - run: pip install zensical
      - run: zensical build --clean --strict
      - uses: actions/upload-pages-artifact@v5
        with:
          path: site
      - uses: actions/deploy-pages@v5
        id: deployment

Points worth knowing about this workflow:

  • permissions grants the job the three rights Pages deployment needs: read the repository contents, write Pages, and request an OIDC token (id-token: write).

  • The job has no explicit dependency installation beyond pip install zensical. A project that renders API documentation also needs its handler, for example pip install zensical mkdocstrings-python, or a requirements file or pyproject.toml that lists both; see API Docs with mkdocstrings.

  • To pin the tool, replace pip install zensical with pip install zensical==0.0.72. While Zensical is in the 0.0.x line, unpinned installs can change behaviour between runs; see Roadmap and Upgrade Policy.

  • The official page advises against caching on CI for now, because the caching functionality is expected to change while performance is optimised.

  • site_url in zensical.toml should match the address GitHub publishes to, normally https://<username>.github.io/<repository>/; see Configuration.

  • The action versions are the ones in the official page on the check date, and the same ones zensical new generated in the scratch project. Newer major versions may exist by the time you read this.

To validate pull requests without publishing, add a second workflow (or a second job) that only builds. This variant was not run on GitHub.

File .github/workflows/docs-check.yml
name: Documentation check
on:
  pull_request:
permissions:
  contents: read
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-python@v6
        with:
          python-version: 3.x
      - run: pip install zensical
      - run: zensical build --clean --strict

Both workflow files were checked only for valid YAML syntax and were not run.

GitLab Pages with GitLab CI

For a repository on GitLab, create .gitlab-ci.yml at the repository root. The listing is the official job with --strict added to the build command.

File .gitlab-ci.yml
pages:
  stage: deploy
  image: python:latest
  script:
    - pip install zensical
    - zensical build --clean --strict
  pages:
    publish: site
  rules:
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'

GitLab Pages publishes the folder public by default; the pages.publish: site setting points it at the directory Zensical writes, so no change to site_dir is needed. The job runs only on the default branch. On the project, two settings matter, according to the official page: untick "Use unique domain" in the Pages settings for a production deployment, because GitLab otherwise adds a random string to the domain, and set the Pages visibility under Settings, General, Visibility if the site must be public. The result is served from https://<username>.gitlab.io/<repository>/.

The image python:latest is as in the official example; pin a tag such as a specific Python 3.13 image if you want reproducible builds, since Zensical requires Python 3.11 or newer. This job was not run on GitLab.

Other hosts

The official page does not document every host. It links community guides for Azure Static Web Apps (with GitHub Actions and with Azure DevOps), Read the Docs and Codeberg Pages with Forgejo Actions, and states that it cannot vouch for them. Any host that serves static files works the same way: run zensical build --clean --strict and upload site. Zensical has no equivalent of the MkDocs command gh-deploy; publishing is done through the workflows above or by copying the output directory yourself, as described in Migrating from Material for MkDocs.

Pitfalls

  • Default builds succeed with broken links; only --strict or strict = true turns warnings into a failed build.

  • Strict mode also fails on warnings from the deprecated checks if you enabled them, including the duplicate "unresolved autoref" warning seen above.

  • A heading renamed in one page breaks anchor links to it from other pages; strict builds in pull requests catch this.

  • The warning text and the traceback shown for strict failures are 0.0.x behaviour and may change when the new Markdown parser replaces the current validation.