From cefbca4753a7d98f93f57a6452a09f6308e2fe27 Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Thu, 10 Aug 2023 21:16:07 -0700 Subject: [PATCH] docs on making plugins --- content/advanced/making plugins.md | 287 +++++++++++++++++- content/advanced/paths.md | 8 + content/configuration.md | 6 +- content/features/upcoming features.md | 1 + quartz/plugins/emitters/aliases.ts | 3 +- quartz/plugins/emitters/componentResources.ts | 1 - quartz/plugins/emitters/contentIndex.ts | 15 +- quartz/plugins/emitters/contentPage.tsx | 4 +- quartz/plugins/emitters/folderPage.tsx | 4 +- quartz/plugins/emitters/tagPage.tsx | 4 +- quartz/plugins/transformers/latex.ts | 40 +-- 11 files changed, 330 insertions(+), 43 deletions(-) diff --git a/content/advanced/making plugins.md b/content/advanced/making plugins.md index d2db67e41..377814e96 100644 --- a/content/advanced/making plugins.md +++ b/content/advanced/making plugins.md @@ -2,12 +2,295 @@ title: Making your own plugins --- -This part of the documentation will assume you have some basic coding knowledge in TypeScript and will include code snippets that describe the interface of what Quartz plugins should look like. +> [!warning] +> This part of the documentation will assume you have working knowledge in TypeScript and will include code snippets that describe the interface of what Quartz plugins should look like. + +Quartz's plugins are a series of transformations over content. This is illustrated in the diagram of the processing pipeline below: ![[quartz-transform-pipeline.png]] -## Transformers +All plugins are defined as a function that takes in a single parameter for options `type OptionType = object | undefined` and return an object that corresponds to the type of plugin it is. +```ts +type OptionType = object | undefined +type QuartzPlugin = (opts?: Options) => QuartzPluginInstance +type QuartzPluginInstance = + | QuartzTransformerPluginInstance + | QuartzFilterPluginInstance + | QuartzEmitterPluginInstance +``` + +The following sections will go into detail for what methods can be implemented for each plugin type. Before we do that, let's clarify a few more ambiguous types: + +- `BuildCtx` is defined in `quartz/ctx.ts`. It consists of + - `argv`: The command line arguments passed to the Quartz [[build]] command + - `cfg`: The full Quartz [[configuration]] + - `allSlugs`: a list of all the valid content slugs (see [[paths]] for more information on what a `ServerSlug` is) +- `StaticResources` is defined in `quartz/resources.tsx`. It consists of + - `css`: a list of URLs for stylesheets that should be loaded + - `js`: a list of scripts that should be loaded. A script is described with the `JSResource` type which is also defined in `quartz/resources.tsx`. It allows you to define a load time (either before or after the DOM has been loaded), whether it should be a module, and either the source URL or the inline content of the script. +## Transformers +Transformers **map** over content, taking a Markdown file and outputting modified content or adding metadata to the file itself. + +```ts +export type QuartzTransformerPluginInstance = { + name: string + textTransform?: (ctx: BuildCtx, src: string | Buffer) => string | Buffer + markdownPlugins?: (ctx: BuildCtx) => PluggableList + htmlPlugins?: (ctx: BuildCtx) => PluggableList + externalResources?: (ctx: BuildCtx) => Partial +} +``` + +All transformer plugins must define at least a `name` field to register the plugin and a few optional functions that allow you to hook into various parts of transforming a single Markdown file. + +- `textTransform` performs a text-to-text transformation *before* a file is parsed into the [Markdown AST](https://github.com/syntax-tree/mdast). +- `markdownPlugins` defines a list of [remark plugins](https://github.com/remarkjs/remark/blob/main/doc/plugins.md). `remark` is a tool that transforms Markdown to Markdown in a structured way. +- `htmlPlugins` defines a list of [rehype plugins](https://github.com/rehypejs/rehype/blob/main/doc/plugins.md). Similar to how `remark` works, `rehype` is a tool that transforms HTML to HTML in a structured way. +- `externalResources` defines any external resources the plugin may need to load on the client-side for it to work properly. + +Normally for both `remark` and `rehype`, you can find existing plugins that you can use to . If you'd like to create your own `remark` or `rehype` plugin, checkout the [guide to creating a plugin](https://unifiedjs.com/learn/guide/create-a-plugin/) using `unified` (the underlying AST parser and transformer library). + +A good example of a transformer plugin that borrows from the `remark` and `rehype` ecosystems is the [[Latex]] plugin: + +```ts title="quartz/plugins/transformers/latex.ts" +import remarkMath from "remark-math" +import rehypeKatex from "rehype-katex" +import rehypeMathjax from "rehype-mathjax/svg.js" +import { QuartzTransformerPlugin } from "../types" + +interface Options { + renderEngine: "katex" | "mathjax" +} + +export const Latex: QuartzTransformerPlugin = (opts?: Options) => { + const engine = opts?.renderEngine ?? "katex" + return { + name: "Latex", + markdownPlugins() { + return [remarkMath] + }, + htmlPlugins() { + if (engine === "katex") { + // if you need to pass options into a plugin, you + // can use a tuple of [plugin, options] + return [[rehypeKatex, { output: "html" }]] + } else { + return [rehypeMathjax] + } + }, + externalResources() { + if (engine === "katex") { + return { + css: [ + "https://cdn.jsdelivr.net/npm/katex@0.16.0/dist/katex.min.css", + ], + js: [ + { + src: "https://cdn.jsdelivr.net/npm/katex@0.16.7/dist/contrib/copy-tex.min.js", + loadTime: "afterDOMReady", + contentType: "external", + }, + ], + } + } else { + return {} + } + }, + } +} +``` + +Another common thing that transformer plugins will do is parse a file and add extra data for that file: + +```ts +export const AddWordCount: QuartzTransformerPlugin = () => { + return { + name: "AddWordCount", + markdownPlugins() { + return [() => { + return (tree, file) => { + // tree is an `mdast` root element + // file is a `vfile` + const text = file.value + const words = text.split(" ").length + file.data.wordcount = words + } + }] + } + } +} + +// tell typescript about our custom data fields we are adding +// other plugins will then also be aware of this data field +declare module "vfile" { + interface DataMap { + wordcount: number + } +} +``` + +Finally, you can also perform transformations over Markdown or HTML ASTs using the `visit` function from the `unist-util-visit` package or the `findAndReplace` function from the `mdast-util-find-and-replace` package. + +```ts +export const TextTransforms: QuartzTransformerPlugin = () => { + return { + name: "TextTransforms", + markdownPlugins() { + return [() => { + return (tree, file) => { + // replace _text_ with the italics version + findAndReplace(tree, /_(.+)_/, (_value: string, ...capture: string[]) => { + // inner is the text inside of the () of the regex + const [inner] = capture + // return an mdast node + // https://github.com/syntax-tree/mdast + return { + type: "emphasis", + children: [{ type: 'text', value: inner }] + } + }) + + // remove all links (replace with just the link content) + // match by 'type' field on an mdast node + // https://github.com/syntax-tree/mdast#link in this example + visit(tree, "link", (link: Link) => { + return { + type: "paragraph" + children: [{ type: 'text', value: link.title }] + } + }) + } + }] + } + } +} +``` + +All transformer plugins can be found under `quartz/plugins/transformers`. If you decide to write your own transformer plugin, don't forget to re-export it under `quartz/plugins/transformers/index.ts` + +A parting word: transformer plugins are quite complex so don't worry if you don't get them right away. Take a look at the built in transformers and see how they operate over content to get a better sense for how to accomplish what you are trying to do. ## Filters +Filters **filter** content, taking the output of all the transformers and determining what files to actually keep and what to discard. + +```ts +export type QuartzFilterPlugin = ( + opts?: Options, +) => QuartzFilterPluginInstance + +export type QuartzFilterPluginInstance = { + name: string + shouldPublish(ctx: BuildCtx, content: ProcessedContent): boolean +} +``` + +A filter plugin must define a `name` field and a `shouldPublish` function that takes in a piece of content that has been processed by all the transformers and returns a `true` or `false` depending on whether it should be passed to the emitter plugins or not. + +For example, here is the built-in plugin for removing drafts: + +```ts title="quartz/plugins/filters/draft.ts" +import { QuartzFilterPlugin } from "../types" + +export const RemoveDrafts: QuartzFilterPlugin<{}> = () => ({ + name: "RemoveDrafts", + shouldPublish(_ctx, [_tree, vfile]) { + // uses frontmatter parsed from transformers + const draftFlag: boolean = vfile.data?.frontmatter?.draft ?? false + return !draftFlag + }, +}) +``` ## Emitters +Emitters **reduce** over content, taking in a list of all the transformed and filtered content and creating output files. + +```ts +export type QuartzEmitterPlugin = ( + opts?: Options, +) => QuartzEmitterPluginInstance + +export type QuartzEmitterPluginInstance = { + name: string + emit( + ctx: BuildCtx, + content: ProcessedContent[], + resources: StaticResources, + emitCallback: EmitCallback, + ): Promise + getQuartzComponents(ctx: BuildCtx): QuartzComponent[] +} +``` + +An emitter plugin must define a `name` field an `emit` function and a `getQuartzComponents` function. `emit` is responsible for looking at all the parsed and filtered content and then appropriately creating files and returning a list of paths to files the plugin created. + +Creating new files can be done via regular Node [fs module](https://nodejs.org/api/fs.html) (i.e. `fs.cp` or `fs.writeFile`) or via the `emitCallback` if you are creating files that contain text. The `emitCallback` function is the 4th argument of the emit function. It's interface looks something like this: + +```ts +export type EmitCallback = (data: { + // the name of the file to emit (not including the file extension) + slug: ServerSlug + // the file extension + ext: `.${string}` | "" + // the file content to add + content: string +}) => Promise +``` + +This is a thin wrapper around writing to the appropriate output folder and ensuring that intermediate directories exist. If you choose to use the native Node `fs` APIs, ensure you emit to the `argv.output` folder as well. + +If you are creating an emitter plugin that needs to render components, there are three more things to be aware of: +- Your component should use `getQuartzComponents` to declare a list of `QuartzComponents` that it uses to construct the page. See the page on [[creating components]] for more information. +- You can use the `renderPage` function defined in `quartz/components/renderPage.tsx` to render Quartz components into HTML. +- If you need to render an HTML AST to JSX, you can use the `toJsxRuntime` function from `hast-util-to-jsx-runtime` library. An example of this can be found in `quartz/components/pages/Content.tsx`. + +For example, the following is a simplified version of the content page plugin that renders every single page. + +```tsx title="quartz/plugins/emitters/contentPage.tsx" +export const ContentPage: QuartzEmitterPlugin = () => { + // construct the layout + const layout: FullPageLayout = { + ...sharedPageComponents, + ...defaultContentPageLayout, + pageBody: Content(), + } + const { head, header, beforeBody, pageBody, left, right, footer} = layout + return { + name: "ContentPage", + getQuartzComponents() { + return [head, ...header, ...beforeBody, pageBody, ...left, ...right, footer] + }, + async emit(ctx, content, resources, emit): Promise { + const cfg = ctx.cfg.configuration + const fps: FilePath[] = [] + const allFiles = content.map((c) => c[1].data) + for (const [tree, file] of content) { + const slug = canonicalizeServer(file.data.slug!) + const externalResources = pageResources(slug, resources) + const componentData: QuartzComponentProps = { + fileData: file.data, + externalResources, + cfg, + children: [], + tree, + allFiles, + } + + const content = renderPage(slug, componentData, opts, externalResources) + const fp = await emit({ + content, + slug: file.data.slug!, + ext: ".html", + }) + + fps.push(fp) + } + return fps + }, + } +} +``` + +Note that it takes in a `FullPageLayout` as the options. It's made by combining a `SharedLayout` and a `PageLayout` both of which are provided through the `quartz.layout.ts` file. + +> [!hint] +> Look in `quartz/plugins` for more examples of plugins in Quartz as reference for your own plugins! \ No newline at end of file diff --git a/content/advanced/paths.md b/content/advanced/paths.md index 49651194d..8e2ac9676 100644 --- a/content/advanced/paths.md +++ b/content/advanced/paths.md @@ -43,3 +43,11 @@ graph LR Server --"canonicalizeServer()"--> Canonical style Canonical stroke-width:4px ``` +Here are the main types of slugs with a rough description of each type of path: +- `ClientSlug`: client-side slug, usually obtained through `window.location`. Contains the protocol (i.e. starts with `https://`) +- `CanonicalSlug`: should be used whenever you need to refer to the location of a file/note. Shouldn't be a relative path and shouldn't have leading or trailing slashes `/` either. Also shouldn't have `/index` as an ending or a file extension. +- `RelativeURL`: must start with `.` or `..` to indicate it's a relative URL. Shouldn't have `/index` as an ending or a file extension. +- `ServerSlug`: cannot be relative and may not have leading or trailing slashes. +- `FilePath`: a real file path to a file on disk. Cannot be relative and must have a file extension. + +To get a clearer picture of how these relate to each other, take a look at the path tests in `quartz/path.test.ts`. \ No newline at end of file diff --git a/content/configuration.md b/content/configuration.md index aa262137b..cdf4459e2 100644 --- a/content/configuration.md +++ b/content/configuration.md @@ -58,9 +58,9 @@ plugins: { } ``` -- [[making plugins#Transformers|Transformers]] **map** over content, taking a Markdown file and outputting modified content or adding metadata to the file itself (e.g. parsing frontmatter, generating a description) -- [[making plugins#Filters|Filters]] **filter** content, taking the output of all the transformers and determining what files to actually keep and what to discord (e.g. filtering out drafts) -- [[making plugins#Emitters|Emitters]] **reduce** over content, taking in a list of all the transformed and filtered content and creating output files (e.g. creating an RSS feed or pages that list all files with a specific tag) +- [[making plugins#Transformers|Transformers]] **map** over content (e.g. parsing frontmatter, generating a description) +- [[making plugins#Filters|Filters]] **filter** content (e.g. filtering out drafts) +- [[making plugins#Emitters|Emitters]] **reduce** over content (e.g. creating an RSS feed or pages that list all files with a specific tag) By adding, removing, and reordering plugins from the `tranformers`, `filters`, and `emitters` fields, you can customize the behaviour of Quartz. diff --git a/content/features/upcoming features.md b/content/features/upcoming features.md index d1a3d3062..fb8551cc6 100644 --- a/content/features/upcoming features.md +++ b/content/features/upcoming features.md @@ -6,6 +6,7 @@ draft: true - docs for making plugins - nested tags showing duplicate + - tag page markdown file for description not being rendered - back button with anchors / popovers + spa is broken - search should be fast for large repos - debounce cfg rebuild on large repos diff --git a/quartz/plugins/emitters/aliases.ts b/quartz/plugins/emitters/aliases.ts index 818b1d7d8..cf99d29da 100644 --- a/quartz/plugins/emitters/aliases.ts +++ b/quartz/plugins/emitters/aliases.ts @@ -30,9 +30,8 @@ export const AliasRedirects: QuartzEmitterPlugin = () => ({ for (const alias of aliases) { const slug = path.posix.join(dir, alias) as ServerSlug - const fp = (slug + ".html") as FilePath const redirUrl = resolveRelative(canonicalizeServer(slug), ogSlug) - await emit({ + const fp = await emit({ content: ` diff --git a/quartz/plugins/emitters/componentResources.ts b/quartz/plugins/emitters/componentResources.ts index 68a24cf82..859109fcc 100644 --- a/quartz/plugins/emitters/componentResources.ts +++ b/quartz/plugins/emitters/componentResources.ts @@ -98,7 +98,6 @@ function addGlobalPageResources( componentResources.afterDOMLoaded.push(plausibleScript) } - // spa if (cfg.enableSPA) { componentResources.afterDOMLoaded.push(spaRouterScript) } else { diff --git a/quartz/plugins/emitters/contentIndex.ts b/quartz/plugins/emitters/contentIndex.ts index 66cf13787..a94b552e2 100644 --- a/quartz/plugins/emitters/contentIndex.ts +++ b/quartz/plugins/emitters/contentIndex.ts @@ -88,21 +88,19 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { } if (opts?.enableSiteMap) { - await emit({ + emitted.push(await emit({ content: generateSiteMap(cfg, linkIndex), slug: "sitemap" as ServerSlug, ext: ".xml", - }) - emitted.push("sitemap.xml" as FilePath) + })) } if (opts?.enableRSS) { - await emit({ + emitted.push(await emit({ content: generateRSSFeed(cfg, linkIndex), slug: "index" as ServerSlug, ext: ".xml", - }) - emitted.push("index.xml" as FilePath) + })) } const fp = path.join("static", "contentIndex") as ServerSlug @@ -117,12 +115,11 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { }), ) - await emit({ + emitted.push(await emit({ content: JSON.stringify(simplifiedIndex), slug: fp, ext: ".json", - }) - emitted.push(`${fp}.json` as FilePath) + })) return emitted }, diff --git a/quartz/plugins/emitters/contentPage.tsx b/quartz/plugins/emitters/contentPage.tsx index 25432532b..dcf28290e 100644 --- a/quartz/plugins/emitters/contentPage.tsx +++ b/quartz/plugins/emitters/contentPage.tsx @@ -42,9 +42,7 @@ export const ContentPage: QuartzEmitterPlugin> = (userOp } const content = renderPage(slug, componentData, opts, externalResources) - - const fp = (file.data.slug + ".html") as FilePath - await emit({ + const fp = await emit({ content, slug: file.data.slug!, ext: ".html", diff --git a/quartz/plugins/emitters/folderPage.tsx b/quartz/plugins/emitters/folderPage.tsx index 5de2e923b..f5bc7a6a2 100644 --- a/quartz/plugins/emitters/folderPage.tsx +++ b/quartz/plugins/emitters/folderPage.tsx @@ -74,9 +74,7 @@ export const FolderPage: QuartzEmitterPlugin = (userOpts) => { } const content = renderPage(slug, componentData, opts, externalResources) - - const fp = (file.data.slug! + ".html") as FilePath - await emit({ + const fp = await emit({ content, slug: file.data.slug!, ext: ".html", diff --git a/quartz/plugins/emitters/tagPage.tsx b/quartz/plugins/emitters/tagPage.tsx index ae231d6e0..19e2906b7 100644 --- a/quartz/plugins/emitters/tagPage.tsx +++ b/quartz/plugins/emitters/tagPage.tsx @@ -80,9 +80,7 @@ export const TagPage: QuartzEmitterPlugin = (userOpts) => { } const content = renderPage(slug, componentData, opts, externalResources) - - const fp = (file.data.slug + ".html") as FilePath - await emit({ + const fp = await emit({ content, slug: file.data.slug!, ext: ".html", diff --git a/quartz/plugins/transformers/latex.ts b/quartz/plugins/transformers/latex.ts index 7a98bde42..5c6f76787 100644 --- a/quartz/plugins/transformers/latex.ts +++ b/quartz/plugins/transformers/latex.ts @@ -15,25 +15,31 @@ export const Latex: QuartzTransformerPlugin = (opts?: Options) => { return [remarkMath] }, htmlPlugins() { - return [engine === "katex" ? [rehypeKatex, { output: "html" }] : [rehypeMathjax]] + if (engine === "katex") { + return [[rehypeKatex, { output: "html" }]] + } else { + return [rehypeMathjax] + } }, externalResources() { - return engine === "katex" - ? { - css: [ - // base css - "https://cdn.jsdelivr.net/npm/katex@0.16.0/dist/katex.min.css", - ], - js: [ - { - // fix copy behaviour: https://github.com/KaTeX/KaTeX/blob/main/contrib/copy-tex/README.md - src: "https://cdn.jsdelivr.net/npm/katex@0.16.7/dist/contrib/copy-tex.min.js", - loadTime: "afterDOMReady", - contentType: "external", - }, - ], - } - : {} + if (engine === "katex") { + return { + css: [ + // base css + "https://cdn.jsdelivr.net/npm/katex@0.16.0/dist/katex.min.css", + ], + js: [ + { + // fix copy behaviour: https://github.com/KaTeX/KaTeX/blob/main/contrib/copy-tex/README.md + src: "https://cdn.jsdelivr.net/npm/katex@0.16.7/dist/contrib/copy-tex.min.js", + loadTime: "afterDOMReady", + contentType: "external", + }, + ], + } + } else { + return {} + } }, } }