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.
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-propertiesplugin parses the frontmatter and makes them available underfile.data.frontmatter. We will access itstagsfield to see what preamble files to include. - The
obsidian-flavoured-markdownplugin also checks in-line tags, slugifies them and adds them tofile.data.frontmatter.tags(unless you set the plugin optionparseTags: false).
From this perspective, a Quartz Plugin like ours consists of just a few things:
- a
list oftextTransformfunctionsthat mapsstringtostring(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
markdownPluginsthat map frommdasttomdast - a list of
htmlPluginsthat map fromhasttohastthat each have access to the contextctx. Now,unifieduses thePluggabletype that models general AST transformers. If we look at the source codequartz/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:
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) tonode.valueand does not get touched at the markdown stage - note that single-line
$$x$$is treated asinlineMathbyremark-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), theidis placed intonode.metain themdaststage (label B), which would be dropped byremark-rehype, so we put it insidenode.data.hProperties(label C). - typst-mate lets us define custom processors using the
code .language-<name>property. We match formath(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 thetexmeta 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
- Read the Recipe Preamble to read why this plugin was made.
- Read Quartz Typst Mate Plugin to see a quick overview of its features.
See, putting the long introductory story with irrelevant information after the thing people come to your site for isn’t hard