Extending TeXSmith¶
TeXSmith's dialect is TMark, and TMark is a closed language: the four syntactic families, the role registry and the container registry do not grow per project. That is the point — a construct you have never seen is still readable, and a document still renders somewhere else.
So "writing an extension" is not "adding syntax". It is one of four things:
| You want to… | Do this |
|---|---|
| add a new kind of callout (Solution, Exercise, Risk) | declare it under press.declare.admonitions |
| add a new numbered series (findings, requirements) | declare it under press.declare.counters |
| wrap content in something the template styles | use div {.class} and style the class |
| compute something at build time (fetch, convert, execute) | write an IR pass |
| change how a construct looks | redefine its contract macro, or replace the fragment |
The first two are pure front matter and need no code: Admonitions and Custom counters.
Containers¶
A container is the escape hatch for structure. The container names TMark knows
are a closed registry — the callout types (built-in and declared), aside,
figure, tabs, tab, multicolumn, landscape and div — and there is deliberately no
mechanism to add a name: a template that renders a name the registry does not
know has extended the language.
div is the container that means nothing: a hook for classes and an id.
::: div {#s1 .sidebar}
Content the template lays out.
:::
Every layout container lowers to one contract on the paged backends —
\begin{tsdiv}{name}[attrs], #ts-div("name", ..) — dispatched on the name,
with #id forwarded as id, classes as class={a,b} and key=val as is
(lang and media never). Style the class:
\tcbset{/ts/div/class/sidebar/.style={grow to left by=2cm}}
On the web the same container is <div class="sidebar">, which is exactly what
a site stylesheet needs. A name the registry does not know raises
container-unknown and renders its content transparently, rather than silently
becoming an unstyled <div>.
IR passes¶
Anything that has to compute — read a file, convert a diagram, call a
network service, execute a fence — is a pass: a pure function
(Document, PassContext) -> Document over the IR, running either before
tmark.resolve (pre) or after it (post).
from texsmith.core.documents import Document
from texsmith.passes import PassContext, spec
@spec("exam-questions", stage="pre", after=("include",))
def run(document: Document, ctx: PassContext) -> Document:
...
Declare a template's passes in its manifest so they apply only while that
template renders ([typst.template] reads the same key):
[latex.template]
passes = ["my_exam_pkg.questions:run"]
The entry names a PassSpec, a callable registered by @spec(...) — its
registered spec is reused — or a plain callable, and a typo fails when the
template loads, with a TemplateError naming the template and the entry. The
pass reads the template's attributes from its context:
ctx.attribute("solution", False) is True under -a solution=true.
A pass that claims a container of its own says so next to it, because the parse decides long before the pass runs:
[latex.template]
passes = ["my_exam_pkg.questions:run"]
containers = ["solution"]
solution is then a known container while that template renders — no
container-unknown warning on the terminal, in the counts or in
--diagnostics-json, for that name and no other. An entry that is not a
non-empty string is a TemplateError when the manifest loads.
The full contract — ordering, diagnostics, the rules a pass must not break — is in IR passes and fragment contracts.
Fragments and contract macros¶
The writers emit a fixed macro or environment per construct (\tslead,
\tsaside, \tskeys, tscallout, tscode, …), and a fragment must define
every macro the writer can name. Restyle through the pgfkeys family, redefine
the macro with the same signature, or replace the fragment outright:
press:
fragments:
disable: [ts-code]
append: [./my-code.sty]
See Contract macros for the construct-to-macro table and Fragments for how a fragment is built.
On a MkDocs site the index entries reach search_index.json through the
texsmith plugin, which collects them from the rendered pages
and injects them after the search plugin wrote its index. The separate
texsmith.index MkDocs plugin is deprecated — it warns and does nothing, and
disappears in 0.8; and the texsmith.index Markdown extension went with the
Python-Markdown pipeline.
The Python-Markdown extensions are gone
TeXSmith shipped 17 Python-Markdown extensions (smallcaps, latex_raw,
multi_citations, mermaid, index, counters, …) and registered them as
markdown.extensions entry points. They are removed in 0.8.0 together with
the spellings they implemented: tmark parses every Markdown source, and a
site that listed texsmith.* under markdown_extensions must drop those
entries. See Migrating to TMark.
Inspecting what the parser read¶
tmark parse report.md # the IR as JSON
tmark check --strict FILE # parse, resolve and lint
tmark write --to latex --map FILE # the body, its source map and its Requires
Requires is what a construct demands of the template: packages, fragments,
shell escape, assets, citations, index registries, counter series. If your
extension needs the template to do something, that is where it says so.