Merge commit '76f2664277e07a7d1b011fac236840c6e8e69fdd' into v4
This commit is contained in:
		@@ -1,550 +1,39 @@
 | 
			
		||||
#!/usr/bin/env node
 | 
			
		||||
import { promises, readFileSync } from "fs"
 | 
			
		||||
import yargs from "yargs"
 | 
			
		||||
import path from "path"
 | 
			
		||||
import { hideBin } from "yargs/helpers"
 | 
			
		||||
import esbuild from "esbuild"
 | 
			
		||||
import chalk from "chalk"
 | 
			
		||||
import { sassPlugin } from "esbuild-sass-plugin"
 | 
			
		||||
import fs from "fs"
 | 
			
		||||
import { intro, isCancel, outro, select, text } from "@clack/prompts"
 | 
			
		||||
import { rimraf } from "rimraf"
 | 
			
		||||
import chokidar from "chokidar"
 | 
			
		||||
import prettyBytes from "pretty-bytes"
 | 
			
		||||
import { execSync, spawnSync } from "child_process"
 | 
			
		||||
import http from "http"
 | 
			
		||||
import serveHandler from "serve-handler"
 | 
			
		||||
import { WebSocketServer } from "ws"
 | 
			
		||||
import { randomUUID } from "crypto"
 | 
			
		||||
import { Mutex } from "async-mutex"
 | 
			
		||||
 | 
			
		||||
const ORIGIN_NAME = "origin"
 | 
			
		||||
const UPSTREAM_NAME = "upstream"
 | 
			
		||||
const QUARTZ_SOURCE_BRANCH = "v4"
 | 
			
		||||
const cwd = process.cwd()
 | 
			
		||||
const cacheDir = path.join(cwd, ".quartz-cache")
 | 
			
		||||
const cacheFile = "./.quartz-cache/transpiled-build.mjs"
 | 
			
		||||
const fp = "./quartz/build.ts"
 | 
			
		||||
const { version } = JSON.parse(readFileSync("./package.json").toString())
 | 
			
		||||
const contentCacheFolder = path.join(cacheDir, "content-cache")
 | 
			
		||||
 | 
			
		||||
