Jacky Zhao
2023-06-12 352075ae81a3304a7bfa2512ef69b1cdacb26c12
refactor plugins to be functions instead of classes
20 files modified
971 ■■■■ changed files
package-lock.json 19 ●●●●● patch | view | raw | blame | history
package.json 4 ●●● patch | view | raw | blame | history
quartz.config.ts 22 ●●●● patch | view | raw | blame | history
quartz/components/TableOfContents.tsx 23 ●●●●● patch | view | raw | blame | history
quartz/components/types.ts 3 ●●●● patch | view | raw | blame | history
quartz/plugins/emitters/contentPage.tsx 108 ●●●● patch | view | raw | blame | history
quartz/plugins/filters/draft.ts 9 ●●●●● patch | view | raw | blame | history
quartz/plugins/filters/explicit.ts 9 ●●●●● patch | view | raw | blame | history
quartz/plugins/transformers/description.ts 58 ●●●●● patch | view | raw | blame | history
quartz/plugins/transformers/frontmatter.ts 48 ●●●●● patch | view | raw | blame | history
quartz/plugins/transformers/gfm.ts 41 ●●●● patch | view | raw | blame | history
quartz/plugins/transformers/lastmod.ts 85 ●●●●● patch | view | raw | blame | history
quartz/plugins/transformers/latex.ts 20 ●●●●● patch | view | raw | blame | history
quartz/plugins/transformers/links.ts 112 ●●●● patch | view | raw | blame | history
quartz/plugins/transformers/ofm.ts 280 ●●●● patch | view | raw | blame | history
quartz/plugins/transformers/syntax.ts 15 ●●●●● patch | view | raw | blame | history
quartz/plugins/transformers/toc.ts 64 ●●●● patch | view | raw | blame | history
quartz/plugins/types.ts 41 ●●●●● patch | view | raw | blame | history
quartz/processors/filter.ts 4 ●●●● patch | view | raw | blame | history
quartz/processors/parse.ts 6 ●●●● patch | view | raw | blame | history
package-lock.json
@@ -14,7 +14,6 @@
        "chalk": "^4.1.2",
        "cli-spinner": "^0.2.10",
        "esbuild-sass-plugin": "^2.9.0",
        "flamethrower-router": "^0.0.0-meme.12",
        "github-slugger": "^2.0.0",
        "globby": "^13.1.4",
        "gray-matter": "^4.0.3",
@@ -22,9 +21,12 @@
        "hast-util-to-string": "^2.0.0",
        "is-absolute-url": "^4.0.1",
        "mdast-util-find-and-replace": "^2.2.2",
        "mdast-util-to-string": "^3.2.0",
        "micromorph": "^0.4.5",
        "preact": "^10.14.1",
        "preact-render-to-string": "^6.0.3",
        "pretty-time": "^1.1.0",
        "reading-time": "^1.5.0",
        "rehype-autolink-headings": "^6.1.1",
        "rehype-katex": "^6.0.3",
        "rehype-pretty-code": "^0.9.6",
