project: init

This commit is contained in:
周中平 2024-01-10 14:39:54 +08:00
commit 0f4d9fcfb5
Signed by: zhouzhongping
GPG Key ID: 6666822800008000
127 changed files with 26139 additions and 0 deletions

11
Dockerfile Normal file
View File

@ -0,0 +1,11 @@
FROM node:20-slim as builder
WORKDIR /usr/src/app
COPY package.json .
COPY package-lock.json* .
RUN npm ci
FROM node:20-slim
WORKDIR /usr/src/app
COPY --from=builder /usr/src/app/ /usr/src/app/
COPY . .
CMD ["npx", "quartz", "build", "--serve"]

21
LICENSE.txt Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 jackyzha0
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

20
README.md Normal file
View File

@ -0,0 +1,20 @@
# Quartz v4
> “[One] who works with the door open gets all kinds of interruptions, but [they] also occasionally gets clues as to what the world is and what might be important.” — Richard Hamming
Quartz is a set of tools that helps you publish your [digital garden](https://jzhao.xyz/posts/networked-thought) and notes as a website for free.
Quartz v4 features a from-the-ground rewrite focusing on end-user extensibility and ease-of-use.
**If you are looking for Quartz v3, you can find it on the [`hugo` branch](https://github.com/jackyzha0/quartz/tree/hugo).**
🔗 Read the documentation and get started: https://quartz.jzhao.xyz/
[Join the Discord Community](https://discord.gg/cRFFHYye7t)
## Sponsors
<p align="center">
<a href="https://github.com/sponsors/jackyzha0">
<img src="https://cdn.jsdelivr.net/gh/jackyzha0/jackyzha0/sponsorkit/sponsors.svg" />
</a>
</p>

12
globals.d.ts vendored Normal file
View File

@ -0,0 +1,12 @@
export declare global {
interface Document {
addEventListener<K extends keyof CustomEventMap>(
type: K,
listener: (this: Document, ev: CustomEventMap[K]) => void,
): void
dispatchEvent<K extends keyof CustomEventMap>(ev: CustomEventMap[K]): void
}
interface Window {
spaNavigate(url: URL, isBack: boolean = false)
}
}

11
index.d.ts vendored Normal file
View File

@ -0,0 +1,11 @@
declare module "*.scss" {
const content: string
export = content
}
// dom custom event
interface CustomEventMap {
nav: CustomEvent<{ url: FullSlug }>
}
declare const fetchData: Promise<ContentIndex>

6213
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

109
package.json Normal file
View File

@ -0,0 +1,109 @@
{
"name": "@jackyzha0/quartz",
"description": "🌱 publish your digital garden and notes as a website",
"private": true,
"version": "4.1.0",
"type": "module",
"author": "jackyzha0 <j.zhao2k19@gmail.com>",
"license": "MIT",
"homepage": "https://quartz.jzhao.xyz",
"repository": {
"type": "git",
"url": "https://github.com/jackyzha0/quartz.git"
},
"scripts": {
"docs": "npx quartz build --serve -d docs",
"check": "tsc --noEmit && npx prettier . --check",
"format": "npx prettier . --write",
"test": "tsx ./quartz/util/path.test.ts",
"profile": "0x -D prof ./quartz/bootstrap-cli.mjs build --concurrency=1"
},
"engines": {
"npm": ">=9.3.1",
"node": ">=18.14"
},
"keywords": [
"site generator",
"ssg",
"digital-garden",
"markdown",
"blog",
"quartz"
],
"bin": {
"quartz": "./quartz/bootstrap-cli.mjs"
},
"dependencies": {
"@clack/prompts": "^0.6.3",
"@floating-ui/dom": "^1.4.0",
"@napi-rs/simple-git": "0.1.9",
"async-mutex": "^0.4.0",
"chalk": "^4.1.2",
"chokidar": "^3.5.3",
"cli-spinner": "^0.2.10",
"d3": "^7.8.5",
"esbuild-sass-plugin": "^2.12.0",
"flexsearch": "0.7.21",
"github-slugger": "^2.0.0",
"globby": "^13.1.4",
"gray-matter": "^4.0.3",
"hast-util-to-html": "^8.0.4",
"hast-util-to-jsx-runtime": "^1.2.0",
"hast-util-to-string": "^2.0.0",
"is-absolute-url": "^4.0.1",
"js-yaml": "^4.1.0",
"lightningcss": "1.21.7",
"mdast-util-find-and-replace": "^2.2.2",
"mdast-util-to-hast": "^12.3.0",
"mdast-util-to-string": "^3.2.0",
"micromorph": "^0.4.5",
"plausible-tracker": "^0.3.8",
"preact": "^10.14.1",
"preact-render-to-string": "^6.0.3",
"pretty-bytes": "^6.1.0",
"pretty-time": "^1.1.0",
"reading-time": "^1.5.0",
"rehype-autolink-headings": "^6.1.1",
"rehype-katex": "^6.0.3",
"rehype-mathjax": "^4.0.3",
"rehype-pretty-code": "^0.10.0",
"rehype-raw": "^6.1.1",
"rehype-slug": "^5.1.0",
"remark": "^14.0.2",
"remark-breaks": "^3.0.3",
"remark-frontmatter": "^4.0.1",
"remark-gfm": "^3.0.1",
"remark-math": "^5.1.1",
"remark-parse": "^10.0.1",
"remark-rehype": "^10.1.0",
"remark-smartypants": "^2.0.0",
"rimraf": "^5.0.1",
"serve-handler": "^6.1.5",
"source-map-support": "^0.5.21",
"to-vfile": "^7.2.4",
"toml": "^3.0.0",
"unified": "^10.1.2",
"unist-util-visit": "^4.1.2",
"vfile": "^5.3.7",
"workerpool": "^6.4.0",
"ws": "^8.13.0",
"yargs": "^17.7.2"
},
"devDependencies": {
"@types/cli-spinner": "^0.2.1",
"@types/d3": "^7.4.0",
"@types/flexsearch": "^0.7.3",
"@types/hast": "^2.3.4",
"@types/js-yaml": "^4.0.5",
"@types/node": "^20.1.2",
"@types/pretty-time": "^1.1.2",
"@types/source-map-support": "^0.5.6",
"@types/workerpool": "^6.4.0",
"@types/ws": "^8.5.5",
"@types/yargs": "^17.0.24",
"esbuild": "0.19.2",
"prettier": "^3.0.0",
"tsx": "^3.12.7",
"typescript": "^5.0.4"
}
}

75
quartz.config.ts Normal file
View File

@ -0,0 +1,75 @@
import { QuartzConfig } from "./quartz/cfg"
import * as Plugin from "./quartz/plugins"
const config: QuartzConfig = {
configuration: {
pageTitle: "📚 X·Eden",
enableSPA: true,
enablePopovers: true,
analytics: null,
baseUrl: "wiki.7wate.com",
ignorePatterns: ["private", "Templates", ".obsidian", "Canvas", "Static"],
defaultDateType: "created",
theme: {
typography: {
header: "Schibsted Grotesk",
body: "Source Sans Pro",
code: "IBM Plex Mono",
},
colors: {
lightMode: {
light: "#faf8f8",
lightgray: "#e5e5e5",
gray: "#b8b8b8",
darkgray: "#4e4e4e",
dark: "#2b2b2b",
secondary: "#284b63",
tertiary: "#84a59d",
highlight: "rgba(143, 159, 169, 0.15)",
},
darkMode: {
light: "#161618",
lightgray: "#393639",
gray: "#646464",
darkgray: "#d4d4d4",
dark: "#ebebec",
secondary: "#7b97aa",
tertiary: "#84a59d",
highlight: "rgba(143, 159, 169, 0.15)",
},
},
},
},
plugins: {
transformers: [
Plugin.FrontMatter(),
Plugin.TableOfContents(),
Plugin.CreatedModifiedDate({
priority: ["frontmatter", "filesystem"], // you can add 'git' here for last modified from Git but this makes the build slower
}),
Plugin.SyntaxHighlighting(),
Plugin.ObsidianFlavoredMarkdown({ enableInHtmlEmbed: false }),
Plugin.GitHubFlavoredMarkdown(),
Plugin.CrawlLinks({ markdownLinkResolution: "shortest" }),
Plugin.Latex({ renderEngine: "katex" }),
Plugin.Description(),
],
filters: [Plugin.RemoveDrafts()],
emitters: [
Plugin.AliasRedirects(),
Plugin.ComponentResources({ fontOrigin: "googleFonts" }),
Plugin.ContentPage(),
Plugin.FolderPage(),
Plugin.TagPage(),
Plugin.ContentIndex({
enableSiteMap: true,
enableRSS: true,
}),
Plugin.Assets(),
Plugin.Static(),
Plugin.NotFoundPage(),
],
},
}
export default config

57
quartz.layout.ts Normal file
View File

@ -0,0 +1,57 @@
import { PageLayout, SharedLayout, } from "./quartz/cfg"
import * as Component from "./quartz/components"
import { QuartzPluginData } from "./quartz/plugins/vfile";
// components shared across all pages
export const sharedPageComponents: SharedLayout = {
head: Component.Head(),
header: [],
footer: Component.Footer({
links: {
"Blog": "https://blog.7wate.com",
GitHub: "https://github.com/7wate",
},
}),
}
// components for pages that display a single page (e.g. a single note)
export const defaultContentPageLayout: PageLayout = {
beforeBody: [
Component.Breadcrumbs(),
Component.ArticleTitle(),
Component.ContentMeta(),
Component.TagList(),
],
left: [
Component.PageTitle(),
Component.MobileOnly(Component.Spacer()),
Component.Search(),
Component.Darkmode(),
Component.DesktopOnly(Component.Explorer()),
Component.DesktopOnly(Component.RecentNotes({
filter:(data: QuartzPluginData) => {
// 是否以 'Blog/' 开头
// console.log('Current file path:', data.filePath);
return data.filePath ? data.filePath.startsWith('content/Blog') : false;
}
}
)),
],
right: [
Component.Graph(),
Component.DesktopOnly(Component.TableOfContents()),
Component.Backlinks(),
],
}
// components for pages that display lists of pages (e.g. tags or folders)
export const defaultListPageLayout: PageLayout = {
beforeBody: [Component.ArticleTitle()],
left: [
Component.PageTitle(),
Component.MobileOnly(Component.Spacer()),
Component.Search(),
Component.Darkmode(),
],
right: [],
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

41
quartz/bootstrap-cli.mjs Executable file
View File

@ -0,0 +1,41 @@
#!/usr/bin/env node
import yargs from "yargs"
import { hideBin } from "yargs/helpers"
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", CreateArgv, async (argv) => {
await handleCreate(argv)
})
.command("update", "Get the latest Quartz updates", CommonArgv, async (argv) => {
await handleUpdate(argv)
})
.command(
"restore",
"Try to restore your content folder from the cache",
CommonArgv,
async (argv) => {
await handleRestore(argv)
},
)
.command("sync", "Sync your Quartz to and from GitHub.", SyncArgv, async (argv) => {
await handleSync(argv)
})
.command("build", "Build Quartz into a bundle of static HTML files", BuildArgv, async (argv) => {
await handleBuild(argv)
})
.showHelpOnFail(false)
.help()
.strict()
.demandCommand().argv

View File

@ -0,0 +1,7 @@
#!/usr/bin/env node
import workerpool from "workerpool"
const cacheFile = "./.quartz-cache/transpiled-worker.mjs"
const { parseFiles } = await import(cacheFile)
workerpool.worker({
parseFiles,
})

183
quartz/build.ts Normal file
View File

@ -0,0 +1,183 @@
import sourceMapSupport from "source-map-support"
sourceMapSupport.install(options)
import path from "path"
import { PerfTimer } from "./util/perf"
import { rimraf } from "rimraf"
import { isGitIgnored } from "globby"
import chalk from "chalk"
import { parseMarkdown } from "./processors/parse"
import { filterContent } from "./processors/filter"
import { emitContent } from "./processors/emit"
import cfg from "../quartz.config"
import { FilePath, joinSegments, slugifyFilePath } from "./util/path"
import chokidar from "chokidar"
import { ProcessedContent } from "./plugins/vfile"
import { Argv, BuildCtx } from "./util/ctx"
import { glob, toPosixPath } from "./util/glob"
import { trace } from "./util/trace"
import { options } from "./util/sourcemap"
import { Mutex } from "async-mutex"
async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) {
const ctx: BuildCtx = {
argv,
cfg,
allSlugs: [],
}
const perf = new PerfTimer()
const output = argv.output
const pluginCount = Object.values(cfg.plugins).flat().length
const pluginNames = (key: "transformers" | "filters" | "emitters") =>
cfg.plugins[key].map((plugin) => plugin.name)
if (argv.verbose) {
console.log(`Loaded ${pluginCount} plugins`)
console.log(` Transformers: ${pluginNames("transformers").join(", ")}`)
console.log(` Filters: ${pluginNames("filters").join(", ")}`)
console.log(` Emitters: ${pluginNames("emitters").join(", ")}`)
}
const release = await mut.acquire()
perf.addEvent("clean")
await rimraf(output)
console.log(`Cleaned output directory \`${output}\` in ${perf.timeSince("clean")}`)
perf.addEvent("glob")
const allFiles = await glob("**/*.*", argv.directory, cfg.configuration.ignorePatterns)
const fps = allFiles.filter((fp) => fp.endsWith(".md")).sort()
console.log(
`Found ${fps.length} input files from \`${argv.directory}\` in ${perf.timeSince("glob")}`,
)
const filePaths = fps.map((fp) => joinSegments(argv.directory, fp) as FilePath)
ctx.allSlugs = allFiles.map((fp) => slugifyFilePath(fp as FilePath))
const parsedFiles = await parseMarkdown(ctx, filePaths)
const filteredContent = filterContent(ctx, parsedFiles)
await emitContent(ctx, filteredContent)
console.log(chalk.green(`Done processing ${fps.length} files in ${perf.timeSince()}`))
release()
if (argv.serve) {
return startServing(ctx, mut, parsedFiles, clientRefresh)
}
}
// setup watcher for rebuilds
async function startServing(
ctx: BuildCtx,
mut: Mutex,
initialContent: ProcessedContent[],
clientRefresh: () => void,
) {
const { argv } = ctx
const ignored = await isGitIgnored()
const contentMap = new Map<FilePath, ProcessedContent>()
for (const content of initialContent) {
const [_tree, vfile] = content
contentMap.set(vfile.data.filePath!, content)
}
const initialSlugs = ctx.allSlugs
let lastBuildMs = 0
const toRebuild: Set<FilePath> = new Set()
const toRemove: Set<FilePath> = new Set()
const trackedAssets: Set<FilePath> = new Set()
async function rebuild(fp: string, action: "add" | "change" | "delete") {
// don't do anything for gitignored files
if (ignored(fp)) {
return
}
// dont bother rebuilding for non-content files, just track and refresh
fp = toPosixPath(fp)
const filePath = joinSegments(argv.directory, fp) as FilePath
if (path.extname(fp) !== ".md") {
if (action === "add" || action === "change") {
trackedAssets.add(filePath)
} else if (action === "delete") {
trackedAssets.delete(filePath)
}
clientRefresh()
return
}
if (action === "add" || action === "change") {
toRebuild.add(filePath)
} else if (action === "delete") {
toRemove.add(filePath)
}
// debounce rebuilds every 250ms
const buildStart = new Date().getTime()
lastBuildMs = buildStart
const release = await mut.acquire()
if (lastBuildMs > buildStart) {
release()
return
}
const perf = new PerfTimer()
console.log(chalk.yellow("Detected change, rebuilding..."))
try {
const filesToRebuild = [...toRebuild].filter((fp) => !toRemove.has(fp))
const trackedSlugs = [...new Set([...contentMap.keys(), ...toRebuild, ...trackedAssets])]
.filter((fp) => !toRemove.has(fp))
.map((fp) => slugifyFilePath(path.posix.relative(argv.directory, fp) as FilePath))
ctx.allSlugs = [...new Set([...initialSlugs, ...trackedSlugs])]
const parsedContent = await parseMarkdown(ctx, filesToRebuild)
for (const content of parsedContent) {
const [_tree, vfile] = content
contentMap.set(vfile.data.filePath!, content)
}
for (const fp of toRemove) {
contentMap.delete(fp)
}
const parsedFiles = [...contentMap.values()]
const filteredContent = filterContent(ctx, parsedFiles)
// TODO: we can probably traverse the link graph to figure out what's safe to delete here
// instead of just deleting everything
await rimraf(argv.output)
await emitContent(ctx, filteredContent)
console.log(chalk.green(`Done rebuilding in ${perf.timeSince()}`))
} catch {
console.log(chalk.yellow(`Rebuild failed. Waiting on a change to fix the error...`))
}
clientRefresh()
toRebuild.clear()
toRemove.clear()
release()
}
const watcher = chokidar.watch(".", {
persistent: true,
cwd: argv.directory,
ignoreInitial: true,
})
watcher
.on("add", (fp) => rebuild(fp, "add"))
.on("change", (fp) => rebuild(fp, "change"))
.on("unlink", (fp) => rebuild(fp, "delete"))
return async () => {
await watcher.close()
}
}
export default async (argv: Argv, mut: Mutex, clientRefresh: () => void) => {
try {
return await buildQuartz(argv, mut, clientRefresh)
} catch (err) {
trace("\nExiting Quartz due to a fatal error", err as Error)
}
}

55
quartz/cfg.ts Normal file
View File

@ -0,0 +1,55 @@
import { ValidDateType } from "./components/Date"
import { QuartzComponent } from "./components/types"
import { PluginTypes } from "./plugins/types"
import { Theme } from "./util/theme"
export type Analytics =
| null
| {
provider: "plausible"
}
| {
provider: "google"
tagId: string
}
| {
provider: "umami"
websiteId: string
}
export interface GlobalConfiguration {
pageTitle: string
/** Whether to enable single-page-app style rendering. this prevents flashes of unstyled content and improves smoothness of Quartz */
enableSPA: boolean
/** Whether to display Wikipedia-style popovers when hovering over links */
enablePopovers: boolean
/** Analytics mode */
analytics: Analytics
/** Glob patterns to not search */
ignorePatterns: string[]
/** Whether to use created, modified, or published as the default type of date */
defaultDateType: ValidDateType
/** Base URL to use for CNAME files, sitemaps, and RSS feeds that require an absolute URL.
* Quartz will avoid using this as much as possible and use relative URLs most of the time
*/
baseUrl?: string
theme: Theme
}
export interface QuartzConfig {
configuration: GlobalConfiguration
plugins: PluginTypes
}
export interface FullPageLayout {
head: QuartzComponent
header: QuartzComponent[]
beforeBody: QuartzComponent[]
pageBody: QuartzComponent
left: QuartzComponent[]
right: QuartzComponent[]
footer: QuartzComponent
}
export type PageLayout = Pick<FullPageLayout, "beforeBody" | "left" | "right">
export type SharedLayout = Pick<FullPageLayout, "head" | "header" | "footer">

98
quartz/cli/args.js Normal file
View File

@ -0,0 +1,98 @@
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",
},
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
View 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")