const CommonArgv = {
 | 
			
		||||
  directory: {
 | 
			
		||||
    string: true,
 | 
			
		||||
    alias: ["d"],
 | 
			
		||||
    default: "content",
 | 
			
		||||
    describe: "directory to look for content files",
 | 
			
		||||
  },
 | 
			
		||||
  verbose: {
 | 
			
		||||
    boolean: true,
 | 
			
		||||
    alias: ["v"],
 | 
			
		||||
    default: false,
 | 
			
		||||
    describe: "print out extra logging information",
 | 
			
		||||
  },
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const SyncArgv = {
 | 
			
		||||
  ...CommonArgv,
 | 
			
		||||
  commit: {
 | 
			
		||||
    boolean: true,
 | 
			
		||||
    default: true,
 | 
			
		||||
    describe: "create a git commit for your unsaved changes",
 | 
			
		||||
  },
 | 
			
		||||
  push: {
 | 
			
		||||
    boolean: true,
 | 
			
		||||
    default: true,
 | 
			
		||||
    describe: "push updates to your Quartz fork",
 | 
			
		||||
  },
 | 
			
		||||
  pull: {
 | 
			
		||||
    boolean: true,
 | 
			
		||||
    default: true,
 | 
			
		||||
    describe: "pull updates from your Quartz fork",
 | 
			
		||||
  },
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const BuildArgv = {
 | 
			
		||||
  ...CommonArgv,
 | 
			
		||||
  output: {
 | 
			
		||||
    string: true,
 | 
			
		||||
    alias: ["o"],
 | 
			
		||||
    default: "public",
 | 
			
		||||
    describe: "output folder for files",
 | 
			
		||||
  },
 | 
			
		||||
  serve: {
 | 
			
		||||
    boolean: true,
 | 
			
		||||
    default: false,
 | 
			
		||||
    describe: "run a local server to live-preview your Quartz",
 | 
			
		||||
  },
 | 
			
		||||
  baseDir: {
 | 
			
		||||
    string: true,
 | 
			
		||||
    default: "",
 | 
			
		||||
    describe: "base path to serve your local server on",
 | 
			
		||||
  },
 | 
			
		||||
  port: {
 | 
			
		||||
    number: true,
 | 
			
		||||
    default: 8080,
 | 
			
		||||
    describe: "port to serve Quartz on",
 | 
			
		||||
  },
 | 
			
		||||
  bundleInfo: {
 | 
			
		||||
    boolean: true,
 | 
			
		||||
    default: false,
 | 
			
		||||
    describe: "show detailed bundle information",
 | 
			
		||||
  },
 | 
			
		||||
  concurrency: {
 | 
			
		||||
    number: true,
 | 
			
		||||
    describe: "how many threads to use to parse notes",
 | 
			
		||||
  },
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function escapePath(fp) {
 | 
			
		||||
  return fp
 | 
			
		||||
    .replace(/\\ /g, " ") // unescape spaces
 | 
			
		||||
    .replace(/^".*"$/, "$1")
 | 
			
		||||
    .replace(/^'.*"$/, "$1")
 | 
			
		||||
    .trim()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function exitIfCancel(val) {
 | 
			
		||||
  if (isCancel(val)) {
 | 
			
		||||
    outro(chalk.red("Exiting"))
 | 
			
		||||
    process.exit(0)
 | 
			
		||||
  } else {
 | 
			
		||||
    return val
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function stashContentFolder(contentFolder) {
 | 
			
		||||
  await fs.promises.rm(contentCacheFolder, { force: true, recursive: true })
 | 
			
		||||
  await fs.promises.cp(contentFolder, contentCacheFolder, {
 | 
			
		||||
    force: true,
 | 
			
		||||
    recursive: true,
 | 
			
		||||
    verbatimSymlinks: true,
 | 
			
		||||
    preserveTimestamps: true,
 | 
			
		||||
  })
 | 
			
		||||
  await fs.promises.rm(contentFolder, { force: true, recursive: true })
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function popContentFolder(contentFolder) {
 | 
			
		||||
  await fs.promises.rm(contentFolder, { force: true, recursive: true })
 | 
			
		||||
  await fs.promises.cp(contentCacheFolder, contentFolder, {
 | 
			
		||||
    force: true,
 | 
			
		||||
    recursive: true,
 | 
			
		||||
    verbatimSymlinks: true,
 | 
			
		||||
    preserveTimestamps: true,
 | 
			
		||||
  })
 | 
			
		||||
  await fs.promises.rm(contentCacheFolder, { force: true, recursive: true })
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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}`)
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
import {
 | 
			
		||||
  handleBuild,
 | 
			
		||||
  handleCreate,
 | 
			
		||||
  handleUpdate,
 | 
			
		||||
  handleRestore,
 | 
			
		||||
  handleSync,
 | 
			
		||||
} from "./cli/handlers.js"
 | 
			
		||||
import { CommonArgv, BuildArgv, CreateArgv, SyncArgv } from "./cli/args.js"
 | 
			
		||||
import { version } from "./cli/constants.js"
 | 
			
		||||
 | 
			
		||||
yargs(hideBin(process.argv))
 | 
			
		||||
  .scriptName("quartz")
 | 
			
		||||
  .version(version)
 | 
			
		||||
  .usage("$0 <cmd> [args]")
 | 
			
		||||
  .command("create", "Initialize Quartz", CommonArgv, async (argv) => {
 | 
			
		||||
    console.log()
 | 
			
		||||
    intro(chalk.bgGreen.black(` Quartz v${version} `))
 | 
			
		||||
    const contentFolder = path.join(cwd, argv.directory)
 | 
			
		||||
    const setupStrategy = exitIfCancel(
 | 
			
		||||
      await select({
 | 
			
		||||
        message: `Choose how to initialize the content in \`${contentFolder}\``,
 | 
			
		||||
        options: [
 | 
			
		||||
          { value: "new", label: "Empty Quartz" },
 | 
			
		||||
          { value: "copy", label: "Copy an existing folder", hint: "overwrites `content`" },
 | 
			
		||||
          {
 | 
			
		||||
            value: "symlink",
 | 
			
		||||
            label: "Symlink an existing folder",
 | 
			
		||||
            hint: "don't select this unless you know what you are doing!",
 | 
			
		||||
          },
 | 
			
		||||
        ],
 | 
			
		||||
      }),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    async function rmContentFolder() {
 | 
			
		||||
      const contentStat = await fs.promises.lstat(contentFolder)
 | 
			
		||||
      if (contentStat.isSymbolicLink()) {
 | 
			
		||||
        await fs.promises.unlink(contentFolder)
 | 
			
		||||
      } else {
 | 
			
		||||
        await rimraf(contentFolder)
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    await fs.promises.unlink(path.join(contentFolder, ".gitkeep"))
 | 
			
		||||
    if (setupStrategy === "copy" || setupStrategy === "symlink") {
 | 
			
		||||
      const originalFolder = escapePath(
 | 
			
		||||
        exitIfCancel(
 | 
			
		||||
          await text({
 | 
			
		||||
            message: "Enter the full path to existing content folder",
 | 
			
		||||
            placeholder:
 | 
			
		||||
              "On most terminal emulators, you can drag and drop a folder into the window and it will paste the full path",
 | 
			
		||||
            validate(fp) {
 | 
			
		||||
              const fullPath = escapePath(fp)
 | 
			
		||||
              if (!fs.existsSync(fullPath)) {
 | 
			
		||||
                return "The given path doesn't exist"
 | 
			
		||||
              } else if (!fs.lstatSync(fullPath).isDirectory()) {
 | 
			
		||||
                return "The given path is not a folder"
 | 
			
		||||
              }
 | 
			
		||||
            },
 | 
			
		||||
          }),
 | 
			
		||||
        ),
 | 
			
		||||
      )
 | 
			
		||||
 | 
			
		||||
      await rmContentFolder()
 | 
			
		||||
      if (setupStrategy === "copy") {
 | 
			
		||||
        await fs.promises.cp(originalFolder, contentFolder, {
 | 
			
		||||
          recursive: true,
 | 
			
		||||
          preserveTimestamps: true,
 | 
			
		||||
        })
 | 
			
		||||
      } else if (setupStrategy === "symlink") {
 | 
			
		||||
        await fs.promises.symlink(originalFolder, contentFolder, "dir")
 | 
			
		||||
      }
 | 
			
		||||
    } else if (setupStrategy === "new") {
 | 
			
		||||
      await fs.promises.writeFile(
 | 
			
		||||
        path.join(contentFolder, "index.md"),
 | 
			
		||||
        `---
 | 
			
		||||
title: Welcome to Quartz
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
This is a blank Quartz installation.
 | 
			
		||||
See the [documentation](https://quartz.jzhao.xyz) for how to get started.
 | 
			
		||||
`,
 | 
			
		||||
      )
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // get a preferred link resolution strategy
 | 
			
		||||
    const linkResolutionStrategy = exitIfCancel(
 | 
			
		||||
      await select({
 | 
			
		||||
        message: `Choose how Quartz should resolve links in your content. You can change this later in \`quartz.config.ts\`.`,
 | 
			
		||||
        options: [
 | 
			
		||||
          {
 | 
			
		||||
            value: "absolute",
 | 
			
		||||
            label: "Treat links as absolute path",
 | 
			
		||||
            hint: "for content made for Quartz 3 and Hugo",
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            value: "shortest",
 | 
			
		||||
            label: "Treat links as shortest path",
 | 
			
		||||
            hint: "for most Obsidian vaults",
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            value: "relative",
 | 
			
		||||
            label: "Treat links as relative paths",
 | 
			
		||||
            hint: "for just normal Markdown files",
 | 
			
		||||
          },
 | 
			
		||||
        ],
 | 
			
		||||
      }),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    // now, do config changes
 | 
			
		||||
    const configFilePath = path.join(cwd, "quartz.config.ts")
 | 
			
		||||
    let configContent = await fs.promises.readFile(configFilePath, { encoding: "utf-8" })
 | 
			
		||||
    configContent = configContent.replace(
 | 
			
		||||
      /markdownLinkResolution: '(.+)'/,
 | 
			
		||||
      `markdownLinkResolution: '${linkResolutionStrategy}'`,
 | 
			
		||||
    )
 | 
			
		||||
    await fs.promises.writeFile(configFilePath, configContent)
 | 
			
		||||
 | 
			
		||||
    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
 | 
			
		||||
   • Hosting your Quartz online (see: https://quartz.jzhao.xyz/hosting)
 | 
			
		||||
`)
 | 
			
		||||
  .command("create", "Initialize Quartz", CreateArgv, async (argv) => {
 | 
			
		||||
    await handleCreate(argv)
 | 
			
		||||
  })
 | 
			
		||||
  .command("update", "Get the latest Quartz updates", CommonArgv, async (argv) => {
 | 
			
		||||
    const contentFolder = path.join(cwd, argv.directory)
 | 
			
		||||
    console.log(chalk.bgGreen.black(`\n Quartz v${version} \n`))
 | 
			
		||||
    console.log("Backing up your content")
 | 
			
		||||
    execSync(
 | 
			
		||||
      `git remote show upstream || git remote add upstream https://github.com/jackyzha0/quartz.git`,
 | 
			
		||||
    )
 | 
			
		||||
    await stashContentFolder(contentFolder)
 | 
			
		||||
    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)
 | 
			
		||||
    await popContentFolder(contentFolder)
 | 
			
		||||
    console.log("Ensuring dependencies are up to date")
 | 
			
		||||
    spawnSync("npm", ["i"], { stdio: "inherit" })
 | 
			
		||||
    console.log(chalk.green("Done!"))
 | 
			
		||||
    await handleUpdate(argv)
 | 
			
		||||
  })
 | 
			
		||||
  .command(
 | 
			
		||||
    "restore",
 | 
			
		||||
    "Try to restore your content folder from the cache",
 | 
			
		||||
    CommonArgv,
 | 
			
		||||
    async (argv) => {
 | 
			
		||||
      const contentFolder = path.join(cwd, argv.directory)
 | 
			
		||||
      await popContentFolder(contentFolder)
 | 
			
		||||
      await handleRestore(argv)
 | 
			
		||||
    },
 | 
			
		||||
  )
 | 
			
		||||
  .command("sync", "Sync your Quartz to and from GitHub.", SyncArgv, async (argv) => {
 | 
			
		||||
    const contentFolder = path.join(cwd, argv.directory)
 | 
			
		||||
    console.log(chalk.bgGreen.black(`\n Quartz v${version} \n`))
 | 
			
		||||
    console.log("Backing up your content")
 | 
			
		||||
 | 
			
		||||
    if (argv.commit) {
 | 
			
		||||
      const contentStat = await fs.promises.lstat(contentFolder)
 | 
			
		||||
      if (contentStat.isSymbolicLink()) {
 | 
			
		||||
        const linkTarg = await fs.promises.readlink(contentFolder)
 | 
			
		||||
        console.log(chalk.yellow("Detected symlink, trying to dereference before committing"))
 | 
			
		||||
 | 
			
		||||
        // stash symlink file
 | 
			
		||||
        await stashContentFolder(contentFolder)
 | 
			
		||||
 | 
			
		||||
        // follow symlink and copy content
 | 
			
		||||
        await fs.promises.cp(linkTarg, contentFolder, {
 | 
			
		||||
          recursive: true,
 | 
			
		||||
          preserveTimestamps: true,
 | 
			
		||||
        })
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const currentTimestamp = new Date().toLocaleString("en-US", {
 | 
			
		||||
        dateStyle: "medium",
 | 
			
		||||
        timeStyle: "short",
 | 
			
		||||
      })
 | 
			
		||||
      spawnSync("git", ["add", "."], { stdio: "inherit" })
 | 
			
		||||
      spawnSync("git", ["commit", "-m", `Quartz sync: ${currentTimestamp}`], { stdio: "inherit" })
 | 
			
		||||
 | 
			
		||||
      if (contentStat.isSymbolicLink()) {
 | 
			
		||||
        // put symlink back
 | 
			
		||||
        await popContentFolder(contentFolder)
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    await stashContentFolder(contentFolder)
 | 
			
		||||
 | 
			
		||||
    if (argv.pull) {
 | 
			
		||||
      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)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    await popContentFolder(contentFolder)
 | 
			
		||||
    if (argv.push) {
 | 
			
		||||
      console.log("Pushing your changes")
 | 
			
		||||
      spawnSync("git", ["push", "-f", ORIGIN_NAME, QUARTZ_SOURCE_BRANCH], { stdio: "inherit" })
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    console.log(chalk.green("Done!"))
 | 
			
		||||
    await handleSync(argv)
 | 
			
		||||
  })
 | 
			
		||||
  .command("build", "Build Quartz into a bundle of static HTML files", BuildArgv, async (argv) => {
 | 
			
		||||
    console.log(chalk.bgGreen.black(`\n Quartz v${version} \n`))
 | 
			
		||||
    const ctx = await esbuild.context({
 | 
			
		||||
      entryPoints: [fp],
 | 
			
		||||
      outfile: path.join("quartz", cacheFile),
 | 
			
		||||
      bundle: true,
 | 
			
		||||
      keepNames: true,
 | 
			
		||||
      minifyWhitespace: true,
 | 
			
		||||
      minifySyntax: true,
 | 
			
		||||
      platform: "node",
 | 
			
		||||
      format: "esm",
 | 
			
		||||
      jsx: "automatic",
 | 
			
		||||
      jsxImportSource: "preact",
 | 
			
		||||
      packages: "external",
 | 
			
		||||
      metafile: true,
 | 
			
		||||
      sourcemap: true,
 | 
			
		||||
      sourcesContent: false,
 | 
			
		||||
      plugins: [
 | 
			
		||||
        sassPlugin({
 | 
			
		||||
          type: "css-text",
 | 
			
		||||
          cssImports: true,
 | 
			
		||||
        }),
 | 
			
		||||
        {
 | 
			
		||||
          name: "inline-script-loader",
 | 
			
		||||
          setup(build) {
 | 
			
		||||
            build.onLoad({ filter: /\.inline\.(ts|js)$/ }, async (args) => {
 | 
			
		||||
              let text = await promises.readFile(args.path, "utf8")
 | 
			
		||||
 | 
			
		||||
              // remove default exports that we manually inserted
 | 
			
		||||
              text = text.replace("export default", "")
 | 
			
		||||
              text = text.replace("export", "")
 | 
			
		||||
 | 
			
		||||
              const sourcefile = path.relative(path.resolve("."), args.path)
 | 
			
		||||
              const resolveDir = path.dirname(sourcefile)
 | 
			
		||||
              const transpiled = await esbuild.build({
 | 
			
		||||
                stdin: {
 | 
			
		||||
                  contents: text,
 | 
			
		||||
                  loader: "ts",
 | 
			
		||||
                  resolveDir,
 | 
			
		||||
                  sourcefile,
 | 
			
		||||
                },
 | 
			
		||||
                write: false,
 | 
			
		||||
                bundle: true,
 | 
			
		||||
                platform: "browser",
 | 
			
		||||
                format: "esm",
 | 
			
		||||
              })
 | 
			
		||||
              const rawMod = transpiled.outputFiles[0].text
 | 
			
		||||
              return {
 | 
			
		||||
                contents: rawMod,
 | 
			
		||||
                loader: "text",
 | 
			
		||||
              }
 | 
			
		||||
            })
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
      ],
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    const buildMutex = new Mutex()
 | 
			
		||||
    let lastBuildMs = 0
 | 
			
		||||
    let cleanupBuild = null
 | 
			
		||||
    const build = async (clientRefresh) => {
 | 
			
		||||
      const buildStart = new Date().getTime()
 | 
			
		||||
      lastBuildMs = buildStart
 | 
			
		||||
      const release = await buildMutex.acquire()
 | 
			
		||||
      if (lastBuildMs > buildStart) {
 | 
			
		||||
        release()
 | 
			
		||||
        return
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (cleanupBuild) {
 | 
			
		||||
        await cleanupBuild()
 | 
			
		||||
        console.log(chalk.yellow("Detected a source code change, doing a hard rebuild..."))
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const result = await ctx.rebuild().catch((err) => {
 | 
			
		||||
        console.error(`${chalk.red("Couldn't parse Quartz configuration:")} ${fp}`)
 | 
			
		||||
        console.log(`Reason: ${chalk.grey(err)}`)
 | 
			
		||||
        process.exit(1)
 | 
			
		||||
      })
 | 
			
		||||
      release()
 | 
			
		||||
 | 
			
		||||
      if (argv.bundleInfo) {
 | 
			
		||||
        const outputFileName = "quartz/.quartz-cache/transpiled-build.mjs"
 | 
			
		||||
        const meta = result.metafile.outputs[outputFileName]
 | 
			
		||||
        console.log(
 | 
			
		||||
          `Successfully transpiled ${Object.keys(meta.inputs).length} files (${prettyBytes(
 | 
			
		||||
            meta.bytes,
 | 
			
		||||
          )})`,
 | 
			
		||||
        )
 | 
			
		||||
        console.log(await esbuild.analyzeMetafile(result.metafile, { color: true }))
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // bypass module cache
 | 
			
		||||
      // https://github.com/nodejs/modules/issues/307
 | 
			
		||||
      const { default: buildQuartz } = await import(cacheFile + `?update=${randomUUID()}`)
 | 
			
		||||
      cleanupBuild = await buildQuartz(argv, buildMutex, clientRefresh)
 | 
			
		||||
      clientRefresh()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (argv.serve) {
 | 
			
		||||
      const connections = []
 | 
			
		||||
      const clientRefresh = () => connections.forEach((conn) => conn.send("rebuild"))
 | 
			
		||||
 | 
			
		||||
      if (argv.baseDir !== "" && !argv.baseDir.startsWith("/")) {
 | 
			
		||||
        argv.baseDir = "/" + argv.baseDir
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      await build(clientRefresh)
 | 
			
		||||
      const server = http.createServer(async (req, res) => {
 | 
			
		||||
        if (argv.baseDir && !req.url?.startsWith(argv.baseDir)) {
 | 
			
		||||
          console.log(
 | 
			
		||||
            chalk.red(
 | 
			
		||||
              `[404] ${req.url} (warning: link outside of site, this is likely a Quartz bug)`,
 | 
			
		||||
            ),
 | 
			
		||||
          )
 | 
			
		||||
          res.writeHead(404)
 | 
			
		||||
          res.end()
 | 
			
		||||
          return
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // strip baseDir prefix
 | 
			
		||||
        req.url = req.url?.slice(argv.baseDir.length)
 | 
			
		||||
 | 
			
		||||
        const serve = async () => {
 | 
			
		||||
          const release = await buildMutex.acquire()
 | 
			
		||||
          await serveHandler(req, res, {
 | 
			
		||||
            public: argv.output,
 | 
			
		||||
            directoryListing: false,
 | 
			
		||||
            headers: [
 | 
			
		||||
              {
 | 
			
		||||
                source: "**/*.html",
 | 
			
		||||
                headers: [{ key: "Content-Disposition", value: "inline" }],
 | 
			
		||||
              },
 | 
			
		||||
            ],
 | 
			
		||||
          })
 | 
			
		||||
          const status = res.statusCode
 | 
			
		||||
          const statusString =
 | 
			
		||||
            status >= 200 && status < 300 ? chalk.green(`[${status}]`) : chalk.red(`[${status}]`)
 | 
			
		||||
          console.log(statusString + chalk.grey(` ${argv.baseDir}${req.url}`))
 | 
			
		||||
          release()
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const redirect = (newFp) => {
 | 
			
		||||
          newFp = argv.baseDir + newFp
 | 
			
		||||
          res.writeHead(302, {
 | 
			
		||||
            Location: newFp,
 | 
			
		||||
          })
 | 
			
		||||
          console.log(chalk.yellow("[302]") + chalk.grey(` ${argv.baseDir}${req.url} -> ${newFp}`))
 | 
			
		||||
          res.end()
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        let fp = req.url?.split("?")[0] ?? "/"
 | 
			
		||||
 | 
			
		||||
        // handle redirects
 | 
			
		||||
        if (fp.endsWith("/")) {
 | 
			
		||||
          // /trailing/
 | 
			
		||||
          // does /trailing/index.html exist? if so, serve it
 | 
			
		||||
          const indexFp = path.posix.join(fp, "index.html")
 | 
			
		||||
          if (fs.existsSync(path.posix.join(argv.output, indexFp))) {
 | 
			
		||||
            req.url = fp
 | 
			
		||||
            return serve()
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          // does /trailing.html exist? if so, redirect to /trailing
 | 
			
		||||
          let base = fp.slice(0, -1)
 | 
			
		||||
          if (path.extname(base) === "") {
 | 
			
		||||
            base += ".html"
 | 
			
		||||
          }
 | 
			
		||||
          if (fs.existsSync(path.posix.join(argv.output, base))) {
 | 
			
		||||
            return redirect(fp.slice(0, -1))
 | 
			
		||||
          }
 | 
			
		||||
        } else {
 | 
			
		||||
          // /regular
 | 
			
		||||
          // does /regular.html exist? if so, serve it
 | 
			
		||||
          let base = fp
 | 
			
		||||
          if (path.extname(base) === "") {
 | 
			
		||||
            base += ".html"
 | 
			
		||||
          }
 | 
			
		||||
          if (fs.existsSync(path.posix.join(argv.output, base))) {
 | 
			
		||||
            req.url = fp
 | 
			
		||||
            return serve()
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          // does /regular/index.html exist? if so, redirect to /regular/
 | 
			
		||||
          let indexFp = path.posix.join(fp, "index.html")
 | 
			
		||||
          if (fs.existsSync(path.posix.join(argv.output, indexFp))) {
 | 
			
		||||
            return redirect(fp + "/")
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return serve()
 | 
			
		||||
      })
 | 
			
		||||
      server.listen(argv.port)
 | 
			
		||||
      const wss = new WebSocketServer({ port: 3001 })
 | 
			
		||||
      wss.on("connection", (ws) => connections.push(ws))
 | 
			
		||||
      console.log(
 | 
			
		||||
        chalk.cyan(
 | 
			
		||||
          `Started a Quartz server listening at http://localhost:${argv.port}${argv.baseDir}`,
 | 
			
		||||
        ),
 | 
			
		||||
      )
 | 
			
		||||
      console.log("hint: exit with ctrl+c")
 | 
			
		||||
      chokidar
 | 
			
		||||
        .watch(["**/*.ts", "**/*.tsx", "**/*.scss", "package.json"], {
 | 
			
		||||
          ignoreInitial: true,
 | 
			
		||||
        })
 | 
			
		||||
        .on("all", async () => {
 | 
			
		||||
          build(clientRefresh)
 | 
			
		||||
        })
 | 
			
		||||
    } else {
 | 
			
		||||
      await build(() => {})
 | 
			
		||||
      ctx.dispose()
 | 
			
		||||
    }
 | 
			
		||||
    await handleBuild(argv)
 | 
			
		||||
  })
 | 
			
		||||
  .showHelpOnFail(false)
 | 
			
		||||
  .help()
 | 
			
		||||
 
 | 
			
		||||
@@ -12,6 +12,10 @@ export type Analytics =
 | 
			
		||||
      provider: "google"
 | 
			
		||||
      tagId: string
 | 
			
		||||
    }
 | 
			
		||||
  | {
 | 
			
		||||
      provider: "umami"
 | 
			
		||||
      websiteId: string
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
export interface GlobalConfiguration {
 | 
			
		||||
  pageTitle: string
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										103
									
								
								quartz/cli/args.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										103
									
								
								quartz/cli/args.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,103 @@
 | 
			
		||||
export const CommonArgv = {
 | 
			
		||||
  directory: {
 | 
			
		||||
    string: true,
 | 
			
		||||
    alias: ["d"],
 | 
			
		||||
    default: "content",
 | 
			
		||||
    describe: "directory to look for content files",
 | 
			
		||||
  },
 | 
			
		||||
  verbose: {
 | 
			
		||||
    boolean: true,
 | 
			
		||||
    alias: ["v"],
 | 
			
		||||
    default: false,
 | 
			
		||||
    describe: "print out extra logging information",
 | 
			
		||||
  },
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const CreateArgv = {
 | 
			
		||||
  ...CommonArgv,
 | 
			
		||||
  source: {
 | 
			
		||||
    string: true,
 | 
			
		||||
    alias: ["s"],
 | 
			
		||||
    describe: "source directory to copy/create symlink from",
 | 
			
		||||
  },
 | 
			
		||||
  strategy: {
 | 
			
		||||
    string: true,
 | 
			
		||||
    alias: ["X"],
 | 
			
		||||
    choices: ["new", "copy", "symlink"],
 | 
			
		||||
    describe: "strategy for content folder setup",
 | 
			
		||||
  },
 | 
			
		||||
  links: {
 | 
			
		||||
    string: true,
 | 
			
		||||
    alias: ["l"],
 | 
			
		||||
    choices: ["absolute", "shortest", "relative"],
 | 
			
		||||
    describe: "strategy to resolve links",
 | 
			
		||||
  },
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const SyncArgv = {
 | 
			
		||||
  ...CommonArgv,
 | 
			
		||||
  commit: {
 | 
			
		||||
    boolean: true,
 | 
			
		||||
    default: true,
 | 
			
		||||
    describe: "create a git commit for your unsaved changes",
 | 
			
		||||
  },
 | 
			
		||||
  message: {
 | 
			
		||||
    string: true,
 | 
			
		||||
    alias: ["m"],
 | 
			
		||||
    describe: "option to override the default Quartz commit message",
 | 
			
		||||
  },
 | 
			
		||||
  push: {
 | 
			
		||||
    boolean: true,
 | 
			
		||||
    default: true,
 | 
			
		||||
    describe: "push updates to your Quartz fork",
 | 
			
		||||
  },
 | 
			
		||||
  pull: {
 | 
			
		||||
    boolean: true,
 | 
			
		||||
    default: true,
 | 
			
		||||
    describe: "pull updates from your Quartz fork",
 | 
			
		||||
  },
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const BuildArgv = {
 | 
			
		||||
  ...CommonArgv,
 | 
			
		||||
  output: {
 | 
			
		||||
    string: true,
 | 
			
		||||
    alias: ["o"],
 | 
			
		||||
    default: "public",
 | 
			
		||||
    describe: "output folder for files",
 | 
			
		||||
  },
 | 
			
		||||
  serve: {
 | 
			
		||||
    boolean: true,
 | 
			
		||||
    default: false,
 | 
			
		||||
    describe: "run a local server to live-preview your Quartz",
 | 
			
		||||
  },
 | 
			
		||||
  baseDir: {
 | 
			
		||||
    string: true,
 | 
			
		||||
    default: "",
 | 
			
		||||
    describe: "base path to serve your local server on",
 | 
			
		||||
  },
 | 
			
		||||
  port: {
 | 
			
		||||
    number: true,
 | 
			
		||||
    default: 8080,
 | 
			
		||||
    describe: "port to serve Quartz on",
 | 
			
		||||
  },
 | 
			
		||||
  wsPort: {
 | 
			
		||||
    number: true,
 | 
			
		||||
    default: 3001,
 | 
			
		||||
    describe: "port to use for WebSocket-based hot-reload notifications",
 | 
			
		||||
  },
 | 
			
		||||
  remoteDevHost: {
 | 
			
		||||
    string: true,
 | 
			
		||||
    default: "",
 | 
			
		||||
    describe: "A URL override for the websocket connection if you are not developing on localhost",
 | 
			
		||||
  },
 | 
			
		||||
  bundleInfo: {
 | 
			
		||||
    boolean: true,
 | 
			
		||||
    default: false,
 | 
			
		||||
    describe: "show detailed bundle information",
 | 
			
		||||
  },
 | 
			
		||||
  concurrency: {
 | 
			
		||||
    number: true,
 | 
			
		||||
    describe: "how many threads to use to parse notes",
 | 
			
		||||
  },
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										15
									
								
								quartz/cli/constants.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								quartz/cli/constants.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,15 @@
 | 
			
		||||
import path from "path"
 | 
			
		||||
import { readFileSync } from "fs"
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * All constants relating to helpers or handlers
 | 
			
		||||
 */
 | 
			
		||||
export const ORIGIN_NAME = "origin"
 | 
			
		||||
export const UPSTREAM_NAME = "upstream"
 | 
			
		||||
export const QUARTZ_SOURCE_BRANCH = "v4"
 | 
			
		||||
export const cwd = process.cwd()
 | 
			
		||||
export const cacheDir = path.join(cwd, ".quartz-cache")
 | 
			
		||||
export const cacheFile = "./quartz/.quartz-cache/transpiled-build.mjs"
 | 
			
		||||
export const fp = "./quartz/build.ts"
 | 
			
		||||
export const { version } = JSON.parse(readFileSync("./package.json").toString())
 | 
			
		||||
export const contentCacheFolder = path.join(cacheDir, "content-cache")
 | 
			
		||||
							
								
								
									
										512
									
								
								quartz/cli/handlers.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										512
									
								
								quartz/cli/handlers.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,512 @@
 | 
			
		||||
import { promises } from "fs"
 | 
			
		||||
import path from "path"
 | 
			
		||||
import esbuild from "esbuild"
 | 
			
		||||
import chalk from "chalk"
 | 
			
		||||
import { sassPlugin } from "esbuild-sass-plugin"
 | 
			
		||||
import fs from "fs"
 | 
			
		||||
import { intro, outro, select, text } from "@clack/prompts"
 | 
			
		||||
import { rimraf } from "rimraf"
 | 
			
		||||
import chokidar from "chokidar"
 | 
			
		||||
import prettyBytes from "pretty-bytes"
 | 
			
		||||
import { execSync, spawnSync } from "child_process"
 | 
			
		||||
import http from "http"
 | 
			
		||||
import serveHandler from "serve-handler"
 | 
			
		||||
import { WebSocketServer } from "ws"
 | 
			
		||||
import { randomUUID } from "crypto"
 | 
			
		||||
import { Mutex } from "async-mutex"
 | 
			
		||||
import { CreateArgv } from "./args.js"
 | 
			
		||||
import {
 | 
			
		||||
  exitIfCancel,
 | 
			
		||||
  escapePath,
 | 
			
		||||
  gitPull,
 | 
			
		||||
  popContentFolder,
 | 
			
		||||
  stashContentFolder,
 | 
			
		||||
} from "./helpers.js"
 | 
			
		||||
import {
 | 
			
		||||
  UPSTREAM_NAME,
 | 
			
		||||
  QUARTZ_SOURCE_BRANCH,
 | 
			
		||||
  ORIGIN_NAME,
 | 
			
		||||
  version,
 | 
			
		||||
  fp,
 | 
			
		||||
  cacheFile,
 | 
			
		||||
  cwd,
 | 
			
		||||
} from "./constants.js"
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Handles `npx quartz create`
 | 
			
		||||
 * @param {*} argv arguments for `create`
 | 
			
		||||
 */
 | 
			
		||||
export async function handleCreate(argv) {
 | 
			
		||||
  console.log()
 | 
			
		||||
  intro(chalk.bgGreen.black(` Quartz v${version} `))
 | 
			
		||||
  const contentFolder = path.join(cwd, argv.directory)
 | 
			
		||||
  let setupStrategy = argv.strategy?.toLowerCase()
 | 
			
		||||
  let linkResolutionStrategy = argv.links?.toLowerCase()
 | 
			
		||||
  const sourceDirectory = argv.source
 | 
			
		||||
 | 
			
		||||
  // If all cmd arguments were provided, check if theyre valid
 | 
			
		||||
  if (setupStrategy && linkResolutionStrategy) {
 | 
			
		||||
    // If setup isn't, "new", source argument is required
 | 
			
		||||
    if (setupStrategy !== "new") {
 | 
			
		||||
      // Error handling
 | 
			
		||||
      if (!sourceDirectory) {
 | 
			
		||||
        outro(
 | 
			
		||||
          chalk.red(
 | 
			
		||||
            `Setup strategies (arg '${chalk.yellow(
 | 
			
		||||
              `-${CreateArgv.strategy.alias[0]}`,
 | 
			
		||||
            )}') other than '${chalk.yellow(
 | 
			
		||||
              "new",
 | 
			
		||||
            )}' require content folder argument ('${chalk.yellow(
 | 
			
		||||
              `-${CreateArgv.source.alias[0]}`,
 | 
			
		||||
            )}') to be set`,
 | 
			
		||||
          ),
 | 
			
		||||
        )
 | 
			
		||||
        process.exit(1)
 | 
			
		||||
      } else {
 | 
			
		||||
        if (!fs.existsSync(sourceDirectory)) {
 | 
			
		||||
          outro(
 | 
			
		||||
            chalk.red(
 | 
			
		||||
              `Input directory to copy/symlink 'content' from not found ('${chalk.yellow(
 | 
			
		||||
                sourceDirectory,
 | 
			
		||||
              )}', invalid argument "${chalk.yellow(`-${CreateArgv.source.alias[0]}`)})`,
 | 
			
		||||
            ),
 | 
			
		||||
          )
 | 
			
		||||
          process.exit(1)
 | 
			
		||||
        } else if (!fs.lstatSync(sourceDirectory).isDirectory()) {
 | 
			
		||||
          outro(
 | 
			
		||||
            chalk.red(
 | 
			
		||||
              `Source directory to copy/symlink 'content' from is not a directory (found file at '${chalk.yellow(
 | 
			
		||||
                sourceDirectory,
 | 
			
		||||
              )}', invalid argument ${chalk.yellow(`-${CreateArgv.source.alias[0]}`)}")`,
 | 
			
		||||
            ),
 | 
			
		||||
          )
 | 
			
		||||
          process.exit(1)
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Use cli process if cmd args werent provided
 | 
			
		||||
  if (!setupStrategy) {
 | 
			
		||||
    setupStrategy = exitIfCancel(
 | 
			
		||||
      await select({
 | 
			
		||||
        message: `Choose how to initialize the content in \`${contentFolder}\``,
 | 
			
		||||
        options: [
 | 
			
		||||
          { value: "new", label: "Empty Quartz" },
 | 
			
		||||
          { value: "copy", label: "Copy an existing folder", hint: "overwrites `content`" },
 | 
			
		||||
          {
 | 
			
		||||
            value: "symlink",
 | 
			
		||||
            label: "Symlink an existing folder",
 | 
			
		||||
            hint: "don't select this unless you know what you are doing!",
 | 
			
		||||
          },
 | 
			
		||||
        ],
 | 
			
		||||
      }),
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async function rmContentFolder() {
 | 
			
		||||
    const contentStat = await fs.promises.lstat(contentFolder)
 | 
			
		||||
    if (contentStat.isSymbolicLink()) {
 | 
			
		||||
      await fs.promises.unlink(contentFolder)
 | 
			
		||||
    } else {
 | 
			
		||||
      await rimraf(contentFolder)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  await fs.promises.unlink(path.join(contentFolder, ".gitkeep"))
 | 
			
		||||
  if (setupStrategy === "copy" || setupStrategy === "symlink") {
 | 
			
		||||
    let originalFolder = sourceDirectory
 | 
			
		||||
 | 
			
		||||
    // If input directory was not passed, use cli
 | 
			
		||||
    if (!sourceDirectory) {
 | 
			
		||||
      originalFolder = escapePath(
 | 
			
		||||
        exitIfCancel(
 | 
			
		||||
          await text({
 | 
			
		||||
            message: "Enter the full path to existing content folder",
 | 
			
		||||
            placeholder:
 | 
			
		||||
              "On most terminal emulators, you can drag and drop a folder into the window and it will paste the full path",
 | 
			
		||||
            validate(fp) {
 | 
			
		||||
              const fullPath = escapePath(fp)
 | 
			
		||||
              if (!fs.existsSync(fullPath)) {
 | 
			
		||||
                return "The given path doesn't exist"
 | 
			
		||||
              } else if (!fs.lstatSync(fullPath).isDirectory()) {
 | 
			
		||||
                return "The given path is not a folder"
 | 
			
		||||
              }
 | 
			
		||||
            },
 | 
			
		||||
          }),
 | 
			
		||||
        ),
 | 
			
		||||
      )
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    await rmContentFolder()
 | 
			
		||||
    if (setupStrategy === "copy") {
 | 
			
		||||
      await fs.promises.cp(originalFolder, contentFolder, {
 | 
			
		||||
        recursive: true,
 | 
			
		||||
        preserveTimestamps: true,
 | 
			
		||||
      })
 | 
			
		||||
    } else if (setupStrategy === "symlink") {
 | 
			
		||||
      await fs.promises.symlink(originalFolder, contentFolder, "dir")
 | 
			
		||||
    }
 | 
			
		||||
  } else if (setupStrategy === "new") {
 | 
			
		||||
    await fs.promises.writeFile(
 | 
			
		||||
      path.join(contentFolder, "index.md"),
 | 
			
		||||
      `---
 | 
			
		||||
title: Welcome to Quartz
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
This is a blank Quartz installation.
 | 
			
		||||
See the [documentation](https://quartz.jzhao.xyz) for how to get started.
 | 
			
		||||
`,
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Use cli process if cmd args werent provided
 | 
			
		||||
  if (!linkResolutionStrategy) {
 | 
			
		||||
    // get a preferred link resolution strategy
 | 
			
		||||
    linkResolutionStrategy = exitIfCancel(
 | 
			
		||||
      await select({
 | 
			
		||||
        message: `Choose how Quartz should resolve links in your content. You can change this later in \`quartz.config.ts\`.`,
 | 
			
		||||
        options: [
 | 
			
		||||
          {
 | 
			
		||||
            value: "absolute",
 | 
			
		||||
            label: "Treat links as absolute path",
 | 
			
		||||
            hint: "for content made for Quartz 3 and Hugo",
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            value: "shortest",
 | 
			
		||||
            label: "Treat links as shortest path",
 | 
			
		||||
            hint: "for most Obsidian vaults",
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            value: "relative",
 | 
			
		||||
            label: "Treat links as relative paths",
 | 
			
		||||
            hint: "for just normal Markdown files",
 | 
			
		||||
          },
 | 
			
		||||
        ],
 | 
			
		||||
      }),
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // now, do config changes
 | 
			
		||||
  const configFilePath = path.join(cwd, "quartz.config.ts")
 | 
			
		||||
  let configContent = await fs.promises.readFile(configFilePath, { encoding: "utf-8" })
 | 
			
		||||
  configContent = configContent.replace(
 | 
			
		||||
    /markdownLinkResolution: '(.+)'/,
 | 
			
		||||
    `markdownLinkResolution: '${linkResolutionStrategy}'`,
 | 
			
		||||
  )
 | 
			
		||||
  await fs.promises.writeFile(configFilePath, configContent)
 | 
			
		||||
 | 
			
		||||
  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
 | 
			
		||||
  • Hosting your Quartz online (see: https://quartz.jzhao.xyz/hosting)
 | 
			
		||||
`)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Handles `npx quartz build`
 | 
			
		||||
 * @param {*} argv arguments for `build`
 | 
			
		||||
 */
 | 
			
		||||
export async function handleBuild(argv) {
 | 
			
		||||
  console.log(chalk.bgGreen.black(`\n Quartz v${version} \n`))
 | 
			
		||||
  const ctx = await esbuild.context({
 | 
			
		||||
    entryPoints: [fp],
 | 
			
		||||
    outfile: cacheFile,
 | 
			
		||||
    bundle: true,
 | 
			
		||||
    keepNames: true,
 | 
			
		||||
    minifyWhitespace: true,
 | 
			
		||||
    minifySyntax: true,
 | 
			
		||||
    platform: "node",
 | 
			
		||||
    format: "esm",
 | 
			
		||||
    jsx: "automatic",
 | 
			
		||||
    jsxImportSource: "preact",
 | 
			
		||||
    packages: "external",
 | 
			
		||||
    metafile: true,
 | 
			
		||||
    sourcemap: true,
 | 
			
		||||
    sourcesContent: false,
 | 
			
		||||
    plugins: [
 | 
			
		||||
      sassPlugin({
 | 
			
		||||
        type: "css-text",
 | 
			
		||||
        cssImports: true,
 | 
			
		||||
      }),
 | 
			
		||||
      {
 | 
			
		||||
        name: "inline-script-loader",
 | 
			
		||||
        setup(build) {
 | 
			
		||||
          build.onLoad({ filter: /\.inline\.(ts|js)$/ }, async (args) => {
 | 
			
		||||
            let text = await promises.readFile(args.path, "utf8")
 | 
			
		||||
 | 
			
		||||
            // remove default exports that we manually inserted
 | 
			
		||||
            text = text.replace("export default", "")
 | 
			
		||||
            text = text.replace("export", "")
 | 
			
		||||
 | 
			
		||||
            const sourcefile = path.relative(path.resolve("."), args.path)
 | 
			
		||||
            const resolveDir = path.dirname(sourcefile)
 | 
			
		||||
            const transpiled = await esbuild.build({
 | 
			
		||||
              stdin: {
 | 
			
		||||
                contents: text,
 | 
			
		||||
                loader: "ts",
 | 
			
		||||
                resolveDir,
 | 
			
		||||
                sourcefile,
 | 
			
		||||
              },
 | 
			
		||||
              write: false,
 | 
			
		||||
              bundle: true,
 | 
			
		||||
              platform: "browser",
 | 
			
		||||
              format: "esm",
 | 
			
		||||
            })
 | 
			
		||||
            const rawMod = transpiled.outputFiles[0].text
 | 
			
		||||
            return {
 | 
			
		||||
              contents: rawMod,
 | 
			
		||||
              loader: "text",
 | 
			
		||||
            }
 | 
			
		||||
          })
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
    ],
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  const buildMutex = new Mutex()
 | 
			
		||||
  let lastBuildMs = 0
 | 
			
		||||
  let cleanupBuild = null
 | 
			
		||||
  const build = async (clientRefresh) => {
 | 
			
		||||
    const buildStart = new Date().getTime()
 | 
			
		||||
    lastBuildMs = buildStart
 | 
			
		||||
    const release = await buildMutex.acquire()
 | 
			
		||||
    if (lastBuildMs > buildStart) {
 | 
			
		||||
      release()
 | 
			
		||||
      return
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (cleanupBuild) {
 | 
			
		||||
      await cleanupBuild()
 | 
			
		||||
      console.log(chalk.yellow("Detected a source code change, doing a hard rebuild..."))
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const result = await ctx.rebuild().catch((err) => {
 | 
			
		||||
      console.error(`${chalk.red("Couldn't parse Quartz configuration:")} ${fp}`)
 | 
			
		||||
      console.log(`Reason: ${chalk.grey(err)}`)
 | 
			
		||||
      process.exit(1)
 | 
			
		||||
    })
 | 
			
		||||
    release()
 | 
			
		||||
 | 
			
		||||
    if (argv.bundleInfo) {
 | 
			
		||||
      const outputFileName = "quartz/.quartz-cache/transpiled-build.mjs"
 | 
			
		||||
      const meta = result.metafile.outputs[outputFileName]
 | 
			
		||||
      console.log(
 | 
			
		||||
        `Successfully transpiled ${Object.keys(meta.inputs).length} files (${prettyBytes(
 | 
			
		||||
          meta.bytes,
 | 
			
		||||
        )})`,
 | 
			
		||||
      )
 | 
			
		||||
      console.log(await esbuild.analyzeMetafile(result.metafile, { color: true }))
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // bypass module cache
 | 
			
		||||
    // https://github.com/nodejs/modules/issues/307
 | 
			
		||||
    const { default: buildQuartz } = await import(`../../${cacheFile}?update=${randomUUID()}`)
 | 
			
		||||
    // ^ this import is relative, so base "cacheFile" path can't be used
 | 
			
		||||
 | 
			
		||||
    cleanupBuild = await buildQuartz(argv, buildMutex, clientRefresh)
 | 
			
		||||
    clientRefresh()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (argv.serve) {
 | 
			
		||||
    const connections = []
 | 
			
		||||
    const clientRefresh = () => connections.forEach((conn) => conn.send("rebuild"))
 | 
			
		||||
 | 
			
		||||
    if (argv.baseDir !== "" && !argv.baseDir.startsWith("/")) {
 | 
			
		||||
      argv.baseDir = "/" + argv.baseDir
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    await build(clientRefresh)
 | 
			
		||||
    const server = http.createServer(async (req, res) => {
 | 
			
		||||
      if (argv.baseDir && !req.url?.startsWith(argv.baseDir)) {
 | 
			
		||||
        console.log(
 | 
			
		||||
          chalk.red(
 | 
			
		||||
            `[404] ${req.url} (warning: link outside of site, this is likely a Quartz bug)`,
 | 
			
		||||
          ),
 | 
			
		||||
        )
 | 
			
		||||
        res.writeHead(404)
 | 
			
		||||
        res.end()
 | 
			
		||||
        return
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // strip baseDir prefix
 | 
			
		||||
      req.url = req.url?.slice(argv.baseDir.length)
 | 
			
		||||
 | 
			
		||||
      const serve = async () => {
 | 
			
		||||
        const release = await buildMutex.acquire()
 | 
			
		||||
        await serveHandler(req, res, {
 | 
			
		||||
          public: argv.output,
 | 
			
		||||
          directoryListing: false,
 | 
			
		||||
          headers: [
 | 
			
		||||
            {
 | 
			
		||||
              source: "**/*.html",
 | 
			
		||||
              headers: [{ key: "Content-Disposition", value: "inline" }],
 | 
			
		||||
            },
 | 
			
		||||
          ],
 | 
			
		||||
        })
 | 
			
		||||
        const status = res.statusCode
 | 
			
		||||
        const statusString =
 | 
			
		||||
          status >= 200 && status < 300 ? chalk.green(`[${status}]`) : chalk.red(`[${status}]`)
 | 
			
		||||
        console.log(statusString + chalk.grey(` ${argv.baseDir}${req.url}`))
 | 
			
		||||
        release()
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const redirect = (newFp) => {
 | 
			
		||||
        newFp = argv.baseDir + newFp
 | 
			
		||||
        res.writeHead(302, {
 | 
			
		||||
          Location: newFp,
 | 
			
		||||
        })
 | 
			
		||||
        console.log(chalk.yellow("[302]") + chalk.grey(` ${argv.baseDir}${req.url} -> ${newFp}`))
 | 
			
		||||
        res.end()
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      let fp = req.url?.split("?")[0] ?? "/"
 | 
			
		||||
 | 
			
		||||
      // handle redirects
 | 
			
		||||
      if (fp.endsWith("/")) {
 | 
			
		||||
        // /trailing/
 | 
			
		||||
        // does /trailing/index.html exist? if so, serve it
 | 
			
		||||
        const indexFp = path.posix.join(fp, "index.html")
 | 
			
		||||
        if (fs.existsSync(path.posix.join(argv.output, indexFp))) {
 | 
			
		||||
          req.url = fp
 | 
			
		||||
          return serve()
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // does /trailing.html exist? if so, redirect to /trailing
 | 
			
		||||
        let base = fp.slice(0, -1)
 | 
			
		||||
        if (path.extname(base) === "") {
 | 
			
		||||
          base += ".html"
 | 
			
		||||
        }
 | 
			
		||||
        if (fs.existsSync(path.posix.join(argv.output, base))) {
 | 
			
		||||
          return redirect(fp.slice(0, -1))
 | 
			
		||||
        }
 | 
			
		||||
      } else {
 | 
			
		||||
        // /regular
 | 
			
		||||
        // does /regular.html exist? if so, serve it
 | 
			
		||||
        let base = fp
 | 
			
		||||
        if (path.extname(base) === "") {
 | 
			
		||||
          base += ".html"
 | 
			
		||||
        }
 | 
			
		||||
        if (fs.existsSync(path.posix.join(argv.output, base))) {
 | 
			
		||||
          req.url = fp
 | 
			
		||||
          return serve()
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // does /regular/index.html exist? if so, redirect to /regular/
 | 
			
		||||
        let indexFp = path.posix.join(fp, "index.html")
 | 
			
		||||
        if (fs.existsSync(path.posix.join(argv.output, indexFp))) {
 | 
			
		||||
          return redirect(fp + "/")
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return serve()
 | 
			
		||||
    })
 | 
			
		||||
    server.listen(argv.port)
 | 
			
		||||
    const wss = new WebSocketServer({ port: argv.wsPort })
 | 
			
		||||
    wss.on("connection", (ws) => connections.push(ws))
 | 
			
		||||
    console.log(
 | 
			
		||||
      chalk.cyan(
 | 
			
		||||
        `Started a Quartz server listening at http://localhost:${argv.port}${argv.baseDir}`,
 | 
			
		||||
      ),
 | 
			
		||||
    )
 | 
			
		||||
    console.log("hint: exit with ctrl+c")
 | 
			
		||||
    chokidar
 | 
			
		||||
      .watch(["**/*.ts", "**/*.tsx", "**/*.scss", "package.json"], {
 | 
			
		||||
        ignoreInitial: true,
 | 
			
		||||
      })
 | 
			
		||||
      .on("all", async () => {
 | 
			
		||||
        build(clientRefresh)
 | 
			
		||||
      })
 | 
			
		||||
  } else {
 | 
			
		||||
    await build(() => {})
 | 
			
		||||
    ctx.dispose()
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Handles `npx quartz update`
 | 
			
		||||
 * @param {*} argv arguments for `update`
 | 
			
		||||
 */
 | 
			
		||||
export async function handleUpdate(argv) {
 | 
			
		||||
  const contentFolder = path.join(cwd, argv.directory)
 | 
			
		||||
  console.log(chalk.bgGreen.black(`\n Quartz v${version} \n`))
 | 
			
		||||
  console.log("Backing up your content")
 | 
			
		||||
  execSync(
 | 
			
		||||
    `git remote show upstream || git remote add upstream https://github.com/jackyzha0/quartz.git`,
 | 
			
		||||
  )
 | 
			
		||||
  await stashContentFolder(contentFolder)
 | 
			
		||||
  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)
 | 
			
		||||
  await popContentFolder(contentFolder)
 | 
			
		||||
  console.log("Ensuring dependencies are up to date")
 | 
			
		||||
  spawnSync("npm", ["i"], { stdio: "inherit" })
 | 
			
		||||
  console.log(chalk.green("Done!"))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Handles `npx quartz restore`
 | 
			
		||||
 * @param {*} argv arguments for `restore`
 | 
			
		||||
 */
 | 
			
		||||
export async function handleRestore(argv) {
 | 
			
		||||
  const contentFolder = path.join(cwd, argv.directory)
 | 
			
		||||
  await popContentFolder(contentFolder)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Handles `npx quartz sync`
 | 
			
		||||
 * @param {*} argv arguments for `sync`
 | 
			
		||||
 */
 | 
			
		||||
export async function handleSync(argv) {
 | 
			
		||||
  const contentFolder = path.join(cwd, argv.directory)
 | 
			
		||||
  console.log(chalk.bgGreen.black(`\n Quartz v${version} \n`))
 | 
			
		||||
  console.log("Backing up your content")
 | 
			
		||||
 | 
			
		||||
  if (argv.commit) {
 | 
			
		||||
    const contentStat = await fs.promises.lstat(contentFolder)
 | 
			
		||||
    if (contentStat.isSymbolicLink()) {
 | 
			
		||||
      const linkTarg = await fs.promises.readlink(contentFolder)
 | 
			
		||||
      console.log(chalk.yellow("Detected symlink, trying to dereference before committing"))
 | 
			
		||||
 | 
			
		||||
      // stash symlink file
 | 
			
		||||
      await stashContentFolder(contentFolder)
 | 
			
		||||
 | 
			
		||||
      // follow symlink and copy content
 | 
			
		||||
      await fs.promises.cp(linkTarg, contentFolder, {
 | 
			
		||||
        recursive: true,
 | 
			
		||||
        preserveTimestamps: true,
 | 
			
		||||
      })
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const currentTimestamp = new Date().toLocaleString("en-US", {
 | 
			
		||||
      dateStyle: "medium",
 | 
			
		||||
      timeStyle: "short",
 | 
			
		||||
    })
 | 
			
		||||
    const commitMessage = argv.message ?? `Quartz sync: ${currentTimestamp}`
 | 
			
		||||
    spawnSync("git", ["add", "."], { stdio: "inherit" })
 | 
			
		||||
    spawnSync("git", ["commit", "-m", commitMessage], { stdio: "inherit" })
 | 
			
		||||
 | 
			
		||||
    if (contentStat.isSymbolicLink()) {
 | 
			
		||||
      // put symlink back
 | 
			
		||||
      await popContentFolder(contentFolder)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  await stashContentFolder(contentFolder)
 | 
			
		||||
 | 
			
		||||
  if (argv.pull) {
 | 
			
		||||
    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)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  await popContentFolder(contentFolder)
 | 
			
		||||
  if (argv.push) {
 | 
			
		||||
    console.log("Pushing your changes")
 | 
			
		||||
    spawnSync("git", ["push", "-f", ORIGIN_NAME, QUARTZ_SOURCE_BRANCH], { stdio: "inherit" })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  console.log(chalk.green("Done!"))
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										52
									
								
								quartz/cli/helpers.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								quartz/cli/helpers.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,52 @@
 | 
			
		||||
import { isCancel, outro } from "@clack/prompts"
 | 
			
		||||
import chalk from "chalk"
 | 
			
		||||
import { contentCacheFolder } from "./constants.js"
 | 
			
		||||
import { spawnSync } from "child_process"
 | 
			
		||||
import fs from "fs"
 | 
			
		||||
 | 
			
		||||
export function escapePath(fp) {
 | 
			
		||||
  return fp
 | 
			
		||||
    .replace(/\\ /g, " ") // unescape spaces
 | 
			
		||||
    .replace(/^".*"$/, "$1")
 | 
			
		||||
    .replace(/^'.*"$/, "$1")
 | 
			
		||||
    .trim()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function exitIfCancel(val) {
 | 
			
		||||
  if (isCancel(val)) {
 | 
			
		||||
    outro(chalk.red("Exiting"))
 | 
			
		||||
    process.exit(0)
 | 
			
		||||
  } else {
 | 
			
		||||
    return val
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function stashContentFolder(contentFolder) {
 | 
			
		||||
  await fs.promises.rm(contentCacheFolder, { force: true, recursive: true })
 | 
			
		||||
  await fs.promises.cp(contentFolder, contentCacheFolder, {
 | 
			
		||||
    force: true,
 | 
			
		||||
    recursive: true,
 | 
			
		||||
    verbatimSymlinks: true,
 | 
			
		||||
    preserveTimestamps: true,
 | 
			
		||||
  })
 | 
			
		||||
  await fs.promises.rm(contentFolder, { force: true, recursive: true })
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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}`)
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function popContentFolder(contentFolder) {
 | 
			
		||||
  await fs.promises.rm(contentFolder, { force: true, recursive: true })
 | 
			
		||||
  await fs.promises.cp(contentCacheFolder, contentFolder, {
 | 
			
		||||
    force: true,
 | 
			
		||||
    recursive: true,
 | 
			
		||||
    verbatimSymlinks: true,
 | 
			
		||||
    preserveTimestamps: true,
 | 
			
		||||
  })
 | 
			
		||||
  await fs.promises.rm(contentCacheFolder, { force: true, recursive: true })
 | 
			
		||||
}
 | 
			
		||||
@@ -1,9 +1,9 @@
 | 
			
		||||
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
 | 
			
		||||
 | 
			
		||||
function ArticleTitle({ fileData }: QuartzComponentProps) {
 | 
			
		||||
function ArticleTitle({ fileData, displayClass }: QuartzComponentProps) {
 | 
			
		||||
  const title = fileData.frontmatter?.title
 | 
			
		||||
  if (title) {
 | 
			
		||||
    return <h1 class="article-title">{title}</h1>
 | 
			
		||||
    return <h1 class={`article-title ${displayClass ?? ""}`}>{title}</h1>
 | 
			
		||||
  } else {
 | 
			
		||||
    return null
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -2,11 +2,11 @@ import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
 | 
			
		||||
import style from "./styles/backlinks.scss"
 | 
			
		||||
import { resolveRelative, simplifySlug } from "../util/path"
 | 
			
		||||
 | 
			
		||||
function Backlinks({ fileData, allFiles }: QuartzComponentProps) {
 | 
			
		||||
function Backlinks({ fileData, allFiles, displayClass }: QuartzComponentProps) {
 | 
			
		||||
  const slug = simplifySlug(fileData.slug!)
 | 
			
		||||
  const backlinkFiles = allFiles.filter((file) => file.links?.includes(slug))
 | 
			
		||||
  return (
 | 
			
		||||
    <div class="backlinks">
 | 
			
		||||
    <div class={`backlinks ${displayClass ?? ""}`}>
 | 
			
		||||
      <h3>Backlinks</h3>
 | 
			
		||||
      <ul class="overflow">
 | 
			
		||||
        {backlinkFiles.length > 0 ? (
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										116
									
								
								quartz/components/Breadcrumbs.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										116
									
								
								quartz/components/Breadcrumbs.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,116 @@
 | 
			
		||||
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
 | 
			
		||||
import breadcrumbsStyle from "./styles/breadcrumbs.scss"
 | 
			
		||||
import { FullSlug, SimpleSlug, resolveRelative } from "../util/path"
 | 
			
		||||
import { QuartzPluginData } from "../plugins/vfile"
 | 
			
		||||
 | 
			
		||||
type CrumbData = {
 | 
			
		||||
  displayName: string
 | 
			
		||||
  path: string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface BreadcrumbOptions {
 | 
			
		||||
  /**
 | 
			
		||||
   * Symbol between crumbs
 | 
			
		||||
   */
 | 
			
		||||
  spacerSymbol: string
 | 
			
		||||
  /**
 | 
			
		||||
   * Name of first crumb
 | 
			
		||||
   */
 | 
			
		||||
  rootName: string
 | 
			
		||||
  /**
 | 
			
		||||
   * wether to look up frontmatter title for folders (could cause performance problems with big vaults)
 | 
			
		||||
   */
 | 
			
		||||
  resolveFrontmatterTitle: boolean
 | 
			
		||||
  /**
 | 
			
		||||
   * Wether to display breadcrumbs on root `index.md`
 | 
			
		||||
   */
 | 
			
		||||
  hideOnRoot: boolean
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const defaultOptions: BreadcrumbOptions = {
 | 
			
		||||
  spacerSymbol: "❯",
 | 
			
		||||
  rootName: "Home",
 | 
			
		||||
  resolveFrontmatterTitle: true,
 | 
			
		||||
  hideOnRoot: true,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function formatCrumb(displayName: string, baseSlug: FullSlug, currentSlug: SimpleSlug): CrumbData {
 | 
			
		||||
  return {
 | 
			
		||||
    displayName: displayName.replaceAll("-", " "),
 | 
			
		||||
    path: resolveRelative(baseSlug, currentSlug),
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default ((opts?: Partial<BreadcrumbOptions>) => {
 | 
			
		||||
  // Merge options with defaults
 | 
			
		||||
  const options: BreadcrumbOptions = { ...defaultOptions, ...opts }
 | 
			
		||||
 | 
			
		||||
  // computed index of folder name to its associated file data
 | 
			
		||||
  let folderIndex: Map<string, QuartzPluginData> | undefined
 | 
			
		||||
 | 
			
		||||
  function Breadcrumbs({ fileData, allFiles, displayClass }: QuartzComponentProps) {
 | 
			
		||||
    // Hide crumbs on root if enabled
 | 
			
		||||
    if (options.hideOnRoot && fileData.slug === "index") {
 | 
			
		||||
      return <></>
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Format entry for root element
 | 
			
		||||
    const firstEntry = formatCrumb(options.rootName, fileData.slug!, "/" as SimpleSlug)
 | 
			
		||||
    const crumbs: CrumbData[] = [firstEntry]
 | 
			
		||||
 | 
			
		||||
    if (!folderIndex && options.resolveFrontmatterTitle) {
 | 
			
		||||
      folderIndex = new Map()
 | 
			
		||||
      // construct the index for the first time
 | 
			
		||||
      for (const file of allFiles) {
 | 
			
		||||
        if (file.slug?.endsWith("index")) {
 | 
			
		||||
          const folderParts = file.filePath?.split("/")
 | 
			
		||||
          if (folderParts) {
 | 
			
		||||
            const folderName = folderParts[folderParts?.length - 2]
 | 
			
		||||
            folderIndex.set(folderName, file)
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Split slug into hierarchy/parts
 | 
			
		||||
    const slugParts = fileData.slug?.split("/")
 | 
			
		||||
    if (slugParts) {
 | 
			
		||||
      // full path until current part
 | 
			
		||||
      let currentPath = ""
 | 
			
		||||
      for (let i = 0; i < slugParts.length - 1; i++) {
 | 
			
		||||
        let curPathSegment = slugParts[i]
 | 
			
		||||
 | 
			
		||||
        // Try to resolve frontmatter folder title
 | 
			
		||||
        const currentFile = folderIndex?.get(curPathSegment)
 | 
			
		||||
        if (currentFile) {
 | 
			
		||||
          curPathSegment = currentFile.frontmatter!.title
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Add current slug to full path
 | 
			
		||||
        currentPath += slugParts[i] + "/"
 | 
			
		||||
 | 
			
		||||
        // Format and add current crumb
 | 
			
		||||
        const crumb = formatCrumb(curPathSegment, fileData.slug!, currentPath as SimpleSlug)
 | 
			
		||||
        crumbs.push(crumb)
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Add current file to crumb (can directly use frontmatter title)
 | 
			
		||||
      crumbs.push({
 | 
			
		||||
        displayName: fileData.frontmatter!.title,
 | 
			
		||||
        path: "",
 | 
			
		||||
      })
 | 
			
		||||
    }
 | 
			
		||||
    return (
 | 
			
		||||
      <nav class={`breadcrumb-container ${displayClass ?? ""}`} aria-label="breadcrumbs">
 | 
			
		||||
        {crumbs.map((crumb, index) => (
 | 
			
		||||
          <div class="breadcrumb-element">
 | 
			
		||||
            <a href={crumb.path}>{crumb.displayName}</a>
 | 
			
		||||
            {index !== crumbs.length - 1 && <p>{` ${options.spacerSymbol} `}</p>}
 | 
			
		||||
          </div>
 | 
			
		||||
        ))}
 | 
			
		||||
      </nav>
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
  Breadcrumbs.css = breadcrumbsStyle
 | 
			
		||||
  return Breadcrumbs
 | 
			
		||||
}) satisfies QuartzComponentConstructor
 | 
			
		||||
@@ -3,7 +3,7 @@ import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
 | 
			
		||||
import readingTime from "reading-time"
 | 
			
		||||
 | 
			
		||||
export default (() => {
 | 
			
		||||
  function ContentMetadata({ cfg, fileData }: QuartzComponentProps) {
 | 
			
		||||
  function ContentMetadata({ cfg, fileData, displayClass }: QuartzComponentProps) {
 | 
			
		||||
    const text = fileData.text
 | 
			
		||||
    if (text) {
 | 
			
		||||
      const segments: string[] = []
 | 
			
		||||
@@ -14,7 +14,7 @@ export default (() => {
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      segments.push(timeTaken)
 | 
			
		||||
      return <p class="content-meta">{segments.join(", ")}</p>
 | 
			
		||||
      return <p class={`content-meta ${displayClass ?? ""}`}>{segments.join(", ")}</p>
 | 
			
		||||
    } else {
 | 
			
		||||
      return null
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -3,11 +3,11 @@
 | 
			
		||||
// see: https://v8.dev/features/modules#defer
 | 
			
		||||
import darkmodeScript from "./scripts/darkmode.inline"
 | 
			
		||||
import styles from "./styles/darkmode.scss"
 | 
			
		||||
import { QuartzComponentConstructor } from "./types"
 | 
			
		||||
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
 | 
			
		||||
 | 
			
		||||
function Darkmode() {
 | 
			
		||||
function Darkmode({ displayClass }: QuartzComponentProps) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div class="darkmode">
 | 
			
		||||
    <div class={`darkmode ${displayClass ?? ""}`}>
 | 
			
		||||
      <input class="toggle" id="darkmode-toggle" type="checkbox" tabIndex={-1} />
 | 
			
		||||
      <label id="toggle-label-light" for="darkmode-toggle" tabIndex={-1}>
 | 
			
		||||
        <svg
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										126
									
								
								quartz/components/Explorer.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										126
									
								
								quartz/components/Explorer.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,126 @@
 | 
			
		||||
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
 | 
			
		||||
import explorerStyle from "./styles/explorer.scss"
 | 
			
		||||
 | 
			
		||||
// @ts-ignore
 | 
			
		||||
import script from "./scripts/explorer.inline"
 | 
			
		||||
import { ExplorerNode, FileNode, Options } from "./ExplorerNode"
 | 
			
		||||
import { QuartzPluginData } from "../plugins/vfile"
 | 
			
		||||
 | 
			
		||||
// Options interface defined in `ExplorerNode` to avoid circular dependency
 | 
			
		||||
const defaultOptions = {
 | 
			
		||||
  title: "Explorer",
 | 
			
		||||
  folderClickBehavior: "collapse",
 | 
			
		||||
  folderDefaultState: "collapsed",
 | 
			
		||||
  useSavedState: true,
 | 
			
		||||
  sortFn: (a, b) => {
 | 
			
		||||
    // Sort order: folders first, then files. Sort folders and files alphabetically
 | 
			
		||||
    if ((!a.file && !b.file) || (a.file && b.file)) {
 | 
			
		||||
      // numeric: true: Whether numeric collation should be used, such that "1" < "2" < "10"
 | 
			
		||||
      // sensitivity: "base": Only strings that differ in base letters compare as unequal. Examples: a ≠ b, a = á, a = A
 | 
			
		||||
      return a.displayName.localeCompare(b.displayName, undefined, {
 | 
			
		||||
        numeric: true,
 | 
			
		||||
        sensitivity: "base",
 | 
			
		||||
      })
 | 
			
		||||
    }
 | 
			
		||||
    if (a.file && !b.file) {
 | 
			
		||||
      return 1
 | 
			
		||||
    } else {
 | 
			
		||||
      return -1
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  filterFn: (node) => node.name !== "tags",
 | 
			
		||||
  order: ["filter", "map", "sort"],
 | 
			
		||||
} satisfies Options
 | 
			
		||||
 | 
			
		||||
export default ((userOpts?: Partial<Options>) => {
 | 
			
		||||
  // Parse config
 | 
			
		||||
  const opts: Options = { ...defaultOptions, ...userOpts }
 | 
			
		||||
 | 
			
		||||
  // memoized
 | 
			
		||||
  let fileTree: FileNode
 | 
			
		||||
  let jsonTree: string
 | 
			
		||||
 | 
			
		||||
  function constructFileTree(allFiles: QuartzPluginData[]) {
 | 
			
		||||
    if (!fileTree) {
 | 
			
		||||
      // Construct tree from allFiles
 | 
			
		||||
      fileTree = new FileNode("")
 | 
			
		||||
      allFiles.forEach((file) => fileTree.add(file, 1))
 | 
			
		||||
 | 
			
		||||
      /**
 | 
			
		||||
       * 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,
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // 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])
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // 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) {
 | 
			
		||||
    constructFileTree(allFiles)
 | 
			
		||||
    return (
 | 
			
		||||
      <div class={`explorer ${displayClass ?? ""}`}>
 | 
			
		||||
        <button
 | 
			
		||||
          type="button"
 | 
			
		||||
          id="explorer"
 | 
			
		||||
          data-behavior={opts.folderClickBehavior}
 | 
			
		||||
          data-collapsed={opts.folderDefaultState}
 | 
			
		||||
          data-savestate={opts.useSavedState}
 | 
			
		||||
          data-tree={jsonTree}
 | 
			
		||||
        >
 | 
			
		||||
          <h1>{opts.title}</h1>
 | 
			
		||||
          <svg
 | 
			
		||||
            xmlns="http://www.w3.org/2000/svg"
 | 
			
		||||
            width="14"
 | 
			
		||||
            height="14"
 | 
			
		||||
            viewBox="5 8 14 8"
 | 
			
		||||
            fill="none"
 | 
			
		||||
            stroke="currentColor"
 | 
			
		||||
            stroke-width="2"
 | 
			
		||||
            stroke-linecap="round"
 | 
			
		||||
            stroke-linejoin="round"
 | 
			
		||||
            class="fold"
 | 
			
		||||
          >
 | 
			
		||||
            <polyline points="6 9 12 15 18 9"></polyline>
 | 
			
		||||
          </svg>
 | 
			
		||||
        </button>
 | 
			
		||||
        <div id="explorer-content">
 | 
			
		||||
          <ul class="overflow" id="explorer-ul">
 | 
			
		||||
            <ExplorerNode node={fileTree} opts={opts} fileData={fileData} />
 | 
			
		||||
            <li id="explorer-end" />
 | 
			
		||||
          </ul>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
  Explorer.css = explorerStyle
 | 
			
		||||
  Explorer.afterDOMLoaded = script
 | 
			
		||||
  return Explorer
 | 
			
		||||
}) satisfies QuartzComponentConstructor
 | 
			
		||||
							
								
								
									
										224
									
								
								quartz/components/ExplorerNode.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										224
									
								
								quartz/components/ExplorerNode.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,224 @@
 | 
			
		||||
// @ts-ignore
 | 
			
		||||
import { QuartzPluginData } from "../plugins/vfile"
 | 
			
		||||
import { resolveRelative } from "../util/path"
 | 
			
		||||
 | 
			
		||||
type OrderEntries = "sort" | "filter" | "map"
 | 
			
		||||
 | 
			
		||||
export interface Options {
 | 
			
		||||
  title: string
 | 
			
		||||
  folderDefaultState: "collapsed" | "open"
 | 
			
		||||
  folderClickBehavior: "collapse" | "link"
 | 
			
		||||
  useSavedState: boolean
 | 
			
		||||
  sortFn: (a: FileNode, b: FileNode) => number
 | 
			
		||||
  filterFn?: (node: FileNode) => boolean
 | 
			
		||||
  mapFn?: (node: FileNode) => void
 | 
			
		||||
  order?: OrderEntries[]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type DataWrapper = {
 | 
			
		||||
  file: QuartzPluginData
 | 
			
		||||
  path: string[]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type FolderState = {
 | 
			
		||||
  path: string
 | 
			
		||||
  collapsed: boolean
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Structure to add all files into a tree
 | 
			
		||||
export class FileNode {
 | 
			
		||||
  children: FileNode[]
 | 
			
		||||
  name: string
 | 
			
		||||
  displayName: string
 | 
			
		||||
  file: QuartzPluginData | null
 | 
			
		||||
  depth: number
 | 
			
		||||
 | 
			
		||||
  constructor(name: string, file?: QuartzPluginData, depth?: number) {
 | 
			
		||||
    this.children = []
 | 
			
		||||
    this.name = name
 | 
			
		||||
    this.displayName = name
 | 
			
		||||
    this.file = file ? structuredClone(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") {
 | 
			
		||||
          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
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const newChild = new FileNode(next, undefined, this.depth + 1)
 | 
			
		||||
      newChild.insert(file)
 | 
			
		||||
      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))
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Filter FileNode tree. Behaves similar to `Array.prototype.filter()`, but modifies tree in place
 | 
			
		||||
   * @param filterFn function to filter tree with
 | 
			
		||||
   */
 | 
			
		||||
  filter(filterFn: (node: FileNode) => boolean) {
 | 
			
		||||
    this.children = this.children.filter(filterFn)
 | 
			
		||||
    this.children.forEach((child) => child.filter(filterFn))
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Filter FileNode tree. Behaves similar to `Array.prototype.map()`, but modifies tree in place
 | 
			
		||||
   * @param mapFn function to use for mapping over tree
 | 
			
		||||
   */
 | 
			
		||||
  map(mapFn: (node: FileNode) => void) {
 | 
			
		||||
    mapFn(this)
 | 
			
		||||
 | 
			
		||||
    this.children.forEach((child) => child.map(mapFn))
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Get folder representation with state of tree.
 | 
			
		||||
   * Intended to only be called on root node before changes to the tree are made
 | 
			
		||||
   * @param collapsed default state of folders (collapsed by default or not)
 | 
			
		||||
   * @returns array containing folder state for tree
 | 
			
		||||
   */
 | 
			
		||||
  getFolderPaths(collapsed: boolean): FolderState[] {
 | 
			
		||||
    const folderPaths: FolderState[] = []
 | 
			
		||||
 | 
			
		||||
    const traverse = (node: FileNode, currentPath: string) => {
 | 
			
		||||
      if (!node.file) {
 | 
			
		||||
        const folderPath = currentPath + (currentPath ? "/" : "") + node.name
 | 
			
		||||
        if (folderPath !== "") {
 | 
			
		||||
          folderPaths.push({ path: folderPath, collapsed })
 | 
			
		||||
        }
 | 
			
		||||
        node.children.forEach((child) => traverse(child, folderPath))
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    traverse(this, "")
 | 
			
		||||
 | 
			
		||||
    return folderPaths
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Sort order: folders first, then files. Sort folders and files alphabetically
 | 
			
		||||
  /**
 | 
			
		||||
   * Sorts tree according to sort/compare function
 | 
			
		||||
   * @param sortFn compare function used for `.sort()`, also used recursively for children
 | 
			
		||||
   */
 | 
			
		||||
  sort(sortFn: (a: FileNode, b: FileNode) => number) {
 | 
			
		||||
    this.children = this.children.sort(sortFn)
 | 
			
		||||
    this.children.forEach((e) => e.sort(sortFn))
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type ExplorerNodeProps = {
 | 
			
		||||
  node: FileNode
 | 
			
		||||
  opts: Options
 | 
			
		||||
  fileData: QuartzPluginData
 | 
			
		||||
  fullPath?: string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function ExplorerNode({ node, opts, fullPath, fileData }: ExplorerNodeProps) {
 | 
			
		||||
  // Get options
 | 
			
		||||
  const folderBehavior = opts.folderClickBehavior
 | 
			
		||||
  const isDefaultOpen = opts.folderDefaultState === "open"
 | 
			
		||||
 | 
			
		||||
  // Calculate current folderPath
 | 
			
		||||
  let pathOld = fullPath ? fullPath : ""
 | 
			
		||||
  let folderPath = ""
 | 
			
		||||
  if (node.name !== "") {
 | 
			
		||||
    folderPath = `${pathOld}/${node.name}`
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <li>
 | 
			
		||||
      {node.file ? (
 | 
			
		||||
        // Single file node
 | 
			
		||||
        <li key={node.file.slug}>
 | 
			
		||||
          <a href={resolveRelative(fileData.slug!, node.file.slug!)} data-for={node.file.slug}>
 | 
			
		||||
            {node.displayName}
 | 
			
		||||
          </a>
 | 
			
		||||
        </li>
 | 
			
		||||
      ) : (
 | 
			
		||||
        <div>
 | 
			
		||||
          {node.name !== "" && (
 | 
			
		||||
            // Node with entire folder
 | 
			
		||||
            // Render svg button + folder name, then children
 | 
			
		||||
            <div class="folder-container">
 | 
			
		||||
              <svg
 | 
			
		||||
                xmlns="http://www.w3.org/2000/svg"
 | 
			
		||||
                width="12"
 | 
			
		||||
                height="12"
 | 
			
		||||
                viewBox="5 8 14 8"
 | 
			
		||||
                fill="none"
 | 
			
		||||
                stroke="currentColor"
 | 
			
		||||
                stroke-width="2"
 | 
			
		||||
                stroke-linecap="round"
 | 
			
		||||
                stroke-linejoin="round"
 | 
			
		||||
                class="folder-icon"
 | 
			
		||||
              >
 | 
			
		||||
                <polyline points="6 9 12 15 18 9"></polyline>
 | 
			
		||||
              </svg>
 | 
			
		||||
              {/* 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">
 | 
			
		||||
                    {node.displayName}
 | 
			
		||||
                  </a>
 | 
			
		||||
                ) : (
 | 
			
		||||
                  <button class="folder-button">
 | 
			
		||||
                    <p class="folder-title">{node.displayName}</p>
 | 
			
		||||
                  </button>
 | 
			
		||||
                )}
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          )}
 | 
			
		||||
          {/* Recursively render children of folder */}
 | 
			
		||||
          <div class={`folder-outer ${node.depth === 0 || isDefaultOpen ? "open" : ""}`}>
 | 
			
		||||
            <ul
 | 
			
		||||
              // Inline style for left folder paddings
 | 
			
		||||
              style={{
 | 
			
		||||
                paddingLeft: node.name !== "" ? "1.4rem" : "0",
 | 
			
		||||
              }}
 | 
			
		||||
              class="content"
 | 
			
		||||
              data-folderul={folderPath}
 | 
			
		||||
            >
 | 
			
		||||
              {node.children.map((childNode, i) => (
 | 
			
		||||
                <ExplorerNode
 | 
			
		||||
                  node={childNode}
 | 
			
		||||
                  key={i}
 | 
			
		||||
                  opts={opts}
 | 
			
		||||
                  fullPath={folderPath}
 | 
			
		||||
                  fileData={fileData}
 | 
			
		||||
                />
 | 
			
		||||
              ))}
 | 
			
		||||
            </ul>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      )}
 | 
			
		||||
    </li>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
import { QuartzComponentConstructor } from "./types"
 | 
			
		||||
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
 | 
			
		||||
import style from "./styles/footer.scss"
 | 
			
		||||
import { version } from "../../package.json"
 | 
			
		||||
 | 
			
		||||
@@ -7,11 +7,11 @@ interface Options {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default ((opts?: Options) => {
 | 
			
		||||
  function Footer() {
 | 
			
		||||
  function Footer({ displayClass }: QuartzComponentProps) {
 | 
			
		||||
    const year = new Date().getFullYear()
 | 
			
		||||
    const links = opts?.links ?? []
 | 
			
		||||
    return (
 | 
			
		||||
      <footer>
 | 
			
		||||
      <footer class={`${displayClass ?? ""}`}>
 | 
			
		||||
        <hr />
 | 
			
		||||
        <p>
 | 
			
		||||
          Created with <a href="https://quartz.jzhao.xyz/">Quartz v{version}</a>, © {year}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
import { QuartzComponentConstructor } from "./types"
 | 
			
		||||
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
 | 
			
		||||
// @ts-ignore
 | 
			
		||||
import script from "./scripts/graph.inline"
 | 
			
		||||
import style from "./styles/graph.scss"
 | 
			
		||||
@@ -13,6 +13,8 @@ export interface D3Config {
 | 
			
		||||
  linkDistance: number
 | 
			
		||||
  fontSize: number
 | 
			
		||||
  opacityScale: number
 | 
			
		||||
  removeTags: string[]
 | 
			
		||||
  showTags: boolean
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface GraphOptions {
 | 
			
		||||
@@ -31,6 +33,8 @@ const defaultOptions: GraphOptions = {
 | 
			
		||||
    linkDistance: 30,
 | 
			
		||||
    fontSize: 0.6,
 | 
			
		||||
    opacityScale: 1,
 | 
			
		||||
    showTags: true,
 | 
			
		||||
    removeTags: [],
 | 
			
		||||
  },
 | 
			
		||||
  globalGraph: {
 | 
			
		||||
    drag: true,
 | 
			
		||||
@@ -42,15 +46,17 @@ const defaultOptions: GraphOptions = {
 | 
			
		||||
    linkDistance: 30,
 | 
			
		||||
    fontSize: 0.6,
 | 
			
		||||
    opacityScale: 1,
 | 
			
		||||
    showTags: true,
 | 
			
		||||
    removeTags: [],
 | 
			
		||||
  },
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default ((opts?: GraphOptions) => {
 | 
			
		||||
  function Graph() {
 | 
			
		||||
  function Graph({ displayClass }: QuartzComponentProps) {
 | 
			
		||||
    const localGraph = { ...defaultOptions.localGraph, ...opts?.localGraph }
 | 
			
		||||
    const globalGraph = { ...defaultOptions.globalGraph, ...opts?.globalGraph }
 | 
			
		||||
    return (
 | 
			
		||||
      <div class="graph">
 | 
			
		||||
      <div class={`graph ${displayClass ?? ""}`}>
 | 
			
		||||
        <h3>Graph View</h3>
 | 
			
		||||
        <div class="graph-outer">
 | 
			
		||||
          <div id="graph-container" data-cfg={JSON.stringify(localGraph)}></div>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
import { joinSegments, pathToRoot } from "../util/path"
 | 
			
		||||
import { FullSlug, _stripSlashes, joinSegments, pathToRoot } from "../util/path"
 | 
			
		||||
import { JSResourceToScriptElement } from "../util/resources"
 | 
			
		||||
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
 | 
			
		||||
 | 
			
		||||
@@ -7,7 +7,11 @@ export default (() => {
 | 
			
		||||
    const title = fileData.frontmatter?.title ?? "Untitled"
 | 
			
		||||
    const description = fileData.description?.trim() ?? "No description provided"
 | 
			
		||||
    const { css, js } = externalResources
 | 
			
		||||
    const baseDir = pathToRoot(fileData.slug!)
 | 
			
		||||
 | 
			
		||||
    const url = new URL(`https://${cfg.baseUrl ?? "example.com"}`)
 | 
			
		||||
    const path = url.pathname as FullSlug
 | 
			
		||||
    const baseDir = fileData.slug === "404" ? path : pathToRoot(fileData.slug!)
 | 
			
		||||
 | 
			
		||||
    const iconPath = joinSegments(baseDir, "static/icon.png")
 | 
			
		||||
    const ogImagePath = `https://${cfg.baseUrl}/static/og-image.png`
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,11 +1,11 @@
 | 
			
		||||
import { pathToRoot } from "../util/path"
 | 
			
		||||
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
 | 
			
		||||
 | 
			
		||||
function PageTitle({ fileData, cfg }: QuartzComponentProps) {
 | 
			
		||||
function PageTitle({ fileData, cfg, displayClass }: QuartzComponentProps) {
 | 
			
		||||
  const title = cfg?.pageTitle ?? "Untitled Quartz"
 | 
			
		||||
  const baseDir = pathToRoot(fileData.slug!)
 | 
			
		||||
  return (
 | 
			
		||||
    <h1 class="page-title">
 | 
			
		||||
    <h1 class={`page-title ${displayClass ?? ""}`}>
 | 
			
		||||
      <a href={baseDir}>{title}</a>
 | 
			
		||||
    </h1>
 | 
			
		||||
  )
 | 
			
		||||
 
 | 
			
		||||
@@ -23,13 +23,12 @@ const defaultOptions = (cfg: GlobalConfiguration): Options => ({
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
export default ((userOpts?: Partial<Options>) => {
 | 
			
		||||
  function RecentNotes(props: QuartzComponentProps) {
 | 
			
		||||
    const { allFiles, fileData, displayClass, cfg } = props
 | 
			
		||||
  function RecentNotes({ allFiles, fileData, displayClass, cfg }: QuartzComponentProps) {
 | 
			
		||||
    const opts = { ...defaultOptions(cfg), ...userOpts }
 | 
			
		||||
    const pages = allFiles.filter(opts.filter).sort(opts.sort)
 | 
			
		||||
    const remaining = Math.max(0, pages.length - opts.limit)
 | 
			
		||||
    return (
 | 
			
		||||
      <div class={`recent-notes ${displayClass}`}>
 | 
			
		||||
      <div class={`recent-notes ${displayClass ?? ""}`}>
 | 
			
		||||
        <h3>{opts.title}</h3>
 | 
			
		||||
        <ul class="recent-ul">
 | 
			
		||||
          {pages.slice(0, opts.limit).map((page) => {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,12 +1,12 @@
 | 
			
		||||
import { QuartzComponentConstructor } from "./types"
 | 
			
		||||
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
 | 
			
		||||
import style from "./styles/search.scss"
 | 
			
		||||
// @ts-ignore
 | 
			
		||||
import script from "./scripts/search.inline"
 | 
			
		||||
 | 
			
		||||
export default (() => {
 | 
			
		||||
  function Search() {
 | 
			
		||||
  function Search({ displayClass }: QuartzComponentProps) {
 | 
			
		||||
    return (
 | 
			
		||||
      <div class="search">
 | 
			
		||||
      <div class={`search ${displayClass ?? ""}`}>
 | 
			
		||||
        <div id="search-icon">
 | 
			
		||||
          <p>Search</p>
 | 
			
		||||
          <div></div>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,8 +1,7 @@
 | 
			
		||||
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
 | 
			
		||||
 | 
			
		||||
function Spacer({ displayClass }: QuartzComponentProps) {
 | 
			
		||||
  const className = displayClass ? `spacer ${displayClass}` : "spacer"
 | 
			
		||||
  return <div class={className}></div>
 | 
			
		||||
  return <div class={`spacer ${displayClass ?? ""}`}></div>
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default (() => Spacer) satisfies QuartzComponentConstructor
 | 
			
		||||
 
 | 
			
		||||
@@ -19,8 +19,8 @@ function TableOfContents({ fileData, displayClass }: QuartzComponentProps) {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div class={`toc ${displayClass}`}>
 | 
			
		||||
      <button type="button" id="toc">
 | 
			
		||||
    <div class={`toc ${displayClass ?? ""}`}>
 | 
			
		||||
      <button type="button" id="toc" class={fileData.collapseToc ? "collapsed" : ""}>
 | 
			
		||||
        <h3>Table of Contents</h3>
 | 
			
		||||
        <svg
 | 
			
		||||
          xmlns="http://www.w3.org/2000/svg"
 | 
			
		||||
@@ -60,7 +60,7 @@ function LegacyTableOfContents({ fileData }: QuartzComponentProps) {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <details id="toc" open>
 | 
			
		||||
    <details id="toc" open={!fileData.collapseToc}>
 | 
			
		||||
      <summary>
 | 
			
		||||
        <h3>Table of Contents</h3>
 | 
			
		||||
      </summary>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,12 +1,12 @@
 | 
			
		||||
import { pathToRoot, slugTag } from "../util/path"
 | 
			
		||||
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
 | 
			
		||||
 | 
			
		||||
function TagList({ fileData }: QuartzComponentProps) {
 | 
			
		||||
function TagList({ fileData, displayClass }: QuartzComponentProps) {
 | 
			
		||||
  const tags = fileData.frontmatter?.tags
 | 
			
		||||
  const baseDir = pathToRoot(fileData.slug!)
 | 
			
		||||
  if (tags && tags.length > 0) {
 | 
			
		||||
    return (
 | 
			
		||||
      <ul class="tags">
 | 
			
		||||
      <ul class={`tags ${displayClass ?? ""}`}>
 | 
			
		||||
        {tags.map((tag) => {
 | 
			
		||||
          const display = `#${tag}`
 | 
			
		||||
          const linkDest = baseDir + `/tags/${slugTag(tag)}`
 | 
			
		||||
@@ -32,6 +32,12 @@ TagList.css = `
 | 
			
		||||
  padding-left: 0;
 | 
			
		||||
  gap: 0.4rem;
 | 
			
		||||
  margin: 1rem 0;
 | 
			
		||||
  flex-wrap: wrap;
 | 
			
		||||
  justify-self: end;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.section-li > .section > .tags {
 | 
			
		||||
  justify-content: flex-end;
 | 
			
		||||
}
 | 
			
		||||
  
 | 
			
		||||
.tags > li {
 | 
			
		||||
@@ -41,7 +47,7 @@ TagList.css = `
 | 
			
		||||
  overflow-wrap: normal;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
a.tag-link {
 | 
			
		||||
a.internal.tag-link {
 | 
			
		||||
  border-radius: 8px;
 | 
			
		||||
  background-color: var(--highlight);
 | 
			
		||||
  padding: 0.2rem 0.4rem;
 | 
			
		||||
 
 | 
			
		||||
@@ -1,13 +1,15 @@
 | 
			
		||||
import ArticleTitle from "./ArticleTitle"
 | 
			
		||||
import Content from "./pages/Content"
 | 
			
		||||
import TagContent from "./pages/TagContent"
 | 
			
		||||
import FolderContent from "./pages/FolderContent"
 | 
			
		||||
import NotFound from "./pages/404"
 | 
			
		||||
import ArticleTitle from "./ArticleTitle"
 | 
			
		||||
import Darkmode from "./Darkmode"
 | 
			
		||||
import Head from "./Head"
 | 
			
		||||
import PageTitle from "./PageTitle"
 | 
			
		||||
import ContentMeta from "./ContentMeta"
 | 
			
		||||
import Spacer from "./Spacer"
 | 
			
		||||
import TableOfContents from "./TableOfContents"
 | 
			
		||||
import Explorer from "./Explorer"
 | 
			
		||||
import TagList from "./TagList"
 | 
			
		||||
import Graph from "./Graph"
 | 
			
		||||
import Backlinks from "./Backlinks"
 | 
			
		||||
@@ -16,6 +18,7 @@ import Footer from "./Footer"
 | 
			
		||||
import DesktopOnly from "./DesktopOnly"
 | 
			
		||||
import MobileOnly from "./MobileOnly"
 | 
			
		||||
import RecentNotes from "./RecentNotes"
 | 
			
		||||
import Breadcrumbs from "./Breadcrumbs"
 | 
			
		||||
 | 
			
		||||
export {
 | 
			
		||||
  ArticleTitle,
 | 
			
		||||
@@ -28,6 +31,7 @@ export {
 | 
			
		||||
  ContentMeta,
 | 
			
		||||
  Spacer,
 | 
			
		||||
  TableOfContents,
 | 
			
		||||
  Explorer,
 | 
			
		||||
  TagList,
 | 
			
		||||
  Graph,
 | 
			
		||||
  Backlinks,
 | 
			
		||||
@@ -36,4 +40,6 @@ export {
 | 
			
		||||
  DesktopOnly,
 | 
			
		||||
  MobileOnly,
 | 
			
		||||
  RecentNotes,
 | 
			
		||||
  NotFound,
 | 
			
		||||
  Breadcrumbs,
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										12
									
								
								quartz/components/pages/404.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								quartz/components/pages/404.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,12 @@
 | 
			
		||||
import { QuartzComponentConstructor } from "../types"
 | 
			
		||||
 | 
			
		||||
function NotFound() {
 | 
			
		||||
  return (
 | 
			
		||||
    <article class="popover-hint">
 | 
			
		||||
      <h1>404</h1>
 | 
			
		||||
      <p>Either this page is private or doesn't exist.</p>
 | 
			
		||||
    </article>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default (() => NotFound) satisfies QuartzComponentConstructor
 | 
			
		||||
@@ -1,10 +1,8 @@
 | 
			
		||||
import { htmlToJsx } from "../../util/jsx"
 | 
			
		||||
import { QuartzComponentConstructor, QuartzComponentProps } from "../types"
 | 
			
		||||
import { Fragment, jsx, jsxs } from "preact/jsx-runtime"
 | 
			
		||||
import { toJsxRuntime } from "hast-util-to-jsx-runtime"
 | 
			
		||||
 | 
			
		||||
function Content({ tree }: QuartzComponentProps) {
 | 
			
		||||
  // @ts-ignore (preact makes it angry)
 | 
			
		||||
  const content = toJsxRuntime(tree, { Fragment, jsx, jsxs, elementAttributeNameCase: "html" })
 | 
			
		||||
function Content({ fileData, tree }: QuartzComponentProps) {
 | 
			
		||||
  const content = htmlToJsx(fileData.filePath!, tree)
 | 
			
		||||
  return <article class="popover-hint">{content}</article>
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,12 +1,12 @@
 | 
			
		||||
import { QuartzComponentConstructor, QuartzComponentProps } from "../types"
 | 
			
		||||
import { Fragment, jsx, jsxs } from "preact/jsx-runtime"
 | 
			
		||||
import { toJsxRuntime } from "hast-util-to-jsx-runtime"
 | 
			
		||||
import path from "path"
 | 
			
		||||
 | 
			
		||||
import style from "../styles/listPage.scss"
 | 
			
		||||
import { PageList } from "../PageList"
 | 
			
		||||
import { _stripSlashes, simplifySlug } from "../../util/path"
 | 
			
		||||
import { Root } from "hast"
 | 
			
		||||
import { pluralize } from "../../util/lang"
 | 
			
		||||
import { htmlToJsx } from "../../util/jsx"
 | 
			
		||||
 | 
			
		||||
function FolderContent(props: QuartzComponentProps) {
 | 
			
		||||
  const { tree, fileData, allFiles } = props
 | 
			
		||||
@@ -28,15 +28,14 @@ function FolderContent(props: QuartzComponentProps) {
 | 
			
		||||
  const content =
 | 
			
		||||
    (tree as Root).children.length === 0
 | 
			
		||||
      ? fileData.description
 | 
			
		||||
      : // @ts-ignore
 | 
			
		||||
        toJsxRuntime(tree, { Fragment, jsx, jsxs, elementAttributeNameCase: "html" })
 | 
			
		||||
      : htmlToJsx(fileData.filePath!, tree)
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div class="popover-hint">
 | 
			
		||||
      <article>
 | 
			
		||||
        <p>{content}</p>
 | 
			
		||||
      </article>
 | 
			
		||||
      <p>{allPagesInFolder.length} items under this folder.</p>
 | 
			
		||||
      <p>{pluralize(allPagesInFolder.length, "item")} under this folder.</p>
 | 
			
		||||
      <div>
 | 
			
		||||
        <PageList {...listProps} />
 | 
			
		||||
      </div>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,11 +1,11 @@
 | 
			
		||||
import { QuartzComponentConstructor, QuartzComponentProps } from "../types"
 | 
			
		||||
import { Fragment, jsx, jsxs } from "preact/jsx-runtime"
 | 
			
		||||
import { toJsxRuntime } from "hast-util-to-jsx-runtime"
 | 
			
		||||
import style from "../styles/listPage.scss"
 | 
			
		||||
import { PageList } from "../PageList"
 | 
			
		||||
import { FullSlug, getAllSegmentPrefixes, simplifySlug } from "../../util/path"
 | 
			
		||||
import { QuartzPluginData } from "../../plugins/vfile"
 | 
			
		||||
import { Root } from "hast"
 | 
			
		||||
import { pluralize } from "../../util/lang"
 | 
			
		||||
import { htmlToJsx } from "../../util/jsx"
 | 
			
		||||
 | 
			
		||||
const numPages = 10
 | 
			
		||||
function TagContent(props: QuartzComponentProps) {
 | 
			
		||||
@@ -25,8 +25,7 @@ function TagContent(props: QuartzComponentProps) {
 | 
			
		||||
  const content =
 | 
			
		||||
    (tree as Root).children.length === 0
 | 
			
		||||
      ? fileData.description
 | 
			
		||||
      : // @ts-ignore
 | 
			
		||||
        toJsxRuntime(tree, { Fragment, jsx, jsxs, elementAttributeNameCase: "html" })
 | 
			
		||||
      : htmlToJsx(fileData.filePath!, tree)
 | 
			
		||||
 | 
			
		||||
  if (tag === "") {
 | 
			
		||||
    const tags = [...new Set(allFiles.flatMap((data) => data.frontmatter?.tags ?? []))]
 | 
			
		||||
@@ -54,13 +53,13 @@ function TagContent(props: QuartzComponentProps) {
 | 
			
		||||
            return (
 | 
			
		||||
              <div>
 | 
			
		||||
                <h2>
 | 
			
		||||
                  <a class="internal tag-link" href={`./${tag}`}>
 | 
			
		||||
                  <a class="internal tag-link" href={`../tags/${tag}`}>
 | 
			
		||||
                    #{tag}
 | 
			
		||||
                  </a>
 | 
			
		||||
                </h2>
 | 
			
		||||
                {content && <p>{content}</p>}
 | 
			
		||||
                <p>
 | 
			
		||||
                  {pages.length} items with this tag.{" "}
 | 
			
		||||
                  {pluralize(pages.length, "item")} with this tag.{" "}
 | 
			
		||||
                  {pages.length > numPages && `Showing first ${numPages}.`}
 | 
			
		||||
                </p>
 | 
			
		||||
                <PageList limit={numPages} {...listProps} />
 | 
			
		||||
@@ -80,7 +79,7 @@ function TagContent(props: QuartzComponentProps) {
 | 
			
		||||
    return (
 | 
			
		||||
      <div class="popover-hint">
 | 
			
		||||
        <article>{content}</article>
 | 
			
		||||
        <p>{pages.length} items with this tag.</p>
 | 
			
		||||
        <p>{pluralize(pages.length, "item")} with this tag.</p>
 | 
			
		||||
        <div>
 | 
			
		||||
          <PageList {...listProps} />
 | 
			
		||||
        </div>
 | 
			
		||||
 
 | 
			
		||||
@@ -3,7 +3,9 @@ import { QuartzComponent, QuartzComponentProps } from "./types"
 | 
			
		||||
import HeaderConstructor from "./Header"
 | 
			
		||||
import BodyConstructor from "./Body"
 | 
			
		||||
import { JSResourceToScriptElement, StaticResources } from "../util/resources"
 | 
			
		||||
import { FullSlug, joinSegments, pathToRoot } from "../util/path"
 | 
			
		||||
import { FullSlug, RelativeURL, joinSegments } from "../util/path"
 | 
			
		||||
import { visit } from "unist-util-visit"
 | 
			
		||||
import { Root, Element, ElementContent } from "hast"
 | 
			
		||||
 | 
			
		||||
interface RenderComponents {
 | 
			
		||||
  head: QuartzComponent
 | 
			
		||||
@@ -15,9 +17,10 @@ interface RenderComponents {
 | 
			
		||||
  footer: QuartzComponent
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function pageResources(slug: FullSlug, staticResources: StaticResources): StaticResources {
 | 
			
		||||
  const baseDir = pathToRoot(slug)
 | 
			
		||||
 | 
			
		||||
export function pageResources(
 | 
			
		||||
  baseDir: FullSlug | RelativeURL,
 | 
			
		||||
  staticResources: StaticResources,
 | 
			
		||||
): StaticResources {
 | 
			
		||||
  const contentIndexPath = joinSegments(baseDir, "static/contentIndex.json")
 | 
			
		||||
  const contentIndexScript = `const fetchData = fetch(\`${contentIndexPath}\`).then(data => data.json())`
 | 
			
		||||
 | 
			
		||||
@@ -52,6 +55,99 @@ export function renderPage(
 | 
			
		||||
  components: RenderComponents,
 | 
			
		||||
  pageResources: StaticResources,
 | 
			
		||||
): string {
 | 
			
		||||
  // process transcludes in componentData
 | 
			
		||||
  visit(componentData.tree as Root, "element", (node, _index, _parent) => {
 | 
			
		||||
    if (node.tagName === "blockquote") {
 | 
			
		||||
      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)
 | 
			
		||||
        if (!page) {
 | 
			
		||||
          return
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        let blockRef = node.properties?.dataBlock as string | undefined
 | 
			
		||||
        if (blockRef?.startsWith("^")) {
 | 
			
		||||
          // block transclude
 | 
			
		||||
          blockRef = blockRef.slice(1)
 | 
			
		||||
          let blockNode = page.blocks?.[blockRef]
 | 
			
		||||
          if (blockNode) {
 | 
			
		||||
            if (blockNode.tagName === "li") {
 | 
			
		||||
              blockNode = {
 | 
			
		||||
                type: "element",
 | 
			
		||||
                tagName: "ul",
 | 
			
		||||
                children: [blockNode],
 | 
			
		||||
              }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            node.children = [
 | 
			
		||||
              blockNode,
 | 
			
		||||
              {
 | 
			
		||||
                type: "element",
 | 
			
		||||
                tagName: "a",
 | 
			
		||||
                properties: { href: inner.properties?.href, class: ["internal"] },
 | 
			
		||||
                children: [{ type: "text", value: `Link to original` }],
 | 
			
		||||
              },
 | 
			
		||||
            ]
 | 
			
		||||
          }
 | 
			
		||||
        } else if (blockRef?.startsWith("#") && page.htmlAst) {
 | 
			
		||||
          // header transclude
 | 
			
		||||
          blockRef = blockRef.slice(1)
 | 
			
		||||
          let startIdx = undefined
 | 
			
		||||
          let endIdx = undefined
 | 
			
		||||
          for (const [i, el] of page.htmlAst.children.entries()) {
 | 
			
		||||
            if (el.type === "element" && el.tagName.match(/h[1-6]/)) {
 | 
			
		||||
              if (endIdx) {
 | 
			
		||||
                break
 | 
			
		||||
              }
 | 
			
		||||
 | 
			
		||||
              if (startIdx) {
 | 
			
		||||
                endIdx = i
 | 
			
		||||
              } else if (el.properties?.id === blockRef) {
 | 
			
		||||
                startIdx = i
 | 
			
		||||
              }
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          if (!startIdx) {
 | 
			
		||||
            return
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          node.children = [
 | 
			
		||||
            ...(page.htmlAst.children.slice(startIdx, endIdx) as ElementContent[]),
 | 
			
		||||
            {
 | 
			
		||||
              type: "element",
 | 
			
		||||
              tagName: "a",
 | 
			
		||||
              properties: { href: inner.properties?.href, class: ["internal"] },
 | 
			
		||||
              children: [{ type: "text", value: `Link to original` }],
 | 
			
		||||
            },
 | 
			
		||||
          ]
 | 
			
		||||
        } else if (page.htmlAst) {
 | 
			
		||||
          // page transclude
 | 
			
		||||
          node.children = [
 | 
			
		||||
            {
 | 
			
		||||
              type: "element",
 | 
			
		||||
              tagName: "h1",
 | 
			
		||||
              children: [
 | 
			
		||||
                { type: "text", value: page.frontmatter?.title ?? `Transclude of ${page.slug}` },
 | 
			
		||||
              ],
 | 
			
		||||
            },
 | 
			
		||||
            ...(page.htmlAst.children as ElementContent[]),
 | 
			
		||||
            {
 | 
			
		||||
              type: "element",
 | 
			
		||||
              tagName: "a",
 | 
			
		||||
              properties: { href: inner.properties?.href, class: ["internal"] },
 | 
			
		||||
              children: [{ type: "text", value: `Link to original` }],
 | 
			
		||||
            },
 | 
			
		||||
          ]
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  const {
 | 
			
		||||
    head: Head,
 | 
			
		||||
    header,
 | 
			
		||||
 
 | 
			
		||||
@@ -20,4 +20,13 @@ document.addEventListener("nav", () => {
 | 
			
		||||
  if (currentTheme === "dark") {
 | 
			
		||||
    toggleSwitch.checked = true
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Listen for changes in prefers-color-scheme
 | 
			
		||||
  const colorSchemeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
 | 
			
		||||
  colorSchemeMediaQuery.addEventListener("change", (e) => {
 | 
			
		||||
    const newTheme = e.matches ? "dark" : "light"
 | 
			
		||||
    document.documentElement.setAttribute("saved-theme", newTheme)
 | 
			
		||||
    localStorage.setItem("theme", newTheme)
 | 
			
		||||
    toggleSwitch.checked = e.matches
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										164
									
								
								quartz/components/scripts/explorer.inline.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										164
									
								
								quartz/components/scripts/explorer.inline.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,164 @@
 | 
			
		||||
import { FolderState } from "../ExplorerNode"
 | 
			
		||||
 | 
			
		||||
// Current state of folders
 | 
			
		||||
let explorerState: FolderState[]
 | 
			
		||||
 | 
			
		||||
const observer = new IntersectionObserver((entries) => {
 | 
			
		||||
  // If last element is observed, remove gradient of "overflow" class so element is visible
 | 
			
		||||
  const explorer = document.getElementById("explorer-ul")
 | 
			
		||||
  for (const entry of entries) {
 | 
			
		||||
    if (entry.isIntersecting) {
 | 
			
		||||
      explorer?.classList.add("no-background")
 | 
			
		||||
    } else {
 | 
			
		||||
      explorer?.classList.remove("no-background")
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
function toggleExplorer(this: HTMLElement) {
 | 
			
		||||
  // Toggle collapsed state of entire explorer
 | 
			
		||||
  this.classList.toggle("collapsed")
 | 
			
		||||
  const content = this.nextElementSibling as HTMLElement
 | 
			
		||||
  content.classList.toggle("collapsed")
 | 
			
		||||
  content.style.maxHeight = content.style.maxHeight === "0px" ? content.scrollHeight + "px" : "0px"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function toggleFolder(evt: MouseEvent) {
 | 
			
		||||
  evt.stopPropagation()
 | 
			
		||||
 | 
			
		||||
  // Element that was clicked
 | 
			
		||||
  const target = evt.target as HTMLElement
 | 
			
		||||
 | 
			
		||||
  // Check if target was svg icon or button
 | 
			
		||||
  const isSvg = target.nodeName === "svg"
 | 
			
		||||
 | 
			
		||||
  // corresponding <ul> element relative to clicked button/folder
 | 
			
		||||
  let childFolderContainer: HTMLElement
 | 
			
		||||
 | 
			
		||||
  // <li> element of folder (stores folder-path dataset)
 | 
			
		||||
  let currentFolderParent: HTMLElement
 | 
			
		||||
 | 
			
		||||
  // Get correct relative container and toggle collapsed class
 | 
			
		||||
  if (isSvg) {
 | 
			
		||||
    childFolderContainer = target.parentElement?.nextSibling as HTMLElement
 | 
			
		||||
    currentFolderParent = target.nextElementSibling as HTMLElement
 | 
			
		||||
 | 
			
		||||
    childFolderContainer.classList.toggle("open")
 | 
			
		||||
  } else {
 | 
			
		||||
    childFolderContainer = target.parentElement?.parentElement?.nextElementSibling as HTMLElement
 | 
			
		||||
    currentFolderParent = target.parentElement as HTMLElement
 | 
			
		||||
 | 
			
		||||
    childFolderContainer.classList.toggle("open")
 | 
			
		||||
  }
 | 
			
		||||
  if (!childFolderContainer) return
 | 
			
		||||
 | 
			
		||||
  // Collapse folder container
 | 
			
		||||
  const isCollapsed = childFolderContainer.classList.contains("open")
 | 
			
		||||
  setFolderState(childFolderContainer, !isCollapsed)
 | 
			
		||||
 | 
			
		||||
  // Save folder state to localStorage
 | 
			
		||||
  const clickFolderPath = currentFolderParent.dataset.folderpath as string
 | 
			
		||||
 | 
			
		||||
  // Remove leading "/"
 | 
			
		||||
  const fullFolderPath = clickFolderPath.substring(1)
 | 
			
		||||
  toggleCollapsedByPath(explorerState, fullFolderPath)
 | 
			
		||||
 | 
			
		||||
  const stringifiedFileTree = JSON.stringify(explorerState)
 | 
			
		||||
  localStorage.setItem("fileTree", stringifiedFileTree)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function setupExplorer() {
 | 
			
		||||
  // Set click handler for collapsing entire explorer
 | 
			
		||||
  const explorer = document.getElementById("explorer")
 | 
			
		||||
 | 
			
		||||
  // Get folder state from local storage
 | 
			
		||||
  const storageTree = localStorage.getItem("fileTree")
 | 
			
		||||
 | 
			
		||||
  // Convert to bool
 | 
			
		||||
  const useSavedFolderState = explorer?.dataset.savestate === "true"
 | 
			
		||||
 | 
			
		||||
  if (explorer) {
 | 
			
		||||
    // Get config
 | 
			
		||||
    const collapseBehavior = explorer.dataset.behavior
 | 
			
		||||
 | 
			
		||||
    // Add click handlers for all folders (click handler on folder "label")
 | 
			
		||||
    if (collapseBehavior === "collapse") {
 | 
			
		||||
      Array.prototype.forEach.call(
 | 
			
		||||
        document.getElementsByClassName("folder-button"),
 | 
			
		||||
        function (item) {
 | 
			
		||||
          item.removeEventListener("click", toggleFolder)
 | 
			
		||||
          item.addEventListener("click", toggleFolder)
 | 
			
		||||
        },
 | 
			
		||||
      )
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Add click handler to main explorer
 | 
			
		||||
    explorer.removeEventListener("click", toggleExplorer)
 | 
			
		||||
    explorer.addEventListener("click", toggleExplorer)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Set up click handlers for each folder (click handler on folder "icon")
 | 
			
		||||
  Array.prototype.forEach.call(document.getElementsByClassName("folder-icon"), function (item) {
 | 
			
		||||
    item.removeEventListener("click", toggleFolder)
 | 
			
		||||
    item.addEventListener("click", toggleFolder)
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  if (storageTree && useSavedFolderState) {
 | 
			
		||||
    // Get state from localStorage and set folder state
 | 
			
		||||
    explorerState = JSON.parse(storageTree)
 | 
			
		||||
    explorerState.map((folderUl) => {
 | 
			
		||||
      // grab <li> element for matching folder path
 | 
			
		||||
      const folderLi = document.querySelector(
 | 
			
		||||
        `[data-folderpath='/${folderUl.path}']`,
 | 
			
		||||
      ) as HTMLElement
 | 
			
		||||
 | 
			
		||||
      // Get corresponding content <ul> tag and set state
 | 
			
		||||
      if (folderLi) {
 | 
			
		||||
        const folderUL = folderLi.parentElement?.nextElementSibling
 | 
			
		||||
        if (folderUL) {
 | 
			
		||||
          setFolderState(folderUL as HTMLElement, folderUl.collapsed)
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
  } else {
 | 
			
		||||
    // 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)
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
window.addEventListener("resize", setupExplorer)
 | 
			
		||||
document.addEventListener("nav", () => {
 | 
			
		||||
  setupExplorer()
 | 
			
		||||
 | 
			
		||||
  const explorerContent = document.getElementById("explorer-ul")
 | 
			
		||||
  // select pseudo element at end of list
 | 
			
		||||
  const lastItem = document.getElementById("explorer-end")
 | 
			
		||||
 | 
			
		||||
  observer.disconnect()
 | 
			
		||||
  observer.observe(lastItem as Element)
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Toggles the state of a given folder
 | 
			
		||||
 * @param folderElement <div class="folder-outer"> Element of folder (parent)
 | 
			
		||||
 * @param collapsed if folder should be set to collapsed or not
 | 
			
		||||
 */
 | 
			
		||||
function setFolderState(folderElement: HTMLElement, collapsed: boolean) {
 | 
			
		||||
  if (collapsed) {
 | 
			
		||||
    folderElement?.classList.remove("open")
 | 
			
		||||
  } else {
 | 
			
		||||
    folderElement?.classList.add("open")
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Toggles visibility of a folder
 | 
			
		||||
 * @param array array of FolderState (`fileTree`, either get from local storage or data attribute)
 | 
			
		||||
 * @param path path to folder (e.g. 'advanced/more/more2')
 | 
			
		||||
 */
 | 
			
		||||
function toggleCollapsedByPath(array: FolderState[], path: string) {
 | 
			
		||||
  const entry = array.find((item) => item.path === path)
 | 
			
		||||
  if (entry) {
 | 
			
		||||
    entry.collapsed = !entry.collapsed
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -42,19 +42,38 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
 | 
			
		||||
    linkDistance,
 | 
			
		||||
    fontSize,
 | 
			
		||||
    opacityScale,
 | 
			
		||||
    removeTags,
 | 
			
		||||
    showTags,
 | 
			
		||||
  } = JSON.parse(graph.dataset["cfg"]!)
 | 
			
		||||
 | 
			
		||||
  const data = await fetchData
 | 
			
		||||
 | 
			
		||||
  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 outgoing = details.links ?? []
 | 
			
		||||
 | 
			
		||||
    for (const dest of outgoing) {
 | 
			
		||||
      if (dest in data) {
 | 
			
		||||
      if (validLinks.has(dest)) {
 | 
			
		||||
        links.push({ source, target: dest })
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (showTags) {
 | 
			
		||||
      const localTags = details.tags
 | 
			
		||||
        .filter((tag) => !removeTags.includes(tag))
 | 
			
		||||
        .map((tag) => simplifySlug(("tags/" + tag) as FullSlug))
 | 
			
		||||
 | 
			
		||||
      tags.push(...localTags.filter((tag) => !tags.includes(tag)))
 | 
			
		||||
 | 
			
		||||
      for (const tag of localTags) {
 | 
			
		||||
        links.push({ source, target: tag })
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const neighbourhood = new Set<SimpleSlug>()
 | 
			
		||||
@@ -75,14 +94,18 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
 | 
			
		||||
    }
 | 
			
		||||
  } else {
 | 
			
		||||
    Object.keys(data).forEach((id) => neighbourhood.add(simplifySlug(id as FullSlug)))
 | 
			
		||||
    if (showTags) tags.forEach((tag) => neighbourhood.add(tag))
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const graphData: { nodes: NodeData[]; links: LinkData[] } = {
 | 
			
		||||
    nodes: [...neighbourhood].map((url) => ({
 | 
			
		||||
      id: url,
 | 
			
		||||
      text: data[url]?.title ?? url,
 | 
			
		||||
      tags: data[url]?.tags ?? [],
 | 
			
		||||
    })),
 | 
			
		||||
    nodes: [...neighbourhood].map((url) => {
 | 
			
		||||
      const text = url.startsWith("tags/") ? "#" + url.substring(5) : data[url]?.title ?? url
 | 
			
		||||
      return {
 | 
			
		||||
        id: url,
 | 
			
		||||
        text: text,
 | 
			
		||||
        tags: data[url]?.tags ?? [],
 | 
			
		||||
      }
 | 
			
		||||
    }),
 | 
			
		||||
    links: links.filter((l) => neighbourhood.has(l.source) && neighbourhood.has(l.target)),
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -126,7 +149,7 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
 | 
			
		||||
    const isCurrent = d.id === slug
 | 
			
		||||
    if (isCurrent) {
 | 
			
		||||
      return "var(--secondary)"
 | 
			
		||||
    } else if (visited.has(d.id)) {
 | 
			
		||||
    } else if (visited.has(d.id) || d.id.startsWith("tags/")) {
 | 
			
		||||
      return "var(--tertiary)"
 | 
			
		||||
    } else {
 | 
			
		||||
      return "var(--gray)"
 | 
			
		||||
@@ -230,9 +253,7 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
 | 
			
		||||
    .attr("dx", 0)
 | 
			
		||||
    .attr("dy", (d) => -nodeRadius(d) + "px")
 | 
			
		||||
    .attr("text-anchor", "middle")
 | 
			
		||||
    .text(
 | 
			
		||||
      (d) => data[d.id]?.title || (d.id.charAt(1).toUpperCase() + d.id.slice(2)).replace("-", " "),
 | 
			
		||||
    )
 | 
			
		||||
    .text((d) => d.text)
 | 
			
		||||
    .style("opacity", (opacityScale - 1) / 3.75)
 | 
			
		||||
    .style("pointer-events", "none")
 | 
			
		||||
    .style("font-size", fontSize + "em")
 | 
			
		||||
 
 | 
			
		||||
@@ -28,8 +28,11 @@ async function mouseEnterHandler(
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const hasAlreadyBeenFetched = () =>
 | 
			
		||||
    [...link.children].some((child) => child.classList.contains("popover"))
 | 
			
		||||
 | 
			
		||||
  // dont refetch if there's already a popover
 | 
			
		||||
  if ([...link.children].some((child) => child.classList.contains("popover"))) {
 | 
			
		||||
  if (hasAlreadyBeenFetched()) {
 | 
			
		||||
    return setPosition(link.lastChild as HTMLElement)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -49,6 +52,11 @@ async function mouseEnterHandler(
 | 
			
		||||
      console.error(err)
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
  // bailout if another popover exists
 | 
			
		||||
  if (hasAlreadyBeenFetched()) {
 | 
			
		||||
    return
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (!contents) return
 | 
			
		||||
  const html = p.parseFromString(contents, "text/html")
 | 
			
		||||
  normalizeRelativeURLs(html, targetUrl)
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
import { Document } from "flexsearch"
 | 
			
		||||
import { Document, SimpleDocumentSearchResultSetUnit } from "flexsearch"
 | 
			
		||||
import { ContentDetails } from "../../plugins/emitters/contentIndex"
 | 
			
		||||
import { registerEscapeHandler, removeAllChildren } from "./util"
 | 
			
		||||
import { FullSlug, resolveRelative } from "../../util/path"
 | 
			
		||||
@@ -8,12 +8,20 @@ interface Item {
 | 
			
		||||
  slug: FullSlug
 | 
			
		||||
  title: string
 | 
			
		||||
  content: string
 | 
			
		||||
  tags: string[]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
let index: Document<Item> | undefined = undefined
 | 
			
		||||
 | 
			
		||||
// Can be expanded with things like "term" in the future
 | 
			
		||||
type SearchType = "basic" | "tags"
 | 
			
		||||
 | 
			
		||||
// Current searchType
 | 
			
		||||
let searchType: SearchType = "basic"
 | 
			
		||||
 | 
			
		||||
const contextWindowWords = 30
 | 
			
		||||
const numSearchResults = 5
 | 
			
		||||
const numTagResults = 3
 | 
			
		||||
function highlight(searchTerm: string, text: string, trim?: boolean) {
 | 
			
		||||
  // try to highlight longest tokens first
 | 
			
		||||
  const tokenizedTerms = searchTerm
 | 
			
		||||
@@ -74,6 +82,7 @@ document.addEventListener("nav", async (e: unknown) => {
 | 
			
		||||
  const searchIcon = document.getElementById("search-icon")
 | 
			
		||||
  const searchBar = document.getElementById("search-bar") as HTMLInputElement | null
 | 
			
		||||
  const results = document.getElementById("results-container")
 | 
			
		||||
  const resultCards = document.getElementsByClassName("result-card")
 | 
			
		||||
  const idDataMap = Object.keys(data) as FullSlug[]
 | 
			
		||||
 | 
			
		||||
  function hideSearch() {
 | 
			
		||||
@@ -87,9 +96,12 @@ document.addEventListener("nav", async (e: unknown) => {
 | 
			
		||||
    if (results) {
 | 
			
		||||
      removeAllChildren(results)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    searchType = "basic" // reset search type after closing
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  function showSearch() {
 | 
			
		||||
  function showSearch(searchTypeNew: SearchType) {
 | 
			
		||||
    searchType = searchTypeNew
 | 
			
		||||
    if (sidebar) {
 | 
			
		||||
      sidebar.style.zIndex = "1"
 | 
			
		||||
    }
 | 
			
		||||
@@ -98,36 +110,123 @@ document.addEventListener("nav", async (e: unknown) => {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  function shortcutHandler(e: HTMLElementEventMap["keydown"]) {
 | 
			
		||||
    if (e.key === "k" && (e.ctrlKey || e.metaKey)) {
 | 
			
		||||
    if (e.key === "k" && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
 | 
			
		||||
      e.preventDefault()
 | 
			
		||||
      const searchBarOpen = container?.classList.contains("active")
 | 
			
		||||
      searchBarOpen ? hideSearch() : showSearch()
 | 
			
		||||
      searchBarOpen ? hideSearch() : showSearch("basic")
 | 
			
		||||
    } else if (e.shiftKey && (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
 | 
			
		||||
      // Hotkey to open tag search
 | 
			
		||||
      e.preventDefault()
 | 
			
		||||
      const searchBarOpen = container?.classList.contains("active")
 | 
			
		||||
      searchBarOpen ? hideSearch() : showSearch("tags")
 | 
			
		||||
 | 
			
		||||
      // add "#" prefix for tag search
 | 
			
		||||
      if (searchBar) searchBar.value = "#"
 | 
			
		||||
    } else if (e.key === "Enter") {
 | 
			
		||||
      const anchor = document.getElementsByClassName("result-card")[0] as HTMLInputElement | null
 | 
			
		||||
      if (anchor) {
 | 
			
		||||
        anchor.click()
 | 
			
		||||
      // If result has focus, navigate to that one, otherwise pick first result
 | 
			
		||||
      if (results?.contains(document.activeElement)) {
 | 
			
		||||
        const active = document.activeElement as HTMLInputElement
 | 
			
		||||
        active.click()
 | 
			
		||||
      } else {
 | 
			
		||||
        const anchor = document.getElementsByClassName("result-card")[0] as HTMLInputElement | null
 | 
			
		||||
        anchor?.click()
 | 
			
		||||
      }
 | 
			
		||||
    } else if (e.key === "ArrowDown") {
 | 
			
		||||
      e.preventDefault()
 | 
			
		||||
      // When first pressing ArrowDown, results wont contain the active element, so focus first element
 | 
			
		||||
      if (!results?.contains(document.activeElement)) {
 | 
			
		||||
        const firstResult = resultCards[0] as HTMLInputElement | null
 | 
			
		||||
        firstResult?.focus()
 | 
			
		||||
      } else {
 | 
			
		||||
        // If an element in results-container already has focus, focus next one
 | 
			
		||||
        const nextResult = document.activeElement?.nextElementSibling as HTMLInputElement | null
 | 
			
		||||
        nextResult?.focus()
 | 
			
		||||
      }
 | 
			
		||||
    } else if (e.key === "ArrowUp") {
 | 
			
		||||
      e.preventDefault()
 | 
			
		||||
      if (results?.contains(document.activeElement)) {
 | 
			
		||||
        // If an element in results-container already has focus, focus previous one
 | 
			
		||||
        const prevResult = document.activeElement?.previousElementSibling as HTMLInputElement | null
 | 
			
		||||
        prevResult?.focus()
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  function trimContent(content: string) {
 | 
			
		||||
    // works without escaping html like in `description.ts`
 | 
			
		||||
    const sentences = content.replace(/\s+/g, " ").split(".")
 | 
			
		||||
    let finalDesc = ""
 | 
			
		||||
    let sentenceIdx = 0
 | 
			
		||||
 | 
			
		||||
    // Roughly estimate characters by (words * 5). Matches description length in `description.ts`.
 | 
			
		||||
    const len = contextWindowWords * 5
 | 
			
		||||
    while (finalDesc.length < len) {
 | 
			
		||||
      const sentence = sentences[sentenceIdx]
 | 
			
		||||
      if (!sentence) break
 | 
			
		||||
      finalDesc += sentence + "."
 | 
			
		||||
      sentenceIdx++
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // If more content would be available, indicate it by finishing with "..."
 | 
			
		||||
    if (finalDesc.length < content.length) {
 | 
			
		||||
      finalDesc += ".."
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return finalDesc
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const formatForDisplay = (term: string, id: number) => {
 | 
			
		||||
    const slug = idDataMap[id]
 | 
			
		||||
    return {
 | 
			
		||||
      id,
 | 
			
		||||
      slug,
 | 
			
		||||
      title: highlight(term, data[slug].title ?? ""),
 | 
			
		||||
      content: highlight(term, data[slug].content ?? "", true),
 | 
			
		||||
      title: searchType === "tags" ? data[slug].title : highlight(term, data[slug].title ?? ""),
 | 
			
		||||
      // if searchType is tag, display context from start of file and trim, otherwise use regular highlight
 | 
			
		||||
      content:
 | 
			
		||||
        searchType === "tags"
 | 
			
		||||
          ? trimContent(data[slug].content)
 | 
			
		||||
          : highlight(term, data[slug].content ?? "", true),
 | 
			
		||||
      tags: highlightTags(term, data[slug].tags),
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const resultToHTML = ({ slug, title, content }: Item) => {
 | 
			
		||||
  function highlightTags(term: string, tags: string[]) {
 | 
			
		||||
    if (tags && searchType === "tags") {
 | 
			
		||||
      // Find matching tags
 | 
			
		||||
      const termLower = term.toLowerCase()
 | 
			
		||||
      let matching = tags.filter((str) => str.includes(termLower))
 | 
			
		||||
 | 
			
		||||
      // Substract matching from original tags, then push difference
 | 
			
		||||
      if (matching.length > 0) {
 | 
			
		||||
        let difference = tags.filter((x) => !matching.includes(x))
 | 
			
		||||
 | 
			
		||||
        // Convert to html (cant be done later as matches/term dont get passed to `resultToHTML`)
 | 
			
		||||
        matching = matching.map((tag) => `<li><p class="match-tag">#${tag}</p></li>`)
 | 
			
		||||
        difference = difference.map((tag) => `<li><p>#${tag}</p></li>`)
 | 
			
		||||
        matching.push(...difference)
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Only allow max of `numTagResults` in preview
 | 
			
		||||
      if (tags.length > numTagResults) {
 | 
			
		||||
        matching.splice(numTagResults)
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return matching
 | 
			
		||||
    } else {
 | 
			
		||||
      return []
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const resultToHTML = ({ slug, title, content, tags }: Item) => {
 | 
			
		||||
    const htmlTags = tags.length > 0 ? `<ul>${tags.join("")}</ul>` : ``
 | 
			
		||||
    const button = document.createElement("button")
 | 
			
		||||
    button.classList.add("result-card")
 | 
			
		||||
    button.id = slug
 | 
			
		||||
    button.innerHTML = `<h3>${title}</h3><p>${content}</p>`
 | 
			
		||||
    button.innerHTML = `<h3>${title}</h3>${htmlTags}<p>${content}</p>`
 | 
			
		||||
    button.addEventListener("click", () => {
 | 
			
		||||
      const targ = resolveRelative(currentSlug, slug)
 | 
			
		||||
      window.spaNavigate(new URL(targ, window.location.toString()))
 | 
			
		||||
      hideSearch()
 | 
			
		||||
    })
 | 
			
		||||
    return button
 | 
			
		||||
  }
 | 
			
		||||
@@ -147,15 +246,45 @@ document.addEventListener("nav", async (e: unknown) => {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async function onType(e: HTMLElementEventMap["input"]) {
 | 
			
		||||
    const term = (e.target as HTMLInputElement).value
 | 
			
		||||
    const searchResults = (await index?.searchAsync(term, numSearchResults)) ?? []
 | 
			
		||||
    let term = (e.target as HTMLInputElement).value
 | 
			
		||||
    let searchResults: SimpleDocumentSearchResultSetUnit[]
 | 
			
		||||
 | 
			
		||||
    if (term.toLowerCase().startsWith("#")) {
 | 
			
		||||
      searchType = "tags"
 | 
			
		||||
    } else {
 | 
			
		||||
      searchType = "basic"
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    switch (searchType) {
 | 
			
		||||
      case "tags": {
 | 
			
		||||
        term = term.substring(1)
 | 
			
		||||
        searchResults =
 | 
			
		||||
          (await index?.searchAsync({ query: term, limit: numSearchResults, index: ["tags"] })) ??
 | 
			
		||||
          []
 | 
			
		||||
        break
 | 
			
		||||
      }
 | 
			
		||||
      case "basic":
 | 
			
		||||
      default: {
 | 
			
		||||
        searchResults =
 | 
			
		||||
          (await index?.searchAsync({
 | 
			
		||||
            query: term,
 | 
			
		||||
            limit: numSearchResults,
 | 
			
		||||
            index: ["title", "content"],
 | 
			
		||||
          })) ?? []
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const getByField = (field: string): number[] => {
 | 
			
		||||
      const results = searchResults.filter((x) => x.field === field)
 | 
			
		||||
      return results.length === 0 ? [] : ([...results[0].result] as number[])
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // order titles ahead of content
 | 
			
		||||
    const allIds: Set<number> = new Set([...getByField("title"), ...getByField("content")])
 | 
			
		||||
    const allIds: Set<number> = new Set([
 | 
			
		||||
      ...getByField("title"),
 | 
			
		||||
      ...getByField("content"),
 | 
			
		||||
      ...getByField("tags"),
 | 
			
		||||
    ])
 | 
			
		||||
    const finalResults = [...allIds].map((id) => formatForDisplay(term, id))
 | 
			
		||||
    displayResults(finalResults)
 | 
			
		||||
  }
 | 
			
		||||
@@ -166,15 +295,14 @@ document.addEventListener("nav", async (e: unknown) => {
 | 
			
		||||
 | 
			
		||||
  document.addEventListener("keydown", shortcutHandler)
 | 
			
		||||
  prevShortcutHandler = shortcutHandler
 | 
			
		||||
  searchIcon?.removeEventListener("click", showSearch)
 | 
			
		||||
  searchIcon?.addEventListener("click", showSearch)
 | 
			
		||||
  searchIcon?.removeEventListener("click", () => showSearch("basic"))
 | 
			
		||||
  searchIcon?.addEventListener("click", () => showSearch("basic"))
 | 
			
		||||
  searchBar?.removeEventListener("input", onType)
 | 
			
		||||
  searchBar?.addEventListener("input", onType)
 | 
			
		||||
 | 
			
		||||
  // setup index if it hasn't been already
 | 
			
		||||
  if (!index) {
 | 
			
		||||
    index = new Document({
 | 
			
		||||
      cache: true,
 | 
			
		||||
      charset: "latin:extra",
 | 
			
		||||
      optimize: true,
 | 
			
		||||
      encode: encoder,
 | 
			
		||||
@@ -189,22 +317,36 @@ document.addEventListener("nav", async (e: unknown) => {
 | 
			
		||||
            field: "content",
 | 
			
		||||
            tokenize: "reverse",
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            field: "tags",
 | 
			
		||||
            tokenize: "reverse",
 | 
			
		||||
          },
 | 
			
		||||
        ],
 | 
			
		||||
      },
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    let id = 0
 | 
			
		||||
    for (const [slug, fileData] of Object.entries<ContentDetails>(data)) {
 | 
			
		||||
      await index.addAsync(id, {
 | 
			
		||||
        id,
 | 
			
		||||
        slug: slug as FullSlug,
 | 
			
		||||
        title: fileData.title,
 | 
			
		||||
        content: fileData.content,
 | 
			
		||||
      })
 | 
			
		||||
      id++
 | 
			
		||||
    }
 | 
			
		||||
    fillDocument(index, data)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // register handlers
 | 
			
		||||
  registerEscapeHandler(container, hideSearch)
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Fills flexsearch document with data
 | 
			
		||||
 * @param index index to fill
 | 
			
		||||
 * @param data data to fill index with
 | 
			
		||||
 */
 | 
			
		||||
async function fillDocument(index: Document<Item, false>, data: any) {
 | 
			
		||||
  let id = 0
 | 
			
		||||
  for (const [slug, fileData] of Object.entries<ContentDetails>(data)) {
 | 
			
		||||
    await index.addAsync(id, {
 | 
			
		||||
      id,
 | 
			
		||||
      slug: slug as FullSlug,
 | 
			
		||||
      title: fileData.title,
 | 
			
		||||
      content: fileData.content,
 | 
			
		||||
      tags: fileData.tags,
 | 
			
		||||
    })
 | 
			
		||||
    id++
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,6 @@
 | 
			
		||||
import micromorph from "micromorph"
 | 
			
		||||
import { FullSlug, RelativeURL, getFullSlug } from "../../util/path"
 | 
			
		||||
import { normalizeRelativeURLs } from "./popover.inline"
 | 
			
		||||
 | 
			
		||||
// adapted from `micromorph`
 | 
			
		||||
// https://github.com/natemoo-re/micromorph
 | 
			
		||||
@@ -18,8 +19,15 @@ const isLocalUrl = (href: string) => {
 | 
			
		||||
  return false
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const isSamePage = (url: URL): boolean => {
 | 
			
		||||
  const sameOrigin = url.origin === window.location.origin
 | 
			
		||||
  const samePath = url.pathname === window.location.pathname
 | 
			
		||||
  return sameOrigin && samePath
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const getOpts = ({ target }: Event): { url: URL; scroll?: boolean } | undefined => {
 | 
			
		||||
  if (!isElement(target)) return
 | 
			
		||||
  if (target.attributes.getNamedItem("target")?.value === "_blank") return
 | 
			
		||||
  const a = target.closest("a")
 | 
			
		||||
  if (!a) return
 | 
			
		||||
  if ("routerIgnore" in a.dataset) return
 | 
			
		||||
@@ -45,6 +53,8 @@ async function navigate(url: URL, isBack: boolean = false) {
 | 
			
		||||
  if (!contents) return
 | 
			
		||||
 | 
			
		||||
  const html = p.parseFromString(contents, "text/html")
 | 
			
		||||
  normalizeRelativeURLs(html, url)
 | 
			
		||||
 | 
			
		||||
  let title = html.querySelector("title")?.textContent
 | 
			
		||||
  if (title) {
 | 
			
		||||
    document.title = title
 | 
			
		||||
@@ -92,8 +102,16 @@ function createRouter() {
 | 
			
		||||
  if (typeof window !== "undefined") {
 | 
			
		||||
    window.addEventListener("click", async (event) => {
 | 
			
		||||
      const { url } = getOpts(event) ?? {}
 | 
			
		||||
      if (!url) return
 | 
			
		||||
      // dont hijack behaviour, just let browser act normally
 | 
			
		||||
      if (!url || event.ctrlKey || event.metaKey) return
 | 
			
		||||
      event.preventDefault()
 | 
			
		||||
 | 
			
		||||
      if (isSamePage(url) && url.hash) {
 | 
			
		||||
        const el = document.getElementById(decodeURIComponent(url.hash.substring(1)))
 | 
			
		||||
        el?.scrollIntoView()
 | 
			
		||||
        return
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      try {
 | 
			
		||||
        navigate(url, false)
 | 
			
		||||
      } catch (e) {
 | 
			
		||||
 
 | 
			
		||||
@@ -24,8 +24,9 @@ function toggleToc(this: HTMLElement) {
 | 
			
		||||
function setupToc() {
 | 
			
		||||
  const toc = document.getElementById("toc")
 | 
			
		||||
  if (toc) {
 | 
			
		||||
    const collapsed = toc.classList.contains("collapsed")
 | 
			
		||||
    const content = toc.nextElementSibling as HTMLElement
 | 
			
		||||
    content.style.maxHeight = content.scrollHeight + "px"
 | 
			
		||||
    content.style.maxHeight = collapsed ? "0px" : content.scrollHeight + "px"
 | 
			
		||||
    toc.removeEventListener("click", toggleToc)
 | 
			
		||||
    toc.addEventListener("click", toggleToc)
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										22
									
								
								quartz/components/styles/breadcrumbs.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								quartz/components/styles/breadcrumbs.scss
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,22 @@
 | 
			
		||||
.breadcrumb-container {
 | 
			
		||||
  margin: 0;
 | 
			
		||||
  margin-top: 0.75rem;
 | 
			
		||||
  padding: 0;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: row;
 | 
			
		||||
  flex-wrap: wrap;
 | 
			
		||||
  gap: 0.5rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.breadcrumb-element {
 | 
			
		||||
  p {
 | 
			
		||||
    margin: 0;
 | 
			
		||||
    margin-left: 0.5rem;
 | 
			
		||||
    padding: 0;
 | 
			
		||||
    line-height: normal;
 | 
			
		||||
  }
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: row;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  justify-content: center;
 | 
			
		||||
}
 | 
			
		||||
@@ -10,7 +10,6 @@
 | 
			
		||||
  background-color: var(--light);
 | 
			
		||||
  border: 1px solid;
 | 
			
		||||
  border-radius: 5px;
 | 
			
		||||
  z-index: 1;
 | 
			
		||||
  opacity: 0;
 | 
			
		||||
  transition: 0.2s;
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -21,6 +21,14 @@
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:root[saved-theme="dark"] {
 | 
			
		||||
  color-scheme: dark;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:root[saved-theme="light"] {
 | 
			
		||||
  color-scheme: light;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:root[saved-theme="dark"] .toggle ~ label {
 | 
			
		||||
  & > #dayIcon {
 | 
			
		||||
    opacity: 0;
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										146
									
								
								quartz/components/styles/explorer.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										146
									
								
								quartz/components/styles/explorer.scss
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,146 @@
 | 
			
		||||
button#explorer {
 | 
			
		||||
  all: unset;
 | 
			
		||||
  background-color: transparent;
 | 
			
		||||
  border: none;
 | 
			
		||||
  text-align: left;
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
  padding: 0;
 | 
			
		||||
  color: var(--dark);
 | 
			
		||||
  display: flex;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
 | 
			
		||||
  & h1 {
 | 
			
		||||
    font-size: 1rem;
 | 
			
		||||
    display: inline-block;
 | 
			
		||||
    margin: 0;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  & .fold {
 | 
			
		||||
    margin-left: 0.5rem;
 | 
			
		||||
    transition: transform 0.3s ease;
 | 
			
		||||
    opacity: 0.8;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  &.collapsed .fold {
 | 
			
		||||
    transform: rotateZ(-90deg);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.folder-outer {
 | 
			
		||||
  display: grid;
 | 
			
		||||
  grid-template-rows: 0fr;
 | 
			
		||||
  transition: grid-template-rows 0.3s ease-in-out;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.folder-outer.open {
 | 
			
		||||
  grid-template-rows: 1fr;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.folder-outer > ul {
 | 
			
		||||
  overflow: hidden;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#explorer-content {
 | 
			
		||||
  list-style: none;
 | 
			
		||||
  overflow: hidden;
 | 
			
		||||
  max-height: none;
 | 
			
		||||
  transition: max-height 0.35s ease;
 | 
			
		||||
  margin-top: 0.5rem;
 | 
			
		||||
 | 
			
		||||
  &.collapsed > .overflow::after {
 | 
			
		||||
    opacity: 0;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  & ul {
 | 
			
		||||
    list-style: none;
 | 
			
		||||
    margin: 0.08rem 0;
 | 
			
		||||
    padding: 0;
 | 
			
		||||
    transition:
 | 
			
		||||
      max-height 0.35s ease,
 | 
			
		||||
      transform 0.35s ease,
 | 
			
		||||
      opacity 0.2s ease;
 | 
			
		||||
    & li > a {
 | 
			
		||||
      color: var(--dark);
 | 
			
		||||
      opacity: 0.75;
 | 
			
		||||
      pointer-events: all;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
svg {
 | 
			
		||||
  pointer-events: all;
 | 
			
		||||
 | 
			
		||||
  & > polyline {
 | 
			
		||||
    pointer-events: none;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.folder-container {
 | 
			
		||||
  flex-direction: row;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  user-select: none;
 | 
			
		||||
 | 
			
		||||
  & div > a {
 | 
			
		||||
    color: var(--secondary);
 | 
			
		||||
    font-family: var(--headerFont);
 | 
			
		||||
    font-size: 0.95rem;
 | 
			
		||||
    font-weight: 600;
 | 
			
		||||
    line-height: 1.5rem;
 | 
			
		||||
    display: inline-block;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  & div > a:hover {
 | 
			
		||||
    color: var(--tertiary);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  & div > button {
 | 
			
		||||
    color: var(--dark);
 | 
			
		||||
    background-color: transparent;
 | 
			
		||||
    border: none;
 | 
			
		||||
    text-align: left;
 | 
			
		||||
    cursor: pointer;
 | 
			
		||||
    padding-left: 0;
 | 
			
		||||
    padding-right: 0;
 | 
			
		||||
    display: flex;
 | 
			
		||||
    align-items: center;
 | 
			
		||||
    font-family: var(--headerFont);
 | 
			
		||||
 | 
			
		||||
    & p {
 | 
			
		||||
      font-size: 0.95rem;
 | 
			
		||||
      display: inline-block;
 | 
			
		||||
      color: var(--secondary);
 | 
			
		||||
      font-weight: 600;
 | 
			
		||||
      margin: 0;
 | 
			
		||||
      line-height: 1.5rem;
 | 
			
		||||
      pointer-events: none;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.folder-icon {
 | 
			
		||||
  margin-right: 5px;
 | 
			
		||||
  color: var(--secondary);
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
  transition: transform 0.3s ease;
 | 
			
		||||
  backface-visibility: visible;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
div:has(> .folder-outer:not(.open)) > .folder-container > svg {
 | 
			
		||||
  transform: rotate(-90deg);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.folder-icon:hover {
 | 
			
		||||
  color: var(--tertiary);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.no-background::after {
 | 
			
		||||
  background: none !important;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#explorer-end {
 | 
			
		||||
  // needs height so IntersectionObserver gets triggered
 | 
			
		||||
  height: 4px;
 | 
			
		||||
  // remove default margin from li
 | 
			
		||||
  margin: 0;
 | 
			
		||||
}
 | 
			
		||||
@@ -19,11 +19,6 @@ li.section-li {
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    & > .tags {
 | 
			
		||||
      justify-self: end;
 | 
			
		||||
      margin-left: 1rem;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    & > .desc > h3 > a {
 | 
			
		||||
      background-color: transparent;
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -130,6 +130,44 @@
 | 
			
		||||
            margin: 0;
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          & > ul > li {
 | 
			
		||||
            margin: 0;
 | 
			
		||||
            display: inline-block;
 | 
			
		||||
            white-space: nowrap;
 | 
			
		||||
            margin: 0;
 | 
			
		||||
            overflow-wrap: normal;
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          & > ul {
 | 
			
		||||
            list-style: none;
 | 
			
		||||
            display: flex;
 | 
			
		||||
            padding-left: 0;
 | 
			
		||||
            gap: 0.4rem;
 | 
			
		||||
            margin: 0;
 | 
			
		||||
            margin-top: 0.45rem;
 | 
			
		||||
            // Offset border radius
 | 
			
		||||
            margin-left: -2px;
 | 
			
		||||
            overflow: hidden;
 | 
			
		||||
            background-clip: border-box;
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          & > ul > li > p {
 | 
			
		||||
            border-radius: 8px;
 | 
			
		||||
            background-color: var(--highlight);
 | 
			
		||||
            overflow: hidden;
 | 
			
		||||
            background-clip: border-box;
 | 
			
		||||
            padding: 0.03rem 0.4rem;
 | 
			
		||||
            margin: 0;
 | 
			
		||||
            color: var(--secondary);
 | 
			
		||||
            opacity: 0.85;
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          & > ul > li > .match-tag {
 | 
			
		||||
            color: var(--tertiary);
 | 
			
		||||
            font-weight: bold;
 | 
			
		||||
            opacity: 1;
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          & > p {
 | 
			
		||||
            margin-bottom: 0;
 | 
			
		||||
          }
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										59
									
								
								quartz/plugins/emitters/404.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								quartz/plugins/emitters/404.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,59 @@
 | 
			
		||||
import { QuartzEmitterPlugin } from "../types"
 | 
			
		||||
import { QuartzComponentProps } from "../../components/types"
 | 
			
		||||
import BodyConstructor from "../../components/Body"
 | 
			
		||||
import { pageResources, renderPage } from "../../components/renderPage"
 | 
			
		||||
import { FullPageLayout } from "../../cfg"
 | 
			
		||||
import { FilePath, FullSlug } from "../../util/path"
 | 
			
		||||
import { sharedPageComponents } from "../../../quartz.layout"
 | 
			
		||||
import { NotFound } from "../../components"
 | 
			
		||||
import { defaultProcessedContent } from "../vfile"
 | 
			
		||||
 | 
			
		||||
export const NotFoundPage: QuartzEmitterPlugin = () => {
 | 
			
		||||
  const opts: FullPageLayout = {
 | 
			
		||||
    ...sharedPageComponents,
 | 
			
		||||
    pageBody: NotFound(),
 | 
			
		||||
    beforeBody: [],
 | 
			
		||||
    left: [],
 | 
			
		||||
    right: [],
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const { head: Head, pageBody, footer: Footer } = opts
 | 
			
		||||
  const Body = BodyConstructor()
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    name: "404Page",
 | 
			
		||||
    getQuartzComponents() {
 | 
			
		||||
      return [Head, Body, pageBody, Footer]
 | 
			
		||||
    },
 | 
			
		||||
    async emit(ctx, _content, resources, emit): Promise<FilePath[]> {
 | 
			
		||||
      const cfg = ctx.cfg.configuration
 | 
			
		||||
      const slug = "404" as FullSlug
 | 
			
		||||
 | 
			
		||||
      const url = new URL(`https://${cfg.baseUrl ?? "example.com"}`)
 | 
			
		||||
      const path = url.pathname as FullSlug
 | 
			
		||||
      const externalResources = pageResources(path, resources)
 | 
			
		||||
      const [tree, vfile] = defaultProcessedContent({
 | 
			
		||||
        slug,
 | 
			
		||||
        text: "Not Found",
 | 
			
		||||
        description: "Not Found",
 | 
			
		||||
        frontmatter: { title: "Not Found", tags: [] },
 | 
			
		||||
      })
 | 
			
		||||
      const componentData: QuartzComponentProps = {
 | 
			
		||||
        fileData: vfile.data,
 | 
			
		||||
        externalResources,
 | 
			
		||||
        cfg,
 | 
			
		||||
        children: [],
 | 
			
		||||
        tree,
 | 
			
		||||
        allFiles: [],
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return [
 | 
			
		||||
        await emit({
 | 
			
		||||
          content: renderPage(slug, componentData, opts, externalResources),
 | 
			
		||||
          slug,
 | 
			
		||||
          ext: ".html",
 | 
			
		||||
        }),
 | 
			
		||||
      ]
 | 
			
		||||
    },
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
import { FilePath, FullSlug, resolveRelative, simplifySlug } from "../../util/path"
 | 
			
		||||
import { FilePath, FullSlug, joinSegments, resolveRelative, simplifySlug } from "../../util/path"
 | 
			
		||||
import { QuartzEmitterPlugin } from "../types"
 | 
			
		||||
import path from "path"
 | 
			
		||||
 | 
			
		||||
@@ -12,15 +12,25 @@ export const AliasRedirects: QuartzEmitterPlugin = () => ({
 | 
			
		||||
 | 
			
		||||
    for (const [_tree, file] of content) {
 | 
			
		||||
      const ogSlug = simplifySlug(file.data.slug!)
 | 
			
		||||
      const dir = path.posix.relative(argv.directory, file.dirname ?? argv.directory)
 | 
			
		||||
      const dir = path.posix.relative(argv.directory, path.dirname(file.data.filePath!))
 | 
			
		||||
 | 
			
		||||
      let aliases: FullSlug[] = file.data.frontmatter?.aliases ?? file.data.frontmatter?.alias ?? []
 | 
			
		||||
      if (typeof aliases === "string") {
 | 
			
		||||
        aliases = [aliases]
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      for (const alias of aliases) {
 | 
			
		||||
        const slug = path.posix.join(dir, alias) as FullSlug
 | 
			
		||||
      const slugs: FullSlug[] = aliases.map((alias) => path.posix.join(dir, alias) as FullSlug)
 | 
			
		||||
      const permalink = file.data.frontmatter?.permalink
 | 
			
		||||
      if (typeof permalink === "string") {
 | 
			
		||||
        slugs.push(permalink as FullSlug)
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      for (let slug of slugs) {
 | 
			
		||||
        // fix any slugs that have trailing slash
 | 
			
		||||
        if (slug.endsWith("/")) {
 | 
			
		||||
          slug = joinSegments(slug, "index") as FullSlug
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const redirUrl = resolveRelative(slug, file.data.slug!)
 | 
			
		||||
        const fp = await emit({
 | 
			
		||||
          content: `
 | 
			
		||||
 
 | 
			
		||||
@@ -7,7 +7,7 @@ import spaRouterScript from "../../components/scripts/spa.inline"
 | 
			
		||||
import plausibleScript from "../../components/scripts/plausible.inline"
 | 
			
		||||
// @ts-ignore
 | 
			
		||||
import popoverScript from "../../components/scripts/popover.inline"
 | 
			
		||||
import styles from "../../styles/base.scss"
 | 
			
		||||
import styles from "../../styles/custom.scss"
 | 
			
		||||
import popoverStyle from "../../components/styles/popover.scss"
 | 
			
		||||
import { BuildCtx } from "../../util/ctx"
 | 
			
		||||
import { StaticResources } from "../../util/resources"
 | 
			
		||||
@@ -96,6 +96,15 @@ function addGlobalPageResources(
 | 
			
		||||
      });`)
 | 
			
		||||
  } else if (cfg.analytics?.provider === "plausible") {
 | 
			
		||||
    componentResources.afterDOMLoaded.push(plausibleScript)
 | 
			
		||||
  } else if (cfg.analytics?.provider === "umami") {
 | 
			
		||||
    componentResources.afterDOMLoaded.push(`
 | 
			
		||||
      const umamiScript = document.createElement("script")
 | 
			
		||||
      umamiScript.src = "https://analytics.umami.is/script.js"
 | 
			
		||||
      umamiScript.setAttribute("data-website-id", "${cfg.analytics.websiteId}")
 | 
			
		||||
      umamiScript.async = true
 | 
			
		||||
  
 | 
			
		||||
      document.head.appendChild(umamiScript)
 | 
			
		||||
    `)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (cfg.enableSPA) {
 | 
			
		||||
@@ -107,12 +116,18 @@ function addGlobalPageResources(
 | 
			
		||||
        document.dispatchEvent(event)`)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  let wsUrl = `ws://localhost:${ctx.argv.wsPort}`
 | 
			
		||||
 | 
			
		||||
  if (ctx.argv.remoteDevHost) {
 | 
			
		||||
    wsUrl = `wss://${ctx.argv.remoteDevHost}:${ctx.argv.wsPort}`
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (reloadScript) {
 | 
			
		||||
    staticResources.js.push({
 | 
			
		||||
      loadTime: "afterDOMReady",
 | 
			
		||||
      contentType: "inline",
 | 
			
		||||
      script: `
 | 
			
		||||
          const socket = new WebSocket('ws://localhost:3001')
 | 
			
		||||
          const socket = new WebSocket('${wsUrl}')
 | 
			
		||||
          socket.addEventListener('message', () => document.location.reload())
 | 
			
		||||
        `,
 | 
			
		||||
    })
 | 
			
		||||
@@ -149,7 +164,7 @@ export const ComponentResources: QuartzEmitterPlugin<Options> = (opts?: Partial<
 | 
			
		||||
 | 
			
		||||
      addGlobalPageResources(ctx, resources, componentResources)
 | 
			
		||||
 | 
			
		||||
      const stylesheet = joinStyles(ctx.cfg.configuration.theme, styles, ...componentResources.css)
 | 
			
		||||
      const stylesheet = joinStyles(ctx.cfg.configuration.theme, ...componentResources.css, styles)
 | 
			
		||||
      const prescript = joinScripts(componentResources.beforeDOMLoaded)
 | 
			
		||||
      const postscript = joinScripts(componentResources.afterDOMLoaded)
 | 
			
		||||
      const fps = await Promise.all([
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,10 @@
 | 
			
		||||
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 { QuartzEmitterPlugin } from "../types"
 | 
			
		||||
import { toHtml } from "hast-util-to-html"
 | 
			
		||||
import path from "path"
 | 
			
		||||
 | 
			
		||||
export type ContentIndex = Map<FullSlug, ContentDetails>
 | 
			
		||||
@@ -10,6 +13,7 @@ export type ContentDetails = {
 | 
			
		||||
  links: SimpleSlug[]
 | 
			
		||||
  tags: string[]
 | 
			
		||||
  content: string
 | 
			
		||||
  richContent?: string
 | 
			
		||||
  date?: Date
 | 
			
		||||
  description?: string
 | 
			
		||||
}
 | 
			
		||||
@@ -17,19 +21,23 @@ export type ContentDetails = {
 | 
			
		||||
interface Options {
 | 
			
		||||
  enableSiteMap: boolean
 | 
			
		||||
  enableRSS: boolean
 | 
			
		||||
  rssLimit?: number
 | 
			
		||||
  rssFullHtml: boolean
 | 
			
		||||
  includeEmptyFiles: boolean
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const defaultOptions: Options = {
 | 
			
		||||
  enableSiteMap: true,
 | 
			
		||||
  enableRSS: true,
 | 
			
		||||
  rssLimit: 10,
 | 
			
		||||
  rssFullHtml: false,
 | 
			
		||||
  includeEmptyFiles: true,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string {
 | 
			
		||||
  const base = cfg.baseUrl ?? ""
 | 
			
		||||
  const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<url>
 | 
			
		||||
    <loc>https://${base}/${slug}</loc>
 | 
			
		||||
    <loc>https://${base}/${encodeURI(slug)}</loc>
 | 
			
		||||
    <lastmod>${content.date?.toISOString()}</lastmod>
 | 
			
		||||
  </url>`
 | 
			
		||||
  const urls = Array.from(idx)
 | 
			
		||||
@@ -38,27 +46,42 @@ function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string {
 | 
			
		||||
  return `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">${urls}</urlset>`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex): string {
 | 
			
		||||
function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, limit?: number): string {
 | 
			
		||||
  const base = cfg.baseUrl ?? ""
 | 
			
		||||
  const root = `https://${base}`
 | 
			
		||||
 | 
			
		||||
  const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<item>
 | 
			
		||||
    <title>${content.title}</title>
 | 
			
		||||
    <link>${root}/${slug}</link>
 | 
			
		||||
    <guid>${root}/${slug}</guid>
 | 
			
		||||
    <description>${content.description}</description>
 | 
			
		||||
    <title>${escapeHTML(content.title)}</title>
 | 
			
		||||
    <link>${root}/${encodeURI(slug)}</link>
 | 
			
		||||
    <guid>${root}/${encodeURI(slug)}</guid>
 | 
			
		||||
    <description>${content.richContent ?? content.description}</description>
 | 
			
		||||
    <pubDate>${content.date?.toUTCString()}</pubDate>
 | 
			
		||||
  </item>`
 | 
			
		||||
 | 
			
		||||
  const items = Array.from(idx)
 | 
			
		||||
    .sort(([_, f1], [__, f2]) => {
 | 
			
		||||
      if (f1.date && f2.date) {
 | 
			
		||||
        return f2.date.getTime() - f1.date.getTime()
 | 
			
		||||
      } else if (f1.date && !f2.date) {
 | 
			
		||||
        return -1
 | 
			
		||||
      } else if (!f1.date && f2.date) {
 | 
			
		||||
        return 1
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return f1.title.localeCompare(f2.title)
 | 
			
		||||
    })
 | 
			
		||||
    .map(([slug, content]) => createURLEntry(simplifySlug(slug), content))
 | 
			
		||||
    .slice(0, limit ?? idx.size)
 | 
			
		||||
    .join("")
 | 
			
		||||
 | 
			
		||||
  return `<?xml version="1.0" encoding="UTF-8" ?>
 | 
			
		||||
<rss version="2.0">
 | 
			
		||||
    <channel>
 | 
			
		||||
      <title>${cfg.pageTitle}</title>
 | 
			
		||||
      <title>${escapeHTML(cfg.pageTitle)}</title>
 | 
			
		||||
      <link>${root}</link>
 | 
			
		||||
      <description>Recent content on ${cfg.pageTitle}</description>
 | 
			
		||||
      <description>${!!limit ? `Last ${limit} notes` : "Recent notes"} on ${escapeHTML(
 | 
			
		||||
        cfg.pageTitle,
 | 
			
		||||
      )}</description>
 | 
			
		||||
      <generator>Quartz -- quartz.jzhao.xyz</generator>
 | 
			
		||||
      ${items}
 | 
			
		||||
    </channel>
 | 
			
		||||
@@ -73,7 +96,7 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
 | 
			
		||||
      const cfg = ctx.cfg.configuration
 | 
			
		||||
      const emitted: FilePath[] = []
 | 
			
		||||
      const linkIndex: ContentIndex = new Map()
 | 
			
		||||
      for (const [_tree, file] of content) {
 | 
			
		||||
      for (const [tree, file] of content) {
 | 
			
		||||
        const slug = file.data.slug!
 | 
			
		||||
        const date = getDate(ctx.cfg.configuration, file.data) ?? new Date()
 | 
			
		||||
        if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) {
 | 
			
		||||
@@ -82,6 +105,9 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
 | 
			
		||||
            links: file.data.links ?? [],
 | 
			
		||||
            tags: file.data.frontmatter?.tags ?? [],
 | 
			
		||||
            content: file.data.text ?? "",
 | 
			
		||||
            richContent: opts?.rssFullHtml
 | 
			
		||||
              ? escapeHTML(toHtml(tree as Root, { allowDangerousHtml: true }))
 | 
			
		||||
              : undefined,
 | 
			
		||||
            date: date,
 | 
			
		||||
            description: file.data.description ?? "",
 | 
			
		||||
          })
 | 
			
		||||
@@ -101,7 +127,7 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
 | 
			
		||||
      if (opts?.enableRSS) {
 | 
			
		||||
        emitted.push(
 | 
			
		||||
          await emit({
 | 
			
		||||
            content: generateRSSFeed(cfg, linkIndex),
 | 
			
		||||
            content: generateRSSFeed(cfg, linkIndex, opts.rssLimit),
 | 
			
		||||
            slug: "index" as FullSlug,
 | 
			
		||||
            ext: ".xml",
 | 
			
		||||
          }),
 | 
			
		||||
 
 | 
			
		||||
@@ -4,9 +4,10 @@ import HeaderConstructor from "../../components/Header"
 | 
			
		||||
import BodyConstructor from "../../components/Body"
 | 
			
		||||
import { pageResources, renderPage } from "../../components/renderPage"
 | 
			
		||||
import { FullPageLayout } from "../../cfg"
 | 
			
		||||
import { FilePath } from "../../util/path"
 | 
			
		||||
import { FilePath, pathToRoot } from "../../util/path"
 | 
			
		||||
import { defaultContentPageLayout, sharedPageComponents } from "../../../quartz.layout"
 | 
			
		||||
import { Content } from "../../components"
 | 
			
		||||
import chalk from "chalk"
 | 
			
		||||
 | 
			
		||||
export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpts) => {
 | 
			
		||||
  const opts: FullPageLayout = {
 | 
			
		||||
@@ -29,9 +30,15 @@ export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOp
 | 
			
		||||
      const cfg = ctx.cfg.configuration
 | 
			
		||||
      const fps: FilePath[] = []
 | 
			
		||||
      const allFiles = content.map((c) => c[1].data)
 | 
			
		||||
 | 
			
		||||
      let containsIndex = false
 | 
			
		||||
      for (const [tree, file] of content) {
 | 
			
		||||
        const slug = file.data.slug!
 | 
			
		||||
        const externalResources = pageResources(slug, resources)
 | 
			
		||||
        if (slug === "index") {
 | 
			
		||||
          containsIndex = true
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const externalResources = pageResources(pathToRoot(slug), resources)
 | 
			
		||||
        const componentData: QuartzComponentProps = {
 | 
			
		||||
          fileData: file.data,
 | 
			
		||||
          externalResources,
 | 
			
		||||
@@ -50,6 +57,15 @@ export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOp
 | 
			
		||||
 | 
			
		||||
        fps.push(fp)
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (!containsIndex) {
 | 
			
		||||
        console.log(
 | 
			
		||||
          chalk.yellow(
 | 
			
		||||
            `\nWarning: you seem to be missing an \`index.md\` home page file at the root of your \`${ctx.argv.directory}\` folder. This may cause errors when deploying.`,
 | 
			
		||||
          ),
 | 
			
		||||
        )
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return fps
 | 
			
		||||
    },
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -12,6 +12,7 @@ import {
 | 
			
		||||
  SimpleSlug,
 | 
			
		||||
  _stripSlashes,
 | 
			
		||||
  joinSegments,
 | 
			
		||||
  pathToRoot,
 | 
			
		||||
  simplifySlug,
 | 
			
		||||
} from "../../util/path"
 | 
			
		||||
import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
 | 
			
		||||
@@ -69,7 +70,7 @@ export const FolderPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
 | 
			
		||||
 | 
			
		||||
      for (const folder of folders) {
 | 
			
		||||
        const slug = joinSegments(folder, "index") as FullSlug
 | 
			
		||||
        const externalResources = pageResources(slug, resources)
 | 
			
		||||
        const externalResources = pageResources(pathToRoot(slug), resources)
 | 
			
		||||
        const [tree, file] = folderDescriptions[folder]
 | 
			
		||||
        const componentData: QuartzComponentProps = {
 | 
			
		||||
          fileData: file.data,
 | 
			
		||||
 
 | 
			
		||||
@@ -6,3 +6,4 @@ export { AliasRedirects } from "./aliases"
 | 
			
		||||
export { Assets } from "./assets"
 | 
			
		||||
export { Static } from "./static"
 | 
			
		||||
export { ComponentResources } from "./componentResources"
 | 
			
		||||
export { NotFoundPage } from "./404"
 | 
			
		||||
 
 | 
			
		||||
@@ -5,7 +5,13 @@ import BodyConstructor from "../../components/Body"
 | 
			
		||||
import { pageResources, renderPage } from "../../components/renderPage"
 | 
			
		||||
import { ProcessedContent, defaultProcessedContent } from "../vfile"
 | 
			
		||||
import { FullPageLayout } from "../../cfg"
 | 
			
		||||
import { FilePath, FullSlug, getAllSegmentPrefixes, joinSegments } from "../../util/path"
 | 
			
		||||
import {
 | 
			
		||||
  FilePath,
 | 
			
		||||
  FullSlug,
 | 
			
		||||
  getAllSegmentPrefixes,
 | 
			
		||||
  joinSegments,
 | 
			
		||||
  pathToRoot,
 | 
			
		||||
} from "../../util/path"
 | 
			
		||||
import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
 | 
			
		||||
import { TagContent } from "../../components"
 | 
			
		||||
 | 
			
		||||
@@ -62,7 +68,7 @@ export const TagPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
 | 
			
		||||
 | 
			
		||||
      for (const tag of tags) {
 | 
			
		||||
        const slug = joinSegments("tags", tag) as FullSlug
 | 
			
		||||
        const externalResources = pageResources(slug, resources)
 | 
			
		||||
        const externalResources = pageResources(pathToRoot(slug), resources)
 | 
			
		||||
        const [tree, file] = tagDescriptions[tag]
 | 
			
		||||
        const componentData: QuartzComponentProps = {
 | 
			
		||||
          fileData: file.data,
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,7 @@
 | 
			
		||||
import { Root as HTMLRoot } from "hast"
 | 
			
		||||
import { toString } from "hast-util-to-string"
 | 
			
		||||
import { QuartzTransformerPlugin } from "../types"
 | 
			
		||||
import { escapeHTML } from "../../util/escape"
 | 
			
		||||
 | 
			
		||||
export interface Options {
 | 
			
		||||
  descriptionLength: number
 | 
			
		||||
@@ -10,15 +11,6 @@ const defaultOptions: Options = {
 | 
			
		||||
  descriptionLength: 150,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const escapeHTML = (unsafe: string) => {
 | 
			
		||||
  return unsafe
 | 
			
		||||
    .replaceAll("&", "&")
 | 
			
		||||
    .replaceAll("<", "<")
 | 
			
		||||
    .replaceAll(">", ">")
 | 
			
		||||
    .replaceAll('"', """)
 | 
			
		||||
    .replaceAll("'", "'")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const Description: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
 | 
			
		||||
  const opts = { ...defaultOptions, ...userOpts }
 | 
			
		||||
  return {
 | 
			
		||||
 
 | 
			
		||||
@@ -2,14 +2,17 @@ import matter from "gray-matter"
 | 
			
		||||
import remarkFrontmatter from "remark-frontmatter"
 | 
			
		||||
import { QuartzTransformerPlugin } from "../types"
 | 
			
		||||
import yaml from "js-yaml"
 | 
			
		||||
import toml from "toml"
 | 
			
		||||
import { slugTag } from "../../util/path"
 | 
			
		||||
 | 
			
		||||
export interface Options {
 | 
			
		||||
  delims: string | string[]
 | 
			
		||||
  language: "yaml" | "toml"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const defaultOptions: Options = {
 | 
			
		||||
  delims: "---",
 | 
			
		||||
  language: "yaml",
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
 | 
			
		||||
@@ -18,13 +21,14 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined>
 | 
			
		||||
    name: "FrontMatter",
 | 
			
		||||
    markdownPlugins() {
 | 
			
		||||
      return [
 | 
			
		||||
        remarkFrontmatter,
 | 
			
		||||
        [remarkFrontmatter, ["yaml", "toml"]],
 | 
			
		||||
        () => {
 | 
			
		||||
          return (_, file) => {
 | 
			
		||||
            const { data } = matter(file.value, {
 | 
			
		||||
              ...opts,
 | 
			
		||||
              engines: {
 | 
			
		||||
                yaml: (s) => yaml.load(s, { schema: yaml.JSON_SCHEMA }) as object,
 | 
			
		||||
                toml: (s) => toml.parse(s) as object,
 | 
			
		||||
              },
 | 
			
		||||
            })
 | 
			
		||||
 | 
			
		||||
@@ -33,6 +37,11 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined>
 | 
			
		||||
              data.tags = data.tag
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // coerce title to string
 | 
			
		||||
            if (data.title) {
 | 
			
		||||
              data.title = data.title.toString()
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (data.tags && !Array.isArray(data.tags)) {
 | 
			
		||||
              data.tags = data.tags
 | 
			
		||||
                .toString()
 | 
			
		||||
 
 | 
			
		||||
@@ -5,5 +5,7 @@ export { Latex } from "./latex"
 | 
			
		||||
export { Description } from "./description"
 | 
			
		||||
export { CrawlLinks } from "./links"
 | 
			
		||||
export { ObsidianFlavoredMarkdown } from "./ofm"
 | 
			
		||||
export { OxHugoFlavouredMarkdown } from "./oxhugofm"
 | 
			
		||||
export { SyntaxHighlighting } from "./syntax"
 | 
			
		||||
export { TableOfContents } from "./toc"
 | 
			
		||||
export { HardLineBreaks } from "./linebreaks"
 | 
			
		||||
 
 | 
			
		||||
@@ -2,6 +2,7 @@ import fs from "fs"
 | 
			
		||||
import path from "path"
 | 
			
		||||
import { Repository } from "@napi-rs/simple-git"
 | 
			
		||||
import { QuartzTransformerPlugin } from "../types"
 | 
			
		||||
import chalk from "chalk"
 | 
			
		||||
 | 
			
		||||
export interface Options {
 | 
			
		||||
  priority: ("frontmatter" | "git" | "filesystem")[]
 | 
			
		||||
@@ -11,6 +12,20 @@ const defaultOptions: Options = {
 | 
			
		||||
  priority: ["frontmatter", "git", "filesystem"],
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function coerceDate(fp: string, d: any): Date {
 | 
			
		||||
  const dt = new Date(d)
 | 
			
		||||
  const invalidDate = isNaN(dt.getTime()) || dt.getTime() === 0
 | 
			
		||||
  if (invalidDate && d !== undefined) {
 | 
			
		||||
    console.log(
 | 
			
		||||
      chalk.yellow(
 | 
			
		||||
        `\nWarning: found invalid date "${d}" in \`${fp}\`. Supported formats: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#date_time_string_format`,
 | 
			
		||||
      ),
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return invalidDate ? new Date() : dt
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type MaybeDate = undefined | string | number
 | 
			
		||||
export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options> | undefined> = (
 | 
			
		||||
  userOpts,
 | 
			
		||||
@@ -27,10 +42,11 @@ export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options> | und
 | 
			
		||||
            let modified: MaybeDate = undefined
 | 
			
		||||
            let published: MaybeDate = undefined
 | 
			
		||||
 | 
			
		||||
            const fp = path.posix.join(file.cwd, file.data.filePath as string)
 | 
			
		||||
            const fp = file.data.filePath!
 | 
			
		||||
            const fullFp = path.posix.join(file.cwd, fp)
 | 
			
		||||
            for (const source of opts.priority) {
 | 
			
		||||
              if (source === "filesystem") {
 | 
			
		||||
                const st = await fs.promises.stat(fp)
 | 
			
		||||
                const st = await fs.promises.stat(fullFp)
 | 
			
		||||
                created ||= st.birthtimeMs
 | 
			
		||||
                modified ||= st.mtimeMs
 | 
			
		||||
              } else if (source === "frontmatter" && file.data.frontmatter) {
 | 
			
		||||
@@ -49,9 +65,9 @@ export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options> | und
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            file.data.dates = {
 | 
			
		||||
              created: created ? new Date(created) : new Date(),
 | 
			
		||||
              modified: modified ? new Date(modified) : new Date(),
 | 
			
		||||
              published: published ? new Date(published) : new Date(),
 | 
			
		||||
              created: coerceDate(fp, created),
 | 
			
		||||
              modified: coerceDate(fp, modified),
 | 
			
		||||
              published: coerceDate(fp, published),
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										11
									
								
								quartz/plugins/transformers/linebreaks.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								quartz/plugins/transformers/linebreaks.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,11 @@
 | 
			
		||||
import { QuartzTransformerPlugin } from "../types"
 | 
			
		||||
import remarkBreaks from "remark-breaks"
 | 
			
		||||
 | 
			
		||||
export const HardLineBreaks: QuartzTransformerPlugin = () => {
 | 
			
		||||
  return {
 | 
			
		||||
    name: "HardLineBreaks",
 | 
			
		||||
    markdownPlugins() {
 | 
			
		||||
      return [remarkBreaks]
 | 
			
		||||
    },
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -5,7 +5,6 @@ import {
 | 
			
		||||
  SimpleSlug,
 | 
			
		||||
  TransformOptions,
 | 
			
		||||
  _stripSlashes,
 | 
			
		||||
  joinSegments,
 | 
			
		||||
  simplifySlug,
 | 
			
		||||
  splitAnchor,
 | 
			
		||||
  transformLink,
 | 
			
		||||
@@ -19,11 +18,13 @@ interface Options {
 | 
			
		||||
  markdownLinkResolution: TransformOptions["strategy"]
 | 
			
		||||
  /** Strips folders from a link so that it looks nice */
 | 
			
		||||
  prettyLinks: boolean
 | 
			
		||||
  openLinksInNewTab: boolean
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const defaultOptions: Options = {
 | 
			
		||||
  markdownLinkResolution: "absolute",
 | 
			
		||||
  prettyLinks: true,
 | 
			
		||||
  openLinksInNewTab: false,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
 | 
			
		||||
@@ -53,8 +54,13 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> =
 | 
			
		||||
                node.properties.className ??= []
 | 
			
		||||
                node.properties.className.push(isAbsoluteUrl(dest) ? "external" : "internal")
 | 
			
		||||
 | 
			
		||||
                if (opts.openLinksInNewTab) {
 | 
			
		||||
                  node.properties.target = "_blank"
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                // don't process external links or intra-document anchors
 | 
			
		||||
                if (!(isAbsoluteUrl(dest) || dest.startsWith("#"))) {
 | 
			
		||||
                const isInternal = !(isAbsoluteUrl(dest) || dest.startsWith("#"))
 | 
			
		||||
                if (isInternal) {
 | 
			
		||||
                  dest = node.properties.href = transformLink(
 | 
			
		||||
                    file.data.slug!,
 | 
			
		||||
                    dest,
 | 
			
		||||
@@ -72,11 +78,13 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> =
 | 
			
		||||
                    simplifySlug(destCanonical as FullSlug),
 | 
			
		||||
                  ) as SimpleSlug
 | 
			
		||||
                  outgoing.add(simple)
 | 
			
		||||
                  node.properties["data-slug"] = simple
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                // rewrite link internals if prettylinks is on
 | 
			
		||||
                if (
 | 
			
		||||
                  opts.prettyLinks &&
 | 
			
		||||
                  isInternal &&
 | 
			
		||||
                  node.children.length === 1 &&
 | 
			
		||||
                  node.children[0].type === "text" &&
 | 
			
		||||
                  !node.children[0].value.startsWith("#")
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,7 @@
 | 
			
		||||
import { PluggableList } from "unified"
 | 
			
		||||
import { QuartzTransformerPlugin } from "../types"
 | 
			
		||||
import { Root, HTML, BlockContent, DefinitionContent, Code, Paragraph } from "mdast"
 | 
			
		||||
import { Element, Literal, Root as HtmlRoot } from "hast"
 | 
			
		||||
import { Replace, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace"
 | 
			
		||||
import { slug as slugAnchor } from "github-slugger"
 | 
			
		||||
import rehypeRaw from "rehype-raw"
 | 
			
		||||
@@ -13,6 +14,7 @@ import { FilePath, pathToRoot, slugTag, slugifyFilePath } from "../../util/path"
 | 
			
		||||
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"
 | 
			
		||||
 | 
			
		||||
export interface Options {
 | 
			
		||||
  comments: boolean
 | 
			
		||||
@@ -21,6 +23,7 @@ export interface Options {
 | 
			
		||||
  callouts: boolean
 | 
			
		||||
  mermaid: boolean
 | 
			
		||||
  parseTags: boolean
 | 
			
		||||
  parseBlockReferences: boolean
 | 
			
		||||
  enableInHtmlEmbed: boolean
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -31,6 +34,7 @@ const defaultOptions: Options = {
 | 
			
		||||
  callouts: true,
 | 
			
		||||
  mermaid: true,
 | 
			
		||||
  parseTags: true,
 | 
			
		||||
  parseBlockReferences: true,
 | 
			
		||||
  enableInHtmlEmbed: false,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -69,6 +73,8 @@ const callouts = {
 | 
			
		||||
const calloutMapping: Record<string, keyof typeof callouts> = {
 | 
			
		||||
  note: "note",
 | 
			
		||||
  abstract: "abstract",
 | 
			
		||||
  summary: "abstract",
 | 
			
		||||
  tldr: "abstract",
 | 
			
		||||
  info: "info",
 | 
			
		||||
  todo: "todo",
 | 
			
		||||
  tip: "tip",
 | 
			
		||||
@@ -96,11 +102,7 @@ const calloutMapping: Record<string, keyof typeof callouts> = {
 | 
			
		||||
 | 
			
		||||
function canonicalizeCallout(calloutName: string): keyof typeof callouts {
 | 
			
		||||
  let callout = calloutName.toLowerCase() as keyof typeof calloutMapping
 | 
			
		||||
  return calloutMapping[callout] ?? calloutName
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const capitalize = (s: string): string => {
 | 
			
		||||
  return s.substring(0, 1).toUpperCase() + s.substring(1)
 | 
			
		||||
  return calloutMapping[callout] ?? "note"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// !?               -> optional embedding
 | 
			
		||||
@@ -109,14 +111,17 @@ const capitalize = (s: string): string => {
 | 
			
		||||
// (#[^\[\]\|\#]+)? -> # then one or more non-special characters (heading link)
 | 
			
		||||
// (|[^\[\]\|\#]+)? -> | then one or more non-special characters (alias)
 | 
			
		||||
const wikilinkRegex = new RegExp(/!?\[\[([^\[\]\|\#]+)?(#[^\[\]\|\#]+)?(\|[^\[\]\|\#]+)?\]\]/, "g")
 | 
			
		||||
const highlightRegex = 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
 | 
			
		||||
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
 | 
			
		||||
// #(\w+)    -> tag itself is # followed by a string of alpha-numeric characters
 | 
			
		||||
const tagRegex = new RegExp(/(?:^| )#(\p{L}+)/, "gu")
 | 
			
		||||
// (?:^| )              -> 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 "/"
 | 
			
		||||
const tagRegex = new RegExp(/(?:^| )#((?:[-_\p{L}\d])+(?:\/[-_\p{L}\d]+)*)/, "gu")
 | 
			
		||||
const blockReferenceRegex = new RegExp(/\^([A-Za-z0-9]+)$/, "g")
 | 
			
		||||
 | 
			
		||||
export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> | undefined> = (
 | 
			
		||||
  userOpts,
 | 
			
		||||
@@ -230,8 +235,16 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
 | 
			
		||||
                    value: `<iframe src="${url}"></iframe>`,
 | 
			
		||||
                  }
 | 
			
		||||
                } else if (ext === "") {
 | 
			
		||||
                  // TODO: note embed
 | 
			
		||||
                  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
 | 
			
		||||
              }
 | 
			
		||||
 | 
			
		||||
@@ -320,7 +333,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
 | 
			
		||||
 | 
			
		||||
                const titleHtml: HTML = {
 | 
			
		||||
                  type: "html",
 | 
			
		||||
                  value: `<div 
 | 
			
		||||
                  value: `<div
 | 
			
		||||
                  class="callout-title"
 | 
			
		||||
                >
 | 
			
		||||
                  <div class="callout-icon">${callouts[calloutType]}</div>
 | 
			
		||||
@@ -383,13 +396,18 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
 | 
			
		||||
          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/${slugTag(tag)}`,
 | 
			
		||||
                url: base + `/tags/${tag}`,
 | 
			
		||||
                data: {
 | 
			
		||||
                  hProperties: {
 | 
			
		||||
                    className: ["tag-link"],
 | 
			
		||||
@@ -406,11 +424,64 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
 | 
			
		||||
          }
 | 
			
		||||
        })
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return plugins
 | 
			
		||||
    },
 | 
			
		||||
    htmlPlugins() {
 | 
			
		||||
      return [rehypeRaw]
 | 
			
		||||
      const plugins = [rehypeRaw]
 | 
			
		||||
 | 
			
		||||
      if (opts.parseBlockReferences) {
 | 
			
		||||
        plugins.push(() => {
 | 
			
		||||
          const inlineTagTypes = new Set(["p", "li"])
 | 
			
		||||
          const blockTagTypes = new Set(["blockquote"])
 | 
			
		||||
          return (tree, file) => {
 | 
			
		||||
            file.data.blocks = {}
 | 
			
		||||
            file.data.htmlAst = tree
 | 
			
		||||
 | 
			
		||||
            visit(tree, "element", (node, index, parent) => {
 | 
			
		||||
              if (blockTagTypes.has(node.tagName)) {
 | 
			
		||||
                const nextChild = parent?.children.at(index! + 2) as Element
 | 
			
		||||
                if (nextChild && nextChild.tagName === "p") {
 | 
			
		||||
                  const text = nextChild.children.at(0) as Literal
 | 
			
		||||
                  if (text && text.value && text.type === "text") {
 | 
			
		||||
                    const matches = text.value.match(blockReferenceRegex)
 | 
			
		||||
                    if (matches && matches.length >= 1) {
 | 
			
		||||
                      parent!.children.splice(index! + 2, 1)
 | 
			
		||||
                      const block = matches[0].slice(1)
 | 
			
		||||
 | 
			
		||||
                      if (!Object.keys(file.data.blocks!).includes(block)) {
 | 
			
		||||
                        node.properties = {
 | 
			
		||||
                          ...node.properties,
 | 
			
		||||
                          id: block,
 | 
			
		||||
                        }
 | 
			
		||||
                        file.data.blocks![block] = node
 | 
			
		||||
                      }
 | 
			
		||||
                    }
 | 
			
		||||
                  }
 | 
			
		||||
                }
 | 
			
		||||
              } else if (inlineTagTypes.has(node.tagName)) {
 | 
			
		||||
                const last = node.children.at(-1) as Literal
 | 
			
		||||
                if (last && last.value && typeof last.value === "string") {
 | 
			
		||||
                  const matches = last.value.match(blockReferenceRegex)
 | 
			
		||||
                  if (matches && matches.length >= 1) {
 | 
			
		||||
                    last.value = last.value.slice(0, -matches[0].length)
 | 
			
		||||
                    const block = matches[0].slice(1)
 | 
			
		||||
 | 
			
		||||
                    if (!Object.keys(file.data.blocks!).includes(block)) {
 | 
			
		||||
                      node.properties = {
 | 
			
		||||
                        ...node.properties,
 | 
			
		||||
                        id: block,
 | 
			
		||||
                      }
 | 
			
		||||
                      file.data.blocks![block] = node
 | 
			
		||||
                    }
 | 
			
		||||
                  }
 | 
			
		||||
                }
 | 
			
		||||
              }
 | 
			
		||||
            })
 | 
			
		||||
          }
 | 
			
		||||
        })
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return plugins
 | 
			
		||||
    },
 | 
			
		||||
    externalResources() {
 | 
			
		||||
      const js: JSResource[] = []
 | 
			
		||||
@@ -428,7 +499,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
 | 
			
		||||
          script: `
 | 
			
		||||
          import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.esm.min.mjs';
 | 
			
		||||
          const darkMode = document.documentElement.getAttribute('saved-theme') === 'dark'
 | 
			
		||||
          mermaid.initialize({ 
 | 
			
		||||
          mermaid.initialize({
 | 
			
		||||
            startOnLoad: false,
 | 
			
		||||
            securityLevel: 'loose',
 | 
			
		||||
            theme: darkMode ? 'dark' : 'default'
 | 
			
		||||
@@ -449,3 +520,10 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
 | 
			
		||||
    },
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
declare module "vfile" {
 | 
			
		||||
  interface DataMap {
 | 
			
		||||
    blocks: Record<string, Element>
 | 
			
		||||
    htmlAst: HtmlRoot
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										108
									
								
								quartz/plugins/transformers/oxhugofm.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								quartz/plugins/transformers/oxhugofm.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,108 @@
 | 
			
		||||
import { QuartzTransformerPlugin } from "../types"
 | 
			
		||||
 | 
			
		||||
export interface Options {
 | 
			
		||||
  /** Replace {{ relref }} with quartz wikilinks []() */
 | 
			
		||||
  wikilinks: boolean
 | 
			
		||||
  /** Remove pre-defined anchor (see https://ox-hugo.scripter.co/doc/anchors/) */
 | 
			
		||||
  removePredefinedAnchor: boolean
 | 
			
		||||
  /** Remove hugo shortcode syntax */
 | 
			
		||||
  removeHugoShortcode: boolean
 | 
			
		||||
  /** Replace <figure/> with ![]() */
 | 
			
		||||
  replaceFigureWithMdImg: boolean
 | 
			
		||||
 | 
			
		||||
  /** Replace org latex fragments with $ and $$ */
 | 
			
		||||
  replaceOrgLatex: boolean
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const defaultOptions: Options = {
 | 
			
		||||
  wikilinks: true,
 | 
			
		||||
  removePredefinedAnchor: true,
 | 
			
		||||
  removeHugoShortcode: true,
 | 
			
		||||
  replaceFigureWithMdImg: true,
 | 
			
		||||
  replaceOrgLatex: true,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const relrefRegex = new RegExp(/\[([^\]]+)\]\(\{\{< relref "([^"]+)" >\}\}\)/, "g")
 | 
			
		||||
const predefinedHeadingIdRegex = new RegExp(/(.*) {#(?:.*)}/, "g")
 | 
			
		||||
const hugoShortcodeRegex = new RegExp(/{{(.*)}}/, "g")
 | 
			
		||||
const figureTagRegex = new RegExp(/< ?figure src="(.*)" ?>/, "g")
 | 
			
		||||
// \\\\\( -> matches \\(
 | 
			
		||||
// (.+?) -> Lazy match for capturing the equation
 | 
			
		||||
// \\\\\) -> matches \\)
 | 
			
		||||
const inlineLatexRegex = new RegExp(/\\\\\((.+?)\\\\\)/, "g")
 | 
			
		||||
// (?:\\begin{equation}|\\\\\(|\\\\\[) -> start of equation
 | 
			
		||||
// ([\s\S]*?) -> Matches the block equation
 | 
			
		||||
// (?:\\\\\]|\\\\\)|\\end{equation}) -> end of equation
 | 
			
		||||
const blockLatexRegex = new RegExp(
 | 
			
		||||
  /(?:\\begin{equation}|\\\\\(|\\\\\[)([\s\S]*?)(?:\\\\\]|\\\\\)|\\end{equation})/,
 | 
			
		||||
  "g",
 | 
			
		||||
)
 | 
			
		||||
// \$\$[\s\S]*?\$\$ -> Matches block equations
 | 
			
		||||
// \$.*?\$ -> Matches inline equations
 | 
			
		||||
const quartzLatexRegex = new RegExp(/\$\$[\s\S]*?\$\$|\$.*?\$/, "g")
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * ox-hugo is an org exporter backend that exports org files to hugo-compatible
 | 
			
		||||
 * markdown in an opinionated way. This plugin adds some tweaks to the generated
 | 
			
		||||
 * markdown to make it compatible with quartz but the list of changes applied it
 | 
			
		||||
 * is not exhaustive.
 | 
			
		||||
 * */
 | 
			
		||||
export const OxHugoFlavouredMarkdown: QuartzTransformerPlugin<Partial<Options> | undefined> = (
 | 
			
		||||
  userOpts,
 | 
			
		||||
) => {
 | 
			
		||||
  const opts = { ...defaultOptions, ...userOpts }
 | 
			
		||||
  return {
 | 
			
		||||
    name: "OxHugoFlavouredMarkdown",
 | 
			
		||||
    textTransform(_ctx, src) {
 | 
			
		||||
      if (opts.wikilinks) {
 | 
			
		||||
        src = src.toString()
 | 
			
		||||
        src = src.replaceAll(relrefRegex, (value, ...capture) => {
 | 
			
		||||
          const [text, link] = capture
 | 
			
		||||
          return `[${text}](${link})`
 | 
			
		||||
        })
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (opts.removePredefinedAnchor) {
 | 
			
		||||
        src = src.toString()
 | 
			
		||||
        src = src.replaceAll(predefinedHeadingIdRegex, (value, ...capture) => {
 | 
			
		||||
          const [headingText] = capture
 | 
			
		||||
          return headingText
 | 
			
		||||
        })
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (opts.removeHugoShortcode) {
 | 
			
		||||
        src = src.toString()
 | 
			
		||||
        src = src.replaceAll(hugoShortcodeRegex, (value, ...capture) => {
 | 
			
		||||
          const [scContent] = capture
 | 
			
		||||
          return scContent
 | 
			
		||||
        })
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (opts.replaceFigureWithMdImg) {
 | 
			
		||||
        src = src.toString()
 | 
			
		||||
        src = src.replaceAll(figureTagRegex, (value, ...capture) => {
 | 
			
		||||
          const [src] = capture
 | 
			
		||||
          return ``
 | 
			
		||||
        })
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (opts.replaceOrgLatex) {
 | 
			
		||||
        src = src.toString()
 | 
			
		||||
        src = src.replaceAll(inlineLatexRegex, (value, ...capture) => {
 | 
			
		||||
          const [eqn] = capture
 | 
			
		||||
          return `$${eqn}$`
 | 
			
		||||
        })
 | 
			
		||||
        src = src.replaceAll(blockLatexRegex, (value, ...capture) => {
 | 
			
		||||
          const [eqn] = capture
 | 
			
		||||
          return `$$${eqn}$$`
 | 
			
		||||
        })
 | 
			
		||||
 | 
			
		||||
        // ox-hugo escapes _ as \_
 | 
			
		||||
        src = src.replaceAll(quartzLatexRegex, (value) => {
 | 
			
		||||
          return value.replaceAll("\\_", "_")
 | 
			
		||||
        })
 | 
			
		||||
      }
 | 
			
		||||
      return src
 | 
			
		||||
    },
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -8,12 +8,14 @@ export interface Options {
 | 
			
		||||
  maxDepth: 1 | 2 | 3 | 4 | 5 | 6
 | 
			
		||||
  minEntries: 1
 | 
			
		||||
  showByDefault: boolean
 | 
			
		||||
  collapseByDefault: boolean
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const defaultOptions: Options = {
 | 
			
		||||
  maxDepth: 3,
 | 
			
		||||
  minEntries: 1,
 | 
			
		||||
  showByDefault: true,
 | 
			
		||||
  collapseByDefault: false,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface TocEntry {
 | 
			
		||||
@@ -54,6 +56,7 @@ export const TableOfContents: QuartzTransformerPlugin<Partial<Options> | undefin
 | 
			
		||||
                  ...entry,
 | 
			
		||||
                  depth: entry.depth - highestDepth,
 | 
			
		||||
                }))
 | 
			
		||||
                file.data.collapseToc = opts.collapseByDefault
 | 
			
		||||
              }
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
@@ -66,5 +69,6 @@ export const TableOfContents: QuartzTransformerPlugin<Partial<Options> | undefin
 | 
			
		||||
declare module "vfile" {
 | 
			
		||||
  interface DataMap {
 | 
			
		||||
    toc: TocEntry[]
 | 
			
		||||
    collapseToc: boolean
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,3 @@
 | 
			
		||||
@use "./custom.scss";
 | 
			
		||||
@use "./syntax.scss";
 | 
			
		||||
@use "./callouts.scss";
 | 
			
		||||
@use "./variables.scss" as *;
 | 
			
		||||
@@ -65,7 +64,7 @@ a {
 | 
			
		||||
    color: var(--tertiary) !important;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  &.internal {
 | 
			
		||||
  &.internal:not(:has(> img)) {
 | 
			
		||||
    text-decoration: none;
 | 
			
		||||
    background-color: var(--highlight);
 | 
			
		||||
    padding: 0 0.1rem;
 | 
			
		||||
@@ -95,6 +94,8 @@ a {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  & article {
 | 
			
		||||
    position: relative;
 | 
			
		||||
 | 
			
		||||
    & > h1 {
 | 
			
		||||
      font-size: 2rem;
 | 
			
		||||
    }
 | 
			
		||||
@@ -389,23 +390,33 @@ p {
 | 
			
		||||
  line-height: 1.6rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
table {
 | 
			
		||||
  margin: 1rem;
 | 
			
		||||
  padding: 1.5rem;
 | 
			
		||||
  border-collapse: collapse;
 | 
			
		||||
  & > * {
 | 
			
		||||
    line-height: 2rem;
 | 
			
		||||
.table-container {
 | 
			
		||||
  overflow-x: auto;
 | 
			
		||||
 | 
			
		||||
  & > table {
 | 
			
		||||
    margin: 1rem;
 | 
			
		||||
    padding: 1.5rem;
 | 
			
		||||
    border-collapse: collapse;
 | 
			
		||||
 | 
			
		||||
    th,
 | 
			
		||||
    td {
 | 
			
		||||
      min-width: 75px;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    & > * {
 | 
			
		||||
      line-height: 2rem;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
th {
 | 
			
		||||
  text-align: left;
 | 
			
		||||
  padding: 0.4rem 1rem;
 | 
			
		||||
  padding: 0.4rem 0.7rem;
 | 
			
		||||
  border-bottom: 2px solid var(--gray);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
td {
 | 
			
		||||
  padding: 0.2rem 1rem;
 | 
			
		||||
  padding: 0.2rem 0.7rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
tr {
 | 
			
		||||
@@ -446,7 +457,7 @@ video {
 | 
			
		||||
 | 
			
		||||
ul.overflow,
 | 
			
		||||
ol.overflow {
 | 
			
		||||
  height: 300px;
 | 
			
		||||
  max-height: 400;
 | 
			
		||||
  overflow-y: auto;
 | 
			
		||||
 | 
			
		||||
  // clearfix
 | 
			
		||||
@@ -454,7 +465,7 @@ ol.overflow {
 | 
			
		||||
  clear: both;
 | 
			
		||||
 | 
			
		||||
  & > li:last-of-type {
 | 
			
		||||
    margin-bottom: 50px;
 | 
			
		||||
    margin-bottom: 30px;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  &:after {
 | 
			
		||||
@@ -470,3 +481,9 @@ ol.overflow {
 | 
			
		||||
    background: linear-gradient(transparent 0px, var(--light));
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.transclude {
 | 
			
		||||
  ul {
 | 
			
		||||
    padding-left: 1rem;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -82,7 +82,6 @@
 | 
			
		||||
 | 
			
		||||
.callout-title {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  gap: 5px;
 | 
			
		||||
  padding: 1rem 0;
 | 
			
		||||
  color: var(--color);
 | 
			
		||||
@@ -103,6 +102,8 @@
 | 
			
		||||
.callout-icon {
 | 
			
		||||
  width: 18px;
 | 
			
		||||
  height: 18px;
 | 
			
		||||
  flex: 0 0 18px;
 | 
			
		||||
  padding-top: 4px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.callout-title-inner {
 | 
			
		||||
 
 | 
			
		||||
@@ -10,4 +10,7 @@
 | 
			
		||||
    --outlinegray: #dadada;
 | 
			
		||||
    --million-progress-bar-color: var(--secondary);
 | 
			
		||||
    --highlighted: #f5dfaf88;
 | 
			
		||||
  }
 | 
			
		||||
  }
 | 
			
		||||
@use "./base.scss";
 | 
			
		||||
 | 
			
		||||
// put your custom CSS here!
 | 
			
		||||
 
 | 
			
		||||
@@ -7,6 +7,8 @@ export interface Argv {
 | 
			
		||||
  output: string
 | 
			
		||||
  serve: boolean
 | 
			
		||||
  port: number
 | 
			
		||||
  wsPort: number
 | 
			
		||||
  remoteDevHost?: string
 | 
			
		||||
  concurrency?: number
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										8
									
								
								quartz/util/escape.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								quartz/util/escape.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,8 @@
 | 
			
		||||
export const escapeHTML = (unsafe: string) => {
 | 
			
		||||
  return unsafe
 | 
			
		||||
    .replaceAll("&", "&")
 | 
			
		||||
    .replaceAll("<", "<")
 | 
			
		||||
    .replaceAll(">", ">")
 | 
			
		||||
    .replaceAll('"', """)
 | 
			
		||||
    .replaceAll("'", "'")
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										28
									
								
								quartz/util/jsx.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								quartz/util/jsx.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,28 @@
 | 
			
		||||
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"
 | 
			
		||||
import { type FilePath } from "./path"
 | 
			
		||||
 | 
			
		||||
const customComponents: Components = {
 | 
			
		||||
  table: (props) => (
 | 
			
		||||
    <div class="table-container">
 | 
			
		||||
      <table {...props} />
 | 
			
		||||
    </div>
 | 
			
		||||
  ),
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function htmlToJsx(fp: FilePath, tree: Node<QuartzPluginData>) {
 | 
			
		||||
  try {
 | 
			
		||||
    return toJsxRuntime(tree as Root, {
 | 
			
		||||
      Fragment,
 | 
			
		||||
      jsx: jsx as Jsx,
 | 
			
		||||
      jsxs: jsxs as Jsx,
 | 
			
		||||
      elementAttributeNameCase: "html",
 | 
			
		||||
      components: customComponents,
 | 
			
		||||
    })
 | 
			
		||||
  } catch (e) {
 | 
			
		||||
    trace(`Failed to parse Markdown in \`${fp}\` into JSX`, e as Error)
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										11
									
								
								quartz/util/lang.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								quartz/util/lang.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,11 @@
 | 
			
		||||
export function pluralize(count: number, s: string): string {
 | 
			
		||||
  if (count === 1) {
 | 
			
		||||
    return `1 ${s}`
 | 
			
		||||
  } else {
 | 
			
		||||
    return `${count} ${s}s`
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function capitalize(s: string): string {
 | 
			
		||||
  return s.substring(0, 1).toUpperCase() + s.substring(1)
 | 
			
		||||
}
 | 
			
		||||
@@ -52,7 +52,7 @@ export function slugifyFilePath(fp: FilePath, excludeExt?: boolean): FullSlug {
 | 
			
		||||
 | 
			
		||||
  let slug = withoutFileExt
 | 
			
		||||
    .split("/")
 | 
			
		||||
    .map((segment) => segment.replace(/\s/g, "-").replace(/%/g, "-percent")) // slugify all segments
 | 
			
		||||
    .map((segment) => segment.replace(/\s/g, "-").replace(/%/g, "-percent").replace(/\?/g, "-q")) // slugify all segments
 | 
			
		||||
    .join("/") // always use / as sep
 | 
			
		||||
    .replace(/\/$/, "") // remove trailing slash
 | 
			
		||||
 | 
			
		||||
@@ -123,7 +123,10 @@ export function slugTag(tag: string) {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function joinSegments(...args: string[]): string {
 | 
			
		||||
  return args.filter((segment) => segment !== "").join("/")
 | 
			
		||||
  return args
 | 
			
		||||
    .filter((segment) => segment !== "")
 | 
			
		||||
    .join("/")
 | 
			
		||||
    .replace(/\/\/+/g, "/")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function getAllSegmentPrefixes(tags: string): string[] {
 | 
			
		||||
 
 | 
			
		||||
@@ -4,7 +4,7 @@ import { isMainThread } from "workerpool"
 | 
			
		||||
 | 
			
		||||
const rootFile = /.*at file:/
 | 
			
		||||
export function trace(msg: string, err: Error) {
 | 
			
		||||
  const stack = err.stack
 | 
			
		||||
  let stack = err.stack ?? ""
 | 
			
		||||
 | 
			
		||||
  const lines: string[] = []
 | 
			
		||||
 | 
			
		||||
@@ -12,15 +12,11 @@ export function trace(msg: string, err: Error) {
 | 
			
		||||
  lines.push(
 | 
			
		||||
    "\n" +
 | 
			
		||||
      chalk.bgRed.black.bold(" ERROR ") +
 | 
			
		||||
      "\n" +
 | 
			
		||||
      "\n\n" +
 | 
			
		||||
      chalk.red(` ${msg}`) +
 | 
			
		||||
      (err.message.length > 0 ? `: ${err.message}` : ""),
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  if (!stack) {
 | 
			
		||||
    return
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  let reachedEndOfLegibleTrace = false
 | 
			
		||||
  for (const line of stack.split("\n").slice(1)) {
 | 
			
		||||
    if (reachedEndOfLegibleTrace) {
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user