bump to v4
This commit is contained in:
		@@ -147,14 +147,17 @@ async function startServing(
 | 
			
		||||
      await rimraf(argv.output)
 | 
			
		||||
      await emitContent(ctx, filteredContent)
 | 
			
		||||
      console.log(chalk.green(`Done rebuilding in ${perf.timeSince()}`))
 | 
			
		||||
    } catch {
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      console.log(chalk.yellow(`Rebuild failed. Waiting on a change to fix the error...`))
 | 
			
		||||
      if (argv.verbose) {
 | 
			
		||||
        console.log(chalk.red(err))
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    release()
 | 
			
		||||
    clientRefresh()
 | 
			
		||||
    toRebuild.clear()
 | 
			
		||||
    toRemove.clear()
 | 
			
		||||
    release()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const watcher = chokidar.watch(".", {
 | 
			
		||||
 
 | 
			
		||||
@@ -7,6 +7,7 @@ export type Analytics =
 | 
			
		||||
  | null
 | 
			
		||||
  | {
 | 
			
		||||
      provider: "plausible"
 | 
			
		||||
      host?: string
 | 
			
		||||
    }
 | 
			
		||||
  | {
 | 
			
		||||
      provider: "google"
 | 
			
		||||
 
 | 
			
		||||
@@ -196,6 +196,11 @@ See the [documentation](https://quartz.jzhao.xyz) for how to get started.
 | 
			
		||||
  )
 | 
			
		||||
  await fs.promises.writeFile(configFilePath, configContent)
 | 
			
		||||
 | 
			
		||||
  // setup remote
 | 
			
		||||
  execSync(
 | 
			
		||||
    `git remote show upstream || git remote add upstream https://github.com/jackyzha0/quartz.git`,
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  outro(`You're all set! Not sure what to do next? Try:
 | 
			
		||||
  • Customizing Quartz a bit more by editing \`quartz.config.ts\`
 | 
			
		||||
  • Running \`npx quartz build --serve\` to preview your Quartz locally
 | 
			
		||||
@@ -438,11 +443,23 @@ export async function handleUpdate(argv) {
 | 
			
		||||
  console.log(
 | 
			
		||||
    "Pulling updates... you may need to resolve some `git` conflicts if you've made changes to components or plugins.",
 | 
			
		||||
  )
 | 
			
		||||
  gitPull(UPSTREAM_NAME, QUARTZ_SOURCE_BRANCH)
 | 
			
		||||
 | 
			
		||||
  try {
 | 
			
		||||
    gitPull(UPSTREAM_NAME, QUARTZ_SOURCE_BRANCH)
 | 
			
		||||
  } catch {
 | 
			
		||||
    console.log(chalk.red("An error occured above while pulling updates."))
 | 
			
		||||
    await popContentFolder(contentFolder)
 | 
			
		||||
    return
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  await popContentFolder(contentFolder)
 | 
			
		||||
  console.log("Ensuring dependencies are up to date")
 | 
			
		||||
  spawnSync("npm", ["i"], { stdio: "inherit" })
 | 
			
		||||
  console.log(chalk.green("Done!"))
 | 
			
		||||
  const res = spawnSync("npm", ["i"], { stdio: "inherit" })
 | 
			
		||||
  if (res.status === 0) {
 | 
			
		||||
    console.log(chalk.green("Done!"))
 | 
			
		||||
  } else {
 | 
			
		||||
    console.log(chalk.red("An error occurred above while installing dependencies."))
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
@@ -499,13 +516,25 @@ export async function handleSync(argv) {
 | 
			
		||||
    console.log(
 | 
			
		||||
      "Pulling updates from your repository. You may need to resolve some `git` conflicts if you've made changes to components or plugins.",
 | 
			
		||||
    )
 | 
			
		||||
    gitPull(ORIGIN_NAME, QUARTZ_SOURCE_BRANCH)
 | 
			
		||||
    try {
 | 
			
		||||
      gitPull(ORIGIN_NAME, QUARTZ_SOURCE_BRANCH)
 | 
			
		||||
    } catch {
 | 
			
		||||
      console.log(chalk.red("An error occured above while pulling updates."))
 | 
			
		||||
      await popContentFolder(contentFolder)
 | 
			
		||||
      return
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  await popContentFolder(contentFolder)
 | 
			
		||||
  if (argv.push) {
 | 
			
		||||
    console.log("Pushing your changes")
 | 
			
		||||
    spawnSync("git", ["push", "-f", ORIGIN_NAME, QUARTZ_SOURCE_BRANCH], { stdio: "inherit" })
 | 
			
		||||
    const res = spawnSync("git", ["push", "-uf", ORIGIN_NAME, QUARTZ_SOURCE_BRANCH], {
 | 
			
		||||
      stdio: "inherit",
 | 
			
		||||
    })
 | 
			
		||||
    if (res.status !== 0) {
 | 
			
		||||
      console.log(chalk.red(`An error occurred above while pushing to remote ${ORIGIN_NAME}.`))
 | 
			
		||||
      return
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  console.log(chalk.green("Done!"))
 | 
			
		||||
 
 | 
			
		||||
@@ -36,7 +36,9 @@ export function gitPull(origin, branch) {
 | 
			
		||||
  const flags = ["--no-rebase", "--autostash", "-s", "recursive", "-X", "ours", "--no-edit"]
 | 
			
		||||
  const out = spawnSync("git", ["pull", ...flags, origin, branch], { stdio: "inherit" })
 | 
			
		||||
  if (out.stderr) {
 | 
			
		||||
    throw new Error(`Error while pulling updates: ${out.stderr}`)
 | 
			
		||||
    throw new Error(chalk.red(`Error while pulling updates: ${out.stderr}`))
 | 
			
		||||
  } else if (out.status !== 0) {
 | 
			
		||||
    throw new Error(chalk.red("Error while pulling updates"))
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -25,6 +25,10 @@ interface BreadcrumbOptions {
 | 
			
		||||
   * Wether to display breadcrumbs on root `index.md`
 | 
			
		||||
   */
 | 
			
		||||
  hideOnRoot: boolean
 | 
			
		||||
  /**
 | 
			
		||||
   * Wether to display the current page in the breadcrumbs.
 | 
			
		||||
   */
 | 
			
		||||
  showCurrentPage: boolean
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const defaultOptions: BreadcrumbOptions = {
 | 
			
		||||
@@ -32,6 +36,7 @@ const defaultOptions: BreadcrumbOptions = {
 | 
			
		||||
  rootName: "Home",
 | 
			
		||||
  resolveFrontmatterTitle: true,
 | 
			
		||||
  hideOnRoot: true,
 | 
			
		||||
  showCurrentPage: true,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function formatCrumb(displayName: string, baseSlug: FullSlug, currentSlug: SimpleSlug): CrumbData {
 | 
			
		||||
@@ -63,8 +68,9 @@ export default ((opts?: Partial<BreadcrumbOptions>) => {
 | 
			
		||||
      // construct the index for the first time
 | 
			
		||||
      for (const file of allFiles) {
 | 
			
		||||
        if (file.slug?.endsWith("index")) {
 | 
			
		||||
          const folderParts = file.filePath?.split("/")
 | 
			
		||||
          const folderParts = file.slug?.split("/")
 | 
			
		||||
          if (folderParts) {
 | 
			
		||||
            // 2nd last to exclude the /index
 | 
			
		||||
            const folderName = folderParts[folderParts?.length - 2]
 | 
			
		||||
            folderIndex.set(folderName, file)
 | 
			
		||||
          }
 | 
			
		||||
@@ -83,7 +89,10 @@ export default ((opts?: Partial<BreadcrumbOptions>) => {
 | 
			
		||||
        // Try to resolve frontmatter folder title
 | 
			
		||||
        const currentFile = folderIndex?.get(curPathSegment)
 | 
			
		||||
        if (currentFile) {
 | 
			
		||||
          curPathSegment = currentFile.frontmatter!.title
 | 
			
		||||
          const title = currentFile.frontmatter!.title
 | 
			
		||||
          if (title !== "index") {
 | 
			
		||||
            curPathSegment = title
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Add current slug to full path
 | 
			
		||||
@@ -95,10 +104,12 @@ export default ((opts?: Partial<BreadcrumbOptions>) => {
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Add current file to crumb (can directly use frontmatter title)
 | 
			
		||||
      crumbs.push({
 | 
			
		||||
        displayName: fileData.frontmatter!.title,
 | 
			
		||||
        path: "",
 | 
			
		||||
      })
 | 
			
		||||
      if (options.showCurrentPage) {
 | 
			
		||||
        crumbs.push({
 | 
			
		||||
          displayName: fileData.frontmatter!.title,
 | 
			
		||||
          path: "",
 | 
			
		||||
        })
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    return (
 | 
			
		||||
      <nav class={`breadcrumb-container ${displayClass ?? ""}`} aria-label="breadcrumbs">
 | 
			
		||||
 
 | 
			
		||||
@@ -18,7 +18,7 @@ function Darkmode({ displayClass }: QuartzComponentProps) {
 | 
			
		||||
          x="0px"
 | 
			
		||||
          y="0px"
 | 
			
		||||
          viewBox="0 0 35 35"
 | 
			
		||||
          style="enable-background:new 0 0 35 35;"
 | 
			
		||||
          style="enable-background:new 0 0 35 35"
 | 
			
		||||
          xmlSpace="preserve"
 | 
			
		||||
        >
 | 
			
		||||
          <title>Light mode</title>
 | 
			
		||||
@@ -34,7 +34,7 @@ function Darkmode({ displayClass }: QuartzComponentProps) {
 | 
			
		||||
          x="0px"
 | 
			
		||||
          y="0px"
 | 
			
		||||
          viewBox="0 0 100 100"
 | 
			
		||||
          style="enable-background='new 0 0 100 100'"
 | 
			
		||||
          style="enable-background:new 0 0 100 100"
 | 
			
		||||
          xmlSpace="preserve"
 | 
			
		||||
        >
 | 
			
		||||
          <title>Dark mode</title>
 | 
			
		||||
 
 | 
			
		||||
@@ -12,6 +12,9 @@ const defaultOptions = {
 | 
			
		||||
  folderClickBehavior: "collapse",
 | 
			
		||||
  folderDefaultState: "collapsed",
 | 
			
		||||
  useSavedState: true,
 | 
			
		||||
  mapFn: (node) => {
 | 
			
		||||
    return node
 | 
			
		||||
  },
 | 
			
		||||
  sortFn: (a, b) => {
 | 
			
		||||
    // Sort order: folders first, then files. Sort folders and files alphabetically
 | 
			
		||||
    if ((!a.file && !b.file) || (a.file && b.file)) {
 | 
			
		||||
@@ -22,6 +25,7 @@ const defaultOptions = {
 | 
			
		||||
        sensitivity: "base",
 | 
			
		||||
      })
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (a.file && !b.file) {
 | 
			
		||||
      return 1
 | 
			
		||||
    } else {
 | 
			
		||||
@@ -41,46 +45,34 @@ export default ((userOpts?: Partial<Options>) => {
 | 
			
		||||
  let jsonTree: string
 | 
			
		||||
 | 
			
		||||
  function constructFileTree(allFiles: QuartzPluginData[]) {
 | 
			
		||||
    if (!fileTree) {
 | 
			
		||||
      // Construct tree from allFiles
 | 
			
		||||
      fileTree = new FileNode("")
 | 
			
		||||
      allFiles.forEach((file) => fileTree.add(file, 1))
 | 
			
		||||
    if (fileTree) {
 | 
			
		||||
      return
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
      /**
 | 
			
		||||
       * Keys of this object must match corresponding function name of `FileNode`,
 | 
			
		||||
       * while values must be the argument that will be passed to the function.
 | 
			
		||||
       *
 | 
			
		||||
       * e.g. entry for FileNode.sort: `sort: opts.sortFn` (value is sort function from options)
 | 
			
		||||
       */
 | 
			
		||||
      const functions = {
 | 
			
		||||
        map: opts.mapFn,
 | 
			
		||||
        sort: opts.sortFn,
 | 
			
		||||
        filter: opts.filterFn,
 | 
			
		||||
      }
 | 
			
		||||
    // Construct tree from allFiles
 | 
			
		||||
    fileTree = new FileNode("")
 | 
			
		||||
    allFiles.forEach((file) => fileTree.add(file))
 | 
			
		||||
 | 
			
		||||
      // Execute all functions (sort, filter, map) that were provided (if none were provided, only default "sort" is applied)
 | 
			
		||||
      if (opts.order) {
 | 
			
		||||
        // Order is important, use loop with index instead of order.map()
 | 
			
		||||
        for (let i = 0; i < opts.order.length; i++) {
 | 
			
		||||
          const functionName = opts.order[i]
 | 
			
		||||
          if (functions[functionName]) {
 | 
			
		||||
            // for every entry in order, call matching function in FileNode and pass matching argument
 | 
			
		||||
            // e.g. i = 0; functionName = "filter"
 | 
			
		||||
            // converted to: (if opts.filterFn) => fileTree.filter(opts.filterFn)
 | 
			
		||||
 | 
			
		||||
            // @ts-ignore
 | 
			
		||||
            // typescript cant statically check these dynamic references, so manually make sure reference is valid and ignore warning
 | 
			
		||||
            fileTree[functionName].call(fileTree, functions[functionName])
 | 
			
		||||
          }
 | 
			
		||||
    // Execute all functions (sort, filter, map) that were provided (if none were provided, only default "sort" is applied)
 | 
			
		||||
    if (opts.order) {
 | 
			
		||||
      // Order is important, use loop with index instead of order.map()
 | 
			
		||||
      for (let i = 0; i < opts.order.length; i++) {
 | 
			
		||||
        const functionName = opts.order[i]
 | 
			
		||||
        if (functionName === "map") {
 | 
			
		||||
          fileTree.map(opts.mapFn)
 | 
			
		||||
        } else if (functionName === "sort") {
 | 
			
		||||
          fileTree.sort(opts.sortFn)
 | 
			
		||||
        } else if (functionName === "filter") {
 | 
			
		||||
          fileTree.filter(opts.filterFn)
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Get all folders of tree. Initialize with collapsed state
 | 
			
		||||
      const folders = fileTree.getFolderPaths(opts.folderDefaultState === "collapsed")
 | 
			
		||||
 | 
			
		||||
      // Stringify to pass json tree as data attribute ([data-tree])
 | 
			
		||||
      jsonTree = JSON.stringify(folders)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Get all folders of tree. Initialize with collapsed state
 | 
			
		||||
    const folders = fileTree.getFolderPaths(opts.folderDefaultState === "collapsed")
 | 
			
		||||
 | 
			
		||||
    // Stringify to pass json tree as data attribute ([data-tree])
 | 
			
		||||
    jsonTree = JSON.stringify(folders)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  function Explorer({ allFiles, displayClass, fileData }: QuartzComponentProps) {
 | 
			
		||||
@@ -120,6 +112,7 @@ export default ((userOpts?: Partial<Options>) => {
 | 
			
		||||
      </div>
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Explorer.css = explorerStyle
 | 
			
		||||
  Explorer.afterDOMLoaded = script
 | 
			
		||||
  return Explorer
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,13 @@
 | 
			
		||||
// @ts-ignore
 | 
			
		||||
import { QuartzPluginData } from "../plugins/vfile"
 | 
			
		||||
import { resolveRelative } from "../util/path"
 | 
			
		||||
import {
 | 
			
		||||
  joinSegments,
 | 
			
		||||
  resolveRelative,
 | 
			
		||||
  clone,
 | 
			
		||||
  simplifySlug,
 | 
			
		||||
  SimpleSlug,
 | 
			
		||||
  FilePath,
 | 
			
		||||
} from "../util/path"
 | 
			
		||||
 | 
			
		||||
type OrderEntries = "sort" | "filter" | "map"
 | 
			
		||||
 | 
			
		||||
@@ -10,9 +17,9 @@ export interface Options {
 | 
			
		||||
  folderClickBehavior: "collapse" | "link"
 | 
			
		||||
  useSavedState: boolean
 | 
			
		||||
  sortFn: (a: FileNode, b: FileNode) => number
 | 
			
		||||
  filterFn?: (node: FileNode) => boolean
 | 
			
		||||
  mapFn?: (node: FileNode) => void
 | 
			
		||||
  order?: OrderEntries[]
 | 
			
		||||
  filterFn: (node: FileNode) => boolean
 | 
			
		||||
  mapFn: (node: FileNode) => void
 | 
			
		||||
  order: OrderEntries[]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type DataWrapper = {
 | 
			
		||||
@@ -25,59 +32,74 @@ export type FolderState = {
 | 
			
		||||
  collapsed: boolean
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getPathSegment(fp: FilePath | undefined, idx: number): string | undefined {
 | 
			
		||||
  if (!fp) {
 | 
			
		||||
    return undefined
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return fp.split("/").at(idx)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Structure to add all files into a tree
 | 
			
		||||
export class FileNode {
 | 
			
		||||
  children: FileNode[]
 | 
			
		||||
  name: string
 | 
			
		||||
  children: Array<FileNode>
 | 
			
		||||
  name: string // this is the slug segment
 | 
			
		||||
  displayName: string
 | 
			
		||||
  file: QuartzPluginData | null
 | 
			
		||||
  depth: number
 | 
			
		||||
 | 
			
		||||
  constructor(name: string, file?: QuartzPluginData, depth?: number) {
 | 
			
		||||
  constructor(slugSegment: string, displayName?: string, file?: QuartzPluginData, depth?: number) {
 | 
			
		||||
    this.children = []
 | 
			
		||||
    this.name = name
 | 
			
		||||
    this.displayName = name
 | 
			
		||||
    this.file = file ? structuredClone(file) : null
 | 
			
		||||
    this.name = slugSegment
 | 
			
		||||
    this.displayName = displayName ?? file?.frontmatter?.title ?? slugSegment
 | 
			
		||||
    this.file = file ? clone(file) : null
 | 
			
		||||
    this.depth = depth ?? 0
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private insert(file: DataWrapper) {
 | 
			
		||||
    if (file.path.length === 1) {
 | 
			
		||||
      if (file.path[0] !== "index.md") {
 | 
			
		||||
        this.children.push(new FileNode(file.file.frontmatter!.title, file.file, this.depth + 1))
 | 
			
		||||
      } else {
 | 
			
		||||
        const title = file.file.frontmatter?.title
 | 
			
		||||
        if (title && title !== "index" && file.path[0] === "index.md") {
 | 
			
		||||
  private insert(fileData: DataWrapper) {
 | 
			
		||||
    if (fileData.path.length === 0) {
 | 
			
		||||
      return
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const nextSegment = fileData.path[0]
 | 
			
		||||
 | 
			
		||||
    // base case, insert here
 | 
			
		||||
    if (fileData.path.length === 1) {
 | 
			
		||||
      if (nextSegment === "") {
 | 
			
		||||
        // index case (we are the root and we just found index.md), set our data appropriately
 | 
			
		||||
        const title = fileData.file.frontmatter?.title
 | 
			
		||||
        if (title && title !== "index") {
 | 
			
		||||
          this.displayName = title
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    } else {
 | 
			
		||||
      const next = file.path[0]
 | 
			
		||||
      file.path = file.path.splice(1)
 | 
			
		||||
      for (const child of this.children) {
 | 
			
		||||
        if (child.name === next) {
 | 
			
		||||
          child.insert(file)
 | 
			
		||||
          return
 | 
			
		||||
        }
 | 
			
		||||
      } else {
 | 
			
		||||
        // direct child
 | 
			
		||||
        this.children.push(new FileNode(nextSegment, undefined, fileData.file, this.depth + 1))
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const newChild = new FileNode(next, undefined, this.depth + 1)
 | 
			
		||||
      newChild.insert(file)
 | 
			
		||||
      this.children.push(newChild)
 | 
			
		||||
      return
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // find the right child to insert into
 | 
			
		||||
    fileData.path = fileData.path.splice(1)
 | 
			
		||||
    const child = this.children.find((c) => c.name === nextSegment)
 | 
			
		||||
    if (child) {
 | 
			
		||||
      child.insert(fileData)
 | 
			
		||||
      return
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const newChild = new FileNode(
 | 
			
		||||
      nextSegment,
 | 
			
		||||
      getPathSegment(fileData.file.relativePath, this.depth),
 | 
			
		||||
      undefined,
 | 
			
		||||
      this.depth + 1,
 | 
			
		||||
    )
 | 
			
		||||
    newChild.insert(fileData)
 | 
			
		||||
    this.children.push(newChild)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Add new file to tree
 | 
			
		||||
  add(file: QuartzPluginData, splice: number = 0) {
 | 
			
		||||
    this.insert({ file, path: file.filePath!.split("/").splice(splice) })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Print tree structure (for debugging)
 | 
			
		||||
  print(depth: number = 0) {
 | 
			
		||||
    let folderChar = ""
 | 
			
		||||
    if (!this.file) folderChar = "|"
 | 
			
		||||
    console.log("-".repeat(depth), folderChar, this.name, this.depth)
 | 
			
		||||
    this.children.forEach((e) => e.print(depth + 1))
 | 
			
		||||
  add(file: QuartzPluginData) {
 | 
			
		||||
    this.insert({ file: file, path: simplifySlug(file.slug!).split("/") })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
@@ -95,7 +117,6 @@ export class FileNode {
 | 
			
		||||
   */
 | 
			
		||||
  map(mapFn: (node: FileNode) => void) {
 | 
			
		||||
    mapFn(this)
 | 
			
		||||
 | 
			
		||||
    this.children.forEach((child) => child.map(mapFn))
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -110,16 +131,16 @@ export class FileNode {
 | 
			
		||||
 | 
			
		||||
    const traverse = (node: FileNode, currentPath: string) => {
 | 
			
		||||
      if (!node.file) {
 | 
			
		||||
        const folderPath = currentPath + (currentPath ? "/" : "") + node.name
 | 
			
		||||
        const folderPath = joinSegments(currentPath, node.name)
 | 
			
		||||
        if (folderPath !== "") {
 | 
			
		||||
          folderPaths.push({ path: folderPath, collapsed })
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        node.children.forEach((child) => traverse(child, folderPath))
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    traverse(this, "")
 | 
			
		||||
 | 
			
		||||
    return folderPaths
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -147,14 +168,13 @@ export function ExplorerNode({ node, opts, fullPath, fileData }: ExplorerNodePro
 | 
			
		||||
  const isDefaultOpen = opts.folderDefaultState === "open"
 | 
			
		||||
 | 
			
		||||
  // Calculate current folderPath
 | 
			
		||||
  let pathOld = fullPath ? fullPath : ""
 | 
			
		||||
  let folderPath = ""
 | 
			
		||||
  if (node.name !== "") {
 | 
			
		||||
    folderPath = `${pathOld}/${node.name}`
 | 
			
		||||
    folderPath = joinSegments(fullPath ?? "", node.name)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <li>
 | 
			
		||||
    <>
 | 
			
		||||
      {node.file ? (
 | 
			
		||||
        // Single file node
 | 
			
		||||
        <li key={node.file.slug}>
 | 
			
		||||
@@ -163,7 +183,7 @@ export function ExplorerNode({ node, opts, fullPath, fileData }: ExplorerNodePro
 | 
			
		||||
          </a>
 | 
			
		||||
        </li>
 | 
			
		||||
      ) : (
 | 
			
		||||
        <div>
 | 
			
		||||
        <li>
 | 
			
		||||
          {node.name !== "" && (
 | 
			
		||||
            // Node with entire folder
 | 
			
		||||
            // Render svg button + folder name, then children
 | 
			
		||||
@@ -185,12 +205,16 @@ export function ExplorerNode({ node, opts, fullPath, fileData }: ExplorerNodePro
 | 
			
		||||
              {/* render <a> tag if folderBehavior is "link", otherwise render <button> with collapse click event */}
 | 
			
		||||
              <div key={node.name} data-folderpath={folderPath}>
 | 
			
		||||
                {folderBehavior === "link" ? (
 | 
			
		||||
                  <a href={`${folderPath}`} data-for={node.name} class="folder-title">
 | 
			
		||||
                  <a
 | 
			
		||||
                    href={resolveRelative(fileData.slug!, folderPath as SimpleSlug)}
 | 
			
		||||
                    data-for={node.name}
 | 
			
		||||
                    class="folder-title"
 | 
			
		||||
                  >
 | 
			
		||||
                    {node.displayName}
 | 
			
		||||
                  </a>
 | 
			
		||||
                ) : (
 | 
			
		||||
                  <button class="folder-button">
 | 
			
		||||
                    <p class="folder-title">{node.displayName}</p>
 | 
			
		||||
                    <span class="folder-title">{node.displayName}</span>
 | 
			
		||||
                  </button>
 | 
			
		||||
                )}
 | 
			
		||||
              </div>
 | 
			
		||||
@@ -217,8 +241,8 @@ export function ExplorerNode({ node, opts, fullPath, fileData }: ExplorerNodePro
 | 
			
		||||
              ))}
 | 
			
		||||
            </ul>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        </li>
 | 
			
		||||
      )}
 | 
			
		||||
    </li>
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -27,8 +27,12 @@ function TagContent(props: QuartzComponentProps) {
 | 
			
		||||
      ? fileData.description
 | 
			
		||||
      : htmlToJsx(fileData.filePath!, tree)
 | 
			
		||||
 | 
			
		||||
  if (tag === "") {
 | 
			
		||||
    const tags = [...new Set(allFiles.flatMap((data) => data.frontmatter?.tags ?? []))]
 | 
			
		||||
  if (tag === "/") {
 | 
			
		||||
    const tags = [
 | 
			
		||||
      ...new Set(
 | 
			
		||||
        allFiles.flatMap((data) => data.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes),
 | 
			
		||||
      ),
 | 
			
		||||
    ].sort((a, b) => a.localeCompare(b))
 | 
			
		||||
    const tagItemMap: Map<string, QuartzPluginData[]> = new Map()
 | 
			
		||||
    for (const tag of tags) {
 | 
			
		||||
      tagItemMap.set(tag, allPagesWithTag(tag))
 | 
			
		||||
 
 | 
			
		||||
@@ -3,9 +3,10 @@ import { QuartzComponent, QuartzComponentProps } from "./types"
 | 
			
		||||
import HeaderConstructor from "./Header"
 | 
			
		||||
import BodyConstructor from "./Body"
 | 
			
		||||
import { JSResourceToScriptElement, StaticResources } from "../util/resources"
 | 
			
		||||
import { FullSlug, RelativeURL, joinSegments } from "../util/path"
 | 
			
		||||
import { FullSlug, RelativeURL, joinSegments, normalizeHastElement } from "../util/path"
 | 
			
		||||
import { visit } from "unist-util-visit"
 | 
			
		||||
import { Root, Element, ElementContent } from "hast"
 | 
			
		||||
import { QuartzPluginData } from "../plugins/vfile"
 | 
			
		||||
 | 
			
		||||
interface RenderComponents {
 | 
			
		||||
  head: QuartzComponent
 | 
			
		||||
@@ -22,7 +23,7 @@ export function pageResources(
 | 
			
		||||
  staticResources: StaticResources,
 | 
			
		||||
): StaticResources {
 | 
			
		||||
  const contentIndexPath = joinSegments(baseDir, "static/contentIndex.json")
 | 
			
		||||
  const contentIndexScript = `const fetchData = fetch(\`${contentIndexPath}\`).then(data => data.json())`
 | 
			
		||||
  const contentIndexScript = `const fetchData = fetch("${contentIndexPath}").then(data => data.json())`
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    css: [joinSegments(baseDir, "index.css"), ...staticResources.css],
 | 
			
		||||
@@ -49,6 +50,18 @@ export function pageResources(
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
let pageIndex: Map<FullSlug, QuartzPluginData> | undefined = undefined
 | 
			
		||||
function getOrComputeFileIndex(allFiles: QuartzPluginData[]): Map<FullSlug, QuartzPluginData> {
 | 
			
		||||
  if (!pageIndex) {
 | 
			
		||||
    pageIndex = new Map()
 | 
			
		||||
    for (const file of allFiles) {
 | 
			
		||||
      pageIndex.set(file.slug!, file)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return pageIndex
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function renderPage(
 | 
			
		||||
  slug: FullSlug,
 | 
			
		||||
  componentData: QuartzComponentProps,
 | 
			
		||||
@@ -61,30 +74,29 @@ export function renderPage(
 | 
			
		||||
      const classNames = (node.properties?.className ?? []) as string[]
 | 
			
		||||
      if (classNames.includes("transclude")) {
 | 
			
		||||
        const inner = node.children[0] as Element
 | 
			
		||||
        const transcludeTarget = inner.properties?.["data-slug"] as FullSlug
 | 
			
		||||
 | 
			
		||||
        // TODO: avoid this expensive find operation and construct an index ahead of time
 | 
			
		||||
        const page = componentData.allFiles.find((f) => f.slug === transcludeTarget)
 | 
			
		||||
        const transcludeTarget = inner.properties["data-slug"] as FullSlug
 | 
			
		||||
        const page = getOrComputeFileIndex(componentData.allFiles).get(transcludeTarget)
 | 
			
		||||
        if (!page) {
 | 
			
		||||
          return
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        let blockRef = node.properties?.dataBlock as string | undefined
 | 
			
		||||
        if (blockRef?.startsWith("^")) {
 | 
			
		||||
        let blockRef = node.properties.dataBlock as string | undefined
 | 
			
		||||
        if (blockRef?.startsWith("#^")) {
 | 
			
		||||
          // block transclude
 | 
			
		||||
          blockRef = blockRef.slice(1)
 | 
			
		||||
          blockRef = blockRef.slice("#^".length)
 | 
			
		||||
          let blockNode = page.blocks?.[blockRef]
 | 
			
		||||
          if (blockNode) {
 | 
			
		||||
            if (blockNode.tagName === "li") {
 | 
			
		||||
              blockNode = {
 | 
			
		||||
                type: "element",
 | 
			
		||||
                tagName: "ul",
 | 
			
		||||
                properties: {},
 | 
			
		||||
                children: [blockNode],
 | 
			
		||||
              }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            node.children = [
 | 
			
		||||
              blockNode,
 | 
			
		||||
              normalizeHastElement(blockNode, slug, transcludeTarget),
 | 
			
		||||
              {
 | 
			
		||||
                type: "element",
 | 
			
		||||
                tagName: "a",
 | 
			
		||||
@@ -104,7 +116,7 @@ export function renderPage(
 | 
			
		||||
                break
 | 
			
		||||
              }
 | 
			
		||||
 | 
			
		||||
              if (startIdx) {
 | 
			
		||||
              if (startIdx !== undefined) {
 | 
			
		||||
                endIdx = i
 | 
			
		||||
              } else if (el.properties?.id === blockRef) {
 | 
			
		||||
                startIdx = i
 | 
			
		||||
@@ -112,12 +124,14 @@ export function renderPage(
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          if (!startIdx) {
 | 
			
		||||
          if (startIdx === undefined) {
 | 
			
		||||
            return
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          node.children = [
 | 
			
		||||
            ...(page.htmlAst.children.slice(startIdx, endIdx) as ElementContent[]),
 | 
			
		||||
            ...(page.htmlAst.children.slice(startIdx, endIdx) as ElementContent[]).map((child) =>
 | 
			
		||||
              normalizeHastElement(child as Element, slug, transcludeTarget),
 | 
			
		||||
            ),
 | 
			
		||||
            {
 | 
			
		||||
              type: "element",
 | 
			
		||||
              tagName: "a",
 | 
			
		||||
@@ -131,11 +145,14 @@ export function renderPage(
 | 
			
		||||
            {
 | 
			
		||||
              type: "element",
 | 
			
		||||
              tagName: "h1",
 | 
			
		||||
              properties: {},
 | 
			
		||||
              children: [
 | 
			
		||||
                { type: "text", value: page.frontmatter?.title ?? `Transclude of ${page.slug}` },
 | 
			
		||||
              ],
 | 
			
		||||
            },
 | 
			
		||||
            ...(page.htmlAst.children as ElementContent[]),
 | 
			
		||||
            ...(page.htmlAst.children as ElementContent[]).map((child) =>
 | 
			
		||||
              normalizeHastElement(child as Element, slug, transcludeTarget),
 | 
			
		||||
            ),
 | 
			
		||||
            {
 | 
			
		||||
              type: "element",
 | 
			
		||||
              tagName: "a",
 | 
			
		||||
 
 | 
			
		||||
@@ -59,8 +59,7 @@ function toggleFolder(evt: MouseEvent) {
 | 
			
		||||
  // Save folder state to localStorage
 | 
			
		||||
  const clickFolderPath = currentFolderParent.dataset.folderpath as string
 | 
			
		||||
 | 
			
		||||
  // Remove leading "/"
 | 
			
		||||
  const fullFolderPath = clickFolderPath.substring(1)
 | 
			
		||||
  const fullFolderPath = clickFolderPath
 | 
			
		||||
  toggleCollapsedByPath(explorerState, fullFolderPath)
 | 
			
		||||
 | 
			
		||||
  const stringifiedFileTree = JSON.stringify(explorerState)
 | 
			
		||||
@@ -108,9 +107,7 @@ function setupExplorer() {
 | 
			
		||||
    explorerState = JSON.parse(storageTree)
 | 
			
		||||
    explorerState.map((folderUl) => {
 | 
			
		||||
      // grab <li> element for matching folder path
 | 
			
		||||
      const folderLi = document.querySelector(
 | 
			
		||||
        `[data-folderpath='/${folderUl.path}']`,
 | 
			
		||||
      ) as HTMLElement
 | 
			
		||||
      const folderLi = document.querySelector(`[data-folderpath='${folderUl.path}']`) as HTMLElement
 | 
			
		||||
 | 
			
		||||
      // Get corresponding content <ul> tag and set state
 | 
			
		||||
      if (folderLi) {
 | 
			
		||||
@@ -120,9 +117,9 @@ function setupExplorer() {
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
  } else {
 | 
			
		||||
  } else if (explorer?.dataset.tree) {
 | 
			
		||||
    // If tree is not in localStorage or config is disabled, use tree passed from Explorer as dataset
 | 
			
		||||
    explorerState = JSON.parse(explorer?.dataset.tree as string)
 | 
			
		||||
    explorerState = JSON.parse(explorer.dataset.tree)
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -130,12 +127,13 @@ window.addEventListener("resize", setupExplorer)
 | 
			
		||||
document.addEventListener("nav", () => {
 | 
			
		||||
  setupExplorer()
 | 
			
		||||
 | 
			
		||||
  const explorerContent = document.getElementById("explorer-ul")
 | 
			
		||||
  observer.disconnect()
 | 
			
		||||
 | 
			
		||||
  // select pseudo element at end of list
 | 
			
		||||
  const lastItem = document.getElementById("explorer-end")
 | 
			
		||||
 | 
			
		||||
  observer.disconnect()
 | 
			
		||||
  observer.observe(lastItem as Element)
 | 
			
		||||
  if (lastItem) {
 | 
			
		||||
    observer.observe(lastItem)
 | 
			
		||||
  }
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
import type { ContentDetails } from "../../plugins/emitters/contentIndex"
 | 
			
		||||
import type { ContentDetails, ContentIndex } from "../../plugins/emitters/contentIndex"
 | 
			
		||||
import * as d3 from "d3"
 | 
			
		||||
import { registerEscapeHandler, removeAllChildren } from "./util"
 | 
			
		||||
import { FullSlug, SimpleSlug, getFullSlug, resolveRelative, simplifySlug } from "../../util/path"
 | 
			
		||||
@@ -46,20 +46,22 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
 | 
			
		||||
    showTags,
 | 
			
		||||
  } = JSON.parse(graph.dataset["cfg"]!)
 | 
			
		||||
 | 
			
		||||
  const data = await fetchData
 | 
			
		||||
 | 
			
		||||
  const data: Map<SimpleSlug, ContentDetails> = new Map(
 | 
			
		||||
    Object.entries<ContentDetails>(await fetchData).map(([k, v]) => [
 | 
			
		||||
      simplifySlug(k as FullSlug),
 | 
			
		||||
      v,
 | 
			
		||||
    ]),
 | 
			
		||||
  )
 | 
			
		||||
  const links: LinkData[] = []
 | 
			
		||||
  const tags: SimpleSlug[] = []
 | 
			
		||||
 | 
			
		||||
  const validLinks = new Set(Object.keys(data).map((slug) => simplifySlug(slug as FullSlug)))
 | 
			
		||||
 | 
			
		||||
  for (const [src, details] of Object.entries<ContentDetails>(data)) {
 | 
			
		||||
    const source = simplifySlug(src as FullSlug)
 | 
			
		||||
  const validLinks = new Set(data.keys())
 | 
			
		||||
  for (const [source, details] of data.entries()) {
 | 
			
		||||
    const outgoing = details.links ?? []
 | 
			
		||||
 | 
			
		||||
    for (const dest of outgoing) {
 | 
			
		||||
      if (validLinks.has(dest)) {
 | 
			
		||||
        links.push({ source, target: dest })
 | 
			
		||||
        links.push({ source: source, target: dest })
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -71,7 +73,7 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
 | 
			
		||||
      tags.push(...localTags.filter((tag) => !tags.includes(tag)))
 | 
			
		||||
 | 
			
		||||
      for (const tag of localTags) {
 | 
			
		||||
        links.push({ source, target: tag })
 | 
			
		||||
        links.push({ source: source, target: tag })
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
@@ -93,17 +95,17 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  } else {
 | 
			
		||||
    Object.keys(data).forEach((id) => neighbourhood.add(simplifySlug(id as FullSlug)))
 | 
			
		||||
    validLinks.forEach((id) => neighbourhood.add(id))
 | 
			
		||||
    if (showTags) tags.forEach((tag) => neighbourhood.add(tag))
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const graphData: { nodes: NodeData[]; links: LinkData[] } = {
 | 
			
		||||
    nodes: [...neighbourhood].map((url) => {
 | 
			
		||||
      const text = url.startsWith("tags/") ? "#" + url.substring(5) : data[url]?.title ?? url
 | 
			
		||||
      const text = url.startsWith("tags/") ? "#" + url.substring(5) : data.get(url)?.title ?? url
 | 
			
		||||
      return {
 | 
			
		||||
        id: url,
 | 
			
		||||
        text: text,
 | 
			
		||||
        tags: data[url]?.tags ?? [],
 | 
			
		||||
        tags: data.get(url)?.tags ?? [],
 | 
			
		||||
      }
 | 
			
		||||
    }),
 | 
			
		||||
    links: links.filter((l) => neighbourhood.has(l.source) && neighbourhood.has(l.target)),
 | 
			
		||||
@@ -200,7 +202,7 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
 | 
			
		||||
      window.spaNavigate(new URL(targ, window.location.toString()))
 | 
			
		||||
    })
 | 
			
		||||
    .on("mouseover", function (_, d) {
 | 
			
		||||
      const neighbours: SimpleSlug[] = data[fullSlug].links ?? []
 | 
			
		||||
      const neighbours: SimpleSlug[] = data.get(slug)?.links ?? []
 | 
			
		||||
      const neighbourNodes = d3
 | 
			
		||||
        .selectAll<HTMLElement, NodeData>(".node")
 | 
			
		||||
        .filter((d) => neighbours.includes(d.id))
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +0,0 @@
 | 
			
		||||
import Plausible from "plausible-tracker"
 | 
			
		||||
const { trackPageview } = Plausible()
 | 
			
		||||
document.addEventListener("nav", () => trackPageview())
 | 
			
		||||
@@ -1,16 +1,5 @@
 | 
			
		||||
import { computePosition, flip, inline, shift } from "@floating-ui/dom"
 | 
			
		||||
 | 
			
		||||
// from micromorph/src/utils.ts
 | 
			
		||||
// https://github.com/natemoo-re/micromorph/blob/main/src/utils.ts#L5
 | 
			
		||||
export function normalizeRelativeURLs(el: Element | Document, base: string | URL) {
 | 
			
		||||
  const update = (el: Element, attr: string, base: string | URL) => {
 | 
			
		||||
    el.setAttribute(attr, new URL(el.getAttribute(attr)!, base).pathname)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  el.querySelectorAll('[href^="./"], [href^="../"]').forEach((item) => update(item, "href", base))
 | 
			
		||||
 | 
			
		||||
  el.querySelectorAll('[src^="./"], [src^="../"]').forEach((item) => update(item, "src", base))
 | 
			
		||||
}
 | 
			
		||||
import { normalizeRelativeURLs } from "../../util/path"
 | 
			
		||||
 | 
			
		||||
const p = new DOMParser()
 | 
			
		||||
async function mouseEnterHandler(
 | 
			
		||||
@@ -18,6 +7,10 @@ async function mouseEnterHandler(
 | 
			
		||||
  { clientX, clientY }: { clientX: number; clientY: number },
 | 
			
		||||
) {
 | 
			
		||||
  const link = this
 | 
			
		||||
  if (link.dataset.noPopover === "true") {
 | 
			
		||||
    return
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async function setPosition(popoverElement: HTMLElement) {
 | 
			
		||||
    const { x, y } = await computePosition(link, popoverElement, {
 | 
			
		||||
      middleware: [inline({ x: clientX, y: clientY }), shift(), flip()],
 | 
			
		||||
@@ -43,8 +36,6 @@ async function mouseEnterHandler(
 | 
			
		||||
  const hash = targetUrl.hash
 | 
			
		||||
  targetUrl.hash = ""
 | 
			
		||||
  targetUrl.search = ""
 | 
			
		||||
  // prevent hover of the same page
 | 
			
		||||
  if (thisUrl.toString() === targetUrl.toString()) return
 | 
			
		||||
 | 
			
		||||
  const contents = await fetch(`${targetUrl}`)
 | 
			
		||||
    .then((res) => res.text())
 | 
			
		||||
 
 | 
			
		||||
@@ -1,10 +1,8 @@
 | 
			
		||||
import micromorph from "micromorph"
 | 
			
		||||
import { FullSlug, RelativeURL, getFullSlug } from "../../util/path"
 | 
			
		||||
import { normalizeRelativeURLs } from "./popover.inline"
 | 
			
		||||
import { FullSlug, RelativeURL, getFullSlug, normalizeRelativeURLs } from "../../util/path"
 | 
			
		||||
 | 
			
		||||
// adapted from `micromorph`
 | 
			
		||||
// https://github.com/natemoo-re/micromorph
 | 
			
		||||
 | 
			
		||||
const NODE_TYPE_ELEMENT = 1
 | 
			
		||||
let announcer = document.createElement("route-announcer")
 | 
			
		||||
const isElement = (target: EventTarget | null): target is Element =>
 | 
			
		||||
@@ -45,7 +43,14 @@ let p: DOMParser
 | 
			
		||||
async function navigate(url: URL, isBack: boolean = false) {
 | 
			
		||||
  p = p || new DOMParser()
 | 
			
		||||
  const contents = await fetch(`${url}`)
 | 
			
		||||
    .then((res) => res.text())
 | 
			
		||||
    .then((res) => {
 | 
			
		||||
      const contentType = res.headers.get("content-type")
 | 
			
		||||
      if (contentType?.startsWith("text/html")) {
 | 
			
		||||
        return res.text()
 | 
			
		||||
      } else {
 | 
			
		||||
        window.location.assign(url)
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
    .catch(() => {
 | 
			
		||||
      window.location.assign(url)
 | 
			
		||||
    })
 | 
			
		||||
@@ -109,6 +114,7 @@ function createRouter() {
 | 
			
		||||
      if (isSamePage(url) && url.hash) {
 | 
			
		||||
        const el = document.getElementById(decodeURIComponent(url.hash.substring(1)))
 | 
			
		||||
        el?.scrollIntoView()
 | 
			
		||||
        history.pushState({}, "", url)
 | 
			
		||||
        return
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -4,7 +4,7 @@
 | 
			
		||||
  float: right;
 | 
			
		||||
  right: 0;
 | 
			
		||||
  padding: 0.4rem;
 | 
			
		||||
  margin: -0.2rem 0.3rem;
 | 
			
		||||
  margin: 0.3rem;
 | 
			
		||||
  color: var(--gray);
 | 
			
		||||
  border-color: var(--dark);
 | 
			
		||||
  background-color: var(--light);
 | 
			
		||||
 
 | 
			
		||||
@@ -106,7 +106,7 @@ svg {
 | 
			
		||||
    align-items: center;
 | 
			
		||||
    font-family: var(--headerFont);
 | 
			
		||||
 | 
			
		||||
    & p {
 | 
			
		||||
    & span {
 | 
			
		||||
      font-size: 0.95rem;
 | 
			
		||||
      display: inline-block;
 | 
			
		||||
      color: var(--secondary);
 | 
			
		||||
 
 | 
			
		||||
@@ -30,6 +30,7 @@ button#toc {
 | 
			
		||||
  overflow: hidden;
 | 
			
		||||
  max-height: none;
 | 
			
		||||
  transition: max-height 0.5s ease;
 | 
			
		||||
  position: relative;
 | 
			
		||||
 | 
			
		||||
  &.collapsed > .overflow::after {
 | 
			
		||||
    opacity: 0;
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,7 @@ export type QuartzComponentProps = {
 | 
			
		||||
  fileData: QuartzPluginData
 | 
			
		||||
  cfg: GlobalConfiguration
 | 
			
		||||
  children: (QuartzComponent | JSX.Element)[]
 | 
			
		||||
  tree: Node<QuartzPluginData>
 | 
			
		||||
  tree: Node
 | 
			
		||||
  allFiles: QuartzPluginData[]
 | 
			
		||||
  displayClass?: "mobile-only" | "desktop-only"
 | 
			
		||||
} & JSX.IntrinsicAttributes & {
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										29
									
								
								quartz/plugins/emitters/cname.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								quartz/plugins/emitters/cname.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,29 @@
 | 
			
		||||
import { FilePath, joinSegments } from "../../util/path"
 | 
			
		||||
import { QuartzEmitterPlugin } from "../types"
 | 
			
		||||
import fs from "fs"
 | 
			
		||||
import chalk from "chalk"
 | 
			
		||||
 | 
			
		||||
export function extractDomainFromBaseUrl(baseUrl: string) {
 | 
			
		||||
  const url = new URL(`https://${baseUrl}`)
 | 
			
		||||
  return url.hostname
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const CNAME: QuartzEmitterPlugin = () => ({
 | 
			
		||||
  name: "CNAME",
 | 
			
		||||
  getQuartzComponents() {
 | 
			
		||||
    return []
 | 
			
		||||
  },
 | 
			
		||||
  async emit({ argv, cfg }, _content, _resources, _emit): Promise<FilePath[]> {
 | 
			
		||||
    if (!cfg.configuration.baseUrl) {
 | 
			
		||||
      console.warn(chalk.yellow("CNAME emitter requires `baseUrl` to be set in your configuration"))
 | 
			
		||||
      return []
 | 
			
		||||
    }
 | 
			
		||||
    const path = joinSegments(argv.output, "CNAME")
 | 
			
		||||
    const content = extractDomainFromBaseUrl(cfg.configuration.baseUrl)
 | 
			
		||||
    if (!content) {
 | 
			
		||||
      return []
 | 
			
		||||
    }
 | 
			
		||||
    fs.writeFileSync(path, content)
 | 
			
		||||
    return [path] as FilePath[]
 | 
			
		||||
  },
 | 
			
		||||
})
 | 
			
		||||
@@ -4,8 +4,6 @@ import { QuartzEmitterPlugin } from "../types"
 | 
			
		||||
// @ts-ignore
 | 
			
		||||
import spaRouterScript from "../../components/scripts/spa.inline"
 | 
			
		||||
// @ts-ignore
 | 
			
		||||
import plausibleScript from "../../components/scripts/plausible.inline"
 | 
			
		||||
// @ts-ignore
 | 
			
		||||
import popoverScript from "../../components/scripts/popover.inline"
 | 
			
		||||
import styles from "../../styles/custom.scss"
 | 
			
		||||
import popoverStyle from "../../components/styles/popover.scss"
 | 
			
		||||
@@ -14,6 +12,7 @@ import { StaticResources } from "../../util/resources"
 | 
			
		||||
import { QuartzComponent } from "../../components/types"
 | 
			
		||||
import { googleFontHref, joinStyles } from "../../util/theme"
 | 
			
		||||
import { Features, transform } from "lightningcss"
 | 
			
		||||
import { transform as transpile } from "esbuild"
 | 
			
		||||
 | 
			
		||||
type ComponentResources = {
 | 
			
		||||
  css: string[]
 | 
			
		||||
@@ -56,9 +55,16 @@ function getComponentResources(ctx: BuildCtx): ComponentResources {
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function joinScripts(scripts: string[]): string {
 | 
			
		||||
async function joinScripts(scripts: string[]): Promise<string> {
 | 
			
		||||
  // wrap with iife to prevent scope collision
 | 
			
		||||
  return scripts.map((script) => `(function () {${script}})();`).join("\n")
 | 
			
		||||
  const script = scripts.map((script) => `(function () {${script}})();`).join("\n")
 | 
			
		||||
 | 
			
		||||
  // minify with esbuild
 | 
			
		||||
  const res = await transpile(script, {
 | 
			
		||||
    minify: true,
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  return res.code
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function addGlobalPageResources(
 | 
			
		||||
@@ -85,17 +91,30 @@ function addGlobalPageResources(
 | 
			
		||||
    componentResources.afterDOMLoaded.push(`
 | 
			
		||||
      window.dataLayer = window.dataLayer || [];
 | 
			
		||||
      function gtag() { dataLayer.push(arguments); }
 | 
			
		||||
      gtag(\`js\`, new Date());
 | 
			
		||||
      gtag(\`config\`, \`${tagId}\`, { send_page_view: false });
 | 
			
		||||
      gtag("js", new Date());
 | 
			
		||||
      gtag("config", "${tagId}", { send_page_view: false });
 | 
			
		||||
  
 | 
			
		||||
      document.addEventListener(\`nav\`, () => {
 | 
			
		||||
        gtag(\`event\`, \`page_view\`, {
 | 
			
		||||
      document.addEventListener("nav", () => {
 | 
			
		||||
        gtag("event", "page_view", {
 | 
			
		||||
          page_title: document.title,
 | 
			
		||||
          page_location: location.href,
 | 
			
		||||
        });
 | 
			
		||||
      });`)
 | 
			
		||||
  } else if (cfg.analytics?.provider === "plausible") {
 | 
			
		||||
    componentResources.afterDOMLoaded.push(plausibleScript)
 | 
			
		||||
    const plausibleHost = cfg.analytics.host ?? "https://plausible.io"
 | 
			
		||||
    componentResources.afterDOMLoaded.push(`
 | 
			
		||||
      const plausibleScript = document.createElement("script")
 | 
			
		||||
      plausibleScript.src = "${plausibleHost}/js/script.manual.js"
 | 
			
		||||
      plausibleScript.setAttribute("data-domain", location.hostname)
 | 
			
		||||
      plausibleScript.defer = true
 | 
			
		||||
      document.head.appendChild(plausibleScript)
 | 
			
		||||
 | 
			
		||||
      window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }
 | 
			
		||||
 | 
			
		||||
      document.addEventListener("nav", () => {
 | 
			
		||||
        plausible("pageview")
 | 
			
		||||
      })
 | 
			
		||||
    `)
 | 
			
		||||
  } else if (cfg.analytics?.provider === "umami") {
 | 
			
		||||
    componentResources.afterDOMLoaded.push(`
 | 
			
		||||
      const umamiScript = document.createElement("script")
 | 
			
		||||
@@ -165,8 +184,11 @@ export const ComponentResources: QuartzEmitterPlugin<Options> = (opts?: Partial<
 | 
			
		||||
      addGlobalPageResources(ctx, resources, componentResources)
 | 
			
		||||
 | 
			
		||||
      const stylesheet = joinStyles(ctx.cfg.configuration.theme, ...componentResources.css, styles)
 | 
			
		||||
      const prescript = joinScripts(componentResources.beforeDOMLoaded)
 | 
			
		||||
      const postscript = joinScripts(componentResources.afterDOMLoaded)
 | 
			
		||||
      const [prescript, postscript] = await Promise.all([
 | 
			
		||||
        joinScripts(componentResources.beforeDOMLoaded),
 | 
			
		||||
        joinScripts(componentResources.afterDOMLoaded),
 | 
			
		||||
      ])
 | 
			
		||||
 | 
			
		||||
      const fps = await Promise.all([
 | 
			
		||||
        emit({
 | 
			
		||||
          slug: "index" as FullSlug,
 | 
			
		||||
 
 | 
			
		||||
@@ -2,7 +2,7 @@ import { Root } from "hast"
 | 
			
		||||
import { GlobalConfiguration } from "../../cfg"
 | 
			
		||||
import { getDate } from "../../components/Date"
 | 
			
		||||
import { escapeHTML } from "../../util/escape"
 | 
			
		||||
import { FilePath, FullSlug, SimpleSlug, simplifySlug } from "../../util/path"
 | 
			
		||||
import { FilePath, FullSlug, SimpleSlug, joinSegments, simplifySlug } from "../../util/path"
 | 
			
		||||
import { QuartzEmitterPlugin } from "../types"
 | 
			
		||||
import { toHtml } from "hast-util-to-html"
 | 
			
		||||
import path from "path"
 | 
			
		||||
@@ -37,7 +37,7 @@ const defaultOptions: Options = {
 | 
			
		||||
function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string {
 | 
			
		||||
  const base = cfg.baseUrl ?? ""
 | 
			
		||||
  const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<url>
 | 
			
		||||
    <loc>https://${base}/${encodeURI(slug)}</loc>
 | 
			
		||||
    <loc>https://${joinSegments(base, encodeURI(slug))}</loc>
 | 
			
		||||
    <lastmod>${content.date?.toISOString()}</lastmod>
 | 
			
		||||
  </url>`
 | 
			
		||||
  const urls = Array.from(idx)
 | 
			
		||||
@@ -52,8 +52,8 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, limit?: nu
 | 
			
		||||
 | 
			
		||||
  const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<item>
 | 
			
		||||
    <title>${escapeHTML(content.title)}</title>
 | 
			
		||||
    <link>${root}/${encodeURI(slug)}</link>
 | 
			
		||||
    <guid>${root}/${encodeURI(slug)}</guid>
 | 
			
		||||
    <link>${joinSegments(root, encodeURI(slug))}</link>
 | 
			
		||||
    <guid>${joinSegments(root, encodeURI(slug))}</guid>
 | 
			
		||||
    <description>${content.richContent ?? content.description}</description>
 | 
			
		||||
    <pubDate>${content.date?.toUTCString()}</pubDate>
 | 
			
		||||
  </item>`
 | 
			
		||||
 
 | 
			
		||||
@@ -7,3 +7,4 @@ export { Assets } from "./assets"
 | 
			
		||||
export { Static } from "./static"
 | 
			
		||||
export { ComponentResources } from "./componentResources"
 | 
			
		||||
export { NotFoundPage } from "./404"
 | 
			
		||||
export { CNAME } from "./cname"
 | 
			
		||||
 
 | 
			
		||||
@@ -11,7 +11,10 @@ export const Static: QuartzEmitterPlugin = () => ({
 | 
			
		||||
  async emit({ argv, cfg }, _content, _resources, _emit): Promise<FilePath[]> {
 | 
			
		||||
    const staticPath = joinSegments(QUARTZ, "static")
 | 
			
		||||
    const fps = await glob("**", staticPath, cfg.configuration.ignorePatterns)
 | 
			
		||||
    await fs.promises.cp(staticPath, joinSegments(argv.output, "static"), { recursive: true })
 | 
			
		||||
    await fs.promises.cp(staticPath, joinSegments(argv.output, "static"), {
 | 
			
		||||
      recursive: true,
 | 
			
		||||
      dereference: true,
 | 
			
		||||
    })
 | 
			
		||||
    return fps.map((fp) => joinSegments(argv.output, "static", fp)) as FilePath[]
 | 
			
		||||
  },
 | 
			
		||||
})
 | 
			
		||||
 
 | 
			
		||||
@@ -40,12 +40,13 @@ export const TagPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
 | 
			
		||||
      const tags: Set<string> = new Set(
 | 
			
		||||
        allFiles.flatMap((data) => data.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes),
 | 
			
		||||
      )
 | 
			
		||||
 | 
			
		||||
      // add base tag
 | 
			
		||||
      tags.add("index")
 | 
			
		||||
 | 
			
		||||
      const tagDescriptions: Record<string, ProcessedContent> = Object.fromEntries(
 | 
			
		||||
        [...tags].map((tag) => {
 | 
			
		||||
          const title = tag === "" ? "Tag Index" : `Tag: #${tag}`
 | 
			
		||||
          const title = tag === "index" ? "Tag Index" : `Tag: #${tag}`
 | 
			
		||||
          return [
 | 
			
		||||
            tag,
 | 
			
		||||
            defaultProcessedContent({
 | 
			
		||||
 
 | 
			
		||||
@@ -30,5 +30,6 @@ declare module "vfile" {
 | 
			
		||||
  interface DataMap {
 | 
			
		||||
    slug: FullSlug
 | 
			
		||||
    filePath: FilePath
 | 
			
		||||
    relativePath: FilePath
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -4,15 +4,18 @@ import { QuartzTransformerPlugin } from "../types"
 | 
			
		||||
import yaml from "js-yaml"
 | 
			
		||||
import toml from "toml"
 | 
			
		||||
import { slugTag } from "../../util/path"
 | 
			
		||||
import { QuartzPluginData } from "../vfile"
 | 
			
		||||
 | 
			
		||||
export interface Options {
 | 
			
		||||
  delims: string | string[]
 | 
			
		||||
  language: "yaml" | "toml"
 | 
			
		||||
  oneLineTagDelim: string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const defaultOptions: Options = {
 | 
			
		||||
  delims: "---",
 | 
			
		||||
  language: "yaml",
 | 
			
		||||
  oneLineTagDelim: ",",
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
 | 
			
		||||
@@ -20,11 +23,13 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined>
 | 
			
		||||
  return {
 | 
			
		||||
    name: "FrontMatter",
 | 
			
		||||
    markdownPlugins() {
 | 
			
		||||
      const { oneLineTagDelim } = opts
 | 
			
		||||
 | 
			
		||||
      return [
 | 
			
		||||
        [remarkFrontmatter, ["yaml", "toml"]],
 | 
			
		||||
        () => {
 | 
			
		||||
          return (_, file) => {
 | 
			
		||||
            const { data } = matter(file.value, {
 | 
			
		||||
            const { data } = matter(Buffer.from(file.value), {
 | 
			
		||||
              ...opts,
 | 
			
		||||
              engines: {
 | 
			
		||||
                yaml: (s) => yaml.load(s, { schema: yaml.JSON_SCHEMA }) as object,
 | 
			
		||||
@@ -40,24 +45,30 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined>
 | 
			
		||||
            // coerce title to string
 | 
			
		||||
            if (data.title) {
 | 
			
		||||
              data.title = data.title.toString()
 | 
			
		||||
            } else if (data.title === null || data.title === undefined) {
 | 
			
		||||
              data.title = file.stem ?? "Untitled"
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (data.tags && !Array.isArray(data.tags)) {
 | 
			
		||||
            if (data.tags) {
 | 
			
		||||
              // coerce to array
 | 
			
		||||
              if (!Array.isArray(data.tags)) {
 | 
			
		||||
                data.tags = data.tags
 | 
			
		||||
                  .toString()
 | 
			
		||||
                  .split(oneLineTagDelim)
 | 
			
		||||
                  .map((tag: string) => tag.trim())
 | 
			
		||||
              }
 | 
			
		||||
 | 
			
		||||
              // remove all non-string tags
 | 
			
		||||
              data.tags = data.tags
 | 
			
		||||
                .toString()
 | 
			
		||||
                .split(",")
 | 
			
		||||
                .map((tag: string) => tag.trim())
 | 
			
		||||
                .filter((tag: unknown) => typeof tag === "string" || typeof tag === "number")
 | 
			
		||||
                .map((tag: string | number) => tag.toString())
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // slug them all!!
 | 
			
		||||
            data.tags = [...new Set(data.tags?.map((tag: string) => slugTag(tag)))] ?? []
 | 
			
		||||
            data.tags = [...new Set(data.tags?.map((tag: string) => slugTag(tag)))]
 | 
			
		||||
 | 
			
		||||
            // fill in frontmatter
 | 
			
		||||
            file.data.frontmatter = {
 | 
			
		||||
              title: file.stem ?? "Untitled",
 | 
			
		||||
              tags: [],
 | 
			
		||||
              ...data,
 | 
			
		||||
            }
 | 
			
		||||
            file.data.frontmatter = data as QuartzPluginData["frontmatter"]
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
      ]
 | 
			
		||||
 
 | 
			
		||||
@@ -31,6 +31,11 @@ export const GitHubFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> |
 | 
			
		||||
            rehypeAutolinkHeadings,
 | 
			
		||||
            {
 | 
			
		||||
              behavior: "append",
 | 
			
		||||
              properties: {
 | 
			
		||||
                ariaHidden: true,
 | 
			
		||||
                tabIndex: -1,
 | 
			
		||||
                "data-no-popover": true,
 | 
			
		||||
              },
 | 
			
		||||
              content: {
 | 
			
		||||
                type: "text",
 | 
			
		||||
                value: " §",
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,6 @@
 | 
			
		||||
import remarkMath from "remark-math"
 | 
			
		||||
import rehypeKatex from "rehype-katex"
 | 
			
		||||
import rehypeMathjax from "rehype-mathjax/svg.js"
 | 
			
		||||
import rehypeMathjax from "rehype-mathjax/svg"
 | 
			
		||||
import { QuartzTransformerPlugin } from "../types"
 | 
			
		||||
 | 
			
		||||
interface Options {
 | 
			
		||||
 
 | 
			
		||||
@@ -12,6 +12,7 @@ import {
 | 
			
		||||
import path from "path"
 | 
			
		||||
import { visit } from "unist-util-visit"
 | 
			
		||||
import isAbsoluteUrl from "is-absolute-url"
 | 
			
		||||
import { Root } from "hast"
 | 
			
		||||
 | 
			
		||||
interface Options {
 | 
			
		||||
  /** How to resolve Markdown paths */
 | 
			
		||||
@@ -19,12 +20,14 @@ interface Options {
 | 
			
		||||
  /** Strips folders from a link so that it looks nice */
 | 
			
		||||
  prettyLinks: boolean
 | 
			
		||||
  openLinksInNewTab: boolean
 | 
			
		||||
  lazyLoad: boolean
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const defaultOptions: Options = {
 | 
			
		||||
  markdownLinkResolution: "absolute",
 | 
			
		||||
  prettyLinks: true,
 | 
			
		||||
  openLinksInNewTab: false,
 | 
			
		||||
  lazyLoad: false,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
 | 
			
		||||
@@ -34,7 +37,7 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> =
 | 
			
		||||
    htmlPlugins(ctx) {
 | 
			
		||||
      return [
 | 
			
		||||
        () => {
 | 
			
		||||
          return (tree, file) => {
 | 
			
		||||
          return (tree: Root, file) => {
 | 
			
		||||
            const curSlug = simplifySlug(file.data.slug!)
 | 
			
		||||
            const outgoing: Set<SimpleSlug> = new Set()
 | 
			
		||||
 | 
			
		||||
@@ -51,8 +54,19 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> =
 | 
			
		||||
                typeof node.properties.href === "string"
 | 
			
		||||
              ) {
 | 
			
		||||
                let dest = node.properties.href as RelativeURL
 | 
			
		||||
                node.properties.className ??= []
 | 
			
		||||
                node.properties.className.push(isAbsoluteUrl(dest) ? "external" : "internal")
 | 
			
		||||
                const classes = (node.properties.className ?? []) as string[]
 | 
			
		||||
                classes.push(isAbsoluteUrl(dest) ? "external" : "internal")
 | 
			
		||||
 | 
			
		||||
                // Check if the link has alias text
 | 
			
		||||
                if (
 | 
			
		||||
                  node.children.length === 1 &&
 | 
			
		||||
                  node.children[0].type === "text" &&
 | 
			
		||||
                  node.children[0].value !== dest
 | 
			
		||||
                ) {
 | 
			
		||||
                  // Add the 'alias' class if the text content is not the same as the href
 | 
			
		||||
                  classes.push("alias")
 | 
			
		||||
                }
 | 
			
		||||
                node.properties.className = classes
 | 
			
		||||
 | 
			
		||||
                if (opts.openLinksInNewTab) {
 | 
			
		||||
                  node.properties.target = "_blank"
 | 
			
		||||
@@ -71,14 +85,16 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> =
 | 
			
		||||
                  // WHATWG equivalent https://nodejs.dev/en/api/v18/url/#urlresolvefrom-to
 | 
			
		||||
                  const url = new URL(dest, `https://base.com/${curSlug}`)
 | 
			
		||||
                  const canonicalDest = url.pathname
 | 
			
		||||
                  const [destCanonical, _destAnchor] = splitAnchor(canonicalDest)
 | 
			
		||||
                  let [destCanonical, _destAnchor] = splitAnchor(canonicalDest)
 | 
			
		||||
                  if (destCanonical.endsWith("/")) {
 | 
			
		||||
                    destCanonical += "index"
 | 
			
		||||
                  }
 | 
			
		||||
 | 
			
		||||
                  // need to decodeURIComponent here as WHATWG URL percent-encodes everything
 | 
			
		||||
                  const simple = decodeURIComponent(
 | 
			
		||||
                    simplifySlug(destCanonical as FullSlug),
 | 
			
		||||
                  ) as SimpleSlug
 | 
			
		||||
                  const full = decodeURIComponent(_stripSlashes(destCanonical, true)) as FullSlug
 | 
			
		||||
                  const simple = simplifySlug(full)
 | 
			
		||||
                  outgoing.add(simple)
 | 
			
		||||
                  node.properties["data-slug"] = simple
 | 
			
		||||
                  node.properties["data-slug"] = full
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                // rewrite link internals if prettylinks is on
 | 
			
		||||
@@ -99,6 +115,10 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> =
 | 
			
		||||
                node.properties &&
 | 
			
		||||
                typeof node.properties.src === "string"
 | 
			
		||||
              ) {
 | 
			
		||||
                if (opts.lazyLoad) {
 | 
			
		||||
                  node.properties.loading = "lazy"
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                if (!isAbsoluteUrl(node.properties.src)) {
 | 
			
		||||
                  let dest = node.properties.src as RelativeURL
 | 
			
		||||
                  dest = node.properties.src = transformLink(
 | 
			
		||||
 
 | 
			
		||||
@@ -1,8 +1,7 @@
 | 
			
		||||
import { PluggableList } from "unified"
 | 
			
		||||
import { QuartzTransformerPlugin } from "../types"
 | 
			
		||||
import { Root, HTML, BlockContent, DefinitionContent, Code, Paragraph } from "mdast"
 | 
			
		||||
import { Root, Html, BlockContent, DefinitionContent, Paragraph, Code } from "mdast"
 | 
			
		||||
import { Element, Literal, Root as HtmlRoot } from "hast"
 | 
			
		||||
import { Replace, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace"
 | 
			
		||||
import { ReplaceFunction, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace"
 | 
			
		||||
import { slug as slugAnchor } from "github-slugger"
 | 
			
		||||
import rehypeRaw from "rehype-raw"
 | 
			
		||||
import { visit } from "unist-util-visit"
 | 
			
		||||
@@ -15,6 +14,7 @@ import { toHast } from "mdast-util-to-hast"
 | 
			
		||||
import { toHtml } from "hast-util-to-html"
 | 
			
		||||
import { PhrasingContent } from "mdast-util-find-and-replace/lib"
 | 
			
		||||
import { capitalize } from "../../util/lang"
 | 
			
		||||
import { PluggableList } from "unified"
 | 
			
		||||
 | 
			
		||||
export interface Options {
 | 
			
		||||
  comments: boolean
 | 
			
		||||
@@ -105,12 +105,17 @@ function canonicalizeCallout(calloutName: string): keyof typeof callouts {
 | 
			
		||||
  return calloutMapping[callout] ?? "note"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const externalLinkRegex = /^https?:\/\//i
 | 
			
		||||
 | 
			
		||||
// !?               -> 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 wikilinkRegex = new RegExp(/!?\[\[([^\[\]\|\#]+)?(#[^\[\]\|\#]+)?(\|[^\[\]\|\#]+)?\]\]/, "g")
 | 
			
		||||
export const wikilinkRegex = new RegExp(
 | 
			
		||||
  /!?\[\[([^\[\]\|\#]+)?(#+[^\[\]\|\#]+)?(\|[^\[\]\|\#]+)?\]\]/,
 | 
			
		||||
  "g",
 | 
			
		||||
)
 | 
			
		||||
const highlightRegex = new RegExp(/==([^=]+)==/, "g")
 | 
			
		||||
const commentRegex = new RegExp(/%%(.+)%%/, "g")
 | 
			
		||||
// from https://github.com/escwxyz/remark-obsidian-callout/blob/main/src/index.ts
 | 
			
		||||
@@ -118,8 +123,8 @@ const calloutRegex = new RegExp(/^\[\!(\w+)\]([+-]?)/)
 | 
			
		||||
const calloutLineRegex = new RegExp(/^> *\[\!\w+\][+-]?.*$/, "gm")
 | 
			
		||||
// (?:^| )              -> non-capturing group, tag should start be separated by a space or be the start of the line
 | 
			
		||||
// #(...)               -> capturing group, tag itself must start with #
 | 
			
		||||
// (?:[-_\p{L}])+       -> non-capturing group, non-empty string of (Unicode-aware) alpha-numeric characters, hyphens and/or underscores
 | 
			
		||||
// (?:\/[-_\p{L}]+)*)   -> non-capturing group, matches an arbitrary number of tag strings separated by "/"
 | 
			
		||||
// (?:[-_\p{L}\d\p{Z}])+       -> non-capturing group, non-empty string of (Unicode-aware) alpha-numeric characters and symbols, hyphens and/or underscores
 | 
			
		||||
// (?:\/[-_\p{L}\d\p{Z}]+)*)   -> non-capturing group, matches an arbitrary number of tag strings separated by "/"
 | 
			
		||||
const tagRegex = new RegExp(/(?:^| )#((?:[-_\p{L}\d])+(?:\/[-_\p{L}\d]+)*)/, "gu")
 | 
			
		||||
const blockReferenceRegex = new RegExp(/\^([A-Za-z0-9]+)$/, "g")
 | 
			
		||||
 | 
			
		||||
@@ -132,39 +137,16 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
 | 
			
		||||
    const hast = toHast(ast, { allowDangerousHtml: true })!
 | 
			
		||||
    return toHtml(hast, { allowDangerousHtml: true })
 | 
			
		||||
  }
 | 
			
		||||
  const findAndReplace = opts.enableInHtmlEmbed
 | 
			
		||||
    ? (tree: Root, regex: RegExp, replace?: Replace | null | undefined) => {
 | 
			
		||||
        if (replace) {
 | 
			
		||||
          visit(tree, "html", (node: HTML) => {
 | 
			
		||||
            if (typeof replace === "string") {
 | 
			
		||||
              node.value = node.value.replace(regex, replace)
 | 
			
		||||
            } else {
 | 
			
		||||
              node.value = node.value.replaceAll(regex, (substring: string, ...args) => {
 | 
			
		||||
                const replaceValue = replace(substring, ...args)
 | 
			
		||||
                if (typeof replaceValue === "string") {
 | 
			
		||||
                  return replaceValue
 | 
			
		||||
                } else if (Array.isArray(replaceValue)) {
 | 
			
		||||
                  return replaceValue.map(mdastToHtml).join("")
 | 
			
		||||
                } else if (typeof replaceValue === "object" && replaceValue !== null) {
 | 
			
		||||
                  return mdastToHtml(replaceValue)
 | 
			
		||||
                } else {
 | 
			
		||||
                  return substring
 | 
			
		||||
                }
 | 
			
		||||
              })
 | 
			
		||||
            }
 | 
			
		||||
          })
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        mdastFindReplace(tree, regex, replace)
 | 
			
		||||
      }
 | 
			
		||||
    : mdastFindReplace
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    name: "ObsidianFlavoredMarkdown",
 | 
			
		||||
    textTransform(_ctx, src) {
 | 
			
		||||
      // pre-transform blockquotes
 | 
			
		||||
      if (opts.callouts) {
 | 
			
		||||
        src = src.toString()
 | 
			
		||||
        if (src instanceof Buffer) {
 | 
			
		||||
          src = src.toString()
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        src = src.replaceAll(calloutLineRegex, (value) => {
 | 
			
		||||
          // force newline after title of callout
 | 
			
		||||
          return value + "\n> "
 | 
			
		||||
@@ -173,14 +155,24 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
 | 
			
		||||
 | 
			
		||||
      // pre-transform wikilinks (fix anchors to things that may contain illegal syntax e.g. codeblocks, latex)
 | 
			
		||||
      if (opts.wikilinks) {
 | 
			
		||||
        src = src.toString()
 | 
			
		||||
        if (src instanceof Buffer) {
 | 
			
		||||
          src = src.toString()
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        src = src.replaceAll(wikilinkRegex, (value, ...capture) => {
 | 
			
		||||
          const [rawFp, rawHeader, rawAlias] = capture
 | 
			
		||||
          const [rawFp, rawHeader, rawAlias]: (string | undefined)[] = capture
 | 
			
		||||
 | 
			
		||||
          const fp = rawFp ?? ""
 | 
			
		||||
          const anchor = rawHeader?.trim().slice(1)
 | 
			
		||||
          const displayAnchor = anchor ? `#${slugAnchor(anchor)}` : ""
 | 
			
		||||
          const anchor = rawHeader?.trim().replace(/^#+/, "")
 | 
			
		||||
          const blockRef = Boolean(anchor?.startsWith("^")) ? "^" : ""
 | 
			
		||||
          const displayAnchor = anchor ? `#${blockRef}${slugAnchor(anchor)}` : ""
 | 
			
		||||
          const displayAlias = rawAlias ?? rawHeader?.replace("#", "|") ?? ""
 | 
			
		||||
          const embedDisplay = value.startsWith("!") ? "!" : ""
 | 
			
		||||
 | 
			
		||||
          if (rawFp?.match(externalLinkRegex)) {
 | 
			
		||||
            return `${embedDisplay}[${displayAlias.replace(/^\|/, "")}](${rawFp})`
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          return `${embedDisplay}[[${fp}${displayAnchor}${displayAlias}]]`
 | 
			
		||||
        })
 | 
			
		||||
      }
 | 
			
		||||
@@ -189,108 +181,172 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
 | 
			
		||||
    },
 | 
			
		||||
    markdownPlugins() {
 | 
			
		||||
      const plugins: PluggableList = []
 | 
			
		||||
      if (opts.wikilinks) {
 | 
			
		||||
        plugins.push(() => {
 | 
			
		||||
          return (tree: Root, _file) => {
 | 
			
		||||
            findAndReplace(tree, wikilinkRegex, (value: string, ...capture: string[]) => {
 | 
			
		||||
              let [rawFp, rawHeader, rawAlias] = capture
 | 
			
		||||
              const fp = rawFp?.trim() ?? ""
 | 
			
		||||
              const anchor = rawHeader?.trim() ?? ""
 | 
			
		||||
              const alias = rawAlias?.slice(1).trim()
 | 
			
		||||
 | 
			
		||||
              // embed cases
 | 
			
		||||
              if (value.startsWith("!")) {
 | 
			
		||||
                const ext: string = path.extname(fp).toLowerCase()
 | 
			
		||||
                const url = slugifyFilePath(fp as FilePath)
 | 
			
		||||
                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,
 | 
			
		||||
      // regex replacements
 | 
			
		||||
      plugins.push(() => {
 | 
			
		||||
        return (tree: Root, file) => {
 | 
			
		||||
          const replacements: [RegExp, string | ReplaceFunction][] = []
 | 
			
		||||
          const base = pathToRoot(file.data.slug!)
 | 
			
		||||
 | 
			
		||||
          if (opts.wikilinks) {
 | 
			
		||||
            replacements.push([
 | 
			
		||||
              wikilinkRegex,
 | 
			
		||||
              (value: string, ...capture: string[]) => {
 | 
			
		||||
                let [rawFp, rawHeader, rawAlias] = capture
 | 
			
		||||
                const fp = rawFp?.trim() ?? ""
 | 
			
		||||
                const anchor = rawHeader?.trim() ?? ""
 | 
			
		||||
                const alias = rawAlias?.slice(1).trim()
 | 
			
		||||
 | 
			
		||||
                // embed cases
 | 
			
		||||
                if (value.startsWith("!")) {
 | 
			
		||||
                  const ext: string = path.extname(fp).toLowerCase()
 | 
			
		||||
                  const url = slugifyFilePath(fp as FilePath)
 | 
			
		||||
                  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 (ext === "") {
 | 
			
		||||
                  const block = anchor
 | 
			
		||||
                  return {
 | 
			
		||||
                    type: "html",
 | 
			
		||||
                    data: { hProperties: { transclude: true } },
 | 
			
		||||
                    value: `<blockquote class="transclude" data-url="${url}" data-block="${block}"><a href="${
 | 
			
		||||
                      url + anchor
 | 
			
		||||
                    }" class="transclude-inner">Transclude of ${url}${block}</a></blockquote>`,
 | 
			
		||||
                    }
 | 
			
		||||
                  } 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 (ext === "") {
 | 
			
		||||
                    const block = anchor
 | 
			
		||||
                    return {
 | 
			
		||||
                      type: "html",
 | 
			
		||||
                      data: { hProperties: { transclude: true } },
 | 
			
		||||
                      value: `<blockquote class="transclude" data-url="${url}" data-block="${block}"><a href="${
 | 
			
		||||
                        url + anchor
 | 
			
		||||
                      }" class="transclude-inner">Transclude of ${url}${block}</a></blockquote>`,
 | 
			
		||||
                    }
 | 
			
		||||
                  }
 | 
			
		||||
 | 
			
		||||
                  // otherwise, fall through to regular link
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                // otherwise, fall through to regular link
 | 
			
		||||
              }
 | 
			
		||||
                // internal link
 | 
			
		||||
                const url = fp + anchor
 | 
			
		||||
                return {
 | 
			
		||||
                  type: "link",
 | 
			
		||||
                  url,
 | 
			
		||||
                  children: [
 | 
			
		||||
                    {
 | 
			
		||||
                      type: "text",
 | 
			
		||||
                      value: alias ?? fp,
 | 
			
		||||
                    },
 | 
			
		||||
                  ],
 | 
			
		||||
                }
 | 
			
		||||
              },
 | 
			
		||||
            ])
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
              // internal link
 | 
			
		||||
              const url = fp + anchor
 | 
			
		||||
              return {
 | 
			
		||||
                type: "link",
 | 
			
		||||
                url,
 | 
			
		||||
                children: [
 | 
			
		||||
                  {
 | 
			
		||||
                    type: "text",
 | 
			
		||||
                    value: alias ?? fp,
 | 
			
		||||
          if (opts.highlight) {
 | 
			
		||||
            replacements.push([
 | 
			
		||||
              highlightRegex,
 | 
			
		||||
              (_value: string, ...capture: string[]) => {
 | 
			
		||||
                const [inner] = capture
 | 
			
		||||
                return {
 | 
			
		||||
                  type: "html",
 | 
			
		||||
                  value: `<span class="text-highlight">${inner}</span>`,
 | 
			
		||||
                }
 | 
			
		||||
              },
 | 
			
		||||
            ])
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          if (opts.comments) {
 | 
			
		||||
            replacements.push([
 | 
			
		||||
              commentRegex,
 | 
			
		||||
              (_value: string, ..._capture: string[]) => {
 | 
			
		||||
                return {
 | 
			
		||||
                  type: "text",
 | 
			
		||||
                  value: "",
 | 
			
		||||
                }
 | 
			
		||||
              },
 | 
			
		||||
            ])
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          if (opts.parseTags) {
 | 
			
		||||
            replacements.push([
 | 
			
		||||
              tagRegex,
 | 
			
		||||
              (_value: string, tag: string) => {
 | 
			
		||||
                // Check if the tag only includes numbers
 | 
			
		||||
                if (/^\d+$/.test(tag)) {
 | 
			
		||||
                  return false
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                tag = slugTag(tag)
 | 
			
		||||
                if (file.data.frontmatter && !file.data.frontmatter.tags.includes(tag)) {
 | 
			
		||||
                  file.data.frontmatter.tags.push(tag)
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                return {
 | 
			
		||||
                  type: "link",
 | 
			
		||||
                  url: base + `/tags/${tag}`,
 | 
			
		||||
                  data: {
 | 
			
		||||
                    hProperties: {
 | 
			
		||||
                      className: ["tag-link"],
 | 
			
		||||
                    },
 | 
			
		||||
                  },
 | 
			
		||||
                ],
 | 
			
		||||
              }
 | 
			
		||||
            })
 | 
			
		||||
                  children: [
 | 
			
		||||
                    {
 | 
			
		||||
                      type: "text",
 | 
			
		||||
                      value: `#${tag}`,
 | 
			
		||||
                    },
 | 
			
		||||
                  ],
 | 
			
		||||
                }
 | 
			
		||||
              },
 | 
			
		||||
            ])
 | 
			
		||||
          }
 | 
			
		||||
        })
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (opts.highlight) {
 | 
			
		||||
        plugins.push(() => {
 | 
			
		||||
          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.enableInHtmlEmbed) {
 | 
			
		||||
            visit(tree, "html", (node: Html) => {
 | 
			
		||||
              for (const [regex, replace] of replacements) {
 | 
			
		||||
                if (typeof replace === "string") {
 | 
			
		||||
                  node.value = node.value.replace(regex, replace)
 | 
			
		||||
                } else {
 | 
			
		||||
                  node.value = node.value.replaceAll(regex, (substring: string, ...args) => {
 | 
			
		||||
                    const replaceValue = replace(substring, ...args)
 | 
			
		||||
                    if (typeof replaceValue === "string") {
 | 
			
		||||
                      return replaceValue
 | 
			
		||||
                    } else if (Array.isArray(replaceValue)) {
 | 
			
		||||
                      return replaceValue.map(mdastToHtml).join("")
 | 
			
		||||
                    } else if (typeof replaceValue === "object" && replaceValue !== null) {
 | 
			
		||||
                      return mdastToHtml(replaceValue)
 | 
			
		||||
                    } else {
 | 
			
		||||
                      return substring
 | 
			
		||||
                    }
 | 
			
		||||
                  })
 | 
			
		||||
                }
 | 
			
		||||
              }
 | 
			
		||||
            })
 | 
			
		||||
          }
 | 
			
		||||
        })
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (opts.comments) {
 | 
			
		||||
        plugins.push(() => {
 | 
			
		||||
          return (tree: Root, _file) => {
 | 
			
		||||
            findAndReplace(tree, commentRegex, (_value: string, ..._capture: string[]) => {
 | 
			
		||||
              return {
 | 
			
		||||
                type: "text",
 | 
			
		||||
                value: "",
 | 
			
		||||
              }
 | 
			
		||||
            })
 | 
			
		||||
          }
 | 
			
		||||
        })
 | 
			
		||||
      }
 | 
			
		||||
          mdastFindReplace(tree, replacements)
 | 
			
		||||
        }
 | 
			
		||||
      })
 | 
			
		||||
 | 
			
		||||
      if (opts.callouts) {
 | 
			
		||||
        plugins.push(() => {
 | 
			
		||||
@@ -331,7 +387,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
 | 
			
		||||
                  <polyline points="6 9 12 15 18 9"></polyline>
 | 
			
		||||
                </svg>`
 | 
			
		||||
 | 
			
		||||
                const titleHtml: HTML = {
 | 
			
		||||
                const titleHtml: Html = {
 | 
			
		||||
                  type: "html",
 | 
			
		||||
                  value: `<div
 | 
			
		||||
                  class="callout-title"
 | 
			
		||||
@@ -391,51 +447,17 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
 | 
			
		||||
        })
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (opts.parseTags) {
 | 
			
		||||
        plugins.push(() => {
 | 
			
		||||
          return (tree: Root, file) => {
 | 
			
		||||
            const base = pathToRoot(file.data.slug!)
 | 
			
		||||
            findAndReplace(tree, tagRegex, (_value: string, tag: string) => {
 | 
			
		||||
              // Check if the tag only includes numbers
 | 
			
		||||
              if (/^\d+$/.test(tag)) {
 | 
			
		||||
                return false
 | 
			
		||||
              }
 | 
			
		||||
              tag = slugTag(tag)
 | 
			
		||||
              if (file.data.frontmatter && !file.data.frontmatter.tags.includes(tag)) {
 | 
			
		||||
                file.data.frontmatter.tags.push(tag)
 | 
			
		||||
              }
 | 
			
		||||
 | 
			
		||||
              return {
 | 
			
		||||
                type: "link",
 | 
			
		||||
                url: base + `/tags/${tag}`,
 | 
			
		||||
                data: {
 | 
			
		||||
                  hProperties: {
 | 
			
		||||
                    className: ["tag-link"],
 | 
			
		||||
                  },
 | 
			
		||||
                },
 | 
			
		||||
                children: [
 | 
			
		||||
                  {
 | 
			
		||||
                    type: "text",
 | 
			
		||||
                    value: `#${tag}`,
 | 
			
		||||
                  },
 | 
			
		||||
                ],
 | 
			
		||||
              }
 | 
			
		||||
            })
 | 
			
		||||
          }
 | 
			
		||||
        })
 | 
			
		||||
      }
 | 
			
		||||
      return plugins
 | 
			
		||||
    },
 | 
			
		||||
    htmlPlugins() {
 | 
			
		||||
      const plugins = [rehypeRaw]
 | 
			
		||||
      const plugins: PluggableList = [rehypeRaw]
 | 
			
		||||
 | 
			
		||||
      if (opts.parseBlockReferences) {
 | 
			
		||||
        plugins.push(() => {
 | 
			
		||||
          const inlineTagTypes = new Set(["p", "li"])
 | 
			
		||||
          const blockTagTypes = new Set(["blockquote"])
 | 
			
		||||
          return (tree, file) => {
 | 
			
		||||
          return (tree: HtmlRoot, file) => {
 | 
			
		||||
            file.data.blocks = {}
 | 
			
		||||
            file.data.htmlAst = tree
 | 
			
		||||
 | 
			
		||||
            visit(tree, "element", (node, index, parent) => {
 | 
			
		||||
              if (blockTagTypes.has(node.tagName)) {
 | 
			
		||||
@@ -477,6 +499,8 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
 | 
			
		||||
                }
 | 
			
		||||
              }
 | 
			
		||||
            })
 | 
			
		||||
 | 
			
		||||
            file.data.htmlAst = tree
 | 
			
		||||
          }
 | 
			
		||||
        })
 | 
			
		||||
      }
 | 
			
		||||
 
 | 
			
		||||
@@ -8,7 +8,11 @@ export const SyntaxHighlighting: QuartzTransformerPlugin = () => ({
 | 
			
		||||
      [
 | 
			
		||||
        rehypePrettyCode,
 | 
			
		||||
        {
 | 
			
		||||
          theme: "css-variables",
 | 
			
		||||
          keepBackground: false,
 | 
			
		||||
          theme: {
 | 
			
		||||
            dark: "github-dark",
 | 
			
		||||
            light: "github-light",
 | 
			
		||||
          },
 | 
			
		||||
        } satisfies Partial<CodeOptions>,
 | 
			
		||||
      ],
 | 
			
		||||
    ]
 | 
			
		||||
 
 | 
			
		||||
@@ -3,6 +3,7 @@ import { Root } from "mdast"
 | 
			
		||||
import { visit } from "unist-util-visit"
 | 
			
		||||
import { toString } from "mdast-util-to-string"
 | 
			
		||||
import Slugger from "github-slugger"
 | 
			
		||||
import { wikilinkRegex } from "./ofm"
 | 
			
		||||
 | 
			
		||||
export interface Options {
 | 
			
		||||
  maxDepth: 1 | 2 | 3 | 4 | 5 | 6
 | 
			
		||||
@@ -24,6 +25,7 @@ interface TocEntry {
 | 
			
		||||
  slug: string // this is just the anchor (#some-slug), not the canonical slug
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const regexMdLinks = new RegExp(/\[([^\[]+)\](\(.*\))/, "g")
 | 
			
		||||
export const TableOfContents: QuartzTransformerPlugin<Partial<Options> | undefined> = (
 | 
			
		||||
  userOpts,
 | 
			
		||||
) => {
 | 
			
		||||
@@ -41,7 +43,16 @@ export const TableOfContents: QuartzTransformerPlugin<Partial<Options> | undefin
 | 
			
		||||
              let highestDepth: number = opts.maxDepth
 | 
			
		||||
              visit(tree, "heading", (node) => {
 | 
			
		||||
                if (node.depth <= opts.maxDepth) {
 | 
			
		||||
                  const text = toString(node)
 | 
			
		||||
                  let text = toString(node)
 | 
			
		||||
 | 
			
		||||
                  // strip link formatting from toc entries
 | 
			
		||||
                  text = text.replace(wikilinkRegex, (_, rawFp, __, rawAlias) => {
 | 
			
		||||
                    const fp = rawFp?.trim() ?? ""
 | 
			
		||||
                    const alias = rawAlias?.slice(1).trim()
 | 
			
		||||
                    return alias ?? fp
 | 
			
		||||
                  })
 | 
			
		||||
                  text = text.replace(regexMdLinks, "$1")
 | 
			
		||||
 | 
			
		||||
                  highestDepth = Math.min(highestDepth, node.depth)
 | 
			
		||||
                  toc.push({
 | 
			
		||||
                    depth: node.depth,
 | 
			
		||||
 
 | 
			
		||||
@@ -2,7 +2,7 @@ import { Node, Parent } from "hast"
 | 
			
		||||
import { Data, VFile } from "vfile"
 | 
			
		||||
 | 
			
		||||
export type QuartzPluginData = Data
 | 
			
		||||
export type ProcessedContent = [Node<QuartzPluginData>, VFile]
 | 
			
		||||
export type ProcessedContent = [Node, VFile]
 | 
			
		||||
 | 
			
		||||
export function defaultProcessedContent(vfileData: Partial<QuartzPluginData>): ProcessedContent {
 | 
			
		||||
  const root: Parent = { type: "root", children: [] }
 | 
			
		||||
 
 | 
			
		||||
@@ -14,27 +14,25 @@ import { QuartzLogger } from "../util/log"
 | 
			
		||||
import { trace } from "../util/trace"
 | 
			
		||||
import { BuildCtx } from "../util/ctx"
 | 
			
		||||
 | 
			
		||||
export type QuartzProcessor = Processor<MDRoot, HTMLRoot, void>
 | 
			
		||||
export type QuartzProcessor = Processor<MDRoot, MDRoot, HTMLRoot>
 | 
			
		||||
export function createProcessor(ctx: BuildCtx): QuartzProcessor {
 | 
			
		||||
  const transformers = ctx.cfg.plugins.transformers
 | 
			
		||||
 | 
			
		||||
  // base Markdown -> MD AST
 | 
			
		||||
  let processor = unified().use(remarkParse)
 | 
			
		||||
 | 
			
		||||
  // MD AST -> MD AST transforms
 | 
			
		||||
  for (const plugin of transformers.filter((p) => p.markdownPlugins)) {
 | 
			
		||||
    processor = processor.use(plugin.markdownPlugins!(ctx))
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // MD AST -> HTML AST
 | 
			
		||||
  processor = processor.use(remarkRehype, { allowDangerousHtml: true })
 | 
			
		||||
 | 
			
		||||
  // HTML AST -> HTML AST transforms
 | 
			
		||||
  for (const plugin of transformers.filter((p) => p.htmlPlugins)) {
 | 
			
		||||
    processor = processor.use(plugin.htmlPlugins!(ctx))
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return processor
 | 
			
		||||
  return (
 | 
			
		||||
    unified()
 | 
			
		||||
      // base Markdown -> MD AST
 | 
			
		||||
      .use(remarkParse)
 | 
			
		||||
      // MD AST -> MD AST transforms
 | 
			
		||||
      .use(
 | 
			
		||||
        transformers
 | 
			
		||||
          .filter((p) => p.markdownPlugins)
 | 
			
		||||
          .flatMap((plugin) => plugin.markdownPlugins!(ctx)),
 | 
			
		||||
      )
 | 
			
		||||
      // MD AST -> HTML AST
 | 
			
		||||
      .use(remarkRehype, { allowDangerousHtml: true })
 | 
			
		||||
      // HTML AST -> HTML AST transforms
 | 
			
		||||
      .use(transformers.filter((p) => p.htmlPlugins).flatMap((plugin) => plugin.htmlPlugins!(ctx)))
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function* chunks<T>(arr: T[], n: number) {
 | 
			
		||||
@@ -89,12 +87,13 @@ export function createFileParser(ctx: BuildCtx, fps: FilePath[]) {
 | 
			
		||||
 | 
			
		||||
        // Text -> Text transforms
 | 
			
		||||
        for (const plugin of cfg.plugins.transformers.filter((p) => p.textTransform)) {
 | 
			
		||||
          file.value = plugin.textTransform!(ctx, file.value)
 | 
			
		||||
          file.value = plugin.textTransform!(ctx, file.value.toString())
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // base data properties that plugins may use
 | 
			
		||||
        file.data.slug = slugifyFilePath(path.posix.relative(argv.directory, file.path) as FilePath)
 | 
			
		||||
        file.data.filePath = fp
 | 
			
		||||
        file.data.filePath = file.path as FilePath
 | 
			
		||||
        file.data.relativePath = path.posix.relative(argv.directory, file.path) as FilePath
 | 
			
		||||
        file.data.slug = slugifyFilePath(file.data.relativePath)
 | 
			
		||||
 | 
			
		||||
        const ast = processor.parse(file)
 | 
			
		||||
        const newAst = await processor.run(ast, file)
 | 
			
		||||
 
 | 
			
		||||
@@ -63,11 +63,17 @@ a {
 | 
			
		||||
    color: var(--tertiary) !important;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  &.internal:not(:has(> img)) {
 | 
			
		||||
  &.internal {
 | 
			
		||||
    text-decoration: none;
 | 
			
		||||
    background-color: var(--highlight);
 | 
			
		||||
    padding: 0 0.1rem;
 | 
			
		||||
    border-radius: 5px;
 | 
			
		||||
 | 
			
		||||
    &:has(> img) {
 | 
			
		||||
      background-color: none;
 | 
			
		||||
      border-radius: 0;
 | 
			
		||||
      padding: 0;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -93,8 +99,6 @@ a {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  & article {
 | 
			
		||||
    position: relative;
 | 
			
		||||
 | 
			
		||||
    & > h1 {
 | 
			
		||||
      font-size: 2rem;
 | 
			
		||||
    }
 | 
			
		||||
@@ -299,11 +303,13 @@ h6 {
 | 
			
		||||
  margin-bottom: 1rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
div[data-rehype-pretty-code-fragment] {
 | 
			
		||||
figure[data-rehype-pretty-code-figure] {
 | 
			
		||||
  margin: 0;
 | 
			
		||||
  position: relative;
 | 
			
		||||
  line-height: 1.6rem;
 | 
			
		||||
  position: relative;
 | 
			
		||||
 | 
			
		||||
  & > div[data-rehype-pretty-code-title] {
 | 
			
		||||
  & > [data-rehype-pretty-code-title] {
 | 
			
		||||
    font-family: var(--codeFont);
 | 
			
		||||
    font-size: 0.9rem;
 | 
			
		||||
    padding: 0.1rem 0.5rem;
 | 
			
		||||
@@ -315,13 +321,13 @@ div[data-rehype-pretty-code-fragment] {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  & > pre {
 | 
			
		||||
    padding: 0.5rem 0;
 | 
			
		||||
    padding: 0;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
pre {
 | 
			
		||||
  font-family: var(--codeFont);
 | 
			
		||||
  padding: 0.5rem;
 | 
			
		||||
  padding: 0 0.5rem;
 | 
			
		||||
  border-radius: 5px;
 | 
			
		||||
  overflow-x: auto;
 | 
			
		||||
  border: 1px solid var(--lightgray);
 | 
			
		||||
@@ -337,6 +343,7 @@ pre {
 | 
			
		||||
    counter-reset: line;
 | 
			
		||||
    counter-increment: line 0;
 | 
			
		||||
    display: grid;
 | 
			
		||||
    padding: 0.5rem 0;
 | 
			
		||||
 | 
			
		||||
    & [data-highlighted-chars] {
 | 
			
		||||
      background-color: var(--highlight);
 | 
			
		||||
 
 | 
			
		||||
@@ -1,29 +1,17 @@
 | 
			
		||||
// npx convert-sh-theme https://raw.githubusercontent.com/shikijs/shiki/main/packages/shiki/themes/github-light.json
 | 
			
		||||
:root {
 | 
			
		||||
  --shiki-color-text: #24292e;
 | 
			
		||||
  --shiki-color-background: #f8f8f8;
 | 
			
		||||
  --shiki-token-constant: #005cc5;
 | 
			
		||||
  --shiki-token-string: #032f62;
 | 
			
		||||
  --shiki-token-comment: #6a737d;
 | 
			
		||||
  --shiki-token-keyword: #d73a49;
 | 
			
		||||
  --shiki-token-parameter: #24292e;
 | 
			
		||||
  --shiki-token-function: #24292e;
 | 
			
		||||
  --shiki-token-string-expression: #22863a;
 | 
			
		||||
  --shiki-token-punctuation: #24292e;
 | 
			
		||||
  --shiki-token-link: #24292e;
 | 
			
		||||
code[data-theme*=" "] {
 | 
			
		||||
  color: var(--shiki-light);
 | 
			
		||||
  background-color: var(--shiki-light-bg);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// npx convert-sh-theme https://raw.githubusercontent.com/shikijs/shiki/main/packages/shiki/themes/github-dark.json
 | 
			
		||||
[saved-theme="dark"] {
 | 
			
		||||
  --shiki-color-text: #e1e4e8 !important;
 | 
			
		||||
  --shiki-color-background: #24292e !important;
 | 
			
		||||
  --shiki-token-constant: #79b8ff !important;
 | 
			
		||||
  --shiki-token-string: #9ecbff !important;
 | 
			
		||||
  --shiki-token-comment: #6a737d !important;
 | 
			
		||||
  --shiki-token-keyword: #f97583 !important;
 | 
			
		||||
  --shiki-token-parameter: #e1e4e8 !important;
 | 
			
		||||
  --shiki-token-function: #e1e4e8 !important;
 | 
			
		||||
  --shiki-token-string-expression: #85e89d !important;
 | 
			
		||||
  --shiki-token-punctuation: #e1e4e8 !important;
 | 
			
		||||
  --shiki-token-link: #e1e4e8 !important;
 | 
			
		||||
code[data-theme*=" "] span {
 | 
			
		||||
  color: var(--shiki-light);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
[saved-theme="dark"] code[data-theme*=" "] {
 | 
			
		||||
  color: var(--shiki-dark);
 | 
			
		||||
  background-color: var(--shiki-dark-bg);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
[saved-theme="dark"] code[data-theme*=" "] span {
 | 
			
		||||
  color: var(--shiki-dark);
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,4 @@
 | 
			
		||||
import { Components, Jsx, toJsxRuntime } from "hast-util-to-jsx-runtime"
 | 
			
		||||
import { QuartzPluginData } from "../plugins/vfile"
 | 
			
		||||
import { Node, Root } from "hast"
 | 
			
		||||
import { Fragment, jsx, jsxs } from "preact/jsx-runtime"
 | 
			
		||||
import { trace } from "./trace"
 | 
			
		||||
@@ -13,7 +12,7 @@ const customComponents: Components = {
 | 
			
		||||
  ),
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function htmlToJsx(fp: FilePath, tree: Node<QuartzPluginData>) {
 | 
			
		||||
export function htmlToJsx(fp: FilePath, tree: Node) {
 | 
			
		||||
  try {
 | 
			
		||||
    return toJsxRuntime(tree as Root, {
 | 
			
		||||
      Fragment,
 | 
			
		||||
 
 | 
			
		||||
@@ -83,7 +83,7 @@ describe("transforms", () => {
 | 
			
		||||
  test("simplifySlug", () => {
 | 
			
		||||
    asserts(
 | 
			
		||||
      [
 | 
			
		||||
        ["index", ""],
 | 
			
		||||
        ["index", "/"],
 | 
			
		||||
        ["abc", "abc"],
 | 
			
		||||
        ["abc/index", "abc/"],
 | 
			
		||||
        ["abc/def", "abc/def"],
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,9 @@
 | 
			
		||||
import { slug } from "github-slugger"
 | 
			
		||||
import { slug as slugAnchor } from "github-slugger"
 | 
			
		||||
import type { Element as HastElement } from "hast"
 | 
			
		||||
import rfdc from "rfdc"
 | 
			
		||||
 | 
			
		||||
export const clone = rfdc()
 | 
			
		||||
 | 
			
		||||
// this file must be isomorphic so it can't use node libs (e.g. path)
 | 
			
		||||
 | 
			
		||||
export const QUARTZ = "quartz"
 | 
			
		||||
@@ -24,7 +29,7 @@ export function isFullSlug(s: string): s is FullSlug {
 | 
			
		||||
/** Shouldn't be a relative path and shouldn't have `/index` as an ending or a file extension. It _can_ however have a trailing slash to indicate a folder path. */
 | 
			
		||||
export type SimpleSlug = SlugLike<"simple">
 | 
			
		||||
export function isSimpleSlug(s: string): s is SimpleSlug {
 | 
			
		||||
  const validStart = !(s.startsWith(".") || s.startsWith("/"))
 | 
			
		||||
  const validStart = !(s.startsWith(".") || (s.length > 1 && s.startsWith("/")))
 | 
			
		||||
  const validEnding = !(s.endsWith("/index") || s === "index")
 | 
			
		||||
  return validStart && !_containsForbiddenCharacters(s) && validEnding && !_hasFileExtension(s)
 | 
			
		||||
}
 | 
			
		||||
@@ -42,6 +47,14 @@ export function getFullSlug(window: Window): FullSlug {
 | 
			
		||||
  return res
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function sluggify(s: string): string {
 | 
			
		||||
  return s
 | 
			
		||||
    .split("/")
 | 
			
		||||
    .map((segment) => segment.replace(/\s/g, "-").replace(/%/g, "-percent").replace(/\?/g, "-q")) // slugify all segments
 | 
			
		||||
    .join("/") // always use / as sep
 | 
			
		||||
    .replace(/\/$/, "")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function slugifyFilePath(fp: FilePath, excludeExt?: boolean): FullSlug {
 | 
			
		||||
  fp = _stripSlashes(fp) as FilePath
 | 
			
		||||
  let ext = _getFileExtension(fp)
 | 
			
		||||
@@ -50,11 +63,7 @@ export function slugifyFilePath(fp: FilePath, excludeExt?: boolean): FullSlug {
 | 
			
		||||
    ext = ""
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  let slug = withoutFileExt
 | 
			
		||||
    .split("/")
 | 
			
		||||
    .map((segment) => segment.replace(/\s/g, "-").replace(/%/g, "-percent").replace(/\?/g, "-q")) // slugify all segments
 | 
			
		||||
    .join("/") // always use / as sep
 | 
			
		||||
    .replace(/\/$/, "") // remove trailing slash
 | 
			
		||||
  let slug = sluggify(withoutFileExt)
 | 
			
		||||
 | 
			
		||||
  // treat _index as index
 | 
			
		||||
  if (_endsWith(slug, "_index")) {
 | 
			
		||||
@@ -65,7 +74,8 @@ export function slugifyFilePath(fp: FilePath, excludeExt?: boolean): FullSlug {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function simplifySlug(fp: FullSlug): SimpleSlug {
 | 
			
		||||
  return _stripSlashes(_trimSuffix(fp, "index"), true) as SimpleSlug
 | 
			
		||||
  const res = _stripSlashes(_trimSuffix(fp, "index"), true)
 | 
			
		||||
  return (res.length === 0 ? "/" : res) as SimpleSlug
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function transformInternalLink(link: string): RelativeURL {
 | 
			
		||||
@@ -84,6 +94,50 @@ export function transformInternalLink(link: string): RelativeURL {
 | 
			
		||||
  return res
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// from micromorph/src/utils.ts
 | 
			
		||||
// https://github.com/natemoo-re/micromorph/blob/main/src/utils.ts#L5
 | 
			
		||||
const _rebaseHtmlElement = (el: Element, attr: string, newBase: string | URL) => {
 | 
			
		||||
  const rebased = new URL(el.getAttribute(attr)!, newBase)
 | 
			
		||||
  el.setAttribute(attr, rebased.pathname + rebased.hash)
 | 
			
		||||
}
 | 
			
		||||
export function normalizeRelativeURLs(el: Element | Document, destination: string | URL) {
 | 
			
		||||
  el.querySelectorAll('[href^="./"], [href^="../"]').forEach((item) =>
 | 
			
		||||
    _rebaseHtmlElement(item, "href", destination),
 | 
			
		||||
  )
 | 
			
		||||
  el.querySelectorAll('[src^="./"], [src^="../"]').forEach((item) =>
 | 
			
		||||
    _rebaseHtmlElement(item, "src", destination),
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const _rebaseHastElement = (
 | 
			
		||||
  el: HastElement,
 | 
			
		||||
  attr: string,
 | 
			
		||||
  curBase: FullSlug,
 | 
			
		||||
  newBase: FullSlug,
 | 
			
		||||
) => {
 | 
			
		||||
  if (el.properties?.[attr]) {
 | 
			
		||||
    if (!isRelativeURL(String(el.properties[attr]))) {
 | 
			
		||||
      return
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const rel = joinSegments(resolveRelative(curBase, newBase), "..", el.properties[attr] as string)
 | 
			
		||||
    el.properties[attr] = rel
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function normalizeHastElement(rawEl: HastElement, curBase: FullSlug, newBase: FullSlug) {
 | 
			
		||||
  const el = clone(rawEl) // clone so we dont modify the original page
 | 
			
		||||
  _rebaseHastElement(el, "src", curBase, newBase)
 | 
			
		||||
  _rebaseHastElement(el, "href", curBase, newBase)
 | 
			
		||||
  if (el.children) {
 | 
			
		||||
    el.children = el.children.map((child) =>
 | 
			
		||||
      normalizeHastElement(child as HastElement, curBase, newBase),
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return el
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// resolve /a/b/c to ../..
 | 
			
		||||
export function pathToRoot(slug: FullSlug): RelativeURL {
 | 
			
		||||
  let rootPath = slug
 | 
			
		||||
@@ -111,14 +165,10 @@ export function splitAnchor(link: string): [string, string] {
 | 
			
		||||
  return [fp, anchor]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function slugAnchor(anchor: string) {
 | 
			
		||||
  return slug(anchor)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function slugTag(tag: string) {
 | 
			
		||||
  return tag
 | 
			
		||||
    .split("/")
 | 
			
		||||
    .map((tagSegment) => slug(tagSegment))
 | 
			
		||||
    .map((tagSegment) => sluggify(tagSegment))
 | 
			
		||||
    .join("/")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -26,9 +26,12 @@ export function JSResourceToScriptElement(resource: JSResource, preserve?: boole
 | 
			
		||||
  } else {
 | 
			
		||||
    const content = resource.script
 | 
			
		||||
    return (
 | 
			
		||||
      <script key={randomUUID()} type={scriptType} spa-preserve={spaPreserve}>
 | 
			
		||||
        {content}
 | 
			
		||||
      </script>
 | 
			
		||||
      <script
 | 
			
		||||
        key={randomUUID()}
 | 
			
		||||
        type={scriptType}
 | 
			
		||||
        spa-preserve={spaPreserve}
 | 
			
		||||
        dangerouslySetInnerHTML={{ __html: content }}
 | 
			
		||||
      ></script>
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user