511
quartz/cli/handlers.js Normal file
View File

@ -0,0 +1,511 @@
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",
})
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!"))
}

52
quartz/cli/helpers.js Normal file
View 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 })
}

View File

@ -0,0 +1,17 @@
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
function ArticleTitle({ fileData, displayClass }: QuartzComponentProps) {
const title = fileData.frontmatter?.title
if (title) {
return <h1 class={`article-title ${displayClass ?? ""}`}>{title}</h1>
} else {
return null
}
}
ArticleTitle.css = `
.article-title {
margin: 2rem 0 0 0;
}
`
export default (() => ArticleTitle) satisfies QuartzComponentConstructor

View File

@ -0,0 +1,29 @@
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
import style from "./styles/backlinks.scss"
import { resolveRelative, simplifySlug } from "../util/path"
function Backlinks({ fileData, allFiles, displayClass }: QuartzComponentProps) {
const slug = simplifySlug(fileData.slug!)
const backlinkFiles = allFiles.filter((file) => file.links?.includes(slug))
return (
<div class={`backlinks ${displayClass ?? ""}`}>
<h3>Backlinks</h3>
<ul class="overflow">
{backlinkFiles.length > 0 ? (
backlinkFiles.map((f) => (
<li>
<a href={resolveRelative(fileData.slug!, f.slug!)} class="internal">
{f.frontmatter?.title}
</a>
</li>
))
) : (
<li>No backlinks found</li>
)}
</ul>
</div>
)
}
Backlinks.css = style
export default (() => Backlinks) satisfies QuartzComponentConstructor

View File

@ -0,0 +1,13 @@
// @ts-ignore
import clipboardScript from "./scripts/clipboard.inline"
import clipboardStyle from "./styles/clipboard.scss"
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
function Body({ children }: QuartzComponentProps) {
return <div id="quartz-body">{children}</div>
}
Body.afterDOMLoaded = clipboardScript
Body.css = clipboardStyle
export default (() => Body) satisfies QuartzComponentConstructor

View File

@ -0,0 +1,117 @@
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: false,
hideOnRoot: true,
}
function formatCrumb(displayName: string, baseSlug: FullSlug, currentSlug: SimpleSlug): CrumbData {
return {
displayName: displayName.replaceAll("-", " "),
path: resolveRelative(baseSlug, currentSlug),
}
}
// given a folderName (e.g. "features"), search for the corresponding `index.md` file
function findCurrentFile(allFiles: QuartzPluginData[], folderName: string) {
return allFiles.find((file) => {
if (file.slug?.endsWith("index")) {
const folderParts = file.filePath?.split("/")
if (folderParts) {
const name = folderParts[folderParts?.length - 2]
if (name === folderName) {
return true
}
}
}
})
}
export default ((opts?: Partial<BreadcrumbOptions>) => {
// Merge options with defaults
const options: BreadcrumbOptions = { ...defaultOptions, ...opts }
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]
// 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 currentTitle = slugParts[i]
// TODO: performance optimizations/memoizing
// Try to resolve frontmatter folder title
if (options?.resolveFrontmatterTitle) {
// try to find file for current path
const currentFile = findCurrentFile(allFiles, currentTitle)
if (currentFile) {
currentTitle = currentFile.frontmatter!.title
}
}
// Add current slug to full path
currentPath += slugParts[i] + "/"
// Format and add current crumb
const crumb = formatCrumb(currentTitle, 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

View File

@ -0,0 +1,30 @@
import { formatDate, getDate } from "./Date"
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
import readingTime from "reading-time"
export default (() => {
function ContentMetadata({ cfg, fileData, displayClass }: QuartzComponentProps) {
const text = fileData.text
if (text) {
const segments: string[] = []
const { text: timeTaken, words: _words } = readingTime(text)
if (fileData.dates) {
segments.push(formatDate(getDate(cfg, fileData)!))
}
segments.push(timeTaken)
return <p class={`content-meta ${displayClass ?? ""}`}>{segments.join(", ")}</p>
} else {
return null
}
}
ContentMetadata.css = `
.content-meta {
margin-top: 0;
color: var(--gray);
}
`
return ContentMetadata
}) satisfies QuartzComponentConstructor

View File

@ -0,0 +1,51 @@
// @ts-ignore: this is safe, we don't want to actually make darkmode.inline.ts a module as
// modules are automatically deferred and we don't want that to happen for critical beforeDOMLoads
// see: https://v8.dev/features/modules#defer
import darkmodeScript from "./scripts/darkmode.inline"
import styles from "./styles/darkmode.scss"
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
function Darkmode({ displayClass }: QuartzComponentProps) {
return (
<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
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
version="1.1"
id="dayIcon"
x="0px"
y="0px"
viewBox="0 0 35 35"
style="enable-background:new 0 0 35 35;"
xmlSpace="preserve"
>
<title>Light mode</title>
<path d="M6,17.5C6,16.672,5.328,16,4.5,16h-3C0.672,16,0,16.672,0,17.5 S0.672,19,1.5,19h3C5.328,19,6,18.328,6,17.5z M7.5,26c-0.414,0-0.789,0.168-1.061,0.439l-2,2C4.168,28.711,4,29.086,4,29.5 C4,30.328,4.671,31,5.5,31c0.414,0,0.789-0.168,1.06-0.44l2-2C8.832,28.289,9,27.914,9,27.5C9,26.672,8.329,26,7.5,26z M17.5,6 C18.329,6,19,5.328,19,4.5v-3C19,0.672,18.329,0,17.5,0S16,0.672,16,1.5v3C16,5.328,16.671,6,17.5,6z M27.5,9 c0.414,0,0.789-0.168,1.06-0.439l2-2C30.832,6.289,31,5.914,31,5.5C31,4.672,30.329,4,29.5,4c-0.414,0-0.789,0.168-1.061,0.44 l-2,2C26.168,6.711,26,7.086,26,7.5C26,8.328,26.671,9,27.5,9z M6.439,8.561C6.711,8.832,7.086,9,7.5,9C8.328,9,9,8.328,9,7.5 c0-0.414-0.168-0.789-0.439-1.061l-2-2C6.289,4.168,5.914,4,5.5,4C4.672,4,4,4.672,4,5.5c0,0.414,0.168,0.789,0.439,1.06 L6.439,8.561z M33.5,16h-3c-0.828,0-1.5,0.672-1.5,1.5s0.672,1.5,1.5,1.5h3c0.828,0,1.5-0.672,1.5-1.5S34.328,16,33.5,16z M28.561,26.439C28.289,26.168,27.914,26,27.5,26c-0.828,0-1.5,0.672-1.5,1.5c0,0.414,0.168,0.789,0.439,1.06l2,2 C28.711,30.832,29.086,31,29.5,31c0.828,0,1.5-0.672,1.5-1.5c0-0.414-0.168-0.789-0.439-1.061L28.561,26.439z M17.5,29 c-0.829,0-1.5,0.672-1.5,1.5v3c0,0.828,0.671,1.5,1.5,1.5s1.5-0.672,1.5-1.5v-3C19,29.672,18.329,29,17.5,29z M17.5,7 C11.71,7,7,11.71,7,17.5S11.71,28,17.5,28S28,23.29,28,17.5S23.29,7,17.5,7z M17.5,25c-4.136,0-7.5-3.364-7.5-7.5 c0-4.136,3.364-7.5,7.5-7.5c4.136,0,7.5,3.364,7.5,7.5C25,21.636,21.636,25,17.5,25z"></path>
</svg>
</label>
<label id="toggle-label-dark" for="darkmode-toggle" tabIndex={-1}>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
version="1.1"
id="nightIcon"
x="0px"
y="0px"
viewBox="0 0 100 100"
style="enable-background='new 0 0 100 100'"
xmlSpace="preserve"
>
<title>Dark mode</title>
<path d="M96.76,66.458c-0.853-0.852-2.15-1.064-3.23-0.534c-6.063,2.991-12.858,4.571-19.655,4.571 C62.022,70.495,50.88,65.88,42.5,57.5C29.043,44.043,25.658,23.536,34.076,6.47c0.532-1.08,0.318-2.379-0.534-3.23 c-0.851-0.852-2.15-1.064-3.23-0.534c-4.918,2.427-9.375,5.619-13.246,9.491c-9.447,9.447-14.65,22.008-14.65,35.369 c0,13.36,5.203,25.921,14.65,35.368s22.008,14.65,35.368,14.65c13.361,0,25.921-5.203,35.369-14.65 c3.872-3.871,7.064-8.328,9.491-13.246C97.826,68.608,97.611,67.309,96.76,66.458z"></path>
</svg>
</label>
</div>
)
}
Darkmode.beforeDOMLoaded = darkmodeScript
Darkmode.css = styles
export default (() => Darkmode) satisfies QuartzComponentConstructor

View File

@ -0,0 +1,29 @@
import { GlobalConfiguration } from "../cfg"
import { QuartzPluginData } from "../plugins/vfile"
interface Props {
date: Date
}
export type ValidDateType = keyof Required<QuartzPluginData>["dates"]
export function getDate(cfg: GlobalConfiguration, data: QuartzPluginData): Date | undefined {
if (!cfg.defaultDateType) {
throw new Error(
`Field 'defaultDateType' was not set in the configuration object of quartz.config.ts. See https://quartz.jzhao.xyz/configuration#general-configuration for more details.`,
)
}
return data.dates?.[cfg.defaultDateType]
}
export function formatDate(d: Date): string {
return d.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "2-digit",
})
}
export function Date({ date }: Props) {
return <>{formatDate(date)}</>
}

View File

@ -0,0 +1,18 @@
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
export default ((component?: QuartzComponent) => {
if (component) {
const Component = component
function DesktopOnly(props: QuartzComponentProps) {
return <Component displayClass="desktop-only" {...props} />
}
DesktopOnly.displayName = component.displayName
DesktopOnly.afterDOMLoaded = component?.afterDOMLoaded
DesktopOnly.beforeDOMLoaded = component?.beforeDOMLoaded
DesktopOnly.css = component?.css
return DesktopOnly
} else {
return () => <></>
}
}) satisfies QuartzComponentConstructor

View 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

View 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>
)
}

View File

@ -0,0 +1,32 @@
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
import style from "./styles/footer.scss"
import { version } from "../../package.json"
interface Options {
links: Record<string, string>
}
export default ((opts?: Options) => {
function Footer({ displayClass }: QuartzComponentProps) {
const year = new Date().getFullYear()
const links = opts?.links ?? []
return (
<footer class={`${displayClass ?? ""}`}>
<hr />
<p>
Created with <a href="https://quartz.jzhao.xyz/">Quartz v{version}</a>, © {year}
</p>
<ul>
{Object.entries(links).map(([text, link]) => (
<li>
<a href={link}>{text}</a>
</li>
))}
</ul>
</footer>
)
}
Footer.css = style
return Footer
}) satisfies QuartzComponentConstructor

100
quartz/components/Graph.tsx Normal file
View File

