Jacky Zhao
2023-06-17 6d5491fdcbccfad7af6c6dcc63ce2f67abd3850c
quartz/plugins/transformers/ofm.ts
@@ -1,21 +1,25 @@
import { PluggableList } from "unified"
import { QuartzTransformerPlugin } from "../types"
import { Root, HTML, BlockContent, DefinitionContent } from 'mdast'
import { Root, HTML, BlockContent, DefinitionContent, Code } from 'mdast'
import { findAndReplace } from "mdast-util-find-and-replace"
import { slugify } from "../../path"
import rehypeRaw from "rehype-raw"
import { visit } from "unist-util-visit"
import path from "path"
import { JSResource } from "../../resources"
export interface Options {
  highlight: boolean
  wikilinks: boolean
  callouts: boolean
  mermaid: boolean
}
const defaultOptions: Options = {
  highlight: true,
  wikilinks: true,
  callouts: true
  callouts: true,
  mermaid: false,
}
const icons = {
@@ -88,139 +92,199 @@
  return s.substring(0, 1).toUpperCase() + s.substring(1);
}
export class ObsidianFlavoredMarkdown extends QuartzTransformerPlugin {
  name = "ObsidianFlavoredMarkdown"
  opts: Options
  constructor(opts?: 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[]) => {
            if (value.startsWith("!")) {
              // TODO: handle embeds
            } else {
              const [path, rawHeader, rawAlias] = capture
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() ?? path
              const url = slugify(path.trim() + anchor)
              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
                      }
                    }
                  }
                } 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
              }
              // internal link
              const url = slugify(fp.trim() + anchor)
              return {
                type: 'link',
                url,
                children: [{
                  type: 'text',
                  value: alias
                  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
                // TODO: add the js to actually support collapsing callout
                node.data = {
                  hProperties: {
                    ...(node.data?.hProperties ?? {}),
                    className: `callout ${collapse ? "is-collapsible" : ""} ${defaultState === "collapsed" ? "is-collapsed" : ""}`,
                    "data-callout": calloutType,
                    "data-callout-fold": collapse,
                  }
                }
              }
            }
          })
        }
      })
            })
          }
        })
      }
      if (opts.mermaid) {
        plugins.push(() => {
          return (tree: Root, _file) => {
            visit(tree, 'code', (node: Code) => {
              if (node.lang === 'mermaid') {
                node.data = {
                  hProperties: {
                    className: 'mermaid'
                  }
                }
              }
            })
          }
        })
      }
      return plugins
    },
    htmlPlugins() {
      return [rehypeRaw]
    },
    externalResources() {
      const mermaidScript: JSResource = {
        script: `
          import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.esm.min.mjs';
          mermaid.initialize({ startOnLoad: true });
          `,
        loadTime: 'afterDOMReady',
        moduleType: 'module',
        contentType: 'inline'
      }
      return {
        js: opts.mermaid ? [mermaidScript] : []
      }
    }
    return plugins
  }
  htmlPlugins(): PluggableList {
    return [rehypeRaw]
  }
}