Jacky Zhao
2023-06-20 24348b24a94c5f9ca285642b751e6798b92eedd9
fix: parsing wikilinks that have codeblock anchors, scroll to anchor
1 files deleted
1 files added
15 files modified
219 ■■■■ changed files
quartz/components/Backlinks.tsx 3 ●●●● patch | view | raw | blame | history
quartz/components/scripts/graph.inline.ts 15 ●●●●● patch | view | raw | blame | history
quartz/components/scripts/handler.ts 19 ●●●●● patch | view | raw | blame | history
quartz/components/scripts/popover.inline.ts 30 ●●●● patch | view | raw | blame | history
quartz/components/scripts/search.inline.ts 12 ●●●●● patch | view | raw | blame | history
quartz/components/scripts/util.ts 38 ●●●●● patch | view | raw | blame | history
quartz/components/styles/popover.scss 1 ●●●● patch | view | raw | blame | history
quartz/plugins/transformers/description.ts 3 ●●●●● patch | view | raw | blame | history
quartz/plugins/transformers/frontmatter.ts 3 ●●●●● patch | view | raw | blame | history
quartz/plugins/transformers/lastmod.ts 3 ●●●●● patch | view | raw | blame | history
quartz/plugins/transformers/links.ts 12 ●●●●● patch | view | raw | blame | history
quartz/plugins/transformers/ofm.ts 43 ●●●● patch | view | raw | blame | history
quartz/plugins/transformers/syntax.ts 7 ●●●●● patch | view | raw | blame | history
quartz/plugins/transformers/toc.ts 3 ●●●●● patch | view | raw | blame | history
quartz/plugins/types.ts 7 ●●●●● patch | view | raw | blame | history
quartz/processors/parse.ts 18 ●●●●● patch | view | raw | blame | history
quartz/worker.ts 2 ●●● patch | view | raw | blame | history
quartz/components/Backlinks.tsx
@@ -1,6 +1,7 @@
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
import style from "./styles/backlinks.scss"
import { relativeToRoot } from "../path"
import { stripIndex } from "./scripts/util"
function Backlinks({ fileData, allFiles }: QuartzComponentProps) {
  const slug = fileData.slug!
@@ -9,7 +10,7 @@
    <h3>Backlinks</h3>
    <ul>
      {backlinkFiles.length > 0 ?
        backlinkFiles.map(f => <li><a href={relativeToRoot(slug, f.slug!)} class="internal">{f.frontmatter?.title}</a></li>)
        backlinkFiles.map(f => <li><a href={stripIndex(relativeToRoot(slug, f.slug!))} class="internal">{f.frontmatter?.title}</a></li>)
        : <li>No backlinks found</li>}
    </ul>
  </div> 
quartz/components/scripts/graph.inline.ts
@@ -1,6 +1,6 @@
import { ContentDetails } from "../../plugins/emitters/contentIndex"
import * as d3 from 'd3'
import { registerEscapeHandler } from "./handler"
import { registerEscapeHandler, relative, removeAllChildren } from "./util"
type NodeData = {
  id: string,
@@ -13,18 +13,6 @@
  target: string
}
function relative(from: string, to: string) {
  const pieces = [location.protocol, '//', location.host, location.pathname]
  const url = pieces.join('').slice(0, -from.length) + to
  return url
}
function removeAllChildren(node: HTMLElement) {
  while (node.firstChild) {
    node.removeChild(node.firstChild)
  }
}
async function renderGraph(container: string, slug: string) {
  const graph = document.getElementById(container)
  if (!graph) return
@@ -117,7 +105,6 @@
  // calculate radius
  const color = (d: NodeData) => {
    // TODO: does this handle the index page
    const isCurrent = d.id === slug
    return isCurrent ? "var(--secondary)" : "var(--gray)"
  }
quartz/components/scripts/handler.ts
File was deleted
quartz/components/scripts/popover.inline.ts
@@ -7,10 +7,11 @@
    link.addEventListener("mouseenter", async ({ clientX, clientY }) => {
      async function setPosition(popoverElement: HTMLElement) {
        const { x, y } = await computePosition(link, popoverElement, {
          middleware: [inline({
            x: clientX,
            y: clientY
          }), shift(), flip()]
          middleware: [
            inline({ x: clientX, y: clientY }),
            shift(),
            flip()
          ]
        })
        Object.assign(popoverElement.style, {
          left: `${x}px`,
@@ -22,11 +23,17 @@
        return setPosition(link.lastChild as HTMLElement)
      }
      const url = link.href
      const anchor = new URL(url).hash
      if (anchor.startsWith("#")) return
      const thisUrl = new URL(document.location.href)
      thisUrl.hash = ""
      thisUrl.search = ""
      const targetUrl = new URL(link.href)
      const hash = targetUrl.hash
      targetUrl.hash = ""
      targetUrl.search = ""
      // prevent hover of the same page
      if (thisUrl.toString() === targetUrl.toString()) return
      const contents = await fetch(`${url}`)
      const contents = await fetch(`${targetUrl}`)
        .then((res) => res.text())
        .catch((err) => {
          console.error(err)
@@ -39,7 +46,6 @@
      const popoverElement = document.createElement("div")
      popoverElement.classList.add("popover")
      // TODO: scroll this element if we specify a header/anchor to jump to
      const popoverInner = document.createElement("div")
      popoverInner.classList.add("popover-inner")
      popoverElement.appendChild(popoverInner)
@@ -48,6 +54,12 @@
      setPosition(popoverElement)
      link.appendChild(popoverElement)
      link.dataset.fetchedPopover = "true"
      const heading = popoverInner.querySelector(hash) as HTMLElement | null
      if (heading) {
        // leave ~12px of buffer when scrolling to a heading
        popoverInner.scroll({ top: heading.offsetTop - 12, behavior: 'instant' })
      }
    })
  }
})
quartz/components/scripts/search.inline.ts
@@ -1,6 +1,6 @@
import { Document } from "flexsearch"
import { ContentDetails } from "../../plugins/emitters/contentIndex"
import { registerEscapeHandler } from "./handler"
import { registerEscapeHandler, relative, removeAllChildren } from "./util"
interface Item {
  slug: string,
@@ -9,16 +9,6 @@
}
let index: Document<Item> | undefined = undefined
function relative(from: string, to: string) {
  const pieces = [location.protocol, '//', location.host, location.pathname]
  const url = pieces.join('').slice(0, -from.length) + to
  return url
}
function removeAllChildren(node: HTMLElement) {
  node.innerHTML = ``
}
const contextWindowWords = 30
function highlight(searchTerm: string, text: string, trim?: boolean) {
  const tokenizedTerms = searchTerm.split(/\s+/).filter(t => t !== "")
quartz/components/scripts/util.ts
New file
@@ -0,0 +1,38 @@
export function registerEscapeHandler(outsideContainer: HTMLElement | null, cb: () => void) {
  if (!outsideContainer) return
  function click(this: HTMLElement, e: HTMLElementEventMap["click"]) {
    if (e.target !== this) return
    e.preventDefault()
    cb()
  }
  function esc(e: HTMLElementEventMap["keydown"]) {
    if (!e.key.startsWith("Esc")) return
    e.preventDefault()
    cb()
  }
  outsideContainer?.removeEventListener("click", click)
  outsideContainer?.addEventListener("click", click)
  document.removeEventListener("keydown", esc)
  document.addEventListener('keydown', esc)
}
export function stripIndex(s: string): string {
  return s.endsWith("index") ? s.slice(0, -"index".length) : s
}
export function relative(from: string, to: string) {
  from = encodeURI(stripIndex(from))
  to = encodeURI(stripIndex(to))
  const start = [location.protocol, '//', location.host, location.pathname].join('')
  const trimEnd = from.length === 0 ? start.length : -from.length
  const url = start.slice(0, trimEnd) + to
  return url
}
export function removeAllChildren(node: HTMLElement) {
  while (node.firstChild) {
    node.removeChild(node.firstChild)
  }
}
quartz/components/styles/popover.scss
@@ -19,6 +19,7 @@
  padding: 1rem;
  & > .popover-inner {
    position: relative;
    width: 30rem;
    height: 20rem;
    padding: 0 1rem 1rem 1rem;
quartz/plugins/transformers/description.ts
@@ -14,9 +14,6 @@
  const opts = { ...defaultOptions, ...userOpts }
  return {
    name: "Description",
    markdownPlugins() {
      return []
    },
    htmlPlugins() {
      return [
        () => {
quartz/plugins/transformers/frontmatter.ts
@@ -33,9 +33,6 @@
        }
      ]
    },
    htmlPlugins() {
      return []
    }
  }
}
quartz/plugins/transformers/lastmod.ts
@@ -53,9 +53,6 @@
        }
      ]
    },
    htmlPlugins() {
      return []
    }
  }
}
quartz/plugins/transformers/links.ts
@@ -1,5 +1,5 @@
import { QuartzTransformerPlugin } from "../types"
import { relative, relativeToRoot, slugify, trimPathSuffix } from "../../path"
import { relativeToRoot, slugify, trimPathSuffix } from "../../path"
import path from "path"
import { visit } from 'unist-util-visit'
import isAbsoluteUrl from "is-absolute-url"
@@ -24,9 +24,6 @@
  const opts = { ...defaultOptions, ...userOpts }
  return {
    name: "LinkProcessing",
    markdownPlugins() {
      return []
    },
    htmlPlugins() {
      return [() => {
        return (tree, file) => {
@@ -34,7 +31,8 @@
          const transformLink = (target: string) => {
            const targetSlug = slugify(decodeURI(target).trim())
            if (opts.markdownLinkResolution === 'relative' && !path.isAbsolute(targetSlug)) {
              return './' + relative(curSlug, targetSlug)
              // TODO
              // return './' + relative(curSlug, targetSlug)
            } else {
              return './' + relativeToRoot(curSlug, targetSlug)
            }
@@ -77,9 +75,9 @@
              }
            }
            // transform all images
            // transform all other resources that may use links
            if (
              node.tagName === 'img' &&
              ["img", "video", "audio", "iframe"].includes(node.tagName) &&
              node.properties &&
              typeof node.properties.src === 'string'
            ) {
quartz/plugins/transformers/ofm.ts
@@ -3,6 +3,7 @@
import { Root, HTML, BlockContent, DefinitionContent, Code } from 'mdast'
import { findAndReplace } from "mdast-util-find-and-replace"
import { slugify } from "../../path"
import { slug as slugAnchor } from 'github-slugger'
import rehypeRaw from "rehype-raw"
import { visit } from "unist-util-visit"
import path from "path"
@@ -94,14 +95,6 @@
  return s.substring(0, 1).toUpperCase() + s.substring(1);
}
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
@@ -109,6 +102,36 @@
          // (#[^\[\]\|\#]+)? -> # then one or more non-special characters (heading link)
          // (|[^\[\]\|\#]+)? -> | then one or more non-special characters (alias)
          const backlinkRegex = new RegExp(/!?\[\[([^\[\]\|\#]+)(#[^\[\]\|\#]+)?(\|[^\[\]\|\#]+)?\]\]/, "g")
// Match highlights
const highlightRegex = new RegExp(/==(.+)==/, "g")
// from https://github.com/escwxyz/remark-obsidian-callout/blob/main/src/index.ts
const calloutRegex = new RegExp(/^\[\!(\w+)\]([+-]?)/)
export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
  const opts = { ...defaultOptions, ...userOpts }
  return {
    name: "ObsidianFlavoredMarkdown",
    textTransform(src) {
      // pre-transform wikilinks (fix anchors to things that may contain illegal syntax e.g. codeblocks, latex)
      if (opts.wikilinks) {
        src = src.toString()
        return src.replaceAll(backlinkRegex, (value, ...capture) => {
          const [fp, rawHeader, rawAlias] = capture
          const anchor = rawHeader?.trim().slice(1)
          const displayAnchor = anchor ? `#${slugAnchor(anchor)}` : ""
          const displayAlias = rawAlias ?? ""
          const embedDisplay = value.startsWith("!") ? "!" : ""
          return `${embedDisplay}[[${fp}${displayAnchor}${displayAlias}]]`
        })
      }
      return src
    },
    markdownPlugins() {
      const plugins: PluggableList = []
      if (opts.wikilinks) {
        plugins.push(() => {
          return (tree: Root, _file) => {
            findAndReplace(tree, backlinkRegex, (value: string, ...capture: string[]) => {
              const [fp, rawHeader, rawAlias] = capture
@@ -170,8 +193,6 @@
      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
@@ -186,8 +207,6 @@
      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) {
quartz/plugins/transformers/syntax.ts
@@ -3,9 +3,6 @@
export const SyntaxHighlighting: QuartzTransformerPlugin = () => ({
  name: "SyntaxHighlighting",
  markdownPlugins() {
    return []
  },
  htmlPlugins() {
    return [[rehypePrettyCode, {
      theme: 'css-variables',
@@ -15,10 +12,12 @@
        }
      },
      onVisitHighlightedLine(node) {
        node.properties.className ??= []
        node.properties.className.push('highlighted')
      },
      onVisitHighlightedWord(node) {
        node.properties.className = ['word']
        node.properties.className ??= []
        node.properties.className.push('word')
      },
    } satisfies Partial<CodeOptions>]]
  }
quartz/plugins/transformers/toc.ts
@@ -52,9 +52,6 @@
        }
      }]
    },
    htmlPlugins() {
      return []
    }
  }
}
quartz/plugins/types.ts
@@ -14,9 +14,10 @@
export type QuartzTransformerPlugin<Options extends OptionType = undefined> = (opts?: Options) => QuartzTransformerPluginInstance
export type QuartzTransformerPluginInstance = {
  name: string
  markdownPlugins(): PluggableList
  htmlPlugins(): PluggableList
  externalResources?(): Partial<StaticResources>
  textTransform?: (src: string | Buffer) => string | Buffer
  markdownPlugins?: () => PluggableList
  htmlPlugins?: () => PluggableList
  externalResources?: () => Partial<StaticResources>
}
export type QuartzFilterPlugin<Options extends OptionType = undefined> = (opts?: Options) => QuartzFilterPluginInstance 
quartz/processors/parse.ts
@@ -21,8 +21,8 @@
  let processor = unified().use(remarkParse)
  // MD AST -> MD AST transforms
  for (const plugin of transformers) {
    processor = processor.use(plugin.markdownPlugins())
  for (const plugin of transformers.filter(p => p.markdownPlugins)) {
    processor = processor.use(plugin.markdownPlugins!())
  }
  // MD AST -> HTML AST
@@ -30,8 +30,8 @@
  // HTML AST -> HTML AST transforms
  for (const plugin of transformers) {
    processor = processor.use(plugin.htmlPlugins())
  for (const plugin of transformers.filter(p => p.htmlPlugins)) {
    processor = processor.use(plugin.htmlPlugins!())
  }
  return processor
@@ -73,13 +73,18 @@
  })
}
export function createFileParser(baseDir: string, fps: string[], verbose: boolean) {
export function createFileParser(transformers: QuartzTransformerPluginInstance[], baseDir: string, fps: string[], verbose: boolean) {
  return async (processor: QuartzProcessor) => {
    const res: ProcessedContent[] = []
    for (const fp of fps) {
      try {
        const file = await read(fp)
        // Text -> Text transforms
        for (const plugin of transformers.filter(p => p.textTransform)) {
          file.value = plugin.textTransform!(file.value)
        }
        // base data properties that plugins may use
        file.data.slug = slugify(path.relative(baseDir, file.path))
        file.data.filePath = fp
@@ -111,9 +116,8 @@
  log.start(`Parsing input files using ${concurrency} threads`)
  if (concurrency === 1) {
    // single-thread
    const processor = createProcessor(transformers)
    const parse = createFileParser(baseDir, fps, verbose)
    const parse = createFileParser(transformers, baseDir, fps, verbose)
    res = await parse(processor)
  } else {
    await transpileWorkerScript()
quartz/worker.ts
@@ -6,6 +6,6 @@
// only called from worker thread
export async function parseFiles(baseDir: string, fps: string[], verbose: boolean) {
  const parse = createFileParser(baseDir, fps, verbose)
  const parse = createFileParser(transformers, baseDir, fps, verbose)
  return parse(processor)
}