@ -0,0 +1,100 @@
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
// @ts-ignore
import script from "./scripts/graph.inline"
import style from "./styles/graph.scss"
export interface D3Config {
drag: boolean
zoom: boolean
depth: number
scale: number
repelForce: number
centerForce: number
linkDistance: number
fontSize: number
opacityScale: number
removeTags: string[]
showTags: boolean
}
interface GraphOptions {
localGraph: Partial<D3Config> | undefined
globalGraph: Partial<D3Config> | undefined
}
const defaultOptions: GraphOptions = {
localGraph: {
drag: true,
zoom: true,
depth: 1,
scale: 1.1,
repelForce: 0.5,
centerForce: 0.3,
linkDistance: 30,
fontSize: 0.6,
opacityScale: 1,
showTags: true,
removeTags: [],
},
globalGraph: {
drag: true,
zoom: true,
depth: -1,
scale: 0.9,
repelForce: 0.5,
centerForce: 0.3,
linkDistance: 30,
fontSize: 0.6,
opacityScale: 1,
showTags: true,
removeTags: [],
},
}
export default ((opts?: GraphOptions) => {
function Graph({ displayClass }: QuartzComponentProps) {
const localGraph = { ...defaultOptions.localGraph, ...opts?.localGraph }
const globalGraph = { ...defaultOptions.globalGraph, ...opts?.globalGraph }
return (
<div class={`graph ${displayClass ?? ""}`}>
<h3>Graph View</h3>
<div class="graph-outer">
<div id="graph-container" data-cfg={JSON.stringify(localGraph)}></div>
<svg
version="1.1"
id="global-graph-icon"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
x="0px"
y="0px"
viewBox="0 0 55 55"
fill="currentColor"
xmlSpace="preserve"
>
<path
d="M49,0c-3.309,0-6,2.691-6,6c0,1.035,0.263,2.009,0.726,2.86l-9.829,9.829C32.542,17.634,30.846,17,29,17
s-3.542,0.634-4.898,1.688l-7.669-7.669C16.785,10.424,17,9.74,17,9c0-2.206-1.794-4-4-4S9,6.794,9,9s1.794,4,4,4
c0.74,0,1.424-0.215,2.019-0.567l7.669,7.669C21.634,21.458,21,23.154,21,25s0.634,3.542,1.688,4.897L10.024,42.562
C8.958,41.595,7.549,41,6,41c-3.309,0-6,2.691-6,6s2.691,6,6,6s6-2.691,6-6c0-1.035-0.263-2.009-0.726-2.86l12.829-12.829
c1.106,0.86,2.44,1.436,3.898,1.619v10.16c-2.833,0.478-5,2.942-5,5.91c0,3.309,2.691,6,6,6s6-2.691,6-6c0-2.967-2.167-5.431-5-5.91
v-10.16c1.458-0.183,2.792-0.759,3.898-1.619l7.669,7.669C41.215,39.576,41,40.26,41,41c0,2.206,1.794,4,4,4s4-1.794,4-4
s-1.794-4-4-4c-0.74,0-1.424,0.215-2.019,0.567l-7.669-7.669C36.366,28.542,37,26.846,37,25s-0.634-3.542-1.688-4.897l9.665-9.665
C46.042,11.405,47.451,12,49,12c3.309,0,6-2.691,6-6S52.309,0,49,0z M11,9c0-1.103,0.897-2,2-2s2,0.897,2,2s-0.897,2-2,2
S11,10.103,11,9z M6,51c-2.206,0-4-1.794-4-4s1.794-4,4-4s4,1.794,4,4S8.206,51,6,51z M33,49c0,2.206-1.794,4-4,4s-4-1.794-4-4
s1.794-4,4-4S33,46.794,33,49z M29,31c-3.309,0-6-2.691-6-6s2.691-6,6-6s6,2.691,6,6S32.309,31,29,31z M47,41c0,1.103-0.897,2-2,2
s-2-0.897-2-2s0.897-2,2-2S47,39.897,47,41z M49,10c-2.206,0-4-1.794-4-4s1.794-4,4-4s4,1.794,4,4S51.206,10,49,10z"
/>
</svg>
</div>
<div id="global-graph-outer">
<div id="global-graph-container" data-cfg={JSON.stringify(globalGraph)}></div>
</div>
</div>
)
}
Graph.css = style
Graph.afterDOMLoaded = script
return Graph
}) satisfies QuartzComponentConstructor

View File

@ -0,0 +1,44 @@
import { FullSlug, _stripSlashes, joinSegments, pathToRoot } from "../util/path"
import { JSResourceToScriptElement } from "../util/resources"
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
export default (() => {
function Head({ cfg, fileData, externalResources }: QuartzComponentProps) {
const title = fileData.frontmatter?.title ?? "Untitled"
const description = fileData.description?.trim() ?? "No description provided"
const { css, js } = externalResources
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`
return (
<head>
<title>{title}</title>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
{cfg.baseUrl && <meta property="og:image" content={ogImagePath} />}
<meta property="og:width" content="1200" />
<meta property="og:height" content="675" />
<link rel="icon" href={iconPath} />
<meta name="description" content={description} />
<meta name="generator" content="Quartz" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" />
{css.map((href) => (
<link key={href} href={href} rel="stylesheet" type="text/css" spa-preserve />
))}
{js
.filter((resource) => resource.loadTime === "beforeDOMReady")
.map((res) => JSResourceToScriptElement(res, true))}
</head>
)
}
return Head
}) satisfies QuartzComponentConstructor

View File

@ -0,0 +1,22 @@
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
function Header({ children }: QuartzComponentProps) {
return children.length > 0 ? <header>{children}</header> : null
}
Header.css = `
header {
display: flex;
flex-direction: row;
align-items: center;
margin: 2rem 0;
gap: 1.5rem;
}
header h1 {
margin: 0;
flex: auto;
}
`
export default (() => Header) satisfies QuartzComponentConstructor

View File

@ -0,0 +1,18 @@
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
export default ((component?: QuartzComponent) => {
if (component) {
const Component = component
function MobileOnly(props: QuartzComponentProps) {
return <Component displayClass="mobile-only" {...props} />
}
MobileOnly.displayName = component.displayName
MobileOnly.afterDOMLoaded = component?.afterDOMLoaded
MobileOnly.beforeDOMLoaded = component?.beforeDOMLoaded
MobileOnly.css = component?.css
return MobileOnly
} else {
return () => <></>
}
}) satisfies QuartzComponentConstructor

View File

@ -0,0 +1,87 @@
import { FullSlug, resolveRelative } from "../util/path"
import { QuartzPluginData } from "../plugins/vfile"
import { Date, getDate } from "./Date"
import { QuartzComponentProps } from "./types"
import { GlobalConfiguration } from "../cfg"
export function byDateAndAlphabetical(
cfg: GlobalConfiguration,
): (f1: QuartzPluginData, f2: QuartzPluginData) => number {
return (f1, f2) => {
if (f1.dates && f2.dates) {
// sort descending
return getDate(cfg, f2)!.getTime() - getDate(cfg, f1)!.getTime()
} else if (f1.dates && !f2.dates) {
// prioritize files with dates
return -1
} else if (!f1.dates && f2.dates) {
return 1
}
// otherwise, sort lexographically by title
const f1Title = f1.frontmatter?.title.toLowerCase() ?? ""
const f2Title = f2.frontmatter?.title.toLowerCase() ?? ""
return f1Title.localeCompare(f2Title)
}
}
type Props = {
limit?: number
} & QuartzComponentProps
export function PageList({ cfg, fileData, allFiles, limit }: Props) {
let list = allFiles.sort(byDateAndAlphabetical(cfg))
if (limit) {
list = list.slice(0, limit)
}
return (
<ul class="section-ul">
{list.map((page) => {
const title = page.frontmatter?.title
const tags = page.frontmatter?.tags ?? []
return (
<li class="section-li">
<div class="section">
{page.dates && (
<p class="meta">
<Date date={getDate(cfg, page)!} />
</p>
)}
<div class="desc">
<h3>
<a href={resolveRelative(fileData.slug!, page.slug!)} class="internal">
{title}
</a>
</h3>
</div>
<ul class="tags">
{tags.map((tag) => (
<li>
<a
class="internal tag-link"
href={resolveRelative(fileData.slug!, `tags/${tag}` as FullSlug)}
>
#{tag}
</a>
</li>
))}
</ul>
</div>
</li>
)
})}
</ul>
)
}
PageList.css = `
.section h3 {
margin: 0;
}
.section > .tags {
margin: 0;
}
`

View File

@ -0,0 +1,20 @@
import { pathToRoot } from "../util/path"
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
function PageTitle({ fileData, cfg, displayClass }: QuartzComponentProps) {
const title = cfg?.pageTitle ?? "Untitled Quartz"
const baseDir = pathToRoot(fileData.slug!)
return (
<h1 class={`page-title ${displayClass ?? ""}`}>
<a href={baseDir}>{title}</a>
</h1>
)
}
PageTitle.css = `
.page-title {
margin: 0;
}
`
export default (() => PageTitle) satisfies QuartzComponentConstructor

View File

@ -0,0 +1,85 @@
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
import { FullSlug, SimpleSlug, resolveRelative } from "../util/path"
import { QuartzPluginData } from "../plugins/vfile"
import { byDateAndAlphabetical } from "./PageList"
import style from "./styles/recentNotes.scss"
import { Date, getDate } from "./Date"
import { GlobalConfiguration } from "../cfg"
interface Options {
title: string
limit: number
linkToMore: SimpleSlug | false
filter: (f: QuartzPluginData) => boolean
sort: (f1: QuartzPluginData, f2: QuartzPluginData) => number
showTags: boolean
}
const defaultOptions = (cfg: GlobalConfiguration): Options => ({
title: "Recent Notes",
limit: 3,
linkToMore: false,
filter: () => true,
sort: byDateAndAlphabetical(cfg),
showTags: false,
})
export default ((userOpts?: Partial<Options>) => {
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 ?? ""}`}>
<h3>{opts.title}</h3>
<ul class="recent-ul">
{pages.slice(0, opts.limit).map((page) => {
const title = page.frontmatter?.title
const tags = page.frontmatter?.tags ?? []
return (
<li class="recent-li">
<div class="section">
<div class="desc">
<h3>
<a href={resolveRelative(fileData.slug!, page.slug!)} class="internal">
{title}
</a>
</h3>
</div>
{page.dates && (
<p class="meta">
<Date date={getDate(cfg, page)!} />
</p>
)}
{opts.showTags && tags.length > 0 && ( // 根据 opts.showTags 决定是否渲染标签
<ul class="tags">
{tags.map((tag) => (
<li>
<a
class="internal tag-link"
href={resolveRelative(fileData.slug!, `tags/${tag}` as FullSlug)}
>
#{tag}
</a>
</li>
))}
</ul>
)}
</div>
</li>
)
})}
</ul>
{opts.linkToMore && remaining > 0 && (
<p>
<a href={resolveRelative(fileData.slug!, opts.linkToMore)}>See {remaining} more </a>
</p>
)}
</div>
)
}
RecentNotes.css = style
return RecentNotes
}) satisfies QuartzComponentConstructor

View File

@ -0,0 +1,49 @@
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
import style from "./styles/search.scss"
// @ts-ignore
import script from "./scripts/search.inline"
export default (() => {
function Search({ displayClass }: QuartzComponentProps) {
return (
<div class={`search ${displayClass ?? ""}`}>
<div id="search-icon">
<p>Search</p>
<div></div>
<svg
tabIndex={0}
aria-labelledby="title desc"
role="img"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 19.9 19.7"
>
<title id="title">Search</title>
<desc id="desc">Search</desc>
<g class="search-path" fill="none">
<path stroke-linecap="square" d="M18.5 18.3l-5.4-5.4" />
<circle cx="8" cy="8" r="7" />
</g>
</svg>
</div>
<div id="search-container">
<div id="search-space">
<input
autocomplete="off"
id="search-bar"
name="search"
type="text"
aria-label="Search for something"
placeholder="Search for something"
/>
<div id="results-container"></div>
</div>
</div>
</div>
)
}
Search.afterDOMLoaded = script
Search.css = style
return Search
}) satisfies QuartzComponentConstructor

View File

@ -0,0 +1,7 @@
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
function Spacer({ displayClass }: QuartzComponentProps) {
return <div class={`spacer ${displayClass ?? ""}`}></div>
}
export default (() => Spacer) satisfies QuartzComponentConstructor

View File

@ -0,0 +1,84 @@
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
import legacyStyle from "./styles/legacyToc.scss"
import modernStyle from "./styles/toc.scss"
// @ts-ignore
import script from "./scripts/toc.inline"
interface Options {
layout: "modern" | "legacy"
}
const defaultOptions: Options = {
layout: "modern",
}
function TableOfContents({ fileData, displayClass }: QuartzComponentProps) {
if (!fileData.toc) {
return null
}
return (
<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"
width="24"
height="24"
viewBox="0 0 24 24"
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="toc-content">
<ul class="overflow">
{fileData.toc.map((tocEntry) => (
<li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}>
<a href={`#${tocEntry.slug}`} data-for={tocEntry.slug}>
{tocEntry.text}
</a>
</li>
))}
</ul>
</div>
</div>
)
}
TableOfContents.css = modernStyle
TableOfContents.afterDOMLoaded = script
function LegacyTableOfContents({ fileData }: QuartzComponentProps) {
if (!fileData.toc) {
return null
}
return (
<details id="toc" open={!fileData.collapseToc}>
<summary>
<h3>Table of Contents</h3>
</summary>
<ul>
{fileData.toc.map((tocEntry) => (
<li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}>
<a href={`#${tocEntry.slug}`} data-for={tocEntry.slug}>
{tocEntry.text}
</a>
</li>
))}
</ul>
</details>
)
}
LegacyTableOfContents.css = legacyStyle
export default ((opts?: Partial<Options>) => {
const layout = opts?.layout ?? defaultOptions.layout
return layout === "modern" ? TableOfContents : LegacyTableOfContents
}) satisfies QuartzComponentConstructor

View File

@ -0,0 +1,58 @@
import { pathToRoot, slugTag } from "../util/path"
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
function TagList({ fileData, displayClass }: QuartzComponentProps) {
const tags = fileData.frontmatter?.tags
const baseDir = pathToRoot(fileData.slug!)
if (tags && tags.length > 0) {
return (
<ul class={`tags ${displayClass ?? ""}`}>
{tags.map((tag) => {
const display = `#${tag}`
const linkDest = baseDir + `/tags/${slugTag(tag)}`
return (
<li>
<a href={linkDest} class="internal tag-link">
{display}
</a>
</li>
)
})}
</ul>
)
} else {
return null
}
}
TagList.css = `
.tags {
list-style: none;
display: flex;
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 {
display: inline-block;
white-space: nowrap;
margin: 0;
overflow-wrap: normal;
}
a.internal.tag-link {
border-radius: 8px;
background-color: var(--highlight);
padding: 0.2rem 0.4rem;
margin: 0 0.1rem;
}
`
export default (() => TagList) satisfies QuartzComponentConstructor

View File

@ -0,0 +1,45 @@
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"
import Search from "./Search"
import Footer from "./Footer"
import DesktopOnly from "./DesktopOnly"
import MobileOnly from "./MobileOnly"
import RecentNotes from "./RecentNotes"
import Breadcrumbs from "./Breadcrumbs"
export {
ArticleTitle,
Content,
TagContent,
FolderContent,
Darkmode,
Head,
PageTitle,
ContentMeta,
Spacer,
TableOfContents,
Explorer,
TagList,
Graph,
Backlinks,
Search,
Footer,
DesktopOnly,
MobileOnly,
RecentNotes,
NotFound,
Breadcrumbs,
}

View 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

View File

@ -0,0 +1,9 @@
import { htmlToJsx } from "../../util/jsx"
import { QuartzComponentConstructor, QuartzComponentProps } from "../types"
function Content({ fileData, tree }: QuartzComponentProps) {
const content = htmlToJsx(fileData.filePath!, tree)
return <article class="popover-hint">{content}</article>
}
export default (() => Content) satisfies QuartzComponentConstructor

View File

@ -0,0 +1,47 @@
import { QuartzComponentConstructor, QuartzComponentProps } from "../types"
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
const folderSlug = _stripSlashes(simplifySlug(fileData.slug!))
const allPagesInFolder = allFiles.filter((file) => {
const fileSlug = _stripSlashes(simplifySlug(file.slug!))
const prefixed = fileSlug.startsWith(folderSlug) && fileSlug !== folderSlug
const folderParts = folderSlug.split(path.posix.sep)
const fileParts = fileSlug.split(path.posix.sep)
const isDirectChild = fileParts.length === folderParts.length + 1
return prefixed && isDirectChild
})
const listProps = {
...props,
allFiles: allPagesInFolder,
}
const content =
(tree as Root).children.length === 0
? fileData.description
: htmlToJsx(fileData.filePath!, tree)
return (
<div class="popover-hint">
<article>
<p>{content}</p>
</article>
<p>{pluralize(allPagesInFolder.length, "item")} under this folder.</p>
<div>
<PageList {...listProps} />
</div>
</div>
)
}
FolderContent.css = style + PageList.css
export default (() => FolderContent) satisfies QuartzComponentConstructor

View File

