Integration with MkDocs¶
One plugin, texsmith, carries the whole integration: it renders the TMark
constructs on the site and exports the same sources as a PDF. Add it to
mkdocs.yml and nothing else:
plugins:
- search
- texsmith
No Markdown extension to wire by hand. The plugin turns on the five extensions
its output relies on — attr_list, md_in_html, admonition,
pymdownx.details and pymdownx.superfences — when they are absent, and ships
a small stylesheet (assets/texsmith/texsmith.css: asides, the subfigure grid,
caption labels, counter spans) through extra_css. Set
inject_markdown_extensions: false or css: false to take either over.
The texsmith.counters and texsmith.index plugins are gone
Both jobs belong to texsmith now. The two entry points still load, log a
warning and do nothing; they disappear in 0.8. Remove them from plugins:
and move any counters: you declared under them to declare.counters
(below).
What the plugin does to a page¶
Before Python-Markdown runs, every page goes through tmark:
- a pre-pass parses and resolves every page in navigation order, chaining the counters from one page to the next, and collects the labels of the whole site;
- each page is then resolved again against that site map and lowered: each
TMark construct is replaced by the Markdown or HTML Material renders — a
callout becomes an admonition, a figure a
<figure>, a counter item a<span class="ts-counter">— and every other byte is left alone.
So Material's own syntax (++ctrl+alt+del++, ==marked==, tab sets, emoji,
mermaid fences, an mkdocstrings block) passes through untouched, and a
construct TeXSmith owns renders the same way it does in the PDF.
Diagnostics are reported with the page path: docs/findings.md:12:5: warning
ref-unresolved: ….
French typography¶
A French page wants a narrow no-break space before ;, :, ! and ?, and
« … » where the keyboard typed "…". babel-french does it for the PDF,
where the engine spaces the punctuation itself; a browser does nothing of the
sort, so the rendered page carries the characters. Once the site's language is
fr — theme.language, theme.locale, site_language or the plugin's own
language — the rendered HTML goes through those two rules:
| Written | Published |
|---|---|
Attention : ceci |
Attention U+202F : ceci |
Vraiment ? |
Vraiment U+202F ? |
il a dit "oui" |
« U+202F oui U+202F » |
Only text is touched. A <code>, <pre>, <script> or <style> element,
every attribute value, every character entity and every space the page already
carries — , a narrow one — come out as they went in, and a mark that is
part of a token rather than of a sentence (https://, 12:30, ?page=2) is
left alone. Running the rules over a page twice changes nothing the second
time.
web.typography overrides the language: none turns the rules off on a French
site, fr turns them on for a site that declares another language.
plugins:
- texsmith:
web:
typography: fr # fr | none, the site language by default
Under Zensical the spaces are part of a page's cached render, so changing the
option calls for zensical build -c.
Site-wide declarations¶
Whatever a page declares under press.declare in its front matter, the
plugin's declare: key declares for every page of the site — counters,
callout kinds, glossary terms:
plugins:
- texsmith:
declare:
counters:
req:
name: Requirement
format: "REQ-{n:03d}"
start: 100
admonitions:
exercise:
name: Exercise
A page may still declare its own in its front matter; the page's declaration wins over the site's, kind by kind and name by name, and the two sets merge. See Counters for the numbering rules.
Both reach the parser, not only the renderer, so a callout kind declared
here is one exercise can be spelled with anywhere on the site — on the
web and in the books alike. A kind nothing declares is container-unknown
and its fence stays literal text on the page.
Options¶
| Option | Description | Default |
|---|---|---|
enabled |
Turn the plugin off entirely | true |
template |
Template used for the books | book |
build_dir |
Where the .tex, the assets and the PDF land |
press |
declare |
Site-wide declarations (declare.counters, declare.admonitions, declare.glossary) |
{} |
web |
Web-only options: sections (title | number), citations (inline | passthrough), css_prefix, tags (index | none) and typography (fr | none) |
{} |
inject_markdown_extensions |
Enable the extensions the lowering relies on | true |
css |
Ship and register texsmith.css |
true |
language |
Document language, else the theme's | theme |
bibliography |
.bib files shared by every book |
[] |
books |
The documents to export (below) | [] |
template_overrides |
Template attributes for every book | {} |
copy_assets / clean_assets |
Copy the referenced assets / prune the unused ones | true |
embed_documents |
Inline each page's body instead of \input |
false |
Books¶
A book is one PDF built from a section of the navigation. Several may be
declared; root: "__texsmith_full_navigation__" takes the whole site. Without
an explicit books: entry, the plugin creates one book automatically, using
only the first item of the site's navigation as its root section — every other
page is left out of that default book. Declare books: explicitly, with
root: "__texsmith_full_navigation__" on the one entry, to get the whole site
as a single book instead.
plugins:
- texsmith:
books:
- template: book
title: "Foo, the complete guide"
folder: foolists
root: "foo"
base_level: -1
- template: article
folder: barriers
root: "bar"
The PDF is built from each page's file — the Markdown the page is written
in, read through the tmark reader, never from the rendered HTML and never from
what another plugin made of the text while the site rendered. Each page is
parsed under its own path, with its metadata and the site declarations
re-emitted in front of the body and the site's auto_append after it, so a
diagnostic names the line you edit (docs/syntax/tables.md:42:5) and an asset
the page names relatively is looked up from the page's own directory. The
counters are seeded where the site's chain stood before the book's first page,
so FW-10 is FW-10 on both media.
Because the book reads the files, mkdocs build and
texsmith site build write the same bytes: one
book path, two ways of reaching it. What the parser was handed is dropped next
to the output under <build_dir>/<folder>/sources/ — front matter, body,
appended definitions — as a debugging artefact; nothing reads it back.
A book is not the plugin's own work: texsmith.site.book builds it, and the
plugin is the adapter that hands it MkDocs' navigation. The same builder runs
under texsmith site build, which is how a
generator with no on_post_build gets the same PDF.
Serve¶
Under mkdocs serve the plugin lowers the pages and reports diagnostics but
builds no book: the PDF is a mkdocs build matter.
Build¶
mkdocs build writes every book's .tex and its assets under press/. Set
TEXSMITH_BUILD=1 to compile the PDF in the same run:
$ TEXSMITH_BUILD=1 mkdocs build
A complete example lives in examples/mkdocs (make -C examples/mkdocs builds
the site and the PDF).
Zensical¶
Zensical reads the same mkdocs.yml, but it has no
plugin hooks at all: the only thing it calls Python for is rendering Markdown.
The web lowering is therefore also a Python-Markdown extension,
texsmith.site.web, driving the same code as the plugin:
markdown_extensions:
- texsmith.site.web
extra_css:
- assets/texsmith/texsmith.css
Listing the extension under MkDocs is harmless — it acts only when Zensical's
rendering context is on the Markdown instance, and under MkDocs the plugin
does the work through its hooks. Keep the texsmith: block under plugins::
Zensical ignores the plugins it does not know, and the extension reads the
options from the configuration file, since Zensical drops them from its own
view of it.
Two settings have to suit both generators. pymdownx.snippets' base_path
cannot carry the !relative tag, which Zensical's loader does not know, so it
is a plain base_path: . — the directory every documented command runs from.
And extra_css must name the stylesheet: Zensical reads that list before
Python runs, so the extension cannot add it the way the plugin does.
Snippets and the books¶
pymdownx.snippets renders the web; the books read the Markdown sources
directly, so TeXSmith honours two of its settings itself:
base_pathis where a fence'sinclude=, and the deprecated--8<--, are looked up when the page's own directory does not hold them — on the web and in the PDF alike.auto_appendnames files the extension appends to every page. The books append them to every page's source too: a sharedincludes/abbreviations.mdis how a site gives every page the acronym definitions, and an acronym reaches the PDF's glossary only through a definition in the page that uses it. The book's glossary carries one\newacronymper acronym a page actually writes, however many pages define it.
A site whose navigation lives in .nav.yml files needs one more line. Zensical
reads them only when awesome-nav is listed under plugins: — it arms its own
reader on the plugin's presence, not on the files being there — so a site that
relied on awesome-pages adds the newer name:
plugins:
- awesome-nav
The file's contents also differ on one point: awesome-pages' arrange: lists
the items it reorders and appends everything else implicitly, while awesome-nav
does not. Converting arrange: to nav: therefore ends with a - "*", or the
pages the list does not name disappear from the navigation — and from a book
built out of it:
nav:
- intro.md
- basics
- "*"
Per page the extension does what the plugin does: the pre-pass over every page
in navigation order, and the lowering. It runs once per page — mkdocstrings
renders each docstring through a Markdown instance of its own, and only the
page's own instance is lowered.
The generated files come first¶
What the extension cannot do is leave a file behind for the build to publish. Zensical empties the site directory when a build starts, and it caches every rendered page: a second build finds the cache warm, replays the HTML and never calls Python at all. A stylesheet or a preview written while a page rendered is therefore deleted by the next build and never written again — the page keeps linking to a file nobody makes any more.
That cache is also why a change to the extension can look like no change at
all: .cache/ replays a page whose source has not moved without calling Python
again, so a new TeXSmith, a new option under texsmith: or an edited extension
reaches the pages only once the cache is dropped:
$ zensical build -c -f mkdocs.yml
The generated files are sources instead. texsmith site assets writes them
under docs_dir, before Zensical runs, and the build copies them like any
hand-written image:
$ texsmith site assets mkdocs.yml
$ zensical build -f mkdocs.yml
The command reads the same configuration file the extension reads — pass a
path, or let it find mkdocs.yml, mkdocs.yaml or zensical.toml in the
current directory. It resolves the navigation for the order and walks every
Markdown page under docs_dir for .snippet fences and .drawio images — the
pages exclude_docs hides included, since Zensical publishes them anyway — and
renders each fence's preview into
docs/assets/snippets/, exports each diagram to docs/assets/drawio/ as an
SVG under the diagram's own relative path, writes
docs/assets/texsmith/texsmith.css, and deletes from both directories what no
page asks for any more. A preview is named after the digest of its fence and a
diagram is exported through the converter's digest cache, so the second run
rebuilds nothing; an asset that will not build is reported with the name of its
page, and the others are made anyway. All three directories are generated:
keep them out of version control.
The SVG is what Zensical shows of a diagram: it has no draw.io plugin, so the
extension rewrites <img src="../x.drawio"> to the export beside it — and leaves
the tag alone, with a warning naming this command, when the export is not
there. The .drawio file stays where it is: MkDocs' plugin still reads it, and
the PDF export still turns it into a vector PDF.
make docs-zensical and make serve-zensical run the command first.
$ zensical serve -f mkdocs.yml
Under zensical serve an edited page invalidates the pre-pass, which is redone
for the whole site; the pages Zensical does not re-render keep the numbers of
the previous build until the next one. A snippet added or edited between two
runs of texsmith site assets is not lost either: rendering the page writes
the missing preview under docs_dir, which is a directory Zensical watches, so
the file appearing there triggers the rebuild that publishes it. That rebuild
finds the cache warm and writes nothing, so it stops there.
Index entries are the page's tags¶
Zensical writes search.json in Rust once Python is done, so nothing a page
renders can reach it — and nothing needs to. The Filters panel of the search
dialog lists the tags of the matching entries, and clicking one narrows the
results to the pages that carry it; with no query at all, it lists them. Those
tags come from a page's tags: metadata, which Zensical also turns into the
chips under the content and into the entries of a <!-- material/tags -->
listing page. Metadata is the one thing Python can still hand the build, so
that is where a page's index entries go.
#[pointeur] puts pointeur in the page's tags, #[mémoire][allocation]
puts mémoire and stops there — a sub-entry is the shape of a printed index,
not of a chip — and an inverted entry gives the head it files under, so
#[Boole, George] is the chip Boole and #[Hanoï, tours de] the chip
Hanoï. The tags the page declares itself come first and stay. Set web.tags
to none to leave a page's tags: exactly as it wrote them.
plugins:
- texsmith:
web:
tags: index # index (default) | none
A page that would rather not show the chips says hide: [tags] in its front
matter; the search filters are unaffected. And since a page's metadata is part
of its cached render, changing web.tags calls for zensical build -c.
Nothing is added to search.json itself. Its tags are a facet of exact
values, not a searchable field, and the text beside them is what a result's
excerpt is built from — inside a shadow root, where no stylesheet the site
ships can reach. Terms written there showed under every excerpt as a tail of
words with no relation to the match. They buy little: an index term is nearly
always in the prose of the section that declares it (on this project's own
corpus, 91% word for word), a term that is not is still typable through the
tags listing page, which is a page of the site like any other, and the few
that answer neither are inverted spellings nobody types.
This is Zensical only. Under MkDocs, Material's own tags plugin collects a
page's tags from on_page_markdown at the same priority as this plugin and is
declared before it, so it reads the metadata before the lowering writes it;
index entries reach MkDocs' search through the tags field of
search_index.json, which lunr indexes and Material boosts, and which the
plugin patches from on_post_build.
The book is a command¶
A book hangs off on_post_build under MkDocs, a hook Zensical has not got, so
it is a command of its own — and the same builder, texsmith.site.book, is
behind both:
$ texsmith site build mkdocs.yml
It reads the books: of the texsmith: block exactly as the plugin does,
resolves the navigation the way the pre-pass does, pre-passes every page so the
book's counters and cross-page references carry the numbers the site gives
them, writes the bundle under build_dir (press/ by default) and compiles
the PDF, printing where it landed. --build-dir DIR puts the bundle elsewhere,
--book TITLE builds one book of several, --no-pdf stops at the .tex, and
--strict and --diagnostics-json behave as they do on the root command: the
strict check runs once the .tex is written and before the engine, so the
output is there to read. mkdocs is not imported anywhere on its path.
The .tex it writes is the .tex TEXSMITH_BUILD=1 mkdocs build writes, byte
for byte — the same navigation, and the same files read the same way, which is
what makes the claim hold whatever the site's other plugins do to the Markdown
on their way to the HTML. make docs-zensical runs it after the site.
What does not carry over yet:
exclude_docs. Zensical builds the pages MkDocs excludes, so a file underdocs/that only exists to be included somewhere else becomes a page of its own; nothing on the TeXSmith side can prevent that. Each is lowered on its own, with the numbers that follow the last page of the navigation, and neither the numbering of the site nor its label map is affected. Keeping one out of the search is Material's own front matter, which Zensical does honour:
---
search:
exclude: true
---
The versioned site is published by a script¶
The version selector is Material's own bundle, which Zensical ships too: it
builds itself from the versions.json at the root of the gh-pages branch as
soon as the page carries a version, which mkdocs.yml declares once for both
generators:
extra:
version:
provider: mike
What mike cannot do is publish a site it did not build — mike deploy takes
a configuration file and runs MkDocs itself, with no way to be handed the
directory Zensical wrote. scripts/publish_docs.py takes that directory
instead and writes the branch layout mike wrote:
$ uv run zensical build -f mkdocs.yml
$ uv run python scripts/publish_docs.py 0.8.0 --alias latest
The site goes under 0.8.0/, latest/ becomes one redirect page per page of
that version, versions.json gains the entry and loses the alias from its
previous holder, and the root index.html redirects to the alias. It checks
origin/gh-pages out as a git worktree under build/gh-pages — --worktree
DIR uses a checkout of yours — and stops at the commit, so a run is there to
read before anything leaves the machine. --push publishes it, which is what
the release workflow adds. The script needs nothing but Python.