Pelican reader plugin: convert Typst (.typ) source files into HTML, with MathML + LaTeX-fallback math rendering.
Project description
pelican-typst
A Pelican reader plugin that converts .typ (Typst) source files into
HTML, the same way Pelican natively handles .md and .rst files.
No Typst installation is required. This does not shell out to the
typst compiler and does not produce a PDF anywhere in the pipeline
-- it reads the Typst source text directly in Python and converts it,
the same way python-markdown reads Markdown text directly.
Because of that, it supports a markdown-equivalent subset of Typst
(headings, lists, code, emphasis, links, images/figures, quotes, and
math) rather than the full Typst language (no #let, #for, custom
functions, imports, etc.). See "Limitations" below.
Install
On PyPI:
pip install pelican-typst
Then enable it in pelicanconf.py:
PLUGINS = ["typst"]
(Pelican also auto-discovers installed namespace plugins if PLUGINS
is left unset entirely -- but the explicit form above is recommended:
it's obvious from your config what's enabled, and it's the one form
that works no matter how the plugin was installed, including editable
installs during development.)
That's it -- no dependencies beyond Pelican itself. Front matter parsing
and everything else here is pure Python; no pyyaml, no typst
binary required.
Developing the plugin itself? See CONTRIBUTING.md -- in
particular, editable installs (pip install -e .) need PLUGINS
set explicitly, since Pelican's auto-discovery can't see them.
Other ways to install (latest unreleased code, standalone wheel, offline)
Latest code from GitHub, if you want something not in a tagged release yet:
pip install git+https://github.com/dhitchenor/pelican-typst.git
Or clone first if you'd rather have the source on disk:
git clone https://github.com/dhitchenor/pelican-typst.git
cd pelican-typst
pip install .
A standalone wheel file, to hand to someone without pointing them at PyPI or a source tree at all (e.g. an offline/air-gapped install):
pip install build
python -m build # produces dist/pelican_typst-1.0.1-py3-none-any.whl
pip install dist/pelican_typst-1.0.1-py3-none-any.whl
Testing
pip install .[test]
pytest tests/ -v
167 tests across math, numbering, metadata/YAML parsing, and every
markup feature (footnotes, tables/grids/stacks, all 14 layout
functions, cross-references/outline, #let bindings, #lorem(), and
the constructs that are recognised-and-stripped rather than shown
broken). Several tests exist specifically to pin real bugs found during
development in place as regressions -- e.g. a regex-backtracking issue
that corrupted `#metadata()` inside an inline code span, and an
unresolved #name reference with an underscore getting misread as
italic shorthand by a later pass. If either of those ever starts
failing again, something regressed.
There's also a smoke test (test_examples_smoke.py) that runs every
file in examples/ through the real TypstReader -- the same object
Pelican itself instantiates -- checking both that nothing raises and
that no unrestored stash placeholder (a literal null byte) leaks into
the output. That specific failure mode is what actually broke RSS/Atom
feed generation once during development (UnserializableContentError:
Control characters are not supported in XML 1.0) -- pytest alone
wouldn't necessarily catch that class of bug, since it only shows up
once Pelican's own feed generator gets involved, which is why CI (see
below) also does a full end-to-end site build on top of the unit tests.
CI
.github/workflows/tests.yml runs on every push/PR: the full pytest
suite across Python 3.8 through 3.12 (via a real pip install ., not
editable -- catches packaging/namespace-discovery issues the tests
themselves can't see), plus a separate job that builds an actual
Pelican site from every example file and checks the generated HTML for
leftover placeholders, mirroring the manual verification this plugin
was built against throughout development.
Metadata: two supported styles, auto-detected
You can use whichever you prefer, per file -- the reader detects which one you used and there is nothing to configure.
YAML front matter, exactly like Pelican's Markdown reader:
---
title: My First Post
date: 2026-06-01
tags: [typst, pelican, python]
category: Programming
slug: my-first-post
authors: [Your Name]
summary: A short summary of the post.
---
= Body starts here
Native Typst dictionary, if you'd rather the file stay 100% valid
Typst (e.g. so typst compile elsewhere on the same source doesn't choke
on non-Typst syntax):
#metadata((
title: "My First Post",
date: "2026-06-01",
tags: ("typst", "pelican", "python"),
category: "Programming",
slug: "my-first-post",
authors: ("Your Name",),
summary: "A short summary of the post.",
))
= Body starts here
The #metadata() form supports strings, numbers, booleans, none,
arrays, and nested dicts -- it's a small literal parser, not a full
Typst evaluator, so values must be literal (no #let references,
function calls, computed dates, etc.).
Recognised metadata keys are whatever your Pelican settings already
recognise (title, date, modified, tags, category, slug,
authors, summary, status, and any custom fields you've configured).
Body markup supported
| Typst | Renders as |
|---|---|
= Heading / == Sub / ... |
<h1> .. <h6> (depth capped at 6) |
- item |
<ul><li> |
+ item |
<ol><li> |
```lang ... ``` |
<pre><code class="language-lang"> |
`code` |
<code> |
*bold* |
<strong> |
_italic_ |
<em> |
#link("url")[text] |
<a href="url">text</a> |
#quote[text] or #quote(attribution:)[text] |
<blockquote><p> with optional <footer> |
#strong[..] / #emph[..] |
function forms, alongside *..*/_.._ shorthand |
| `#figure(image(...) | table(...) |
#image("path", width:, height:) |
<img>, sizing args optional |
#raw("code", lang:, block:) (any arg order) |
<code> or <pre><code> |
#lorem(n) |
n words of bundled placeholder text (own data file, not Typst's exact algorithm) |
#let name = "value" / = 123 / = true |
binding line stripped; later bare #name references substituted |
text#footnote[note] |
superscript ref + endnote list |
#highlight[text] |
<mark> |
#strike[text] |
<s> |
#underline[text] |
<u> |
#overline[text] |
<span style="text-decoration: overline"> |
#smallcaps[text] |
<span style="font-variant: small-caps"> |
#sub[text] / #super[text] |
<sub> / <sup> (text-level, distinct from math sub/sup) |
#upper[text] / #lower[text] |
case-transformed, plain text only (no nested markup, see note below) |
#text(fill:, size:, weight:, style:, font:)[text] |
<span style="...">, best-effort CSS mapping |
`code` |
<code> (shorthand, unchanged) |
\ at end of a line |
<br> |
#linebreak() |
<br> |
straight "/' quotes |
automatic typographic curly quotes/apostrophes |
#table(columns:, table.header([..]), [cell], ...) |
<table> with optional <thead> |
/ Term: Description |
<dl><dt>/<dd> |
#numbering("1.1.1", n1, n2, ...) |
formatted numbering string |
#set heading(numbering: "1.1.1") |
auto-numbered headings from that point on |
= Heading <label> |
id="label" on the <hN> tag |
@label / #ref(<label>) |
cross-reference link, resolves forward or backward |
#outline() |
nested <nav><ul> table of contents |
#align(pos)[..], #block[..], #box[..], #pad[..], #move[..], #place[..], #rotate[..], #scale[..], #skew[..], #columns(N)[..], #hide[..], #repeat[..] |
mapped to inline CSS (see "Layout" below) |
#grid(columns:, [cell], ...) |
CSS Grid |
#stack(dir:, spacing:, [item], ...) |
CSS flexbox |
#h(len) / #v(len) |
inline / block spacers |
#set page(...), #pagebreak(), #colbreak() |
silently stripped, no output (see "Layout" below) |
// comment |
stripped |
/* comment */ |
stripped |
$ ... $ (spaces inside) |
display/block math |
$...$ (no spaces) |
inline math |
Plain lines with no blank line between them are joined into one <p>,
same reflow behaviour as Markdown.
Math: MathML by default, LaTeX as a real fallback
Every equation is parsed once into a small AST, then rendered twice from that same AST: to MathML (what's shown by default) and to LaTeX (kept as a fallback for browsers that can't render MathML). The output looks like:
<span class="typst-math" data-display="inline">
<math xmlns="http://www.w3.org/1998/Math/MathML" display="inline">
<semantics>
<mrow> ... </mrow>
<annotation encoding="application/x-tex">x^{2} + y^{2}</annotation>
</semantics>
</math>
<span class="typst-math-fallback" aria-hidden="true" hidden>\(x^{2} + y^{2}\)</span>
</span>
The <annotation> is invisible per the MathML spec (browsers only render
the first child of <semantics>), so on any MathML-capable browser you
just get clean native MathML -- selectable, accessible, no JS needed.
static/mathml-fallback.js handles the rest: on page load it does a real
feature test (not a browser sniff) to check whether MathML is actually
rendered, and if not, hides the <math> element and reveals the LaTeX
fallback span. If KaTeX or MathJax is also loaded on the page, the
fallback is rendered through it for a normal-looking equation; if
neither is present, the raw LaTeX source is shown as plain text (still
correct and readable, just not typeset).
Wire it up via Pelican's static file settings, e.g.:
STATIC_PATHS = ["extra/mathml-fallback.js"]
EXTRA_PATH_METADATA = {"extra/mathml-fallback.js": {"path": "mathml-fallback.js"}}
and add <script src="{{ SITEURL }}/mathml-fallback.js"></script> to
your theme's base template (optionally alongside a <script> tag for
KaTeX/MathJax if you want prettier fallback rendering).
Typst math syntax supported by the converter: identifiers, numbers,
Greek letters and common symbols (alpha, infinity, arrow.r,
lt.eq, ...), ^/_ sub/superscripts (including sum_(i=1)^n style
limits), frac(a,b) and the a/b shorthand, sqrt(), root(),
vec(), mat(...; ...), binom(), abs(), norm(), floor(),
ceil(), big operators (sum, product, integral, ...), common
named functions (sin, cos, lim, det, ...), accents (hat(),
tilde(), dot(), ddot(), bar(), breve(), check(), acute(),
grave(), circle(), arrow() for vectors, plus the generic
accent(x, symbol) form), primes (x'''), math-alphabet styles
(bb(), cal(), frak(), sans(), mono(), bold(), italic(),
upright() -- using the real Unicode Mathematical Alphanumeric
Symbols, e.g. bb(R) renders as an actual ℝ, not a styled "R"),
cancel(), class(), lr(), stretch(), sizing overrides (display(),
inline(), script(), sscript()), and overbrace()/underbrace().
This covers every item in Typst's own "math" reference category,
including equation accessibility text via #math.equation(alt: "...")
(see below). Equation numbering (#set math.equation(numbering:))
is still not implemented -- ask if you want that added too, it's a
distinct feature from alt. A few honest simplifications worth
knowing about:
class()andstretch()'s sizing options are recognised-and-ignored (content renders correctly, but the fine spacing/sizing control itself has no effect) -- these are subtle typesetting nuances that don't have a clean equivalent in plain HTML/MathML.- The blackboard-bold/calligraphic/fraktur/italic Unicode remapping only
applies to plain ASCII letters (the overwhelmingly common case, e.g.
bb(R),cal(A)) -- applying a style to a complex nested expression (likebb(x^2)) falls back to rendering the expression normally, unstyled, since the Unicode math-alphanumeric block only covers individual letters, not structural constructs. - "styles" and "variants" (two separate items in Typst's own docs) are treated as the same mechanism here.
Anything genuinely unrecognised (not on the list above) degrades to an upright word/identifier rather than breaking the build.
Equation accessibility (alt:)
#math.equation(
alt: "d S equals delta q divided by T",
block: true,
$ dif S = (delta q) / T $,
)
alt becomes an aria-label on the <math> element -- MathML's actual
accessible-name mechanism, so screen readers announce your plain-language
description instead of trying to read the raw notation aloud. Matches
Typst's own recently-added math.equation.alt property, which exists
specifically because PDF/UA accessibility standards require it. Works
for both block and inline equations; block:/numbering:/supplement:
and other named args are recognised (block affects display mode) or
harmlessly ignored (numbering, supplement) rather than breaking the call.
YAML subset
simpleyaml.py is a small hand-rolled parser, not pyyaml -- it covers
what front matter actually uses:
- flat
key: valuepairs - inline lists (
tags: [a, b, c]) and block lists (- itemon following lines) - quoted (
"..."/'...') and unquoted string scalars - booleans (
true/false/yes/no),null/~ - integers and floats
- ISO dates (
2026-06-01) and datetimes (2026-06-01 10:30) #comments (correctly ignored inside quoted strings)
It deliberately does not support YAML anchors/aliases, multi-line
block scalars (|/>), flow mappings ({a: 1}), nested mappings, or
multiple documents -- none of which come up in normal post front matter.
If you hit one of these, the value will just come back as a plain
string rather than the parser crashing.
Footnotes
#footnote[content] works the same way it does in real Typst -- the
note is written inline at its point of use, not defined separately and
referenced by key. Each one becomes a superscript reference marker in
place, and all footnotes for the article are collected into a numbered
list appended after the body, with back-links from note to reference:
<p>Some claim.<sup id="fnref1" class="footnote-ref"><a href="#fn1">1</a></sup></p>
...
<section class="footnotes" role="doc-endnotes">
<hr>
<ol>
<li id="fn1">The note text. <a href="#fnref1" class="footnote-backref" aria-label="Back to content">↩</a></li>
</ol>
</section>
Numbering is sequential in document order, regardless of how many
paragraphs/lists/blocks the footnotes are spread across. Footnote
content gets full inline processing -- bold/italic, inline math, and
#link(...)[...] (including nested brackets, e.g. a link inside a
footnote) all work correctly inside #footnote[...].
No CSS is bundled for the .footnotes section -- it'll render as a
plain <hr> + numbered list unless you style it in your theme. A
reasonable starting point:
.footnotes { font-size: 0.9em; color: #555; }
.footnote-ref a, .footnote-backref { text-decoration: none; }
Text styling
Most of Typst's text reference category is supported (everything
except lorem, which is a placeholder-text generator and doesn't
really apply to converting already-written content):
- Simple wraps --
#highlight[],#strike[],#underline[],#overline[],#smallcaps[],#sub[],#super[]-- each recursively processes its content (so#highlight[a *bold* word]works correctly) and wraps it in the obvious tag/CSS. #upper[]/#lower[]-- case-transformed, but deliberately not recursively processed. If they were, uppercasing a nested<strong>tag would mangle the tag name itself. So content inside#upper[...]/#lower[...]is treated as plain text -- no nested bold/italic/links/math inside a case-transform. In practice this is rarely a real constraint (case-transforms are almost always applied to short plain phrases).#text(...)[...]-- best-effort mapping to inline CSS. Recognised keys:fill(named CSS colors,rgb("#hex"), orrgb(r, g, b[, a])),size(needs apt/em/cm/mm/inunit),weight(named like"bold"/"light"or numeric 100-900),style("italic"/"normal"/"oblique"), andfont. Unrecognised keys are silently ignored rather than breaking the build.#raw("code", lang: "...", block: true/false)-- the function form, alongside the existing`code`and fenced```shorthand syntax. Known gap: if the string literal itself contains a real newline, that newline gets flattened to a space (paragraph-line-joining doesn't know it's inside a quoted string) -- use a fenced code block instead for genuinely multi-line code, which handles this correctly.- Linebreaks -- both the
\end-of-line shorthand and#linebreak()produce a<br>. - Smart quotes -- straight
"/'are automatically converted to typographic quotes/apostrophes, matching Typst's default behaviour. This is always on; there's currently no equivalent of#set smartquote(enabled: false)to disable it per-file.
Tables
#table(
columns: 3,
table.header([Name], [Role], [Team]),
[Alice], [Engineer], [Platform],
[Bob], [Designer], [Product],
)
Unlike everything else in this converter, table calls are allowed to span multiple physical lines (most real tables are written that way for readability) -- the parser does a paren-depth-aware scan to find the matching close, not a single-line regex.
columns:accepts a plain integer (columns: 3) or an array, where the array's length determines the column count (columns: (1fr, 1fr, 1fr)becomes 3 columns; the actual width values are ignored, since column sizing doesn't map onto this converter's plain HTML<table>output).- An optional leading
table.header([h1], [h2], ...)argument becomes a real<thead>. Without it, all cells go into<tbody>as plain<td>-- there's no automatic header-row detection. - Cell content gets full recursive inline processing -- math, links, bold/italic, etc. all work inside a cell.
- Other named args (
stroke:,align:,fill:,inset:, and so on) are recognised-and-ignored rather than breaking the build -- they're visual/layout concerns that don't map onto plain HTML tables anyway.
Term lists
/ Typst: A markup-based typesetting system.
/ Pelican: A static site generator written in Python.
Becomes <dl><dt>Term</dt><dd>Description</dd>...</dl>. Splits on the
first colon on the line. Same limitation as bullet/numbered lists:
single physical line per item -- a description can't continue onto a
following (unmarked) line.
Numbering
#numbering("pattern", n1, n2, ...) -- the direct function-call form,
matching real Typst semantics:
#numbering("1.", 3) // "3."
#numbering("a)", 27) // "aa)"
#numbering("I.", 2026) // "MMXXVI."
#numbering("1.1.1", 2, 3) // "2.3." (only as many groups as numbers given)
#numbering("*", 7) // "**" (star markers cycle then double)
Counting symbols: 1 (arabic), a/A (lower/uppercase letters, with
z rolling over to aa, ab, ...), i/I (lower/uppercase roman
numerals), * (star/dagger/double-dagger cycle, matching Typst's
default footnote-marker style). Everything else in the pattern is a
literal separator. If more numbers are given than the pattern has
counting positions, the last group (symbol + its trailing literal)
repeats for the remaining numbers, matching Typst's own documented
behaviour.
Auto-numbered headings are also supported via
#set heading(numbering: "pattern"):
#set heading(numbering: "1.1.1")
= Introduction // -> "1. Introduction"
== Background // -> "1.1. Background"
== Motivation // -> "1.2. Motivation"
= Methodology // -> "2. Methodology"
== Data Collection // -> "2.1. Data Collection"
- A per-level counter (levels 1-6) increments on each heading; deeper
levels reset to 0 whenever a shallower heading appears, so section 2
starts subsection numbering over at
2.1, not continuing from1.3. - The directive is a document-wide switch, not scoped to a section --
once seen, every heading after it is numbered until either the
document ends or a later
#set heading(numbering: none)turns it back off. Turning it off doesn't retroactively change headings already rendered before that point. - If you skip a level (e.g.
=straight to===with no==in between), the unused level's counter stays at 0 rather than being silently hidden -- you'd see something like1.0.1, an honest reflection of what happened rather than a guess at what you meant. - This is a document-wide flag, not scoped like Typst's real
#setrule system (which supports proper block/function scoping) -- there's only one heading-numbering pattern active at a time per file. - Unlike real Typst, this doesn't affect an outline/table-of-contents
(not implemented) or cross-references (also not implemented) -- it
only changes what appears directly in the
<h1>-<h6>text.
Cross-references and outline
= Introduction <intro>
...
As covered in @intro, ... // shorthand
As covered in #ref(<intro>), ... // function form, equivalent
Attach a label to a heading with <label> at the end of the heading
line. Reference it from anywhere in the document -- before or after
the heading itself. This works because convert() runs a lightweight
first pass over the whole document (_collect_headings) that computes
every heading's id and number before the real line-by-line render pass
starts, so forward references ("as we'll cover in @later-section")
resolve correctly, not just backward ones.
- The link text shown is the heading's number if
#set heading(numbering:)is active at that point in the document, otherwise the heading's plain text. - Headings without an explicit
<label>still get an auto-generated anchor (id="section-N") so#outline()can always link to them -- they just can't be targeted by@somethingthemselves, since there's no author-chosen name for it (matches real Typst, which also requires an explicit label to reference something). - An unresolved reference (typo, or the label genuinely doesn't exist)
degrades to showing the literal
@labeltext rather than crashing or guessing. - Known caveat: the
@labelshorthand's character set deliberately excludes., because Typst label names combined with normal sentence punctuation created a real ambiguity during testing --@section.at the end of a sentence would otherwise swallow the period into the label name and fail to resolve. Use:/-/_in label names instead of.if you plan to reference them with the@shorthand (the#ref(<label>)function form doesn't have this restriction, since its<...>delimiters make the boundary unambiguous either way). - Only heading labels are supported as reference targets -- not figures, equations, or table labels.
#outline() builds a nested table of contents from every heading in
the document (regardless of where the #outline() call itself sits):
#outline() // default: "Contents" title, all levels
#outline(title: [Table of Contents]) // custom title
#outline(title: none) // no title heading at all
#outline(depth: 2) // only include levels 1-2
Renders as <nav role="doc-toc"><h2>...</h2><ul>...</ul></nav> with
properly nested <ul>s matching heading depth. No CSS bundled -- style
.outline in your theme same as you would for .footnotes. The
target: argument (for building an outline of figures/tables instead
of headings) isn't supported -- always heading-based.
Layout
Typst's "layout" reference category has 27 entries, but roughly a third of them are fundamentally about paged, printed documents -- a Pelican article is a continuously- scrolling web page with no concept of "pages" at all, so those don't translate. Here's the honest breakdown:
Implemented (14 functions, mapped to real CSS):
| Typst | CSS mapping |
|---|---|
align(pos)[..] |
text-align (horizontal component only -- see note below) |
block[..] |
<div> with fill→background-color, inset→padding, radius→border-radius, width/height, stroke→plain border |
box[..] |
same as block, but <span style="display:inline-block"> |
pad[..] |
padding (single positional value = all sides, or left:/right:/top:/bottom:/x:/y:) |
move(dx:, dy:)[..] |
position:relative + offset |
place(pos)[..] |
position:absolute + edge mapping |
rotate(angle)[..] |
transform: rotate() |
scale(factor)[..] |
transform: scale() (150% correctly becomes 1.5, not literal 150%) |
skew(ax:, ay:)[..] |
transform: skew() |
columns(n)[..] |
column-count |
hide[..] |
visibility:hidden (reserves space -- matches Typst's semantics, deliberately not display:none) |
grid(columns:, [cell], ...) |
CSS Grid, multi-line source supported same as #table |
stack(dir:, spacing:, [item], ...) |
CSS flexbox (Typst's default direction, top-to-bottom, is respected) |
h(len) / v(len) |
inline spacer span / block spacer div |
Silently stripped -- produce no HTML output at all, rather than
leaking as broken literal text: #set page(...), #pagebreak(),
#colbreak(). This matters if you write the same .typ source for
both this plugin and real typst compile output (PDF) -- page setup
and manual page/column breaks are meaningful there and meaningless
here, so they're recognised and discarded cleanly instead of showing
up as garbled text in the middle of your article. #set page(...)
supports the same multi-line, one-option-per-line style as #table.
Not implemented -- genuinely can't be, not just "not built yet":
layout() and measure() are compile-time layout-introspection
functions (they need to know computed sizes that only a real layout
engine produces) -- architecturally impossible for a static text-to-
HTML converter to answer, since only the browser computes layout, at
render time, not this plugin at build time.
Simplifications worth knowing about:
align()only handles the horizontal component (left/center/right/start/end). Vertical alignment (top/bottom/horizon) is silently ignored -- it depends on knowing the container's height, which doesn't translate cleanly to flowing HTML anyway.repeat()is supposed to fill available space by repeating content (e.g. dotted leader lines before a page number) -- since that also needs real layout computation, the content renders once instead, rather than showing nothing or broken syntax.align/block/pad/placerender a<div>. If one of these appears alone on its own line, it currently ends up nested inside a<p>-- technically invalid HTML5 (browsers auto-recover and render it correctly regardless, but a strict validator would flag it).
Constructs that are recognised and cleanly stripped
A full audit pass went through every category above checking what happens for constructs we don't support: does it leak as broken literal text, or degrade cleanly? The following are now recognised and produce zero output, rather than showing up as garbled text in the middle of an article:
- Any
#set name(...)rule other than#set heading(numbering:)(which is the one case with real semantic effect here) --#set text(...),#set par(...),#set document(...),#set math.equation(numbering:), etc. A#setrule never produces visible output in real Typst either, so stripping is strictly correct, not a compromise. Multi-line, same as#set page(...). #import "file.typ": *and#import "@preview/pkg:1.0.1": ...-- common in real templated documents, never visible either way.#bibliography(...)and#cite(<key>)-- since bibliographies aren't implemented (see below), showing the raw call would be strictly worse than hiding it.
Variable bindings (#let)
#let version = "1.2.3"
#let year = 2026
#let draft = false
Current version: #version, released #year. Draft: #draft.
Simple literal bindings ("strings", numbers, true/false) are
tracked and substituted wherever the bare name is referenced later --
this is a real, bounded feature, not just cleanup: the binding line
itself is stripped (it never renders in real Typst either), and every
subsequent #name use resolves to the stored value.
What's deliberately not supported, and stays honestly broken rather than silently vanishing:
- Function definitions (
#let f(x) = ...) -- the definition line is still stripped cleanly (it never renders either way), but calling the function isn't evaluated. This isn't a strip case; genuinely calling a function requires real evaluation, which is out of scope. - Anything beyond a plain literal on the right-hand side -- expressions, computed values, arrays, other variable references.
- Forward references --
#xused before#let x = ...appears in the document stays unresolved, matching real Typst's own sequential, top-to-bottom evaluation (a genuine forward reference to a#letvariable is undefined in real Typst too, so this isn't a limitation of the plugin, it's the correct behavior).
The design principle here: a visible gap (#name showing as literal
broken text) tells you something needs attention; a silent gap doesn't.
Every construct in this plugin that can't be fully resolved follows
that same rule rather than guessing or hiding the problem.
Constructs that were too narrowly matched (now widened, not stripped)
A few existing patterns only matched one specific shape and fell through to broken text the moment real usage varied slightly. These now accept the realistic range of forms Typst itself allows, rather than being stripped:
#quote[text]now also accepts#quote(attribution: [Name])[text](or a plain string attribution), rendering a<footer>.#figure(...)now works without a caption, and its body can be animage(...)call, a nestedtable(...)call, or arbitrary bracket content -- not just the one exactimage + captionshape.#image("path")now also acceptswidth:/height:args.#strong[...]/#emph[...](the function forms) now work alongside the existing*..*/_.._shorthand.#raw(...)now accepts its arguments (the code string,lang:,block:) in any order, not just one fixed sequence.
Placeholder text (#lorem())
#lorem(n) produces n words of placeholder text, drawn (with
wraparound) from a bundled classic Lorem Ipsum passage stored in its
own data file (data/lorem.txt) rather than hard-coded inline --
straightforward to swap out if you'd rather use different filler text.
This is not a faithful reproduction of Typst's own lorem()
algorithm (which uses a seeded pseudo-random generator for more varied,
sentence-like output) -- it's a simpler, deterministic "take the next N
words from the pool" approach. Good enough for placeholder purposes,
not trying to be pixel-identical to Typst's own output.
Bibliographies
Not yet implemented -- calls are stripped cleanly (see above) rather than shown broken. See "Limitations" below for the fuller picture.
Limitations (read this before relying on it for complex documents)
- This is not a Typst implementation. Typst-the-language features
(
#let,#for,#if, custom functions, package imports, footnotes, bibliographies, cross-references, etc.) are not evaluated -- if your.typfiles use them, they'll show up as literal text in the output, not be executed. - The Typst-math-to-LaTeX conversion is best-effort. It covers the constructs listed above well; unusual notation (custom operators, exotic delimiters, stretchy accents, multi-line aligned equation systems) will degrade gracefully rather than crash, but may not look exactly like Typst's own renderer would produce.
//comment stripping is a simple scan, not a full tokenizer -- a//inside a string that itself contains unbalanced quotes could theoretically confuse it. This doesn't come up in normal prose.- If you need pixel-perfect Typst rendering (not just "good enough for a
blog post"), consider using Typst's own experimental HTML export
(
typst compile --format html) instead, at the cost of needing the Typst compiler as a build step.
Files
pelican-typst/
├── pyproject.toml
├── LICENSE
├── README.md
├── CONTRIBUTING.md
├── CHANGELOG.md
├── .gitignore
├── pyrightconfig.json
├── .github/
│ └── workflows/
│ └── tests.yml # pytest across Python 3.8-3.12 + a real Pelican build smoke test
├── tests/
│ ├── conftest.py # shared fixtures (converter, latex_of, mathml_of, EXAMPLES_DIR)
│ ├── test_math.py
│ ├── test_numbering.py
│ ├── test_metadata.py
│ ├── test_markup_basics.py
│ ├── test_markup_text_and_footnotes.py
│ ├── test_markup_layout.py
│ ├── test_markup_refs_and_numbering.py
│ ├── test_markup_let_and_lorem.py
│ ├── test_markup_strip_constructs.py # includes the two real-bug regression tests
│ └── test_examples_smoke.py # every example, through the real TypstReader
├── examples/
│ ├── README.md # index -- what each file demonstrates, suggested order
│ ├── 01-getting-started.typ
│ ├── 02-native-metadata.typ
│ ├── 03-text-styling-and-footnotes.typ
│ ├── 04-math-extras.typ
│ ├── 05-tables-and-terms.typ
│ ├── 06-numbering-and-references.typ
│ ├── 07-layout.typ
│ ├── 08-let-bindings-and-graceful-fallbacks.typ
│ └── 09-known-limitations.typ
└── pelican/
└── plugins/
└── typst/ # no __init__.py above this level --
├── __init__.py # plugin registration (readers_init signal)
├── reader.py # TypstReader(BaseReader) -- ties it all together
├── metadata.py # YAML front matter + Typst-dict metadata parsing
├── simpleyaml.py # small YAML-subset parser (no pyyaml dependency)
├── math.py # Typst math -> MathML + LaTeX (shared AST)
├── markup/ # Typst body markup -> HTML (split for readability --
│ │ see the package docstring in markup/__init__.py
│ │ for the full breakdown)
│ ├── __init__.py # re-exports TypstToHTML; design notes live here
│ ├── patterns.py # all regex/lookup constants, single source of truth
│ ├── numbering.py # numbering-pattern formatting algorithm
│ ├── lorem.py # #lorem() word-pool loader
│ ├── text_utils.py # generic string parsing (arg splitting, call
│ │ scanning, comments, smart quotes, #let literals)
│ ├── css_utils.py # Typst-value -> CSS mapping (colors, sizes,
│ │ weights, layout-wrapper CSS builders)
│ ├── outline_utils.py # flat heading list -> nested <ul> tree
│ ├── block_renderers.py # mixin: tables/grids/stacks/figures/images/
│ │ heading-collection/outline
│ ├── inline_processors.py # mixin: the #name(...) substitution
│ │ passes used inside _inline()
│ └── core.py # TypstToHTML: state + convert() +
│ _inline(), combines both mixins
├── static/
│ └── mathml-fallback.js
└── data/
└── lorem.txt # word pool for #lorem()
A note on debugging with this structure
If something in the rendered HTML looks wrong, here's roughly where to look first, in order of how often you'll actually need each one:
- A specific Typst construct renders wrong or not at all ->
patterns.pyfirst (is the regex actually matching what you wrote?), then whichever ofblock_renderers.py/inline_processors.pyhandles that construct. - CSS/styling looks off (colors, sizes,
#text(),#block(),#align(), etc.) ->css_utils.py. - Heading numbers, labels, or
#outline()are wrong ->block_renderers.py(_collect_headings,_render_outline) plusnumbering.pyfor the actual number-formatting algorithm. - Something that should be silently stripped (e.g.
#set page(...)) is leaking as visible text -> checkpatterns.pyfor whether a matching regex exists yet, thencore.py'sconvert()main loop for whether it's actually wired in. - The overall pipeline order is suspect (e.g. one substitution
seems to interfere with another) ->
core.py's_inline()is the single place that lists every pass in order, with comments explaining why each one is positioned where it is.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pelican_typst-1.0.1.tar.gz.
File metadata
- Download URL: pelican_typst-1.0.1.tar.gz
- Upload date:
- Size: 90.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.5
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
02e8f4242379164a90d4c775975e29dd4f4c3b3c891864b6b5739370db999ede
|
|
| MD5 |
46f8cec52718e492d5d7f1f267672ffc
|
|
| BLAKE2b-256 |
f97be0cb32664688e5cfd163c017cb75d381d4e69293623ed06378270e99f68f
|
File details
Details for the file pelican_typst-1.0.1-py3-none-any.whl.
File metadata
- Download URL: pelican_typst-1.0.1-py3-none-any.whl
- Upload date:
- Size: 56.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.5
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c7cebfca7d9d722a42f68edbc1b68bbbaf7555fc7e4378c1bf4dde960048151a
|
|
| MD5 |
e0b5d90172a064e84e2cd1bce24f5e58
|
|
| BLAKE2b-256 |
4eab0c9f77a5ec788bd83ab25377012c61fef38bef3642aef0f2b11d4e1cfcae
|