@ -0,0 +1,92 @@
import { QuartzComponentConstructor, QuartzComponentProps } from "../types"
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) {
const { tree, fileData, allFiles } = props
const slug = fileData.slug
if (!(slug?.startsWith("tags/") || slug === "tags")) {
throw new Error(`Component "TagContent" tried to render a non-tag page: ${slug}`)
}
const tag = simplifySlug(slug.slice("tags/".length) as FullSlug)
const allPagesWithTag = (tag: string) =>
allFiles.filter((file) =>
(file.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes).includes(tag),
)
const content =
(tree as Root).children.length === 0
? fileData.description
: htmlToJsx(fileData.filePath!, tree)
if (tag === "") {
const tags = [...new Set(allFiles.flatMap((data) => data.frontmatter?.tags ?? []))]
const tagItemMap: Map<string, QuartzPluginData[]> = new Map()
for (const tag of tags) {
tagItemMap.set(tag, allPagesWithTag(tag))
}
return (
<div class="popover-hint">
<article>
<p>{content}</p>
</article>
<p>Found {tags.length} total tags.</p>
<div>
{tags.map((tag) => {
const pages = tagItemMap.get(tag)!
const listProps = {
...props,
allFiles: pages,
}
const contentPage = allFiles.filter((file) => file.slug === `tags/${tag}`)[0]
const content = contentPage?.description
return (
<div>
<h2>
<a class="internal tag-link" href={`./${tag}`}>
#{tag}
</a>
</h2>
{content && <p>{content}</p>}
<p>
{pluralize(pages.length, "item")} with this tag.{" "}
{pages.length > numPages && `Showing first ${numPages}.`}
</p>
<PageList limit={numPages} {...listProps} />
</div>
)
})}
</div>
</div>
)
} else {
const pages = allPagesWithTag(tag)
const listProps = {
...props,
allFiles: pages,
}
return (
<div class="popover-hint">
<article>{content}</article>
<p>{pluralize(pages.length, "item")} with this tag.</p>
<div>
<PageList {...listProps} />
</div>
</div>
)
}
}
TagContent.css = style + PageList.css
export default (() => TagContent) satisfies QuartzComponentConstructor

View File

@ -0,0 +1,154 @@
import { render } from "preact-render-to-string"
import { QuartzComponent, QuartzComponentProps } from "./types"
import HeaderConstructor from "./Header"
import BodyConstructor from "./Body"
import { JSResourceToScriptElement, StaticResources } from "../util/resources"
import { FullSlug, RelativeURL, joinSegments } from "../util/path"
import { visit } from "unist-util-visit"
import { Root, Element } from "hast"
interface RenderComponents {
head: QuartzComponent
header: QuartzComponent[]
beforeBody: QuartzComponent[]
pageBody: QuartzComponent
left: QuartzComponent[]
right: QuartzComponent[]
footer: QuartzComponent
}
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())`
return {
css: [joinSegments(baseDir, "index.css"), ...staticResources.css],
js: [
{
src: joinSegments(baseDir, "prescript.js"),
loadTime: "beforeDOMReady",
contentType: "external",
},
{
loadTime: "beforeDOMReady",
contentType: "inline",
spaPreserve: true,
script: contentIndexScript,
},
...staticResources.js,
{
src: joinSegments(baseDir, "postscript.js"),
loadTime: "afterDOMReady",
moduleType: "module",
contentType: "external",
},
],
}
}
export function renderPage(
slug: FullSlug,
componentData: QuartzComponentProps,
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 blockSlug = inner.properties?.["data-slug"] as FullSlug
const blockRef = node.properties!.dataBlock as string
// TODO: avoid this expensive find operation and construct an index ahead of time
let blockNode = componentData.allFiles.find((f) => f.slug === blockSlug)?.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` }],
},
]
}
}
}
})
const {
head: Head,
header,
beforeBody,
pageBody: Content,
left,
right,
footer: Footer,
} = components
const Header = HeaderConstructor()
const Body = BodyConstructor()
const LeftComponent = (
<div class="left sidebar">
{left.map((BodyComponent) => (
<BodyComponent {...componentData} />
))}
</div>
)
const RightComponent = (
<div class="right sidebar">
{right.map((BodyComponent) => (
<BodyComponent {...componentData} />
))}
</div>
)
const doc = (
<html>
<Head {...componentData} />
<body data-slug={slug}>
<div id="quartz-root" class="page">
<Body {...componentData}>
{LeftComponent}
<div class="center">
<div class="page-header">
<Header {...componentData}>
{header.map((HeaderComponent) => (
<HeaderComponent {...componentData} />
))}
</Header>
<div class="popover-hint">
{beforeBody.map((BodyComponent) => (
<BodyComponent {...componentData} />
))}
</div>
</div>
<Content {...componentData} />
</div>
{RightComponent}
</Body>
<Footer {...componentData} />
</div>
</body>
{pageResources.js
.filter((resource) => resource.loadTime === "afterDOMReady")
.map((res) => JSResourceToScriptElement(res))}
</html>
)
return "<!DOCTYPE html>\n" + render(doc)
}

View File

@ -0,0 +1,44 @@
function toggleCallout(this: HTMLElement) {
const outerBlock = this.parentElement!
outerBlock.classList.toggle(`is-collapsed`)
const collapsed = outerBlock.classList.contains(`is-collapsed`)
const height = collapsed ? this.scrollHeight : outerBlock.scrollHeight
outerBlock.style.maxHeight = height + `px`
// walk and adjust height of all parents
let current = outerBlock
let parent = outerBlock.parentElement
while (parent) {
if (!parent.classList.contains(`callout`)) {
return
}
const collapsed = parent.classList.contains(`is-collapsed`)
const height = collapsed ? parent.scrollHeight : parent.scrollHeight + current.scrollHeight
parent.style.maxHeight = height + `px`
current = parent
parent = parent.parentElement
}
}
function setupCallout() {
const collapsible = document.getElementsByClassName(
`callout is-collapsible`,
) as HTMLCollectionOf<HTMLElement>
for (const div of collapsible) {
const title = div.firstElementChild
if (title) {
title.removeEventListener(`click`, toggleCallout)
title.addEventListener(`click`, toggleCallout)
const collapsed = div.classList.contains(`is-collapsed`)
const height = collapsed ? title.scrollHeight : div.scrollHeight
div.style.maxHeight = height + `px`
}
}
}
document.addEventListener(`nav`, setupCallout)
window.addEventListener(`resize`, setupCallout)

View File

@ -0,0 +1,33 @@
const svgCopy =
'<svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true"><path fill-rule="evenodd" d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 010 1.5h-1.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 019.25 16h-7.5A1.75 1.75 0 010 14.25v-7.5z"></path><path fill-rule="evenodd" d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0114.25 11h-7.5A1.75 1.75 0 015 9.25v-7.5zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25h-7.5z"></path></svg>'
const svgCheck =
'<svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true"><path fill-rule="evenodd" fill="rgb(63, 185, 80)" d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"></path></svg>'
document.addEventListener("nav", () => {
const els = document.getElementsByTagName("pre")
for (let i = 0; i < els.length; i++) {
const codeBlock = els[i].getElementsByTagName("code")[0]
if (codeBlock) {
const source = codeBlock.innerText.replace(/\n\n/g, "\n")
const button = document.createElement("button")
button.className = "clipboard-button"
button.type = "button"
button.innerHTML = svgCopy
button.ariaLabel = "Copy source"
button.addEventListener("click", () => {
navigator.clipboard.writeText(source).then(
() => {
button.blur()
button.innerHTML = svgCheck
setTimeout(() => {
button.innerHTML = svgCopy
button.style.borderColor = ""
}, 2000)
},
(error) => console.error(error),
)
})
els[i].prepend(button)
}
}
})

View File

@ -0,0 +1,32 @@
const userPref = window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark"
const currentTheme = localStorage.getItem("theme") ?? userPref
document.documentElement.setAttribute("saved-theme", currentTheme)
document.addEventListener("nav", () => {
const switchTheme = (e: any) => {
if (e.target.checked) {
document.documentElement.setAttribute("saved-theme", "dark")
localStorage.setItem("theme", "dark")
} else {
document.documentElement.setAttribute("saved-theme", "light")
localStorage.setItem("theme", "light")
}
}
// Darkmode toggle
const toggleSwitch = document.querySelector("#darkmode-toggle") as HTMLInputElement
toggleSwitch.removeEventListener("change", switchTheme)
toggleSwitch.addEventListener("change", switchTheme)
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
})
})

View 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
}
}

View File

@ -0,0 +1,328 @@
import type { ContentDetails } from "../../plugins/emitters/contentIndex"
import * as d3 from "d3"
import { registerEscapeHandler, removeAllChildren } from "./util"
import { FullSlug, SimpleSlug, getFullSlug, resolveRelative, simplifySlug } from "../../util/path"
type NodeData = {
id: SimpleSlug
text: string
tags: string[]
} & d3.SimulationNodeDatum
type LinkData = {
source: SimpleSlug
target: SimpleSlug
}
const localStorageKey = "graph-visited"
function getVisited(): Set<SimpleSlug> {
return new Set(JSON.parse(localStorage.getItem(localStorageKey) ?? "[]"))
}
function addToVisited(slug: SimpleSlug) {
const visited = getVisited()
visited.add(slug)
localStorage.setItem(localStorageKey, JSON.stringify([...visited]))
}
async function renderGraph(container: string, fullSlug: FullSlug) {
const slug = simplifySlug(fullSlug)
const visited = getVisited()
const graph = document.getElementById(container)
if (!graph) return
removeAllChildren(graph)
let {
drag: enableDrag,
zoom: enableZoom,
depth,
scale,
repelForce,
centerForce,
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 (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>()
const wl: (SimpleSlug | "__SENTINEL")[] = [slug, "__SENTINEL"]
if (depth >= 0) {
while (depth >= 0 && wl.length > 0) {
// compute neighbours
const cur = wl.shift()!
if (cur === "__SENTINEL") {
depth--
wl.push("__SENTINEL")
} else {
neighbourhood.add(cur)
const outgoing = links.filter((l) => l.source === cur)
const incoming = links.filter((l) => l.target === cur)
wl.push(...outgoing.map((l) => l.target), ...incoming.map((l) => l.source))
}
}
} 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) => {
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)),
}
const simulation: d3.Simulation<NodeData, LinkData> = d3
.forceSimulation(graphData.nodes)
.force("charge", d3.forceManyBody().strength(-100 * repelForce))
.force(
"link",
d3
.forceLink(graphData.links)
.id((d: any) => d.id)
.distance(linkDistance),
)
.force("center", d3.forceCenter().strength(centerForce))
const height = Math.max(graph.offsetHeight, 250)
const width = graph.offsetWidth
const svg = d3
.select<HTMLElement, NodeData>("#" + container)
.append("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", [-width / 2 / scale, -height / 2 / scale, width / scale, height / scale])
// draw links between nodes
const link = svg
.append("g")
.selectAll("line")
.data(graphData.links)
.join("line")
.attr("class", "link")
.attr("stroke", "var(--lightgray)")
.attr("stroke-width", 1)
// svg groups
const graphNode = svg.append("g").selectAll("g").data(graphData.nodes).enter().append("g")
// calculate color
const color = (d: NodeData) => {
const isCurrent = d.id === slug
if (isCurrent) {
return "var(--secondary)"
} else if (visited.has(d.id) || d.id.startsWith("tags/")) {
return "var(--tertiary)"
} else {
return "var(--gray)"
}
}
const drag = (simulation: d3.Simulation<NodeData, LinkData>) => {
function dragstarted(event: any, d: NodeData) {
if (!event.active) simulation.alphaTarget(1).restart()
d.fx = d.x
d.fy = d.y
}
function dragged(event: any, d: NodeData) {
d.fx = event.x
d.fy = event.y
}
function dragended(event: any, d: NodeData) {
if (!event.active) simulation.alphaTarget(0)
d.fx = null
d.fy = null
}
const noop = () => {}
return d3
.drag<Element, NodeData>()
.on("start", enableDrag ? dragstarted : noop)
.on("drag", enableDrag ? dragged : noop)
.on("end", enableDrag ? dragended : noop)
}
function nodeRadius(d: NodeData) {
const numLinks = links.filter((l: any) => l.source.id === d.id || l.target.id === d.id).length
return 2 + Math.sqrt(numLinks)
}
// draw individual nodes
const node = graphNode
.append("circle")
.attr("class", "node")
.attr("id", (d) => d.id)
.attr("r", nodeRadius)
.attr("fill", color)
.style("cursor", "pointer")
.on("click", (_, d) => {
const targ = resolveRelative(fullSlug, d.id)
window.spaNavigate(new URL(targ, window.location.toString()))
})
.on("mouseover", function (_, d) {
const neighbours: SimpleSlug[] = data[fullSlug].links ?? []
const neighbourNodes = d3
.selectAll<HTMLElement, NodeData>(".node")
.filter((d) => neighbours.includes(d.id))
const currentId = d.id
const linkNodes = d3
.selectAll(".link")
.filter((d: any) => d.source.id === currentId || d.target.id === currentId)
// highlight neighbour nodes
neighbourNodes.transition().duration(200).attr("fill", color)
// highlight links
linkNodes.transition().duration(200).attr("stroke", "var(--gray)").attr("stroke-width", 1)
const bigFont = fontSize * 1.5
// show text for self
const parent = this.parentNode as HTMLElement
d3.select<HTMLElement, NodeData>(parent)
.raise()
.select("text")
.transition()
.duration(200)
.attr("opacityOld", d3.select(parent).select("text").style("opacity"))
.style("opacity", 1)
.style("font-size", bigFont + "em")
})
.on("mouseleave", function (_, d) {
const currentId = d.id
const linkNodes = d3
.selectAll(".link")
.filter((d: any) => d.source.id === currentId || d.target.id === currentId)
linkNodes.transition().duration(200).attr("stroke", "var(--lightgray)")
const parent = this.parentNode as HTMLElement
d3.select<HTMLElement, NodeData>(parent)
.select("text")
.transition()
.duration(200)
.style("opacity", d3.select(parent).select("text").attr("opacityOld"))
.style("font-size", fontSize + "em")
})
// @ts-ignore
.call(drag(simulation))
// draw labels
const labels = graphNode
.append("text")
.attr("dx", 0)
.attr("dy", (d) => -nodeRadius(d) + "px")
.attr("text-anchor", "middle")
.text((d) => d.text)
.style("opacity", (opacityScale - 1) / 3.75)
.style("pointer-events", "none")
.style("font-size", fontSize + "em")
.raise()
// @ts-ignore
.call(drag(simulation))
// set panning
if (enableZoom) {
svg.call(
d3
.zoom<SVGSVGElement, NodeData>()
.extent([
[0, 0],
[width, height],
])
.scaleExtent([0.25, 4])
.on("zoom", ({ transform }) => {
link.attr("transform", transform)
node.attr("transform", transform)
const scale = transform.k * opacityScale
const scaledOpacity = Math.max((scale - 1) / 3.75, 0)
labels.attr("transform", transform).style("opacity", scaledOpacity)
}),
)
}
// progress the simulation
simulation.on("tick", () => {
link
.attr("x1", (d: any) => d.source.x)
.attr("y1", (d: any) => d.source.y)
.attr("x2", (d: any) => d.target.x)
.attr("y2", (d: any) => d.target.y)
node.attr("cx", (d: any) => d.x).attr("cy", (d: any) => d.y)
labels.attr("x", (d: any) => d.x).attr("y", (d: any) => d.y)
})
}
function renderGlobalGraph() {
const slug = getFullSlug(window)
const container = document.getElementById("global-graph-outer")
const sidebar = container?.closest(".sidebar") as HTMLElement
container?.classList.add("active")
if (sidebar) {
sidebar.style.zIndex = "1"
}
renderGraph("global-graph-container", slug)
function hideGlobalGraph() {
container?.classList.remove("active")
const graph = document.getElementById("global-graph-container")
if (sidebar) {
sidebar.style.zIndex = "unset"
}
if (!graph) return
removeAllChildren(graph)
}
registerEscapeHandler(container, hideGlobalGraph)
}
document.addEventListener("nav", async (e: unknown) => {
const slug = (e as CustomEventMap["nav"]).detail.url
addToVisited(slug)
await renderGraph("graph-container", slug)
const containerIcon = document.getElementById("global-graph-icon")
containerIcon?.removeEventListener("click", renderGlobalGraph)
containerIcon?.addEventListener("click", renderGlobalGraph)
})

