« Posts

tyquire

published
  • dev-log
  • typst
  • svelte
  • vite

The first post about the blog outlined three issues with the content pipeline:

  1. Target-specific markup contaminates the content, coupling the content representation to the desired HTML output.
  2. Parser correctness demands a tree model rather than a serial model, because the structure of the content is inherently hierarchical.
  3. Extending the output vocabulary propagates changes through every stage of the pipeline, making the system difficult to evolve and regression-test.

Since then, the pipeline has been redesigned and repackaged as tyquire1, a Vite plugin that annotates the Typst HTML export as an AST that a renderer walks. The rewrite was mostly completed with the help of AI as I had a clear idea of the scope of the redesign early on.

plaintext
.
├── plugins/tyquire/svelte/
│   └── TypstRenderer.svelte      
└── src/lib/
    ├── components/
    │   ├── components.ts         # component registry
    │   ├── SideNoteBlock.svelte  # defines `side-note` component rendering
    │   ├── CodeBlock.svelte
    │   └── ...
    └── content/
        ├── blog/
        │   ├── lib.typ           # html helpers
        │   ├── theme.typ         # defines `side-note` as a component
        │   └── colors.typ
        ├── first-post.typ        # consumes `side-note` and other helpers
        └── second-post.typ

Decoupling the Content and Output Target

Typst exposes target(), a contextual function that returns "html" under the --format html --features html flags. The fix branches component defined in lib.typ and theme.typ on the target once at evaluation.

typst
// lib.typ
#let is-html() = context target() == "html"

The target function must be wrapped in context because the target output may vary within a single document. Using this helper, a component like side-note can define the target specific render:

typst
// theme.typ
#let side-note(content) = context {
  if is-html() {
    component("side-note", content)
  } else {
    /* PDF/SVG styling */
    ...
  }
}

Separating target-specific markup creates a clearer producer-consumer boundary. The content pipeline consumes a target-agnostic representation, while individual output targets decide how that representation is rendered. This also preserves the native Typst tooling experience: Tinymist can lint without complaining about #html.frame litter in the source and generate real-time svg preview, while the same source can still be consumed by the HTML exporter for the pipeline. More importantly, the payoff is that the authoring environment and output pipeline can evolve independently.

Figure 1. Single source, multiple targets

Parser Correctness

The old parser only walks the direct children of <body>, representing each as a leaf carrying an HTML string. This serial model was convenient but lazy, discarding the hierarchical structure present in the HTML export and treating each subtree as an opaque blob. The first post’s diagram shows the structure we actually want: a side-note containing a paragraph, a code block, and another paragraph. This is not expressible in the old model.

Figure 2. What the parser produces now

The new parser recursively builds a tree model. This not only matches the structure of HTML export, but gives the pipeline something it can actually operate on: components can be resolved in the context of their parents and children, and components can themselves contain further components.

The question then becomes how the parser knows which parts of the exported HTML contain components. Components are marked in Typst with metadata identifying them as pipeline components. This metadata survives HTML export, providing the parser with the information it needs to decide where the opaque HTML ends and the structured IR begins. A subtree is therefore only traversed if a component() or inline-component() marker is reachable somewhere beneath it; otherwise, the entire subtree is retained as an opaque HTML string.

Open vocabulary

The pipeline vocabulary is now open: adding a new block type does not require modifying the pipeline itself. The parser recognises components by their shape, not by their names, and passes the name and props through to the renderer.

Figure 3. Before: six touch points across three layers. After: two touch points, both leave the plugin untouched.

component() and inline-component() form the protocol. They wrap a construct in an element carrying data-typst-node and a JSON representation of its named arguments. The parser understands this protocol without knowing the component vocabulary; the application resolves the resulting name against its component registry.

typst
// theme.typ
#let callout(kind, body) = component("callout", body, kind: kind)
typescript
// components.ts
export const components: Record<string, Component<TypstComponentProps>> = {
    code: CodeBlock,
    figure: FigureBlock,
    'side-note': SideNote
    // one more line here, plus the component, is the entire cost of `callout`
};

Six touch points collapse to two: define a component and register a renderer. parse.ts never learns that callout exists, and adding it requires no changes to the plugin. An unregistered component simply renders its children, so extending the vocabulary is additive rather than a pipeline-wide change.

Other engineering notes

A few smaller design decisions came out of the implementation.

  1. The renderer needed one indirection for Typst’s named slots. Typst authors can choose slot names dynamically, while Svelte snippet props must be statically named. Rather than trying to manufacture a snippet prop for every possible slot, every component receives a single renderNodes snippet and uses it to render whichever slot it needs. The slot name remains data in the IR.

  2. Presentation is similarly kept out of the component protocol. component() only marks content for the HTML pipeline; it does not decide how that content looks. Both HTML and PDF presentation remain the responsibility of theme.typ. An early implementation violated this boundary by putting PDF styling in component() itself, which was a useful reminder that architectural rules need to be checked against the implementation, not merely stated.

  3. Vite also exposed a boundary between its module graph and Typst’s. Changes to shared .typ dependencies are invisible to Vite’s normal dependency tracking, so the dependencies reported by Typst are explicitly registered with addWatchFile() and fed back through hotUpdate(). Similarly, lifecycle-owned state such as the syntax highlighter belongs to the plugin or transform closure rather than module scope, where it can outlive the Vite environment that owns it.

  4. Finally, the Typst prelude remains in the repository rather than becoming a published package. This is primarily a tooling decision: keeping theme.typ, lib.typ, and colors.typ in the working tree gives tinymist visibility into the helpers they provide. The plugin itself is structurally capable of standing alone, but publishing it is deliberately deferred until a second real consumer provides evidence for the public API.

Final Thoughts

The rewrite ultimately makes boundaries more explicit. Typst produces structured content, the parser turns it into an IR without knowing the component vocabulary, and the application decides how those components are rendered.

The same mechanism can support components that have nothing to do with the original set of blocks. For example, an interactive animation can be implemented as a Svelte component and registered without changes to the parser or the rest of the pipeline.

The result is a pipeline that is easier to extend without making each new component a change to the pipeline itself.

I am quite pleased with the current state of tyquire. If you are interested in trying it in your own project, there is a starter template available on my GitHub.

  1. 1tyquire is short for Typst and ‘quire’, which is a word for assembling sheets of paper into a book.