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 |
|---|---|---|
|
on |
A link whose target page does not exist. |
|
on |
A link to an anchor (a heading identifier, for example |
|
off |
A link or image reference such as |
|
off |
A footnote reference with no matching definition. |
|
off |
A link definition that nothing references. |
|
off |
A footnote definition that nothing references. |
|
off |
A link definition declared more than once. |
|
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.
[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
Source: Zensical docs: Validation
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.
[project]
site_name = "Pocket Units"
site_url = "https://example.org/pocket-units/"
[project.validation]
invalid_link_anchors = false
Source: Zensical docs: Validation
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).
# 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).
Source: Zensical docs: Validation
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
Source: Zensical docs: Build
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.
Escaping brackets that are not links
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.
Reference-style links and the deprecated checks
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
Source: Zensical docs: Build
[project]
site_name = "Pocket Units"
site_url = "https://example.org/pocket-units/"
strict = true
Source: Zensical docs: Validation
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 |
|---|---|---|
|
|
Use a configuration file other than the one found in the working directory. |
|
|
Clear the build cache before building. |
|
|
Abort the build when warnings were reported. |
|
Show the help text. |
Source: Zensical docs: Build
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.
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.
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
Source: Zensical docs: Publish your site
Points worth knowing about this workflow:
-
permissionsgrants 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 examplepip install zensical mkdocstrings-python, or a requirements file orpyproject.tomlthat lists both; see API Docs with mkdocstrings. -
To pin the tool, replace
pip install zensicalwithpip 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_urlinzensical.tomlshould match the address GitHub publishes to, normallyhttps://<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 newgenerated 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.
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
Source: Zensical docs: Publish your site
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.
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'
Source: Zensical docs: Publish your site
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
--strictorstrict = trueturns 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.