Intended Audience

  • You want to learn how Quartz Plugins work
  • Devs who want to build on this plugin

Plugins are just maps

Let us trace how a file gets processed in Quartz. For a full breakdown, check out the Quartz 5 Architecture document. The file content goes through three stages: Raw Text, Markdown, and HTML. At each stage, a sequence of transformations are applied before they are moved down the pipeline with remark-parse and remark-rehype that parse it to/between abstract syntax trees (mdast, hast), respectively.

File.mdremark-parseremark-rehypetextTransformmarkdownPluginshtmlPluginsraw textmdasthast

The transformers also have access to the context (ctx) which stores our plugin configuration as well as other arguments (in ctx.argv). We will use this to access our preambles in typstmate/tags/*.typ.

Let us take a look at some simple transformers:

  • The note-properties plugin parses the frontmatter and makes them available under file.data.frontmatter. We will access its tags field to see what preamble files to include.
  • The obsidian-flavoured-markdown plugin also checks in-line tags, slugifies them and adds them to file.data.frontmatter.tags (unless you set the plugin option parseTags: false).

From this perspective, a Quartz Plugin like ours consists of just a few things:

  • a list of textTransform functions that maps string to string (it currently only lets you pass a single function instead of a list of functions that get composed, but this might change in the future)
  • a list of markdownPlugins that map from mdast to mdast
  • a list of htmlPlugins that map from hast to hast that each have access to the context ctx. Now, unified uses the Pluggable type that models general AST transformers. If we look at the source code quartz/plugins/types.ts, we see exactly that
export type QuartzTransformerPluginInstance = {
	name: string
	textTransform?: (ctx: BuildCtx, src: string) => string
	markdownPlugins?: (ctx: BuildCtx) => PluggableList
	htmlPlugins?: (ctx: BuildCtx) => PluggableList
	externalResources?: ExternalResourcesFn
}

the externalResources function will be mentioned later where we will use it to ship CSS (which includes the backup KaTeX renderer).

Math content

A picture is worth a thousand words; and that is roughly the amount of text the following graph replaces:

AAABCstarts with $$language-mathno matchdefaulttex:texraw textmdasthastdispatchrender$x$${} x {}$$tex: x$$$x$$ (one line)$$id ⋯ $$ (multi-line)$$ ⋯ $$ (multi-line)```<name>inlineMathmathcode lang=<name>code .math-inlinepre>code.math-displaypre>code.language-<name>dispatchInlinestrip {}, match id: prefixdispatchDisplaycheck meta tag in 1st lineput back unmatched meta tagdispatchCodematch <name>ignoretypst svg: `${CODE}$`+ baseline pinKaTeXtypst svg: `$ {CODE} $`block stylingtypst svg: custom format per id

From left to right, it describes how raw input text is routed through remark-math, our custom markdown transformer (label C) , remark-rehype and finally the central piece of our plugin.

  • ${} x {}$ is generated by the “No more flickering math” plugin that adds these braces to make editing math nicer. typst-mate just suppresses those braces and we follow that behaviour.
  • label A indicates that the content is passed verbatim (including $ signs) to node.value and does not get touched at the markdown stage
  • note that single-line $$x$$ is treated as inlineMath by remark-math, even though (and by extension most Obsidian math plugins) treat them as display math. See this issue on remark-math.
  • For $$id ... $$ (multi-line), the id is placed into node.meta in the mdast stage (label B), which would be dropped by remark-rehype, so we put it inside node.data.hProperties (label C).
  • typst-mate lets us define custom processors using the code .language-<name> property. We match for math (default), typst, fletcher, and a few others. Other languages like (js, rust, cpp, etc.) should obviously be ignored by our plugin
  • If our code starts with the prefix $tex: or has the tex meta tag, we let KaTeX handle rendering.
  • in the last step, we the node content is put into typst templates by simply replacing {CODE}. This lets us define arbitrary typst templates (which will be merged with the preamble).

Details

If the above description is still to vague or you would like to contribute, this subsection is meant to help you around the codebase.

As mentioned in labels B and C, the remark-rehype step drops the meta data. Our fix is to add processorTag to our node with a simple recursive function that traverses the AST (simplified)

traverse = (node) => {
	if (node.meta.length > 0) {
		node.data.hProperties.processorTag = node.meta
	}
	// recurse tree
	for (child of node.children) {
		traverse(child)
	}
}

This step is analogous to typst-mate and is documented in transformer.ts:remarkPreserveMathMeta.

The interesting step is what remark-rehype does to our markdown math nodes. They become

  • <code class="language-math math-inline">, or
  • <pre><code class="language-math math-display">..., etc.

In transformer.ts:rehypeTypstMate, we define an htmlPlugin that in very simplified form does the following

tags = get_tags(file.data.frontmatter);
// check frontmatter if LaTeX override is used
preamble = build_preamble(tags, files in typstmate/tags/);
traverse = (node) = {
	classType = node.properties.className; // math-inline or math-display
	parent = node.parent;
	// recall what we did in the previous plugin
	meta_id = (parent.tagName == "pre") ? parent.meta.processorTag;
	
	math_source = strip_meta_from_math(node.value);
	// we omit the case where we use the LaTeX renderer
	try {
		// determine which processer to use
		/* ... */
		template = /* find correct template to use based on processor */
		math_code = template.replace("{CODE}", math_source)	
		typst_source = merge(preamble, math_code);
		svg = typst_compile(typst_source);
		/* compute width and height, and move it around to fit the baseline */
		node.properties = 'typstmate-${styles}';
	} catch (error) {
		// show typst's error message (in red because 
		// it might not be visible otherwise)
		
		// here it would make sense to implement typst-mate's
		// LaTeX fallback on error, but I would not recommend it
	}
	continue;
}

The actual code is quite a bit more complicated because the things that are defined earlier might depend on the result later down, but I think it gets the point across.

Note the line math_code = template.replace("{CODE}", math_source), this should make sense if you look at processor.ts.

Overall, I am super happy to have get something working that has bugged me for over 2 years (see the Recipe Preamble below) and huge thanks go out to the typst-mate and quartz maintainers.

See Also

See, putting the long introductory story with irrelevant information after the thing people come to your site for isn’t hard