View File

@ -0,0 +1,3 @@
import Plausible from "plausible-tracker"
const { trackPageview } = Plausible()
document.addEventListener("nav", () => trackPageview())

View File

@ -0,0 +1,83 @@
import { computePosition, flip, inline, shift } from "@floating-ui/dom"
// from micromorph/src/utils.ts
// https://github.com/natemoo-re/micromorph/blob/main/src/utils.ts#L5
export function normalizeRelativeURLs(el: Element | Document, base: string | URL) {
const update = (el: Element, attr: string, base: string | URL) => {
el.setAttribute(attr, new URL(el.getAttribute(attr)!, base).pathname)
}
el.querySelectorAll('[href^="./"], [href^="../"]').forEach((item) => update(item, "href", base))
el.querySelectorAll('[src^="./"], [src^="../"]').forEach((item) => update(item, "src", base))
}
const p = new DOMParser()
async function mouseEnterHandler(
this: HTMLLinkElement,
{ clientX, clientY }: { clientX: number; clientY: number },
) {
const link = this
async function setPosition(popoverElement: HTMLElement) {
const { x, y } = await computePosition(link, popoverElement, {
middleware: [inline({ x: clientX, y: clientY }), shift(), flip()],
})
Object.assign(popoverElement.style, {
left: `${x}px`,
top: `${y}px`,
})
}
// dont refetch if there's already a popover
if ([...link.children].some((child) => child.classList.contains("popover"))) {
return setPosition(link.lastChild as HTMLElement)
}
const thisUrl = new URL(document.location.href)
thisUrl.hash = ""
thisUrl.search = ""
const targetUrl = new URL(link.href)
const hash = targetUrl.hash
targetUrl.hash = ""
targetUrl.search = ""
// prevent hover of the same page
if (thisUrl.toString() === targetUrl.toString()) return
const contents = await fetch(`${targetUrl}`)
.then((res) => res.text())
.catch((err) => {
console.error(err)
})
if (!contents) return
const html = p.parseFromString(contents, "text/html")
normalizeRelativeURLs(html, targetUrl)
const elts = [...html.getElementsByClassName("popover-hint")]
if (elts.length === 0) return
const popoverElement = document.createElement("div")
popoverElement.classList.add("popover")
const popoverInner = document.createElement("div")
popoverInner.classList.add("popover-inner")
popoverElement.appendChild(popoverInner)
elts.forEach((elt) => popoverInner.appendChild(elt))
setPosition(popoverElement)
link.appendChild(popoverElement)
if (hash !== "") {
const heading = popoverInner.querySelector(hash) as HTMLElement | null
if (heading) {
// leave ~12px of buffer when scrolling to a heading
popoverInner.scroll({ top: heading.offsetTop - 12, behavior: "instant" })
}
}
}
document.addEventListener("nav", () => {
const links = [...document.getElementsByClassName("internal")] as HTMLLinkElement[]
for (const link of links) {
link.removeEventListener("mouseenter", mouseEnterHandler)
link.addEventListener("mouseenter", mouseEnterHandler)
}
})

View File