@@ -1523,11 +1525,6 @@
        "node": ">=8"
      }
    },
    "node_modules/flamethrower-router": {
      "version": "0.0.0-meme.12",
      "resolved": "https://registry.npmjs.org/flamethrower-router/-/flamethrower-router-0.0.0-meme.12.tgz",
      "integrity": "sha512-PWcNrjzItwk61RTk/SbbKJNcAgl6qCXH8xkZjGjUGV/dgKAnURci+k+Yk8emubUQWTdAd1kSqujy0VRjoeEgxg=="
    },
    "node_modules/foreground-child": {
      "version": "3.1.1",
      "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz",
@@ -3006,6 +3003,11 @@
        "node": ">=8.6"
      }
    },
    "node_modules/micromorph": {
      "version": "0.4.5",
      "resolved": "https://registry.npmjs.org/micromorph/-/micromorph-0.4.5.tgz",
      "integrity": "sha512-Erasr0xiDvDeEhh7B/k7RFTwwfaAX10D7BMorNpokkwDh6XsRLYWDPaWF1m5JQeMSkGdqlEtQ8s68NcdDWuGgw=="
    },
    "node_modules/mime-db": {
      "version": "1.33.0",
      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz",
@@ -3268,6 +3270,11 @@
        "node": ">=8.10.0"
      }
    },
    "node_modules/reading-time": {
      "version": "1.5.0",
      "resolved": "https://registry.npmjs.org/reading-time/-/reading-time-1.5.0.tgz",
      "integrity": "sha512-onYyVhBNr4CmAxFsKS7bz+uTLRakypIe4R+5A824vBSkQy/hB3fZepoVEf8OVAxzLvK+H/jm9TzpI3ETSm64Kg=="
    },
    "node_modules/rehype-autolink-headings": {
      "version": "6.1.1",
      "resolved": "https://registry.npmjs.org/rehype-autolink-headings/-/rehype-autolink-headings-6.1.1.tgz",
package.json
@@ -30,7 +30,6 @@
    "chalk": "^4.1.2",
    "cli-spinner": "^0.2.10",
    "esbuild-sass-plugin": "^2.9.0",
    "flamethrower-router": "^0.0.0-meme.12",
    "github-slugger": "^2.0.0",
    "globby": "^13.1.4",
    "gray-matter": "^4.0.3",
@@ -38,9 +37,12 @@
    "hast-util-to-string": "^2.0.0",
    "is-absolute-url": "^4.0.1",
    "mdast-util-find-and-replace": "^2.2.2",
    "mdast-util-to-string": "^3.2.0",
    "micromorph": "^0.4.5",
    "preact": "^10.14.1",
    "preact-render-to-string": "^6.0.3",
    "pretty-time": "^1.1.0",
    "reading-time": "^1.5.0",
    "rehype-autolink-headings": "^6.1.1",
    "rehype-katex": "^6.0.3",
    "rehype-pretty-code": "^0.9.6",
quartz.config.ts
@@ -39,23 +39,23 @@
  },
  plugins: {
    transformers: [
      new Plugin.FrontMatter(),
      new Plugin.Description(),
      new Plugin.TableOfContents({ showByDefault: true }),
      new Plugin.CreatedModifiedDate({
      Plugin.FrontMatter(),
      Plugin.Description(),
      Plugin.TableOfContents({ showByDefault: true }),
      Plugin.CreatedModifiedDate({
        priority: ['frontmatter', 'filesystem'] // you can add 'git' here for last modified from Git but this makes the build slower
      }),
      new Plugin.GitHubFlavoredMarkdown(),
      new Plugin.ObsidianFlavoredMarkdown(),
      new Plugin.ResolveLinks(),
      new Plugin.SyntaxHighlighting(),
      new Plugin.Katex(),
      Plugin.GitHubFlavoredMarkdown(),
      Plugin.ObsidianFlavoredMarkdown(),
      Plugin.ResolveLinks(),
      Plugin.SyntaxHighlighting(),
      Plugin.Katex(),
    ],
    filters: [
      new Plugin.RemoveDrafts()
      Plugin.RemoveDrafts()
    ],
    emitters: [
      new Plugin.ContentPage({
      Plugin.ContentPage({
        head: Component.Head,
        header: [Component.PageTitle, Component.Spacer, Component.Darkmode],
        body: [Component.ArticleTitle, Component.ReadingTime, Component.TableOfContents, Component.Content]
quartz/components/TableOfContents.tsx
@@ -1,24 +1,19 @@
import { QuartzComponentProps } from "./types"
import style from "./styles/toc.scss"
export default function TableOfContents({ fileData, position }: QuartzComponentProps) {
export default function TableOfContents({ fileData }: QuartzComponentProps) {
  if (!fileData.toc) {
    return null
  }
  if (position === 'body') {
    // TODO: animate this
    return <details className="toc" open>
      <summary><h3>Table of Contents</h3></summary>
      <ul>
        {fileData.toc.map(tocEntry => <li key={tocEntry.slug} className={`depth-${tocEntry.depth}`}>
          <a href={`#${tocEntry.slug}`}>{tocEntry.text}</a>
        </li>)}
      </ul>
    </details>
  } else if (position === 'sidebar') {
    // TODO
  }
  return <details class="toc" open>
    <summary><h3>Table of Contents</h3></summary>
    <ul>
      {fileData.toc.map(tocEntry => <li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}>
        <a href={`#${tocEntry.slug}`}>{tocEntry.text}</a>
      </li>)}
    </ul>
  </details>
}
TableOfContents.css = style
quartz/components/types.ts
@@ -10,7 +10,6 @@
  cfg: GlobalConfiguration
  children: QuartzComponent[] | JSX.Element[]
  tree: Node<QuartzPluginData>
  position?: 'sidebar' | 'header' | 'body'
}
export type QuartzComponent = ComponentType<QuartzComponentProps> & {
@@ -18,3 +17,5 @@
  beforeDOMLoaded?: string,
  afterDOMLoaded?: string,
}
export type QuartzComponentConstructor<Options extends object> = (opts: Options) => QuartzComponent
quartz/plugins/emitters/contentPage.tsx
@@ -15,66 +15,64 @@
  body: QuartzComponent[]
}
export class ContentPage extends QuartzEmitterPlugin {
  name = "ContentPage"
  opts: Options
  constructor(opts: Options) {
    super()
    this.opts = opts
export const ContentPage: QuartzEmitterPlugin<Options> = (opts) => {
  if (!opts) {
    throw new Error("ContentPage must be initialized with options specifiying the components to use")
  }
  getQuartzComponents(): QuartzComponent[] {
    return [this.opts.head, Header, ...this.opts.header, ...this.opts.body]
  }
  return {
    name: "ContentPage",
    getQuartzComponents() {
      return [opts.head, Header, ...opts.header, ...opts.body]
    },
    async emit(cfg: GlobalConfiguration, content: ProcessedContent[], resources: StaticResources, emit: EmitCallback): Promise<string[]> {
      const fps: string[] = []
  async emit(cfg: GlobalConfiguration, content: ProcessedContent[], resources: StaticResources, emit: EmitCallback): Promise<string[]> {
    const fps: string[] = []
      const { head: Head, header, body } = opts
      for (const [tree, file] of content) {
        const baseDir = resolveToRoot(file.data.slug!)
        const pageResources: StaticResources = {
          css: [baseDir + "/index.css", ...resources.css],
          js: [
            { src: baseDir + "/prescript.js", loadTime: "beforeDOMReady" },
            ...resources.js,
            { src: baseDir + "/postscript.js", loadTime: "afterDOMReady", type: 'module' }
          ]
        }
    const { head: Head, header, body } = this.opts
    for (const [tree, file] of content) {
      const baseDir = resolveToRoot(file.data.slug!)
      const pageResources: StaticResources = {
        css: [baseDir + "/index.css", ...resources.css],
        js: [
          { src: baseDir + "/prescript.js", loadTime: "beforeDOMReady" },
          ...resources.js,
          { src: baseDir + "/postscript.js", loadTime: "afterDOMReady", type: 'module' }
        ]
        const componentData: QuartzComponentProps = {
          fileData: file.data,
          externalResources: pageResources,
          cfg,
          children: [],
          tree
        }
        const doc = <html>
          <Head {...componentData} />
          <body>
            <div id="quartz-root" class="page">
              <Header {...componentData} >
                {header.map(HeaderComponent => <HeaderComponent {...componentData} />)}
              </Header>
              <Body {...componentData}>
                {body.map(BodyComponent => <BodyComponent {...componentData} />)}
              </Body>
            </div>
          </body>
          {pageResources.js.filter(resource => resource.loadTime === "afterDOMReady").map(resource => <script key={resource.src} {...resource} />)}
        </html>
        const fp = file.data.slug + ".html"
        await emit({
          content: "<!DOCTYPE html>\n" + render(doc),
          slug: file.data.slug!,
          ext: ".html",
        })
        fps.push(fp)
      }
      const componentData: QuartzComponentProps = {
        fileData: file.data,
        externalResources: pageResources,
        cfg,
        children: [],
        tree
      }
      const doc = <html>
        <Head {...componentData} />
        <body>
          <div id="quartz-root" class="page">
            <Header {...componentData} >
              {header.map(HeaderComponent => <HeaderComponent {...componentData} position="header" />)}
            </Header>
            <Body {...componentData}>
              {body.map(BodyComponent => <BodyComponent {...componentData } position="body" />)}
            </Body>
          </div>
        </body>
        {pageResources.js.filter(resource => resource.loadTime === "afterDOMReady").map(resource => <script key={resource.src} {...resource} />)}
      </html>
      const fp = file.data.slug + ".html"
      await emit({
        content: "<!DOCTYPE html>\n" + render(doc),
        slug: file.data.slug!,
        ext: ".html",
      })
      fps.push(fp)
      return fps
    }
    return fps
  }
}
quartz/plugins/filters/draft.ts
@@ -1,10 +1,9 @@
import { QuartzFilterPlugin } from "../types"
import { ProcessedContent } from "../vfile"
export class RemoveDrafts extends QuartzFilterPlugin {
  name = "RemoveDrafts"
  shouldPublish([_tree, vfile]: ProcessedContent): boolean {
export const RemoveDrafts: QuartzFilterPlugin<{}> = () => ({
  name: "RemoveDrafts",
  shouldPublish([_tree, vfile]) {
    const draftFlag: boolean = vfile.data?.frontmatter?.draft ?? false
    return !draftFlag
  }
}
})
quartz/plugins/filters/explicit.ts
@@ -1,10 +1,9 @@
import { QuartzFilterPlugin } from "../types"
import { ProcessedContent } from "../vfile"
export class ExplicitPublish extends QuartzFilterPlugin {
  name = "ExplicitPublish"
  shouldPublish([_tree, vfile]: ProcessedContent): boolean {
export const ExplicitPublish: QuartzFilterPlugin = () => ({
  name: "ExplicitPublish",
  shouldPublish([_tree, vfile]) {
    const publishFlag: boolean = vfile.data?.frontmatter?.publish ?? false
    return publishFlag
  }
}
})
quartz/plugins/transformers/description.ts
@@ -1,4 +1,3 @@
import { PluggableList } from "unified"
import { Root as HTMLRoot } from 'hast'
import { toString } from "hast-util-to-string"
import { QuartzTransformerPlugin } from "../types"
@@ -11,41 +10,36 @@
  descriptionLength: 150
}
export class Description extends QuartzTransformerPlugin {
  name = "Description"
  opts: Options
export const Description: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
  const opts = { ...defaultOptions, ...userOpts }
  return {
    name: "Description",
    markdownPlugins() {
      return []
    },
    htmlPlugins() {
      return [
        () => {
          return async (tree: HTMLRoot, file) => {
            const frontMatterDescription = file.data.frontmatter?.description
            const text = toString(tree)
  constructor(opts?: Partial<Options>) {
    super()
    this.opts = { ...defaultOptions, ...opts }
  }
            const desc = frontMatterDescription ?? text
            const sentences = desc.replace(/\s+/g, ' ').split('.')
            let finalDesc = ""
            let sentenceIdx = 0
            const len = opts.descriptionLength
            while (finalDesc.length < len) {
              finalDesc += sentences[sentenceIdx] + '.'
              sentenceIdx++
            }
  markdownPlugins(): PluggableList {
    return []
  }
  htmlPlugins(): PluggableList {
    return [
      () => {
        return async (tree: HTMLRoot, file) => {
          const frontMatterDescription = file.data.frontmatter?.description
          const text = toString(tree)
          const desc = frontMatterDescription ?? text
          const sentences = desc.replace(/\s+/g, ' ').split('.')
          let finalDesc = ""
          let sentenceIdx = 0
          const len = this.opts.descriptionLength
          while (finalDesc.length < len) {
            finalDesc += sentences[sentenceIdx] + '.'
            sentenceIdx++
            file.data.description = finalDesc
            file.data.text = text
          }
          file.data.description = finalDesc
          file.data.text = text
        }
      }
    ]
      ]
    }
  }
}
quartz/plugins/transformers/frontmatter.ts
@@ -1,4 +1,3 @@
import { PluggableList } from "unified"
import matter from "gray-matter"
import remarkFrontmatter from 'remark-frontmatter'
import { QuartzTransformerPlugin } from "../types"
@@ -13,35 +12,30 @@
  delims: '---'
}
export class FrontMatter extends QuartzTransformerPlugin {
  name = "FrontMatter"
  opts: Options
export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
  const opts = { ...defaultOptions, ...userOpts }
  return {
    name: "FrontMatter",
    markdownPlugins() {
      return [
        remarkFrontmatter,
        () => {
          return (_, file) => {
            const { data } = matter(file.value, opts)
  constructor(opts?: Partial<Options>) {
    super()
    this.opts = { ...defaultOptions, ...opts }
  }
  markdownPlugins(): PluggableList {
    return [
      remarkFrontmatter,
      () => {
        return (_, file) => {
          const { data } = matter(file.value, this.opts)
          // fill in frontmatter
          file.data.frontmatter = {
            title: file.stem ?? "Untitled",
            tags: [],
            ...data
            // fill in frontmatter
            file.data.frontmatter = {
              title: file.stem ?? "Untitled",
              tags: [],
              ...data
            }
          }
        }
      }
    ]
  }
  htmlPlugins(): PluggableList {
    return []
      ]
    },
    htmlPlugins() {
      return []
    }
  }
}
quartz/plugins/transformers/gfm.ts
@@ -15,27 +15,24 @@
  linkHeadings: true
}
export class GitHubFlavoredMarkdown extends QuartzTransformerPlugin {
  name = "GitHubFlavoredMarkdown"
  opts: Options
  constructor(opts?: Partial<Options>) {
    super()
    this.opts = { ...defaultOptions, ...opts }
  }
  markdownPlugins(): PluggableList {
    return this.opts.enableSmartyPants ? [remarkGfm] : [remarkGfm, smartypants]
  }
  htmlPlugins(): PluggableList {
    return this.opts.linkHeadings
      ? [rehypeSlug, [rehypeAutolinkHeadings, {
        behavior: 'append', content: {
          type: 'text',
          value: ' Â§'
        }
      }]]
      : []
export const GitHubFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
  const opts = { ...defaultOptions, ...userOpts }
  return {
    name: "GitHubFlavoredMarkdown",
    markdownPlugins() {
      return opts.enableSmartyPants ? [remarkGfm] : [remarkGfm, smartypants]
    },
    htmlPlugins() {
      if (opts.linkHeadings) {
        return [rehypeSlug, [rehypeAutolinkHeadings, {
          behavior: 'append', content: {
            type: 'text',
            value: ' Â§'
          }
        }]]
      } else {
        return []
      }
    }
  }
}
quartz/plugins/transformers/lastmod.ts
@@ -1,4 +1,3 @@
import { PluggableList } from "unified"
import fs from "fs"
import path from 'path'
import { Repository } from "@napi-rs/simple-git"
@@ -12,59 +11,51 @@
  priority: ['frontmatter', 'git', 'filesystem']
}
export class CreatedModifiedDate extends QuartzTransformerPlugin {
  name = "CreatedModifiedDate"
  opts: Options
export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
  const opts = { ...defaultOptions, ...userOpts }
  return {
    name: "CreatedModifiedDate",
    markdownPlugins() {
      return [
        () => {
          let repo: Repository | undefined = undefined
          return async (_tree, file) => {
            let created: undefined | Date = undefined
            let modified: undefined | Date = undefined
            let published: undefined | Date = undefined
  constructor(opts?: Partial<Options>) {
    super()
    this.opts = {
      ...defaultOptions,
      ...opts,
    }
  }
            const fp = path.join(file.cwd, file.data.filePath as string)
            for (const source of opts.priority) {
              if (source === "filesystem") {
                const st = await fs.promises.stat(fp)
                created ||= new Date(st.birthtimeMs)
                modified ||= new Date(st.mtimeMs)
              } else if (source === "frontmatter" && file.data.frontmatter) {
                created ||= file.data.frontmatter.date
                modified ||= file.data.frontmatter.lastmod
                modified ||= file.data.frontmatter["last-modified"]
                published ||= file.data.frontmatter.publishDate
              } else if (source === "git") {
                if (!repo) {
                  repo = new Repository(file.cwd)
                }
  markdownPlugins(): PluggableList {
    return [
      () => {
        let repo: Repository | undefined = undefined
        return async (_tree, file) => {
          let created: undefined | Date = undefined
          let modified: undefined | Date = undefined
          let published: undefined | Date = undefined
          const fp = path.join(file.cwd, file.data.filePath as string)
          for (const source of this.opts.priority) {
            if (source === "filesystem") {
              const st = await fs.promises.stat(fp)
              created ||= new Date(st.birthtimeMs)
              modified ||= new Date(st.mtimeMs)
            } else if (source === "frontmatter" && file.data.frontmatter) {
              created ||= file.data.frontmatter.date
              modified ||= file.data.frontmatter.lastmod
              modified ||= file.data.frontmatter["last-modified"]
              published ||= file.data.frontmatter.publishDate
            } else if (source === "git") {
              if (!repo) {
                repo = new Repository(file.cwd)
                modified ||= new Date(await repo.getFileLatestModifiedDateAsync(file.data.filePath!))
              }
            }
              modified ||= new Date(await repo.getFileLatestModifiedDateAsync(file.data.filePath!))
            file.data.dates = {
              created: created ?? new Date(),
              modified: modified ?? new Date(),
              published: published ?? new Date()
            }
          }
          file.data.dates = {
            created: created ?? new Date(),
            modified: modified ?? new Date(),
            published: published ?? new Date()
          }
        }
      }
    ]
  }
  htmlPlugins(): PluggableList {
    return []
      ]
    },
    htmlPlugins() {
      return []
    }
  }
}
quartz/plugins/transformers/latex.ts
@@ -1,24 +1,20 @@
import { PluggableList } from "unified"
import remarkMath from "remark-math"
import rehypeKatex from 'rehype-katex'
import { StaticResources } from "../../resources"
import { QuartzTransformerPlugin } from "../types"
export class Katex extends QuartzTransformerPlugin {
  name = "Katex"
  markdownPlugins(): PluggableList {
export const Katex: QuartzTransformerPlugin = () => ({
  name: "Katex",
  markdownPlugins() {
    return [remarkMath]
  }
  htmlPlugins(): PluggableList {
  },
  htmlPlugins() {
    return [
      [rehypeKatex, {
        output: 'html',
      }]
    ]
  }
  externalResources: Partial<StaticResources> = {
  },
  externalResources: {
    css: [
      // base css
      "https://cdn.jsdelivr.net/npm/katex@0.16.0/dist/katex.min.css",
@@ -31,4 +27,4 @@
      }
    ]
  }
}
})
quartz/plugins/transformers/links.ts
@@ -1,4 +1,3 @@
import { PluggableList } from "unified"
import { QuartzTransformerPlugin } from "../types"
import { relative, relativeToRoot, slugify } from "../../path"
import path from "path"
@@ -17,65 +16,60 @@
  prettyLinks: true
}
export class ResolveLinks extends QuartzTransformerPlugin {
  name = "LinkProcessing"
  opts: Options
  constructor(opts?: Partial<Options>) {
    super()
    this.opts = { ...defaultOptions, ...opts }
  }
  markdownPlugins(): PluggableList {
    return []
  }
  htmlPlugins(): PluggableList {
    return [() => {
      return (tree, file) => {
        const curSlug = file.data.slug!
        const transformLink = (target: string) => {
          const targetSlug = slugify(decodeURI(target).trim())
          if (this.opts.markdownLinkResolution === 'relative' && !path.isAbsolute(targetSlug)) {
            return './' + relative(curSlug, targetSlug)
          } else {
            return './' + relativeToRoot(curSlug, targetSlug)
export const ResolveLinks: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
  const opts = { ...defaultOptions, ...userOpts }
  return {
    name: "LinkProcessing",
    markdownPlugins() {
      return []
    },
    htmlPlugins() {
      return [() => {
        return (tree, file) => {
          const curSlug = file.data.slug!
          const transformLink = (target: string) => {
            const targetSlug = slugify(decodeURI(target).trim())
            if (opts.markdownLinkResolution === 'relative' && !path.isAbsolute(targetSlug)) {
              return './' + relative(curSlug, targetSlug)
            } else {
              return './' + relativeToRoot(curSlug, targetSlug)
            }
          }
          visit(tree, 'element', (node, _index, _parent) => {
            // rewrite all links
            if (
              node.tagName === 'a' &&
              node.properties &&
              typeof node.properties.href === 'string'
            ) {
              node.properties.className = isAbsoluteUrl(node.properties.href) ? "external" : "internal"
              // don't process external links or intra-document anchors
              if (!(isAbsoluteUrl(node.properties.href) || node.properties.href.startsWith("#"))) {
                node.properties.href = transformLink(node.properties.href)
              }
              // rewrite link internals if prettylinks is on
              if (opts.prettyLinks && node.children.length === 1 && node.children[0].type === 'text') {
                node.children[0].value = path.basename(node.children[0].value)
              }
            }
            // transform all images
            if (
              node.tagName === 'img' &&
              node.properties &&
              typeof node.properties.src === 'string'
            ) {
              if (!isAbsoluteUrl(node.properties.src)) {
                const ext = path.extname(node.properties.src)
                node.properties.src = transformLink(path.join("assets", node.properties.src)) + ext
              }
            }
          })
        }
        visit(tree, 'element', (node, _index, _parent) => {
          // rewrite all links
          if (
            node.tagName === 'a' &&
            node.properties &&
            typeof node.properties.href === 'string'
          ) {
            node.properties.className = isAbsoluteUrl(node.properties.href) ? "external" : "internal"
            // don't process external links or intra-document anchors
            if (!(isAbsoluteUrl(node.properties.href) || node.properties.href.startsWith("#"))) {
              node.properties.href = transformLink(node.properties.href)
            }
            // rewrite link internals if prettylinks is on
            if (this.opts.prettyLinks && node.children.length === 1 && node.children[0].type === 'text') {
              node.children[0].value = path.basename(node.children[0].value)
            }
          }
          // transform all images
          if (
            node.tagName === 'img' &&
            node.properties &&
            typeof node.properties.src === 'string'
          ) {
            if (!isAbsoluteUrl(node.properties.src)) {
              const ext = path.extname(node.properties.src)
              node.properties.src = transformLink(path.join("assets", node.properties.src)) + ext
            }
          }
        })
      }
    }]
      }]
    }
  }
}
quartz/plugins/transformers/ofm.ts
@@ -89,174 +89,168 @@
  return s.substring(0, 1).toUpperCase() + s.substring(1);
}
export class ObsidianFlavoredMarkdown extends QuartzTransformerPlugin {
  name = "ObsidianFlavoredMarkdown"
  opts: Options
export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
  const opts = { ...defaultOptions, ...userOpts }
  return {
    name: "ObsidianFlavoredMarkdown",
    markdownPlugins() {
      const plugins: PluggableList = []
      if (opts.wikilinks) {
        plugins.push(() => {
          // Match wikilinks
          // !?               -> optional embedding
          // \[\[             -> open brace
          // ([^\[\]\|\#]+)   -> one or more non-special characters ([,],|, or #) (name)
          // (#[^\[\]\|\#]+)? -> # then one or more non-special characters (heading link)
          // (|[^\[\]\|\#]+)? -> | then one or more non-special characters (alias)
          const backlinkRegex = new RegExp(/!?\[\[([^\[\]\|\#]+)(#[^\[\]\|\#]+)?(\|[^\[\]\|\#]+)?\]\]/, "g")
          return (tree: Root, _file) => {
            findAndReplace(tree, backlinkRegex, (value: string, ...capture: string[]) => {
              const [fp, rawHeader, rawAlias] = capture
              const anchor = rawHeader?.trim() ?? ""
              const alias = rawAlias?.slice(1).trim()
  constructor(opts?: Partial<Options>) {
    super()
    this.opts = { ...defaultOptions, ...opts }
  }
  markdownPlugins(): PluggableList {
    const plugins: PluggableList = []
    if (this.opts.wikilinks) {
      plugins.push(() => {
        // Match wikilinks
        // !?               -> optional embedding
        // \[\[             -> open brace
        // ([^\[\]\|\#]+)   -> one or more non-special characters ([,],|, or #) (name)
        // (#[^\[\]\|\#]+)? -> # then one or more non-special characters (heading link)
        // (|[^\[\]\|\#]+)? -> | then one or more non-special characters (alias)
        const backlinkRegex = new RegExp(/!?\[\[([^\[\]\|\#]+)(#[^\[\]\|\#]+)?(\|[^\[\]\|\#]+)?\]\]/, "g")
        return (tree: Root, _file) => {
          findAndReplace(tree, backlinkRegex, (value: string, ...capture: string[]) => {
            const [fp, rawHeader, rawAlias] = capture
            const anchor = rawHeader?.trim() ?? ""
            const alias = rawAlias?.slice(1).trim()
            // embed cases
            if (value.startsWith("!")) {
              const ext = path.extname(fp).toLowerCase()
              const url = slugify(fp.trim()) + ext
              if ([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg"].includes(ext)) {
                const dims = alias ?? ""
                let [width, height] = dims.split("x", 2)
                width ||= "auto"
                height ||= "auto"
                return {
                  type: 'image',
                  url,
                  data: {
                    hProperties: {
                      width, height
              // embed cases
              if (value.startsWith("!")) {
                const ext = path.extname(fp).toLowerCase()
                const url = slugify(fp.trim()) + ext
                if ([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg"].includes(ext)) {
                  const dims = alias ?? ""
                  let [width, height] = dims.split("x", 2)
                  width ||= "auto"
                  height ||= "auto"
                  return {
                    type: 'image',
                    url,
                    data: {
                      hProperties: {
                        width, height
                      }
                    }
                  }
                } else if ([".mp4", ".webm", ".ogv", ".mov", ".mkv"].includes(ext)) {
                  return {
                    type: 'html',
                    value: `<video src="${url}" controls></video>`
                  }
                } else if ([".mp3", ".webm", ".wav", ".m4a", ".ogg", ".3gp", ".flac"].includes(ext)) {
                  return {
                    type: 'html',
                    value: `<audio src="${url}" controls></audio>`
                  }
                } else if ([".pdf"].includes(ext)) {
                  return {
                    type: 'html',
                    value: `<iframe src="${url}"></iframe>`
                  }
                }
              } else if ([".mp4", ".webm", ".ogv", ".mov", ".mkv"].includes(ext)) {
                return {
                  type: 'html',
                  value: `<video src="${url}" controls></video>`
                }
              } else if ([".mp3", ".webm", ".wav", ".m4a", ".ogg", ".3gp", ".flac"].includes(ext)) {
                return {
                  type: 'html',
                  value: `<audio src="${url}" controls></audio>`
                }
              } else if ([".pdf"].includes(ext)) {
                return {
                  type: 'html',
                  value: `<iframe src="${url}"></iframe>`
                }
                // otherwise, fall through to regular link
              }
              // otherwise, fall through to regular link
            }
            // internal link
            const url = slugify(fp.trim() + anchor)
            return {
              type: 'link',
              url,
              children: [{
                type: 'text',
                value: alias ?? fp
              }]
            }
          })
              // internal link
              const url = slugify(fp.trim() + anchor)
              return {
                type: 'link',
                url,
                children: [{
                  type: 'text',
                  value: alias ?? fp
                }]
              }
            })
          }
        }
        )
      }
      )
    }
    if (this.opts.highlight) {
      plugins.push(() => {
        // Match highlights
        const highlightRegex = new RegExp(/==(.+)==/, "g")
        return (tree: Root, _file) => {
          findAndReplace(tree, highlightRegex, (_value: string, ...capture: string[]) => {
            const [inner] = capture
            return {
              type: 'html',
              value: `<span class="text-highlight">${inner}</span>`
            }
          })
        }
      })
    }
      if (opts.highlight) {
        plugins.push(() => {
          // Match highlights
          const highlightRegex = new RegExp(/==(.+)==/, "g")
          return (tree: Root, _file) => {
            findAndReplace(tree, highlightRegex, (_value: string, ...capture: string[]) => {
              const [inner] = capture
              return {
                type: 'html',
                value: `<span class="text-highlight">${inner}</span>`
              }
            })
          }
        })
      }
    if (this.opts.callouts) {
      plugins.push(() => {
        // from https://github.com/escwxyz/remark-obsidian-callout/blob/main/src/index.ts
        const calloutRegex = new RegExp(/^\[\!(\w+)\]([+-]?)/)
        return (tree: Root, _file) => {
          visit(tree, "blockquote", (node) => {
            if (node.children.length === 0) {
              return
            }
      if (opts.callouts) {
        plugins.push(() => {
          // from https://github.com/escwxyz/remark-obsidian-callout/blob/main/src/index.ts
          const calloutRegex = new RegExp(/^\[\!(\w+)\]([+-]?)/)
          return (tree: Root, _file) => {
            visit(tree, "blockquote", (node) => {
              if (node.children.length === 0) {
                return
              }
            // find first line
            const firstChild = node.children[0]
            if (firstChild.type !== "paragraph" || firstChild.children[0]?.type !== "text") {
              return
            }
              // find first line
              const firstChild = node.children[0]
              if (firstChild.type !== "paragraph" || firstChild.children[0]?.type !== "text") {
                return
              }
            const text = firstChild.children[0].value
            const [firstLine, ...remainingLines] = text.split("\n")
            const remainingText = remainingLines.join("\n")
              const text = firstChild.children[0].value
              const [firstLine, ...remainingLines] = text.split("\n")
              const remainingText = remainingLines.join("\n")
            const match = firstLine.match(calloutRegex)
            if (match && match.input) {
              const [calloutDirective, typeString, collapseChar] = match
              const calloutType = typeString.toLowerCase() as keyof typeof callouts
              const collapse = collapseChar === "+" || collapseChar === "-"
              const defaultState = collapseChar === "-" ? "collapsed" : "expanded"
              const title = match.input.slice(calloutDirective.length).trim() || capitalize(calloutType)
              const match = firstLine.match(calloutRegex)
              if (match && match.input) {
                const [calloutDirective, typeString, collapseChar] = match
                const calloutType = typeString.toLowerCase() as keyof typeof callouts
                const collapse = collapseChar === "+" || collapseChar === "-"
                const defaultState = collapseChar === "-" ? "collapsed" : "expanded"
                const title = match.input.slice(calloutDirective.length).trim() || capitalize(calloutType)
              const titleNode: HTML = {
                type: "html",
                value: `<div
                const titleNode: HTML = {
                  type: "html",
                  value: `<div
                  class="callout-title"
                >
                  <div class="callout-icon">${callouts[canonicalizeCallout(calloutType)]}</div>
                  <div class="callout-title-inner">${title}</div>
                </div>`
              }
                }
              const blockquoteContent: (BlockContent | DefinitionContent)[] = [titleNode]
              if (remainingText.length > 0) {
                blockquoteContent.push({
                  type: 'paragraph',
                  children: [{
                    type: 'text',
                    value: remainingText,
                  }]
                const blockquoteContent: (BlockContent | DefinitionContent)[] = [titleNode]
                if (remainingText.length > 0) {
                  blockquoteContent.push({
                    type: 'paragraph',
                    children: [{
                      type: 'text',
                      value: remainingText,
                    }]
                })
              }
                  })
                }
              // replace first line of blockquote with title and rest of the paragraph text
              node.children.splice(0, 1, ...blockquoteContent)
                // replace first line of blockquote with title and rest of the paragraph text
                node.children.splice(0, 1, ...blockquoteContent)
              // add properties to base blockquote
              node.data = {
                hProperties: {
                  ...(node.data?.hProperties ?? {}),
                  className: `callout ${collapse ? "is-collapsible" : ""} ${defaultState === "collapsed" ? "is-collapsed" : ""}`,
                  "data-callout": calloutType,
                  "data-callout-fold": collapse,
                // add properties to base blockquote
                node.data = {
                  hProperties: {
                    ...(node.data?.hProperties ?? {}),
                    className: `callout ${collapse ? "is-collapsible" : ""} ${defaultState === "collapsed" ? "is-collapsed" : ""}`,
                    "data-callout": calloutType,
                    "data-callout-fold": collapse,
                  }
                }
              }
            }
          })
        }
      })
            })
          }
        })
      }
      return plugins
    },
    htmlPlugins() {
      return [rehypeRaw]
    }
    return plugins
  }
  htmlPlugins(): PluggableList {
    return [rehypeRaw]
  }
}
quartz/plugins/transformers/syntax.ts
@@ -1,15 +1,12 @@
import { PluggableList } from "unified"
import { QuartzTransformerPlugin } from "../types"
import rehypePrettyCode, { Options as CodeOptions } from "rehype-pretty-code"
export class SyntaxHighlighting extends QuartzTransformerPlugin {
  name = "SyntaxHighlighting"
  markdownPlugins(): PluggableList {
export const SyntaxHighlighting: QuartzTransformerPlugin = () => ({
  name: "SyntaxHighlighting",
  markdownPlugins() {
    return []
  }
  htmlPlugins(): PluggableList {
  },
  htmlPlugins() {
    return [[rehypePrettyCode, {
      theme: 'css-variables',
      onVisitLine(node) {
@@ -25,4 +22,4 @@
      },
    } satisfies Partial<CodeOptions>]]
  }
}
})
quartz/plugins/transformers/toc.ts
@@ -1,4 +1,3 @@
import { PluggableList } from "unified"
import { QuartzTransformerPlugin } from "../types"
import { Root } from "mdast"
import { visit } from "unist-util-visit"
@@ -23,44 +22,39 @@
  slug: string
}
export class TableOfContents extends QuartzTransformerPlugin {
  name = "TableOfContents"
  opts: Options
export const TableOfContents: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
  const opts = { ...defaultOptions, ...userOpts }
  return {
    name: "TableOfContents",
    markdownPlugins() {
      return [() => {
        return async (tree: Root, file) => {
          const display = file.data.frontmatter?.enableToc ?? opts.showByDefault
          if (display) {
            const toc: TocEntry[] = []
            let highestDepth: number = opts.maxDepth
            visit(tree, 'heading', (node) => {
              if (node.depth <= opts.maxDepth) {
                const text = toString(node)
                highestDepth = Math.min(highestDepth, node.depth)
                toc.push({
                  depth: node.depth,
                  text,
                  slug: slugAnchor.slug(text)
                })
              }
            })
  constructor(opts?: Partial<Options>) {
    super()
    this.opts = { ...defaultOptions, ...opts }
  }
  markdownPlugins(): PluggableList {
    return [() => {
      return async (tree: Root, file) => {
        const display = file.data.frontmatter?.enableToc ?? this.opts.showByDefault
        if (display) {
          const toc: TocEntry[] = []
          let highestDepth: number = this.opts.maxDepth
          visit(tree, 'heading', (node) => {
            if (node.depth <= this.opts.maxDepth) {
              const text = toString(node)
              highestDepth = Math.min(highestDepth, node.depth)
              toc.push({
                depth: node.depth,
                text,
                slug: slugAnchor.slug(text)
              })
            if (toc.length > opts.minEntries) {
              file.data.toc = toc.map(entry => ({ ...entry, depth: entry.depth - highestDepth }))
            }
          })
          if (toc.length > this.opts.minEntries) {
            file.data.toc = toc.map(entry => ({ ...entry, depth: entry.depth - highestDepth }))
          }
        }
      }
    }]
  }
  htmlPlugins(): PluggableList {
    return []
      }]
    },
    htmlPlugins() {
      return []
    }
  }
}
quartz/plugins/types.ts
@@ -4,16 +4,32 @@
import { GlobalConfiguration } from "../cfg"
import { QuartzComponent } from "../components/types"
export abstract class QuartzTransformerPlugin {
  abstract name: string
  abstract markdownPlugins(): PluggableList
  abstract htmlPlugins(): PluggableList
export interface PluginTypes {
  transformers: QuartzTransformerPluginInstance[],
  filters: QuartzFilterPluginInstance[],
  emitters: QuartzEmitterPluginInstance[],
}
type OptionType = object | undefined
export type QuartzTransformerPlugin<Options extends OptionType = undefined> = (opts?: Options) => QuartzTransformerPluginInstance
export type QuartzTransformerPluginInstance = {
  name: string
  markdownPlugins(): PluggableList
  htmlPlugins(): PluggableList
  externalResources?: Partial<StaticResources>
}
export abstract class QuartzFilterPlugin {
  abstract name: string
  abstract shouldPublish(content: ProcessedContent): boolean
export type QuartzFilterPlugin<Options extends OptionType = undefined> = (opts?: Options) => QuartzFilterPluginInstance
export type QuartzFilterPluginInstance = {
  name: string
  shouldPublish(content: ProcessedContent): boolean
}
export type QuartzEmitterPlugin<Options extends OptionType = undefined> = (opts?: Options) => QuartzEmitterPluginInstance
export type QuartzEmitterPluginInstance = {
  name: string
  emit(cfg: GlobalConfiguration, content: ProcessedContent[], resources: StaticResources, emitCallback: EmitCallback): Promise<string[]>
  getQuartzComponents(): QuartzComponent[]
}
export interface EmitOptions {
@@ -23,14 +39,3 @@
}
export type EmitCallback = (data: EmitOptions) => Promise<string>
export abstract class QuartzEmitterPlugin {
  abstract name: string
  abstract emit(cfg: GlobalConfiguration, content: ProcessedContent[], resources: StaticResources, emitCallback: EmitCallback): Promise<string[]>
  abstract getQuartzComponents(): QuartzComponent[]
}
export interface PluginTypes {
  transformers: QuartzTransformerPlugin[],
  filters: QuartzFilterPlugin[],
  emitters: QuartzEmitterPlugin[],
}
quartz/processors/filter.ts
@@ -1,8 +1,8 @@
import { PerfTimer } from "../perf"
import { QuartzFilterPlugin } from "../plugins/types"
import { QuartzFilterPluginInstance } from "../plugins/types"
import { ProcessedContent } from "../plugins/vfile"
export function filterContent(plugins: QuartzFilterPlugin[], content: ProcessedContent[], verbose: boolean): ProcessedContent[] {
export function filterContent(plugins: QuartzFilterPluginInstance[], content: ProcessedContent[], verbose: boolean): ProcessedContent[] {
  const perf = new PerfTimer()
  const initialLength = content.length
  for (const plugin of plugins) {
quartz/processors/parse.ts
@@ -11,12 +11,12 @@
import path from 'path'
import os from 'os'
import workerpool, { Promise as WorkerPromise } from 'workerpool'
import { QuartzTransformerPlugin } from '../plugins/types'
import { QuartzTransformerPluginInstance } from '../plugins/types'
import { QuartzLogger } from '../log'
import chalk from 'chalk'
export type QuartzProcessor = Processor<MDRoot, HTMLRoot, void>
export function createProcessor(transformers: QuartzTransformerPlugin[]): QuartzProcessor {
export function createProcessor(transformers: QuartzTransformerPluginInstance[]): QuartzProcessor {
  // base Markdown -> MD AST
  let processor = unified().use(remarkParse)
@@ -101,7 +101,7 @@
  }
}
export async function parseMarkdown(transformers: QuartzTransformerPlugin[], baseDir: string, fps: string[], verbose: boolean): Promise<ProcessedContent[]> {
export async function parseMarkdown(transformers: QuartzTransformerPluginInstance[], baseDir: string, fps: string[], verbose: boolean): Promise<ProcessedContent[]> {
  const perf = new PerfTimer()
  const log = new QuartzLogger(verbose)