Skip to main content

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() and stretch()'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 (like bb(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: value pairs
  • inline lists (tags: [a, b, c]) and block lists (- item on 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"), or rgb(r, g, b[, a])), size (needs a pt/em/cm/mm/in unit), weight (named like "bold"/"light" or numeric 100-900), style ("italic"/"normal"/ "oblique"), and font. 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 from 1.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 like 1.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 #set rule 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 @something themselves, 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 @label text rather than crashing or guessing.
  • Known caveat: the @label shorthand'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/place render 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 #set rule 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 -- #x used 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 #let variable 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 an image(...) call, a nested table(...) call, or arbitrary bracket content -- not just the one exact image + caption shape.
  • #image("path") now also accepts width:/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 .typ files 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.py first (is the regex actually matching what you wrote?), then whichever of block_renderers.py / inline_processors.py handles 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) plus numbering.py for the actual number-formatting algorithm.
  • Something that should be silently stripped (e.g. #set page(...)) is leaking as visible text -> check patterns.py for whether a matching regex exists yet, then core.py's convert() 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

pelican_typst-1.0.1.tar.gz (90.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

pelican_typst-1.0.1-py3-none-any.whl (56.9 kB view details)

Uploaded Python 3

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

Hashes for pelican_typst-1.0.1.tar.gz
Algorithm Hash digest
SHA256 02e8f4242379164a90d4c775975e29dd4f4c3b3c891864b6b5739370db999ede
MD5 46f8cec52718e492d5d7f1f267672ffc
BLAKE2b-256 f97be0cb32664688e5cfd163c017cb75d381d4e69293623ed06378270e99f68f

See more details on using hashes here.

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

Hashes for pelican_typst-1.0.1-py3-none-any.whl
Algorithm Hash digest
SHA256 c7cebfca7d9d722a42f68edbc1b68bbbaf7555fc7e4378c1bf4dde960048151a
MD5 e0b5d90172a064e84e2cd1bce24f5e58
BLAKE2b-256 4eab0c9f77a5ec788bd83ab25377012c61fef38bef3642aef0f2b11d4e1cfcae

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page