@ -0,0 +1,352 @@
import { Document, SimpleDocumentSearchResultSetUnit } from "flexsearch"
import { ContentDetails } from "../../plugins/emitters/contentIndex"
import { registerEscapeHandler, removeAllChildren } from "./util"
import { FullSlug, resolveRelative } from "../../util/path"
interface Item {
id: number
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
.split(/\s+/)
.filter((t) => t !== "")
.sort((a, b) => b.length - a.length)
let tokenizedText = text.split(/\s+/).filter((t) => t !== "")
let startIndex = 0
let endIndex = tokenizedText.length - 1
if (trim) {
const includesCheck = (tok: string) =>
tokenizedTerms.some((term) => tok.toLowerCase().startsWith(term.toLowerCase()))
const occurencesIndices = tokenizedText.map(includesCheck)
let bestSum = 0
let bestIndex = 0
for (let i = 0; i < Math.max(tokenizedText.length - contextWindowWords, 0); i++) {
const window = occurencesIndices.slice(i, i + contextWindowWords)
const windowSum = window.reduce((total, cur) => total + (cur ? 1 : 0), 0)
if (windowSum >= bestSum) {
bestSum = windowSum
bestIndex = i
}
}
startIndex = Math.max(bestIndex - contextWindowWords, 0)
endIndex = Math.min(startIndex + 2 * contextWindowWords, tokenizedText.length - 1)
tokenizedText = tokenizedText.slice(startIndex, endIndex)
}
const slice = tokenizedText
.map((tok) => {
// see if this tok is prefixed by any search terms
for (const searchTok of tokenizedTerms) {
if (tok.toLowerCase().includes(searchTok.toLowerCase())) {
const regex = new RegExp(searchTok.toLowerCase(), "gi")
return tok.replace(regex, `<span class="highlight">$&</span>`)
}
}
return tok
})
.join(" ")
return `${startIndex === 0 ? "" : "..."}${slice}${
endIndex === tokenizedText.length - 1 ? "" : "..."
}`
}
const encoder = (str: string) => str.toLowerCase().split(/([^a-z]|[^\x00-\x7F])/)
let prevShortcutHandler: ((e: HTMLElementEventMap["keydown"]) => void) | undefined = undefined
document.addEventListener("nav", async (e: unknown) => {
const currentSlug = (e as CustomEventMap["nav"]).detail.url
const data = await fetchData
const container = document.getElementById("search-container")
const sidebar = container?.closest(".sidebar") as HTMLElement
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() {
container?.classList.remove("active")
if (searchBar) {
searchBar.value = "" // clear the input when we dismiss the search
}
if (sidebar) {
sidebar.style.zIndex = "unset"
}
if (results) {
removeAllChildren(results)
}
searchType = "basic" // reset search type after closing
}
function showSearch(searchTypeNew: SearchType) {
searchType = searchTypeNew
if (sidebar) {
sidebar.style.zIndex = "1"
}
container?.classList.add("active")
searchBar?.focus()
}
function shortcutHandler(e: HTMLElementEventMap["keydown"]) {
if (e.key === "k" && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
e.preventDefault()
const searchBarOpen = container?.classList.contains("active")
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") {
// 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: 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),
}
}
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>${htmlTags}<p>${content}</p>`
button.addEventListener("click", () => {
const targ = resolveRelative(currentSlug, slug)
window.spaNavigate(new URL(targ, window.location.toString()))
hideSearch()
})
return button
}
function displayResults(finalResults: Item[]) {
if (!results) return
removeAllChildren(results)
if (finalResults.length === 0) {
results.innerHTML = `<button class="result-card">
<h3>No results.</h3>
<p>Try another search term?</p>
</button>`
} else {
results.append(...finalResults.map(resultToHTML))
}
}
async function onType(e: HTMLElementEventMap["input"]) {
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"),
...getByField("tags"),
])
const finalResults = [...allIds].map((id) => formatForDisplay(term, id))
displayResults(finalResults)
}
if (prevShortcutHandler) {
document.removeEventListener("keydown", prevShortcutHandler)
}
document.addEventListener("keydown", shortcutHandler)
prevShortcutHandler = shortcutHandler
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({
charset: "latin:extra",
optimize: true,
encode: encoder,
document: {
id: "id",
index: [
{
field: "title",
tokenize: "reverse",
},
{
field: "content",
tokenize: "reverse",
},
{
field: "tags",
tokenize: "reverse",
},
],
},
})
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++
}
}

View File

@ -0,0 +1,156 @@
import micromorph from "micromorph"
import { FullSlug, RelativeURL, getFullSlug } from "../../util/path"
// adapted from `micromorph`
// https://github.com/natemoo-re/micromorph
const NODE_TYPE_ELEMENT = 1
let announcer = document.createElement("route-announcer")
const isElement = (target: EventTarget | null): target is Element =>
(target as Node)?.nodeType === NODE_TYPE_ELEMENT
const isLocalUrl = (href: string) => {
try {
const url = new URL(href)
if (window.location.origin === url.origin) {
return true
}
} catch (e) {}
return false
}
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
const { href } = a
if (!isLocalUrl(href)) return
return { url: new URL(href), scroll: "routerNoscroll" in a.dataset ? false : undefined }
}
function notifyNav(url: FullSlug) {
const event: CustomEventMap["nav"] = new CustomEvent("nav", { detail: { url } })
document.dispatchEvent(event)
}
let p: DOMParser
async function navigate(url: URL, isBack: boolean = false) {
p = p || new DOMParser()
const contents = await fetch(`${url}`)
.then((res) => res.text())
.catch(() => {
window.location.assign(url)
})
if (!contents) return
const html = p.parseFromString(contents, "text/html")
let title = html.querySelector("title")?.textContent
if (title) {
document.title = title
} else {
const h1 = document.querySelector("h1")
title = h1?.innerText ?? h1?.textContent ?? url.pathname
}
if (announcer.textContent !== title) {
announcer.textContent = title
}
announcer.dataset.persist = ""
html.body.appendChild(announcer)
// morph body
micromorph(document.body, html.body)
// scroll into place and add history
if (!isBack) {
if (url.hash) {
const el = document.getElementById(decodeURIComponent(url.hash.substring(1)))
el?.scrollIntoView()
} else {
window.scrollTo({ top: 0 })
}
}
// now, patch head
const elementsToRemove = document.head.querySelectorAll(":not([spa-preserve])")
elementsToRemove.forEach((el) => el.remove())
const elementsToAdd = html.head.querySelectorAll(":not([spa-preserve])")
elementsToAdd.forEach((el) => document.head.appendChild(el))
// delay setting the url until now
// at this point everything is loaded so changing the url should resolve to the correct addresses
if (!isBack) {
history.pushState({}, "", url)
}
notifyNav(getFullSlug(window))
delete announcer.dataset.persist
}
window.spaNavigate = navigate
function createRouter() {
if (typeof window !== "undefined") {
window.addEventListener("click", async (event) => {
const { url } = getOpts(event) ?? {}
if (!url || event.ctrlKey || event.metaKey) return
event.preventDefault()
try {
navigate(url, false)
} catch (e) {
window.location.assign(url)
}
})
window.addEventListener("popstate", (event) => {
const { url } = getOpts(event) ?? {}
if (window.location.hash && window.location.pathname === url?.pathname) return
try {
navigate(new URL(window.location.toString()), true)
} catch (e) {
window.location.reload()
}
return
})
}
return new (class Router {
go(pathname: RelativeURL) {
const url = new URL(pathname, window.location.toString())
return navigate(url, false)
}
back() {
return window.history.back()
}
forward() {
return window.history.forward()
}
})()
}
createRouter()
notifyNav(getFullSlug(window))
if (!customElements.get("route-announcer")) {
const attrs = {
"aria-live": "assertive",
"aria-atomic": "true",
style:
"position: absolute; left: 0; top: 0; clip: rect(0 0 0 0); clip-path: inset(50%); overflow: hidden; white-space: nowrap; width: 1px; height: 1px",
}
customElements.define(
"route-announcer",
class RouteAnnouncer extends HTMLElement {
constructor() {
super()
}
connectedCallback() {
for (const [key, value] of Object.entries(attrs)) {
this.setAttribute(key, value)
}
}
},
)
}

View File

@ -0,0 +1,43 @@
const bufferPx = 150
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
const slug = entry.target.id
const tocEntryElement = document.querySelector(`a[data-for="${slug}"]`)
const windowHeight = entry.rootBounds?.height
if (windowHeight && tocEntryElement) {
if (entry.boundingClientRect.y < windowHeight) {
tocEntryElement.classList.add("in-view")
} else {
tocEntryElement.classList.remove("in-view")
}
}
}
})
function toggleToc(this: HTMLElement) {
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 setupToc() {
const toc = document.getElementById("toc")
if (toc) {
const collapsed = toc.classList.contains("collapsed")
const content = toc.nextElementSibling as HTMLElement
content.style.maxHeight = collapsed ? "0px" : content.scrollHeight + "px"
toc.removeEventListener("click", toggleToc)
toc.addEventListener("click", toggleToc)
}
}
window.addEventListener("resize", setupToc)
document.addEventListener("nav", () => {
setupToc()
// update toc entry highlighting
observer.disconnect()
const headers = document.querySelectorAll("h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]")
headers.forEach((header) => observer.observe(header))
})

View File

@ -0,0 +1,25 @@
export function registerEscapeHandler(outsideContainer: HTMLElement | null, cb: () => void) {
if (!outsideContainer) return
function click(this: HTMLElement, e: HTMLElementEventMap["click"]) {
if (e.target !== this) return
e.preventDefault()
cb()
}
function esc(e: HTMLElementEventMap["keydown"]) {
if (!e.key.startsWith("Esc")) return
e.preventDefault()
cb()
}
outsideContainer?.removeEventListener("click", click)
outsideContainer?.addEventListener("click", click)
document.removeEventListener("keydown", esc)
document.addEventListener("keydown", esc)
}
export function removeAllChildren(node: HTMLElement) {
while (node.firstChild) {
node.removeChild(node.firstChild)
}
}

View File

@ -0,0 +1,20 @@
.backlinks {
position: relative;
& > h3 {
font-size: 1rem;
margin: 0;
}
& > ul {
list-style: none;
padding: 0;
margin: 0.5rem 0;
& > li {
& > a {
background-color: transparent;
}
}
}
}

View 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;
}

View File

@ -0,0 +1,36 @@
.clipboard-button {
position: absolute;
display: flex;
float: right;
right: 0;
padding: 0.4rem;
margin: -0.2rem 0.3rem;
color: var(--gray);
border-color: var(--dark);
background-color: var(--light);
border: 1px solid;
border-radius: 5px;
opacity: 0;
transition: 0.2s;
& > svg {
fill: var(--light);
filter: contrast(0.3);
}
&:hover {
cursor: pointer;
border-color: var(--secondary);
}
&:focus {
outline: 0;
}
}
pre {
&:hover > .clipboard-button {
opacity: 1;
transition: 0.2s;
}
}

View File

@ -0,0 +1,48 @@
.darkmode {
position: relative;
width: 20px;
height: 20px;
margin: 0 10px;
& > .toggle {
display: none;
box-sizing: border-box;
}
& svg {
cursor: pointer;
opacity: 0;
position: absolute;
width: 20px;
height: 20px;
top: calc(50% - 10px);
fill: var(--darkgray);
transition: opacity 0.1s ease;
}
}
:root[saved-theme="dark"] {
color-scheme: dark;
}
:root[saved-theme="light"] {
color-scheme: light;
}
:root[saved-theme="dark"] .toggle ~ label {
& > #dayIcon {
opacity: 0;
}
& > #nightIcon {
opacity: 1;
}
}
:root .toggle ~ label {
& > #dayIcon {
opacity: 1;
}
& > #nightIcon {
opacity: 0;
}
}

View 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;
}

View File

@ -0,0 +1,15 @@
footer {
text-align: left;
margin-bottom: 4rem;
opacity: 0.7;
& ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: row;
gap: 1rem;
margin-top: -1rem;
}
}

View File

@ -0,0 +1,70 @@
@use "../../styles/variables.scss" as *;
.graph {
& > h3 {
font-size: 1rem;
margin: 0;
}
& > .graph-outer {
border-radius: 5px;
border: 1px solid var(--lightgray);
box-sizing: border-box;
height: 250px;
margin: 0.5em 0;
position: relative;
overflow: hidden;
& > #global-graph-icon {
color: var(--dark);
opacity: 0.5;
width: 18px;
height: 18px;
position: absolute;
padding: 0.2rem;
margin: 0.3rem;
top: 0;
right: 0;
border-radius: 4px;
background-color: transparent;
transition: background-color 0.5s ease;
cursor: pointer;
&:hover {
background-color: var(--lightgray);
}
}
}
& > #global-graph-outer {
position: fixed;
z-index: 9999;
left: 0;
top: 0;
width: 100vw;
height: 100%;
backdrop-filter: blur(4px);
display: none;
overflow: hidden;
&.active {
display: inline-block;
}
& > #global-graph-container {
border: 1px solid var(--lightgray);
background-color: var(--light);
border-radius: 5px;
box-sizing: border-box;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
height: 60vh;
width: 50vw;
@media all and (max-width: $fullPageWidth) {
width: 90%;
}
}
}
}

View File

@ -0,0 +1,27 @@
details#toc {
& summary {
cursor: pointer;
&::marker {
color: var(--dark);
}
& > * {
padding-left: 0.25rem;
display: inline-block;
margin: 0;
}
}
& ul {
list-style: none;
margin: 0.5rem 1.25rem;
padding: 0;
}
@for $i from 1 through 6 {
& .depth-#{$i} {
padding-left: calc(1rem * #{$i});
}
}
}

View File

@ -0,0 +1,40 @@
@use "../../styles/variables.scss" as *;
ul.section-ul {
list-style: none;
margin-top: 2em;
padding-left: 0;
}
li.section-li {
margin-bottom: 1em;
& > .section {
display: grid;
grid-template-columns: 6em 3fr 1fr;
@media all and (max-width: $mobileBreakpoint) {
& > .tags {
display: none;
}
}
& > .desc > h3 > a {
background-color: transparent;
}
& > .meta {
margin: 0;
flex-basis: 6em;
opacity: 0.6;
}
}
}
// modifications in popover context
.popover .section {
grid-template-columns: 6em 1fr !important;
& > .tags {
display: none;
}
}

View File

@ -0,0 +1,60 @@
@use "../../styles/variables.scss" as *;
@keyframes dropin {
0% {
opacity: 0;
visibility: hidden;
}
1% {
opacity: 0;
}
100% {
opacity: 1;
visibility: visible;
}
}
.popover {
z-index: 999;
position: absolute;
overflow: visible;
padding: 1rem;
& > .popover-inner {
position: relative;
width: 30rem;
max-height: 20rem;
padding: 0 1rem 1rem 1rem;
font-weight: initial;
line-height: normal;
font-size: initial;
font-family: var(--bodyFont);
border: 1px solid var(--lightgray);
background-color: var(--light);
border-radius: 5px;
box-shadow: 6px 6px 36px 0 rgba(0, 0, 0, 0.25);
overflow: auto;
white-space: normal;
}
h1 {
font-size: 1.5rem;
}
visibility: hidden;
opacity: 0;
transition:
opacity 0.3s ease,
visibility 0.3s ease;
@media all and (max-width: $mobileBreakpoint) {
display: none !important;
}
}
a:hover .popover,
.popover:hover {
animation: dropin 0.3s ease;
animation-fill-mode: forwards;
animation-delay: 0.2s;
}

View File

@ -0,0 +1,24 @@
.recent-notes {
& > h3 {
margin: 0.5rem 0 0 0;
font-size: 1rem;
}
& > ul.recent-ul {
list-style: none;
margin-top: 1rem;
padding-left: 0;
& > li {
margin: 1rem 0;
.section > .desc > h3 > a {
background-color: transparent;
}
.section > .meta {
margin: 0 0 0.5rem 0;
opacity: 0.6;
}
}
}
}

View File

@ -0,0 +1,178 @@
@use "../../styles/variables.scss" as *;
.search {
min-width: fit-content;
max-width: 14rem;
flex-grow: 0.3;
& > #search-icon {
background-color: var(--lightgray);
border-radius: 4px;
height: 2rem;
display: flex;
align-items: center;
cursor: pointer;
white-space: nowrap;
& > div {
flex-grow: 1;
}
& > p {
display: inline;
padding: 0 1rem;
}
& svg {
cursor: pointer;
width: 18px;
min-width: 18px;
margin: 0 0.5rem;
.search-path {
stroke: var(--darkgray);
stroke-width: 2px;
transition: stroke 0.5s ease;
}
}
}
& > #search-container {
position: fixed;
contain: layout;
z-index: 999;
left: 0;
top: 0;
width: 100vw;
height: 100vh;
overflow-y: auto;
display: none;
backdrop-filter: blur(4px);
&.active {
display: inline-block;
}
& > #search-space {
width: 50%;
margin-top: 15vh;
margin-left: auto;
margin-right: auto;
@media all and (max-width: $fullPageWidth) {
width: 90%;
}
& > * {
width: 100%;
border-radius: 5px;
background: var(--light);
box-shadow:
0 14px 50px rgba(27, 33, 48, 0.12),
0 10px 30px rgba(27, 33, 48, 0.16);
margin-bottom: 2em;
}
& > input {
box-sizing: border-box;
padding: 0.5em 1em;
font-family: var(--bodyFont);
color: var(--dark);
font-size: 1.1em;
border: 1px solid var(--lightgray);
&:focus {
outline: none;
}
}
& > #results-container {
& .result-card {
padding: 1em;
cursor: pointer;
transition: background 0.2s ease;
border: 1px solid var(--lightgray);
border-bottom: none;
width: 100%;
// normalize button props
font-family: inherit;
font-size: 100%;
line-height: 1.15;
margin: 0;
text-transform: none;
text-align: left;
background: var(--light);
outline: none;
& .highlight {
color: var(--secondary);
font-weight: 700;
}
&:hover,
&:focus {
background: var(--lightgray);
}
&:first-of-type {
border-top-left-radius: 5px;
border-top-right-radius: 5px;
}
&:last-of-type {
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
border-bottom: 1px solid var(--lightgray);
}
& > h3 {
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;
}
}
}
}
}
}

View File

@ -0,0 +1,59 @@
button#toc {
background-color: transparent;
border: none;
text-align: left;
cursor: pointer;
padding: 0;
color: var(--dark);
display: flex;
align-items: center;
& h3 {
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);
}
}
#toc-content {
list-style: none;
overflow: hidden;
max-height: none;
transition: max-height 0.5s ease;
&.collapsed > .overflow::after {
opacity: 0;
}
& ul {
list-style: none;
margin: 0.5rem 0;
padding: 0;
& > li > a {
color: var(--dark);
opacity: 0.35;
transition:
0.5s ease opacity,
0.3s ease color;
&.in-view {
opacity: 0.75;
}
}
}
@for $i from 0 through 6 {
& .depth-#{$i} {
padding-left: calc(1rem * #{$i});
}
}
}

View File

@ -0,0 +1,27 @@
import { ComponentType, JSX } from "preact"
import { StaticResources } from "../util/resources"
import { QuartzPluginData } from "../plugins/vfile"
import { GlobalConfiguration } from "../cfg"
import { Node } from "hast"
export type QuartzComponentProps = {
externalResources: StaticResources
fileData: QuartzPluginData
cfg: GlobalConfiguration
children: (QuartzComponent | JSX.Element)[]
tree: Node<QuartzPluginData>
allFiles: QuartzPluginData[]
displayClass?: "mobile-only" | "desktop-only"
} & JSX.IntrinsicAttributes & {
[key: string]: any
}
export type QuartzComponent = ComponentType<QuartzComponentProps> & {
css?: string
beforeDOMLoaded?: string
afterDOMLoaded?: string
}
export type QuartzComponentConstructor<Options extends object | undefined = undefined> = (
opts: Options,
) => QuartzComponent

View 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",
}),
]
},
}
}

View File

@ -0,0 +1,52 @@
import { FilePath, FullSlug, resolveRelative, simplifySlug } from "../../util/path"
import { QuartzEmitterPlugin } from "../types"
import path from "path"
export const AliasRedirects: QuartzEmitterPlugin = () => ({
name: "AliasRedirects",
getQuartzComponents() {
return []
},
async emit({ argv }, content, _resources, emit): Promise<FilePath[]> {
const fps: FilePath[] = []
for (const [_tree, file] of content) {
const ogSlug = simplifySlug(file.data.slug!)
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]
}
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 (const slug of slugs) {
const redirUrl = resolveRelative(slug, file.data.slug!)
const fp = await emit({
content: `
<!DOCTYPE html>
<html lang="en-us">
<head>
<title>${ogSlug}</title>
<link rel="canonical" href="${redirUrl}">
<meta name="robots" content="noindex">
<meta charset="utf-8">
<meta http-equiv="refresh" content="0; url=${redirUrl}">
</head>
</html>
`,
slug,
ext: ".html",
})
fps.push(fp)
}
}
return fps
},
})

View File

@ -0,0 +1,33 @@
import { FilePath, joinSegments, slugifyFilePath } from "../../util/path"
import { QuartzEmitterPlugin } from "../types"
import path from "path"
import fs from "fs"
import { glob } from "../../util/glob"
export const Assets: QuartzEmitterPlugin = () => {
return {
name: "Assets",
getQuartzComponents() {
return []
},
async emit({ argv, cfg }, _content, _resources, _emit): Promise<FilePath[]> {
// glob all non MD/MDX/HTML files in content folder and copy it over
const assetsPath = argv.output
const fps = await glob("**", argv.directory, ["**/*.md", ...cfg.configuration.ignorePatterns])
const res: FilePath[] = []
for (const fp of fps) {
const ext = path.extname(fp)
const src = joinSegments(argv.directory, fp) as FilePath
const name = (slugifyFilePath(fp as FilePath, true) + ext) as FilePath
const dest = joinSegments(assetsPath, name) as FilePath
const dir = path.dirname(dest) as FilePath
await fs.promises.mkdir(dir, { recursive: true }) // ensure dir exists
await fs.promises.copyFile(src, dest)
res.push(dest)
}
return res
},
}
}

View File

@ -0,0 +1,202 @@
import { FilePath, FullSlug } from "../../util/path"
import { QuartzEmitterPlugin } from "../types"
// @ts-ignore
import spaRouterScript from "../../components/scripts/spa.inline"
// @ts-ignore
import plausibleScript from "../../components/scripts/plausible.inline"
// @ts-ignore
import popoverScript from "../../components/scripts/popover.inline"
import styles from "../../styles/custom.scss"
import popoverStyle from "../../components/styles/popover.scss"
import { BuildCtx } from "../../util/ctx"
import { StaticResources } from "../../util/resources"
import { QuartzComponent } from "../../components/types"
import { googleFontHref, joinStyles } from "../../util/theme"
import { Features, transform } from "lightningcss"
type ComponentResources = {
css: string[]
beforeDOMLoaded: string[]
afterDOMLoaded: string[]
}
function getComponentResources(ctx: BuildCtx): ComponentResources {
const allComponents: Set<QuartzComponent> = new Set()
for (const emitter of ctx.cfg.plugins.emitters) {
const components = emitter.getQuartzComponents(ctx)
for (const component of components) {
allComponents.add(component)
}
}
const componentResources = {
css: new Set<string>(),
beforeDOMLoaded: new Set<string>(),
afterDOMLoaded: new Set<string>(),
}
for (const component of allComponents) {
const { css, beforeDOMLoaded, afterDOMLoaded } = component
if (css) {
componentResources.css.add(css)
}
if (beforeDOMLoaded) {
componentResources.beforeDOMLoaded.add(beforeDOMLoaded)
}
if (afterDOMLoaded) {
componentResources.afterDOMLoaded.add(afterDOMLoaded)
}
}
return {
css: [...componentResources.css],
beforeDOMLoaded: [...componentResources.beforeDOMLoaded],
afterDOMLoaded: [...componentResources.afterDOMLoaded],
}
}
function joinScripts(scripts: string[]): string {
// wrap with iife to prevent scope collision
return scripts.map((script) => `(function () {${script}})();`).join("\n")
}
function addGlobalPageResources(
ctx: BuildCtx,
staticResources: StaticResources,
componentResources: ComponentResources,
) {
const cfg = ctx.cfg.configuration
const reloadScript = ctx.argv.serve
// popovers
if (cfg.enablePopovers) {
componentResources.afterDOMLoaded.push(popoverScript)
componentResources.css.push(popoverStyle)
}
if (cfg.analytics?.provider === "google") {
const tagId = cfg.analytics.tagId
staticResources.js.push({
src: `https://www.googletagmanager.com/gtag/js?id=${tagId}`,
contentType: "external",
loadTime: "afterDOMReady",
})
componentResources.afterDOMLoaded.push(`
window.dataLayer = window.dataLayer || [];
function gtag() { dataLayer.push(arguments); }
gtag(\`js\`, new Date());
gtag(\`config\`, \`${tagId}\`, { send_page_view: false });
document.addEventListener(\`nav\`, () => {
gtag(\`event\`, \`page_view\`, {
page_title: document.title,
page_location: location.href,
});
});`)
} else if (cfg.analytics?.provider === "plausible") {
componentResources.afterDOMLoaded.push(plausibleScript)
} 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) {
componentResources.afterDOMLoaded.push(spaRouterScript)
} else {
componentResources.afterDOMLoaded.push(`
window.spaNavigate = (url, _) => window.location.assign(url)
const event = new CustomEvent("nav", { detail: { url: document.body.dataset.slug } })
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('${wsUrl}')
socket.addEventListener('message', () => document.location.reload())
`,
})
}
}
interface Options {
fontOrigin: "googleFonts" | "local"
}
const defaultOptions: Options = {
fontOrigin: "googleFonts",
}
export const ComponentResources: QuartzEmitterPlugin<Options> = (opts?: Partial<Options>) => {
const { fontOrigin } = { ...defaultOptions, ...opts }
return {
name: "ComponentResources",
getQuartzComponents() {
return []
},
async emit(ctx, _content, resources, emit): Promise<FilePath[]> {
// component specific scripts and styles
const componentResources = getComponentResources(ctx)
// important that this goes *after* component scripts
// as the "nav" event gets triggered here and we should make sure
// that everyone else had the chance to register a listener for it
if (fontOrigin === "googleFonts") {
resources.css.push(googleFontHref(ctx.cfg.configuration.theme))
} else if (fontOrigin === "local") {
// let the user do it themselves in css
}
addGlobalPageResources(ctx, resources, componentResources)
const stylesheet = joinStyles(ctx.cfg.configuration.theme, ...componentResources.css, styles)
const prescript = joinScripts(componentResources.beforeDOMLoaded)
const postscript = joinScripts(componentResources.afterDOMLoaded)
const fps = await Promise.all([
emit({
slug: "index" as FullSlug,
ext: ".css",
content: transform({
filename: "index.css",
code: Buffer.from(stylesheet),
minify: true,
targets: {
safari: (15 << 16) | (6 << 8), // 15.6
ios_saf: (15 << 16) | (6 << 8), // 15.6
edge: 115 << 16,
firefox: 102 << 16,
chrome: 109 << 16,
},
include: Features.MediaQueries,
}).code.toString(),
}),
emit({
slug: "prescript" as FullSlug,
ext: ".js",
content: prescript,
}),
emit({
slug: "postscript" as FullSlug,
ext: ".js",
content: postscript,
}),
])
return fps
},
}
}

View File

@ -0,0 +1,150 @@
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>
export type ContentDetails = {
title: string
links: SimpleSlug[]
tags: string[]
content: string
richContent?: string
date?: Date
description?: string
}
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}/${encodeURI(slug)}</loc>
<lastmod>${content.date?.toISOString()}</lastmod>
</url>`
const urls = Array.from(idx)
.map(([slug, content]) => createURLEntry(simplifySlug(slug), content))
.join("")
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, limit?: number): string {
const base = cfg.baseUrl ?? ""
const root = `https://${base}`
const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<item>
<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)
.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>${escapeHTML(cfg.pageTitle)}</title>
<link>${root}</link>
<description>${!!limit ? `Last ${limit} notes` : "Recent notes"} on ${escapeHTML(
cfg.pageTitle,
)}</description>
<generator>Quartz -- quartz.jzhao.xyz</generator>
${items}
</channel>
</rss>`
}
export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
opts = { ...defaultOptions, ...opts }
return {
name: "ContentIndex",
async emit(ctx, content, _resources, emit) {
const cfg = ctx.cfg.configuration
const emitted: FilePath[] = []
const linkIndex: ContentIndex = new Map()
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 !== "")) {
linkIndex.set(slug, {
title: file.data.frontmatter?.title!,
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 ?? "",
})
}
}
if (opts?.enableSiteMap) {
emitted.push(
await emit({
content: generateSiteMap(cfg, linkIndex),
slug: "sitemap" as FullSlug,
ext: ".xml",
}),
)
}
if (opts?.enableRSS) {
emitted.push(
await emit({
content: generateRSSFeed(cfg, linkIndex, opts.rssLimit),
slug: "index" as FullSlug,
ext: ".xml",
}),
)
}
const fp = path.join("static", "contentIndex") as FullSlug
const simplifiedIndex = Object.fromEntries(
Array.from(linkIndex).map(([slug, content]) => {
// remove description and from content index as nothing downstream
// actually uses it. we only keep it in the index as we need it
// for the RSS feed
delete content.description
delete content.date
return [slug, content]
}),
)
emitted.push(
await emit({
content: JSON.stringify(simplifiedIndex),
slug: fp,
ext: ".json",
}),
)
return emitted
},
getQuartzComponents: () => [],
}
}

View File

@ -0,0 +1,72 @@
import { QuartzEmitterPlugin } from "../types"
import { QuartzComponentProps } from "../../components/types"
import HeaderConstructor from "../../components/Header"
import BodyConstructor from "../../components/Body"
import { pageResources, renderPage } from "../../components/renderPage"
import { FullPageLayout } from "../../cfg"
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 = {
...sharedPageComponents,
...defaultContentPageLayout,
pageBody: Content(),
...userOpts,
}
const { head: Head, header, beforeBody, pageBody, left, right, footer: Footer } = opts
const Header = HeaderConstructor()
const Body = BodyConstructor()
return {
name: "ContentPage",
getQuartzComponents() {
return [Head, Header, Body, ...header, ...beforeBody, pageBody, ...left, ...right, Footer]
},
async emit(ctx, content, resources, emit): Promise<FilePath[]> {
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!
if (slug === "index") {
containsIndex = true
}
const externalResources = pageResources(pathToRoot(slug), resources)
const componentData: QuartzComponentProps = {
fileData: file.data,
externalResources,
cfg,
children: [],
tree,
allFiles,
}
const content = renderPage(slug, componentData, opts, externalResources)
const fp = await emit({
content,
slug,
ext: ".html",
})
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
},
}
}

View File

@ -0,0 +1,96 @@
import { QuartzEmitterPlugin } from "../types"
import { QuartzComponentProps } from "../../components/types"
import HeaderConstructor from "../../components/Header"
import BodyConstructor from "../../components/Body"
import { pageResources, renderPage } from "../../components/renderPage"
import { ProcessedContent, defaultProcessedContent } from "../vfile"
import { FullPageLayout } from "../../cfg"
import path from "path"
import {
FilePath,
FullSlug,
SimpleSlug,
_stripSlashes,
joinSegments,
pathToRoot,
simplifySlug,
} from "../../util/path"
import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
import { FolderContent } from "../../components"
export const FolderPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
const opts: FullPageLayout = {
...sharedPageComponents,
...defaultListPageLayout,
pageBody: FolderContent(),
...userOpts,
}
const { head: Head, header, beforeBody, pageBody, left, right, footer: Footer } = opts
const Header = HeaderConstructor()
const Body = BodyConstructor()
return {
name: "FolderPage",
getQuartzComponents() {
return [Head, Header, Body, ...header, ...beforeBody, pageBody, ...left, ...right, Footer]
},
async emit(ctx, content, resources, emit): Promise<FilePath[]> {
const fps: FilePath[] = []
const allFiles = content.map((c) => c[1].data)
const cfg = ctx.cfg.configuration
const folders: Set<SimpleSlug> = new Set(
allFiles.flatMap((data) => {
const slug = data.slug
const folderName = path.dirname(slug ?? "") as SimpleSlug
if (slug && folderName !== "." && folderName !== "tags") {
return [folderName]
}
return []
}),
)
const folderDescriptions: Record<string, ProcessedContent> = Object.fromEntries(
[...folders].map((folder) => [
folder,
defaultProcessedContent({
slug: joinSegments(folder, "index") as FullSlug,
frontmatter: { title: `Folder: ${folder}`, tags: [] },
}),
]),
)
for (const [tree, file] of content) {
const slug = _stripSlashes(simplifySlug(file.data.slug!)) as SimpleSlug
if (folders.has(slug)) {
folderDescriptions[slug] = [tree, file]
}
}
for (const folder of folders) {
const slug = joinSegments(folder, "index") as FullSlug
const externalResources = pageResources(pathToRoot(slug), resources)
const [tree, file] = folderDescriptions[folder]
const componentData: QuartzComponentProps = {
fileData: file.data,
externalResources,
cfg,
children: [],
tree,
allFiles,
}
const content = renderPage(slug, componentData, opts, externalResources)
const fp = await emit({
content,
slug,
ext: ".html",
})
fps.push(fp)
}
return fps
},
}
}

View File

@ -0,0 +1,9 @@
export { ContentPage } from "./contentPage"
export { TagPage } from "./tagPage"
export { FolderPage } from "./folderPage"
export { ContentIndex } from "./contentIndex"
export { AliasRedirects } from "./aliases"
export { Assets } from "./assets"
export { Static } from "./static"
export { ComponentResources } from "./componentResources"
export { NotFoundPage } from "./404"

View File

@ -0,0 +1,17 @@
import { FilePath, QUARTZ, joinSegments } from "../../util/path"
import { QuartzEmitterPlugin } from "../types"
import fs from "fs"
import { glob } from "../../util/glob"
export const Static: QuartzEmitterPlugin = () => ({
name: "Static",
getQuartzComponents() {
return []
},
async emit({ argv, cfg }, _content, _resources, _emit): Promise<FilePath[]> {
const staticPath = joinSegments(QUARTZ, "static")
const fps = await glob("**", staticPath, cfg.configuration.ignorePatterns)
await fs.promises.cp(staticPath, joinSegments(argv.output, "static"), { recursive: true })
return fps.map((fp) => joinSegments(argv.output, "static", fp)) as FilePath[]
},
})

View File

@ -0,0 +1,94 @@
import { QuartzEmitterPlugin } from "../types"
import { QuartzComponentProps } from "../../components/types"
import HeaderConstructor from "../../components/Header"
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,
pathToRoot,
} from "../../util/path"
import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
import { TagContent } from "../../components"
export const TagPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
const opts: FullPageLayout = {
...sharedPageComponents,
...defaultListPageLayout,
pageBody: TagContent(),
...userOpts,
}
const { head: Head, header, beforeBody, pageBody, left, right, footer: Footer } = opts
const Header = HeaderConstructor()
const Body = BodyConstructor()
return {
name: "TagPage",
getQuartzComponents() {
return [Head, Header, Body, ...header, ...beforeBody, pageBody, ...left, ...right, Footer]
},
async emit(ctx, content, resources, emit): Promise<FilePath[]> {
const fps: FilePath[] = []
const allFiles = content.map((c) => c[1].data)
const cfg = ctx.cfg.configuration
const tags: Set<string> = new Set(
allFiles.flatMap((data) => data.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes),
)
// add base tag
tags.add("index")
const tagDescriptions: Record<string, ProcessedContent> = Object.fromEntries(
[...tags].map((tag) => {
const title = tag === "" ? "Tag Index" : `Tag: #${tag}`
return [
tag,
defaultProcessedContent({
slug: joinSegments("tags", tag) as FullSlug,
frontmatter: { title, tags: [] },
}),
]
}),
)
for (const [tree, file] of content) {
const slug = file.data.slug!
if (slug.startsWith("tags/")) {
const tag = slug.slice("tags/".length)
if (tags.has(tag)) {
tagDescriptions[tag] = [tree, file]
}
}
}
for (const tag of tags) {
const slug = joinSegments("tags", tag) as FullSlug
const externalResources = pageResources(pathToRoot(slug), resources)
const [tree, file] = tagDescriptions[tag]
const componentData: QuartzComponentProps = {
fileData: file.data,
externalResources,
cfg,
children: [],
tree,
allFiles,
}
const content = renderPage(slug, componentData, opts, externalResources)
const fp = await emit({
content,
slug: file.data.slug!,
ext: ".html",
})
fps.push(fp)
}
return fps
},
}
}

View File

@ -0,0 +1,9 @@
import { QuartzFilterPlugin } from "../types"
export const RemoveDrafts: QuartzFilterPlugin<{}> = () => ({
name: "RemoveDrafts",
shouldPublish(_ctx, [_tree, vfile]) {
const draftFlag: boolean = vfile.data?.frontmatter?.draft ?? false
return !draftFlag
},
})

View File

@ -0,0 +1,9 @@
import { QuartzFilterPlugin } from "../types"
export const ExplicitPublish: QuartzFilterPlugin = () => ({
name: "ExplicitPublish",
shouldPublish(_ctx, [_tree, vfile]) {
const publishFlag: boolean = vfile.data?.frontmatter?.publish ?? false
return publishFlag
},
})

View File

@ -0,0 +1,2 @@
export { RemoveDrafts } from "./draft"
export { ExplicitPublish } from "./explicit"

34
quartz/plugins/index.ts Normal file
View File

@ -0,0 +1,34 @@
import { StaticResources } from "../util/resources"
import { FilePath, FullSlug } from "../util/path"
import { BuildCtx } from "../util/ctx"
export function getStaticResourcesFromPlugins(ctx: BuildCtx) {
const staticResources: StaticResources = {
css: [],
js: [],
}
for (const transformer of ctx.cfg.plugins.transformers) {
const res = transformer.externalResources ? transformer.externalResources(ctx) : {}
if (res?.js) {
staticResources.js.push(...res.js)
}
if (res?.css) {
staticResources.css.push(...res.css)
}
}
return staticResources
}
export * from "./transformers"
export * from "./filters"
export * from "./emitters"
declare module "vfile" {
// inserted in processors.ts
interface DataMap {
slug: FullSlug
filePath: FilePath
}
}

View File

@ -0,0 +1,51 @@
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
}
const defaultOptions: Options = {
descriptionLength: 150,
}
export const Description: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
const opts = { ...defaultOptions, ...userOpts }
return {
name: "Description",
htmlPlugins() {
return [
() => {
return async (tree: HTMLRoot, file) => {
const frontMatterDescription = file.data.frontmatter?.description
const text = escapeHTML(toString(tree))
const desc = frontMatterDescription ?? text
const sentences = desc.replace(/\s+/g, " ").split(".")
let finalDesc = ""
let sentenceIdx = 0
const len = opts.descriptionLength
while (finalDesc.length < len) {
const sentence = sentences[sentenceIdx]
if (!sentence) break
finalDesc += sentence + "."
sentenceIdx++
}
file.data.description = finalDesc
file.data.text = text
}
},
]
},
}
}
declare module "vfile" {
interface DataMap {
description: string
text: string
}
}

View File

@ -0,0 +1,75 @@
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) => {
const opts = { ...defaultOptions, ...userOpts }
return {
name: "FrontMatter",
markdownPlugins() {
return [
[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,
},
})
// tag is an alias for tags
if (data.tag) {
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()
.split(",")
.map((tag: string) => tag.trim())
}
// slug them all!!
data.tags = [...new Set(data.tags?.map((tag: string) => slugTag(tag)))] ?? []
// fill in frontmatter
file.data.frontmatter = {
title: file.stem ?? "Untitled",
tags: [],
...data,
}
}
},
]
},
}
}
declare module "vfile" {
interface DataMap {
frontmatter: { [key: string]: any } & {
title: string
tags: string[]
}
}
}

View File

@ -0,0 +1,46 @@
import remarkGfm from "remark-gfm"
import smartypants from "remark-smartypants"
import { QuartzTransformerPlugin } from "../types"
import rehypeSlug from "rehype-slug"
import rehypeAutolinkHeadings from "rehype-autolink-headings"
export interface Options {
enableSmartyPants: boolean
linkHeadings: boolean
}
const defaultOptions: Options = {
enableSmartyPants: true,
linkHeadings: true,
}
export const GitHubFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> | undefined> = (
userOpts,
) => {
const opts = { ...defaultOptions, ...userOpts }
return {
name: "GitHubFlavoredMarkdown",
markdownPlugins() {
return opts.enableSmartyPants ? [remarkGfm, smartypants] : [remarkGfm]
},
htmlPlugins() {
if (opts.linkHeadings) {
return [
rehypeSlug,
[
rehypeAutolinkHeadings,
{
behavior: "append",
content: {
type: "text",
value: " §",
},
},
],
]
} else {
return []
}
},
}
}

View File

@ -0,0 +1,11 @@
export { FrontMatter } from "./frontmatter"
export { GitHubFlavoredMarkdown } from "./gfm"
export { CreatedModifiedDate } from "./lastmod"
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"

View File

@ -0,0 +1,87 @@
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")[]
}
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,
) => {
const opts = { ...defaultOptions, ...userOpts }
return {
name: "CreatedModifiedDate",
markdownPlugins() {
return [
() => {
let repo: Repository | undefined = undefined
return async (_tree, file) => {
let created: MaybeDate = undefined
let modified: MaybeDate = undefined
let published: MaybeDate = undefined
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(fullFp)
created ||= st.birthtimeMs
modified ||= st.mtimeMs
} else if (source === "frontmatter" && file.data.frontmatter) {
created ||= file.data.frontmatter.date
modified ||= file.data.frontmatter.lastmod
modified ||= file.data.frontmatter.updated
modified ||= file.data.frontmatter["last-modified"]
published ||= file.data.frontmatter.publishDate
} else if (source === "git") {
if (!repo) {
repo = new Repository(file.cwd)
}
modified ||= await repo.getFileLatestModifiedDateAsync(file.data.filePath!)
}
}
file.data.dates = {
created: coerceDate(fp, created),
modified: coerceDate(fp, modified),
published: coerceDate(fp, published),
}
}
},
]
},
}
}
declare module "vfile" {
interface DataMap {
dates: {
created: Date
modified: Date
published: Date
}
}
}

View File

@ -0,0 +1,45 @@
import remarkMath from "remark-math"
import rehypeKatex from "rehype-katex"
import rehypeMathjax from "rehype-mathjax/svg.js"
import { QuartzTransformerPlugin } from "../types"
interface Options {
renderEngine: "katex" | "mathjax"
}
export const Latex: QuartzTransformerPlugin<Options> = (opts?: Options) => {
const engine = opts?.renderEngine ?? "katex"
return {
name: "Latex",
markdownPlugins() {
return [remarkMath]
},
htmlPlugins() {
if (engine === "katex") {
return [[rehypeKatex, { output: "html" }]]
} else {
return [rehypeMathjax]
}
},
externalResources() {
if (engine === "katex") {
return {
css: [
// base css
"https://cdn.jsdelivr.net/npm/katex@0.16.0/dist/katex.min.css",
],
js: [
{
// fix copy behaviour: https://github.com/KaTeX/KaTeX/blob/main/contrib/copy-tex/README.md
src: "https://cdn.jsdelivr.net/npm/katex@0.16.7/dist/contrib/copy-tex.min.js",
loadTime: "afterDOMReady",
contentType: "external",
},
],
}
} else {
return {}
}
},
}
}

View File

@ -0,0 +1,11 @@
import { QuartzTransformerPlugin } from "../types"
import remarkBreaks from "remark-breaks"
export const HardLineBreaks: QuartzTransformerPlugin = () => {
return {
name: "HardLineBreaks",
markdownPlugins() {
return [remarkBreaks]
},
}
}

View File

@ -0,0 +1,126 @@
import { QuartzTransformerPlugin } from "../types"
import {
FullSlug,
RelativeURL,
SimpleSlug,
TransformOptions,
_stripSlashes,
simplifySlug,
splitAnchor,
transformLink,
} from "../../util/path"
import path from "path"
import { visit } from "unist-util-visit"
import isAbsoluteUrl from "is-absolute-url"
interface Options {
/** How to resolve Markdown paths */
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) => {
const opts = { ...defaultOptions, ...userOpts }
return {
name: "LinkProcessing",
htmlPlugins(ctx) {
return [
() => {
return (tree, file) => {
const curSlug = simplifySlug(file.data.slug!)
const outgoing: Set<SimpleSlug> = new Set()
const transformOptions: TransformOptions = {
strategy: opts.markdownLinkResolution,
allSlugs: ctx.allSlugs,
}
visit(tree, "element", (node, _index, _parent) => {
// rewrite all links
if (
node.tagName === "a" &&
node.properties &&
typeof node.properties.href === "string"
) {
let dest = node.properties.href as RelativeURL
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
const isInternal = !(isAbsoluteUrl(dest) || dest.startsWith("#"))
if (isInternal) {
dest = node.properties.href = transformLink(
file.data.slug!,
dest,
transformOptions,
)
// url.resolve is considered legacy
// WHATWG equivalent https://nodejs.dev/en/api/v18/url/#urlresolvefrom-to
const url = new URL(dest, `https://base.com/${curSlug}`)
const canonicalDest = url.pathname
const [destCanonical, _destAnchor] = splitAnchor(canonicalDest)
// need to decodeURIComponent here as WHATWG URL percent-encodes everything
const simple = decodeURIComponent(
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("#")
) {
node.children[0].value = path.basename(node.children[0].value)
}
}
// transform all other resources that may use links
if (
["img", "video", "audio", "iframe"].includes(node.tagName) &&
node.properties &&
typeof node.properties.src === "string"
) {
if (!isAbsoluteUrl(node.properties.src)) {
let dest = node.properties.src as RelativeURL
dest = node.properties.src = transformLink(
file.data.slug!,
dest,
transformOptions,
)
node.properties.src = dest
}
}
})
file.data.links = [...outgoing]
}
},
]
},
}
}
declare module "vfile" {
interface DataMap {
links: SimpleSlug[]
}
}

View File

@ -0,0 +1,528 @@
import { PluggableList } from "unified"
import { QuartzTransformerPlugin } from "../types"
import { Root, HTML, BlockContent, DefinitionContent, Code, Paragraph } from "mdast"
import { Element, Literal } 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"
import { visit } from "unist-util-visit"
import path from "path"
import { JSResource } from "../../util/resources"
// @ts-ignore
import calloutScript from "../../components/scripts/callout.inline.ts"
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
highlight: boolean
wikilinks: boolean
callouts: boolean
mermaid: boolean
parseTags: boolean
parseBlockReferences: boolean
enableInHtmlEmbed: boolean
}
const defaultOptions: Options = {
comments: true,
highlight: true,
wikilinks: true,
callouts: true,
mermaid: true,
parseTags: true,
parseBlockReferences: true,
enableInHtmlEmbed: false,
}
const icons = {
infoIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>`,
pencilIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="2" x2="22" y2="6"></line><path d="M7.5 20.5 19 9l-4-4L3.5 16.5 2 22z"></path></svg>`,
clipboardListIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path><path d="M12 11h4"></path><path d="M12 16h4"></path><path d="M8 11h.01"></path><path d="M8 16h.01"></path></svg>`,
checkCircleIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"></path><path d="m9 12 2 2 4-4"></path></svg>`,
flameIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"></path></svg>`,
checkIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>`,
helpCircleIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>`,
alertTriangleIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>`,
xIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>`,
zapIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>`,
bugIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="8" height="14" x="8" y="6" rx="4"></rect><path d="m19 7-3 2"></path><path d="m5 7 3 2"></path><path d="m19 19-3-2"></path><path d="m5 19 3-2"></path><path d="M20 13h-4"></path><path d="M4 13h4"></path><path d="m10 4 1 2"></path><path d="m14 4-1 2"></path></svg>`,
listIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></svg>`,
quoteIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"></path><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"></path></svg>`,
}
const callouts = {
note: icons.pencilIcon,
abstract: icons.clipboardListIcon,
info: icons.infoIcon,
todo: icons.checkCircleIcon,
tip: icons.flameIcon,
success: icons.checkIcon,
question: icons.helpCircleIcon,
warning: icons.alertTriangleIcon,
failure: icons.xIcon,
danger: icons.zapIcon,
bug: icons.bugIcon,
example: icons.listIcon,
quote: icons.quoteIcon,
}
const calloutMapping: Record<string, keyof typeof callouts> = {
note: "note",
abstract: "abstract",
summary: "abstract",
tldr: "abstract",
info: "info",
todo: "todo",
tip: "tip",
hint: "tip",
important: "tip",
success: "success",
check: "success",
done: "success",
question: "question",
help: "question",
faq: "question",
warning: "warning",
attention: "warning",
caution: "warning",
failure: "failure",
missing: "failure",
fail: "failure",
danger: "danger",
error: "danger",
bug: "bug",
example: "example",
quote: "quote",
cite: "quote",
}
function canonicalizeCallout(calloutName: string): keyof typeof callouts {
let callout = calloutName.toLowerCase() as keyof typeof calloutMapping
return calloutMapping[callout] ?? "note"
}
// !? -> optional embedding
// \[\[ -> open brace
// ([^\[\]\|\#]+) -> one or more non-special characters ([,],|, or #) (name)
// (#[^\[\]\|\#]+)? -> # then one or more non-special characters (heading link)
// (|[^\[\]\|\#]+)? -> | then one or more non-special characters (alias)
const wikilinkRegex = new RegExp(/!?\[\[([^\[\]\|\#]+)?(#[^\[\]\|\#]+)?(\|[^\[\]\|\#]+)?\]\]/, "g")
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
// #(...) -> 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,
) => {
const opts = { ...defaultOptions, ...userOpts }
const mdastToHtml = (ast: PhrasingContent | Paragraph) => {
const hast = toHast(ast, { allowDangerousHtml: true })!
return toHtml(hast, { allowDangerousHtml: true })
}
const findAndReplace = opts.enableInHtmlEmbed
? (tree: Root, regex: RegExp, replace?: Replace | null | undefined) => {
if (replace) {
visit(tree, "html", (node: HTML) => {
if (typeof replace === "string") {
node.value = node.value.replace(regex, replace)
} else {
node.value = node.value.replaceAll(regex, (substring: string, ...args) => {
const replaceValue = replace(substring, ...args)
if (typeof replaceValue === "string") {
return replaceValue
} else if (Array.isArray(replaceValue)) {
return replaceValue.map(mdastToHtml).join("")
} else if (typeof replaceValue === "object" && replaceValue !== null) {
return mdastToHtml(replaceValue)
} else {
return substring
}
})
}
})
}
mdastFindReplace(tree, regex, replace)
}
: mdastFindReplace
return {
name: "ObsidianFlavoredMarkdown",
textTransform(_ctx, src) {
// pre-transform blockquotes
if (opts.callouts) {
src = src.toString()
src = src.replaceAll(calloutLineRegex, (value) => {
// force newline after title of callout
return value + "\n> "
})
}
// pre-transform wikilinks (fix anchors to things that may contain illegal syntax e.g. codeblocks, latex)
if (opts.wikilinks) {
src = src.toString()
src = src.replaceAll(wikilinkRegex, (value, ...capture) => {
const [rawFp, rawHeader, rawAlias] = capture
const fp = rawFp ?? ""
const anchor = rawHeader?.trim().slice(1)
const displayAnchor = anchor ? `#${slugAnchor(anchor)}` : ""
const displayAlias = rawAlias ?? rawHeader?.replace("#", "|") ?? ""
const embedDisplay = value.startsWith("!") ? "!" : ""
return `${embedDisplay}[[${fp}${displayAnchor}${displayAlias}]]`
})
}
return src
},
markdownPlugins() {
const plugins: PluggableList = []
if (opts.wikilinks) {
plugins.push(() => {
return (tree: Root, _file) => {
findAndReplace(tree, wikilinkRegex, (value: string, ...capture: string[]) => {
let [rawFp, rawHeader, rawAlias] = capture
const fp = rawFp?.trim() ?? ""
const anchor = rawHeader?.trim() ?? ""
const alias = rawAlias?.slice(1).trim()
// embed cases
if (value.startsWith("!")) {
const ext: string = path.extname(fp).toLowerCase()
const url = slugifyFilePath(fp as FilePath)
if ([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg"].includes(ext)) {
const dims = alias ?? ""
let [width, height] = dims.split("x", 2)
width ||= "auto"
height ||= "auto"
return {
type: "image",
url,
data: {
hProperties: {
width,
height,
},
},
}
} else if ([".mp4", ".webm", ".ogv", ".mov", ".mkv"].includes(ext)) {
return {
type: "html",
value: `<video src="${url}" controls></video>`,
}
} else if (
[".mp3", ".webm", ".wav", ".m4a", ".ogg", ".3gp", ".flac"].includes(ext)
) {
return {
type: "html",
value: `<audio src="${url}" controls></audio>`,
}
} else if ([".pdf"].includes(ext)) {
return {
type: "html",
value: `<iframe src="${url}"></iframe>`,
}
} else if (ext === "") {
const block = anchor.slice(1)
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 block ${block}</a></blockquote>`,
}
}
// otherwise, fall through to regular link
}
// internal link
const url = fp + anchor
return {
type: "link",
url,
children: [
{
type: "text",
value: alias ?? fp,
},
],
}
})
}
})
}
if (opts.highlight) {
plugins.push(() => {
return (tree: Root, _file) => {
findAndReplace(tree, highlightRegex, (_value: string, ...capture: string[]) => {
const [inner] = capture
return {
type: "html",
value: `<span class="text-highlight">${inner}</span>`,
}
})
}
})
}
if (opts.comments) {
plugins.push(() => {
return (tree: Root, _file) => {
findAndReplace(tree, commentRegex, (_value: string, ..._capture: string[]) => {
return {
type: "text",
value: "",
}
})
}
})
}
if (opts.callouts) {
plugins.push(() => {
return (tree: Root, _file) => {
visit(tree, "blockquote", (node) => {
if (node.children.length === 0) {
return
}
// find first line
const firstChild = node.children[0]
if (firstChild.type !== "paragraph" || firstChild.children[0]?.type !== "text") {
return
}
const text = firstChild.children[0].value
const restChildren = firstChild.children.slice(1)
const [firstLine, ...remainingLines] = text.split("\n")
const remainingText = remainingLines.join("\n")
const match = firstLine.match(calloutRegex)
if (match && match.input) {
const [calloutDirective, typeString, collapseChar] = match
const calloutType = canonicalizeCallout(
typeString.toLowerCase() as keyof typeof calloutMapping,
)
const collapse = collapseChar === "+" || collapseChar === "-"
const defaultState = collapseChar === "-" ? "collapsed" : "expanded"
const titleContent =
match.input.slice(calloutDirective.length).trim() || capitalize(calloutType)
const titleNode: Paragraph = {
type: "paragraph",
children: [{ type: "text", value: titleContent + " " }, ...restChildren],
}
const title = mdastToHtml(titleNode)
const toggleIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" 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>`
const titleHtml: HTML = {
type: "html",
value: `<div
class="callout-title"
>
<div class="callout-icon">${callouts[calloutType]}</div>
<div class="callout-title-inner">${title}</div>
${collapse ? toggleIcon : ""}
</div>`,
}
const blockquoteContent: (BlockContent | DefinitionContent)[] = [titleHtml]
if (remainingText.length > 0) {
blockquoteContent.push({
type: "paragraph",
children: [
{
type: "text",
value: remainingText,
},
],
})
}
// replace first line of blockquote with title and rest of the paragraph text
node.children.splice(0, 1, ...blockquoteContent)
// add properties to base blockquote
node.data = {
hProperties: {
...(node.data?.hProperties ?? {}),
className: `callout ${collapse ? "is-collapsible" : ""} ${
defaultState === "collapsed" ? "is-collapsed" : ""
}`,
"data-callout": calloutType,
"data-callout-fold": collapse,
},
}
}
})
}
})
}
if (opts.mermaid) {
plugins.push(() => {
return (tree: Root, _file) => {
visit(tree, "code", (node: Code) => {
if (node.lang === "mermaid") {
node.data = {
hProperties: {
className: ["mermaid"],
},
}
}
})
}
})
}
if (opts.parseTags) {
plugins.push(() => {
return (tree: Root, file) => {
const base = pathToRoot(file.data.slug!)
findAndReplace(tree, tagRegex, (_value: string, tag: string) => {
// Check if the tag only includes numbers
if (/^\d+$/.test(tag)) {
return false
}
tag = slugTag(tag)
if (file.data.frontmatter && !file.data.frontmatter.tags.includes(tag)) {
file.data.frontmatter.tags.push(tag)
}
return {
type: "link",
url: base + `/tags/${tag}`,
data: {
hProperties: {
className: ["tag-link"],
},
},
children: [
{
type: "text",
value: `#${tag}`,
},
],
}
})
}
})
}
return plugins
},
htmlPlugins() {
const plugins = [rehypeRaw]
if (opts.parseBlockReferences) {
plugins.push(() => {
const inlineTagTypes = new Set(["p", "li"])
const blockTagTypes = new Set(["blockquote"])
return (tree, file) => {
file.data.blocks = {}
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[] = []
if (opts.callouts) {
js.push({
script: calloutScript,
loadTime: "afterDOMReady",
contentType: "inline",
})
}
if (opts.mermaid) {
js.push({
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({
startOnLoad: false,
securityLevel: 'loose',
theme: darkMode ? 'dark' : 'default'
});
document.addEventListener('nav', async () => {
await mermaid.run({
querySelector: '.mermaid'
})
});
`,
loadTime: "afterDOMReady",
moduleType: "module",
contentType: "inline",
})
}
return { js }
},
}
}
declare module "vfile" {
interface DataMap {
blocks: Record<string, Element>
}
}

View 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 `![](${src})`
})
}
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
},
}
}

View File

@ -0,0 +1,16 @@
import { QuartzTransformerPlugin } from "../types"
import rehypePrettyCode, { Options as CodeOptions } from "rehype-pretty-code"
export const SyntaxHighlighting: QuartzTransformerPlugin = () => ({
name: "SyntaxHighlighting",
htmlPlugins() {
return [
[
rehypePrettyCode,
{
theme: "css-variables",
} satisfies Partial<CodeOptions>,
],
]
},
})

View File

@ -0,0 +1,74 @@
import { QuartzTransformerPlugin } from "../types"
import { Root } from "mdast"
import { visit } from "unist-util-visit"
import { toString } from "mdast-util-to-string"
import Slugger from "github-slugger"
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 {
depth: number
text: string
slug: string // this is just the anchor (#some-slug), not the canonical slug
}
export const TableOfContents: QuartzTransformerPlugin<Partial<Options> | undefined> = (
userOpts,
) => {
const opts = { ...defaultOptions, ...userOpts }
return {
name: "TableOfContents",
markdownPlugins() {
return [
() => {
return async (tree: Root, file) => {
const display = file.data.frontmatter?.enableToc ?? opts.showByDefault
if (display) {
const slugAnchor = new Slugger()
const toc: TocEntry[] = []
let highestDepth: number = opts.maxDepth
visit(tree, "heading", (node) => {
if (node.depth <= opts.maxDepth) {
const text = toString(node)
highestDepth = Math.min(highestDepth, node.depth)
toc.push({
depth: node.depth,
text,
slug: slugAnchor.slug(text),
})
}
})
if (toc.length > opts.minEntries) {
file.data.toc = toc.map((entry) => ({
...entry,
depth: entry.depth - highestDepth,
}))
file.data.collapseToc = opts.collapseByDefault
}
}
}
},
]
},
}
}
declare module "vfile" {
interface DataMap {
toc: TocEntry[]
collapseToc: boolean
}
}

Some files were not shown because too many files have changed in this diff Show More