Merge commit '76f2664277e07a7d1b011fac236840c6e8e69fdd' into v4

This commit is contained in:
松浦 知也 Matsuura Tomoya 2023-11-27 18:01:51 +09:00
commit ae602f72e4
73 changed files with 2527 additions and 710 deletions

2
.gitignore vendored
View File

@ -7,3 +7,5 @@ tsconfig.tsbuildinfo
content/.obsidian/workspace.json content/.obsidian/workspace.json
.quartz-cache .quartz-cache
private/ private/
.replit
replit.nix

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"]

38
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "@jackyzha0/quartz", "name": "@jackyzha0/quartz",
"version": "4.0.10", "version": "4.0.11",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@jackyzha0/quartz", "name": "@jackyzha0/quartz",
"version": "4.0.10", "version": "4.0.11",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@clack/prompts": "^0.6.3", "@clack/prompts": "^0.6.3",
@ -45,6 +45,7 @@
"rehype-raw": "^6.1.1", "rehype-raw": "^6.1.1",
"rehype-slug": "^5.1.0", "rehype-slug": "^5.1.0",
"remark": "^14.0.2", "remark": "^14.0.2",
"remark-breaks": "^3.0.3",
"remark-frontmatter": "^4.0.1", "remark-frontmatter": "^4.0.1",
"remark-gfm": "^3.0.1", "remark-gfm": "^3.0.1",
"remark-math": "^5.1.1", "remark-math": "^5.1.1",
@ -55,6 +56,7 @@
"serve-handler": "^6.1.5", "serve-handler": "^6.1.5",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"to-vfile": "^7.2.4", "to-vfile": "^7.2.4",
"toml": "^3.0.0",
"unified": "^10.1.2", "unified": "^10.1.2",
"unist-util-visit": "^4.1.2", "unist-util-visit": "^4.1.2",
"vfile": "^5.3.7", "vfile": "^5.3.7",
@ -3809,6 +3811,19 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/mdast-util-newline-to-break": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-newline-to-break/-/mdast-util-newline-to-break-1.0.0.tgz",
"integrity": "sha512-491LcYv3gbGhhCrLoeALncQmega2xPh+m3gbsIhVsOX4sw85+ShLFPvPyibxc1Swx/6GtzxgVodq+cGa/47ULg==",
"dependencies": {
"@types/mdast": "^3.0.0",
"mdast-util-find-and-replace": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-phrasing": { "node_modules/mdast-util-phrasing": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-3.0.1.tgz", "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-3.0.1.tgz",
@ -4902,6 +4917,20 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/remark-breaks": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/remark-breaks/-/remark-breaks-3.0.3.tgz",
"integrity": "sha512-C7VkvcUp1TPUc2eAYzsPdaUh8Xj4FSbQnYA5A9f80diApLZscTDeG7efiWP65W8hV2sEy3JuGVU0i6qr5D8Hug==",
"dependencies": {
"@types/mdast": "^3.0.0",
"mdast-util-newline-to-break": "^1.0.0",
"unified": "^10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-frontmatter": { "node_modules/remark-frontmatter": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/remark-frontmatter/-/remark-frontmatter-4.0.1.tgz", "resolved": "https://registry.npmjs.org/remark-frontmatter/-/remark-frontmatter-4.0.1.tgz",
@ -5548,6 +5577,11 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/toml": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/toml/-/toml-3.0.0.tgz",
"integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="
},
"node_modules/tough-cookie": { "node_modules/tough-cookie": {
"version": "4.1.3", "version": "4.1.3",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz",

View File

@ -2,7 +2,7 @@
"name": "@jackyzha0/quartz", "name": "@jackyzha0/quartz",
"description": "🌱 publish your digital garden and notes as a website", "description": "🌱 publish your digital garden and notes as a website",
"private": true, "private": true,
"version": "4.0.10", "version": "4.1.1",
"type": "module", "type": "module",
"author": "jackyzha0 <j.zhao2k19@gmail.com>", "author": "jackyzha0 <j.zhao2k19@gmail.com>",
"license": "MIT", "license": "MIT",
@ -19,6 +19,7 @@
"profile": "0x -D prof ./quartz/bootstrap-cli.mjs build --concurrency=1" "profile": "0x -D prof ./quartz/bootstrap-cli.mjs build --concurrency=1"
}, },
"engines": { "engines": {
"npm": ">=9.3.1",
"node": ">=18.14" "node": ">=18.14"
}, },
"keywords": [ "keywords": [
@ -69,6 +70,7 @@
"rehype-raw": "^6.1.1", "rehype-raw": "^6.1.1",
"rehype-slug": "^5.1.0", "rehype-slug": "^5.1.0",
"remark": "^14.0.2", "remark": "^14.0.2",
"remark-breaks": "^3.0.3",
"remark-frontmatter": "^4.0.1", "remark-frontmatter": "^4.0.1",
"remark-gfm": "^3.0.1", "remark-gfm": "^3.0.1",
"remark-math": "^5.1.1", "remark-math": "^5.1.1",
@ -79,6 +81,7 @@
"serve-handler": "^6.1.5", "serve-handler": "^6.1.5",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"to-vfile": "^7.2.4", "to-vfile": "^7.2.4",
"toml": "^3.0.0",
"unified": "^10.1.2", "unified": "^10.1.2",
"unist-util-visit": "^4.1.2", "unist-util-visit": "^4.1.2",
"vfile": "^5.3.7", "vfile": "^5.3.7",

View File

@ -69,6 +69,7 @@ const config: QuartzConfig = {
}), }),
Plugin.Assets(), Plugin.Assets(),
Plugin.Static(), Plugin.Static(),
Plugin.NotFoundPage(),
], ],
}, },
} }

View File

@ -41,7 +41,8 @@ const graph_cfg = {
}, },
}; };
export const defaultContentPageLayout: PageLayout = { export const defaultContentPageLayout: PageLayout = {
beforeBody: [Component.ArticleTitle(), Component.ContentMeta(), /*Component.TagList()*/], beforeBody: [Component.Breadcrumbs(),
Component.ArticleTitle(), Component.ContentMeta(), /*Component.TagList()*/],
left: [ left: [
Component.PageTitle(), Component.PageTitle(),
Component.MobileOnly(Component.Spacer()), Component.MobileOnly(Component.Spacer()),
@ -63,5 +64,3 @@ export const defaultListPageLayout: PageLayout = {
], ],
right: [], right: [],
} }

View File

@ -1,550 +1,39 @@
#!/usr/bin/env node #!/usr/bin/env node
import { promises, readFileSync } from "fs"
import yargs from "yargs" import yargs from "yargs"
import path from "path"
import { hideBin } from "yargs/helpers" import { hideBin } from "yargs/helpers"
import esbuild from "esbuild" import {
import chalk from "chalk" handleBuild,
import { sassPlugin } from "esbuild-sass-plugin" handleCreate,
import fs from "fs" handleUpdate,
import { intro, isCancel, outro, select, text } from "@clack/prompts" handleRestore,
import { rimraf } from "rimraf" handleSync,
import chokidar from "chokidar" } from "./cli/handlers.js"
import prettyBytes from "pretty-bytes" import { CommonArgv, BuildArgv, CreateArgv, SyncArgv } from "./cli/args.js"
import { execSync, spawnSync } from "child_process" import { version } from "./cli/constants.js"
import http from "http"
import serveHandler from "serve-handler"
import { WebSocketServer } from "ws"
import { randomUUID } from "crypto"
import { Mutex } from "async-mutex"
const ORIGIN_NAME = "origin"
const UPSTREAM_NAME = "upstream"
const QUARTZ_SOURCE_BRANCH = "v4"
const cwd = process.cwd()
const cacheDir = path.join(cwd, ".quartz-cache")
const cacheFile = "./.quartz-cache/transpiled-build.mjs"
const fp = "./quartz/build.ts"
const { version } = JSON.parse(readFileSync("./package.json").toString())
const contentCacheFolder = path.join(cacheDir, "content-cache")
const CommonArgv = {
directory: {
string: true,
alias: ["d"],
default: "content",
describe: "directory to look for content files",
},
verbose: {
boolean: true,
alias: ["v"],
default: false,
describe: "print out extra logging information",
},
}
const SyncArgv = {
...CommonArgv,
commit: {
boolean: true,
default: true,
describe: "create a git commit for your unsaved changes",
},
push: {
boolean: true,
default: true,
describe: "push updates to your Quartz fork",
},
pull: {
boolean: true,
default: true,
describe: "pull updates from your Quartz fork",
},
}
const BuildArgv = {
...CommonArgv,
output: {
string: true,
alias: ["o"],
default: "public",
describe: "output folder for files",
},
serve: {
boolean: true,
default: false,
describe: "run a local server to live-preview your Quartz",
},
baseDir: {
string: true,
default: "",
describe: "base path to serve your local server on",
},
port: {
number: true,
default: 8080,
describe: "port to serve Quartz on",
},
bundleInfo: {
boolean: true,
default: false,
describe: "show detailed bundle information",
},
concurrency: {
number: true,
describe: "how many threads to use to parse notes",
},
}
function escapePath(fp) {
return fp
.replace(/\\ /g, " ") // unescape spaces
.replace(/^".*"$/, "$1")
.replace(/^'.*"$/, "$1")
.trim()
}
function exitIfCancel(val) {
if (isCancel(val)) {
outro(chalk.red("Exiting"))
process.exit(0)
} else {
return val
}
}
async function stashContentFolder(contentFolder) {
await fs.promises.rm(contentCacheFolder, { force: true, recursive: true })
await fs.promises.cp(contentFolder, contentCacheFolder, {
force: true,
recursive: true,
verbatimSymlinks: true,
preserveTimestamps: true,
})
await fs.promises.rm(contentFolder, { force: true, recursive: true })
}
async function popContentFolder(contentFolder) {
await fs.promises.rm(contentFolder, { force: true, recursive: true })
await fs.promises.cp(contentCacheFolder, contentFolder, {
force: true,
recursive: true,
verbatimSymlinks: true,
preserveTimestamps: true,
})
await fs.promises.rm(contentCacheFolder, { force: true, recursive: true })
}
function gitPull(origin, branch) {
const flags = ["--no-rebase", "--autostash", "-s", "recursive", "-X", "ours", "--no-edit"]
const out = spawnSync("git", ["pull", ...flags, origin, branch], { stdio: "inherit" })
if (out.stderr) {
throw new Error(`Error while pulling updates: ${out.stderr}`)
}
}
yargs(hideBin(process.argv)) yargs(hideBin(process.argv))
.scriptName("quartz") .scriptName("quartz")
.version(version) .version(version)
.usage("$0 <cmd> [args]") .usage("$0 <cmd> [args]")
.command("create", "Initialize Quartz", CommonArgv, async (argv) => { .command("create", "Initialize Quartz", CreateArgv, async (argv) => {
console.log() await handleCreate(argv)
intro(chalk.bgGreen.black(` Quartz v${version} `))
const contentFolder = path.join(cwd, argv.directory)
const setupStrategy = exitIfCancel(
await select({
message: `Choose how to initialize the content in \`${contentFolder}\``,
options: [
{ value: "new", label: "Empty Quartz" },
{ value: "copy", label: "Copy an existing folder", hint: "overwrites `content`" },
{
value: "symlink",
label: "Symlink an existing folder",
hint: "don't select this unless you know what you are doing!",
},
],
}),
)
async function rmContentFolder() {
const contentStat = await fs.promises.lstat(contentFolder)
if (contentStat.isSymbolicLink()) {
await fs.promises.unlink(contentFolder)
} else {
await rimraf(contentFolder)
}
}
await fs.promises.unlink(path.join(contentFolder, ".gitkeep"))
if (setupStrategy === "copy" || setupStrategy === "symlink") {
const originalFolder = escapePath(
exitIfCancel(
await text({
message: "Enter the full path to existing content folder",
placeholder:
"On most terminal emulators, you can drag and drop a folder into the window and it will paste the full path",
validate(fp) {
const fullPath = escapePath(fp)
if (!fs.existsSync(fullPath)) {
return "The given path doesn't exist"
} else if (!fs.lstatSync(fullPath).isDirectory()) {
return "The given path is not a folder"
}
},
}),
),
)
await rmContentFolder()
if (setupStrategy === "copy") {
await fs.promises.cp(originalFolder, contentFolder, {
recursive: true,
preserveTimestamps: true,
})
} else if (setupStrategy === "symlink") {
await fs.promises.symlink(originalFolder, contentFolder, "dir")
}
} else if (setupStrategy === "new") {
await fs.promises.writeFile(
path.join(contentFolder, "index.md"),
`---
title: Welcome to Quartz
---
This is a blank Quartz installation.
See the [documentation](https://quartz.jzhao.xyz) for how to get started.
`,
)
}
// get a preferred link resolution strategy
const linkResolutionStrategy = exitIfCancel(
await select({
message: `Choose how Quartz should resolve links in your content. You can change this later in \`quartz.config.ts\`.`,
options: [
{
value: "absolute",
label: "Treat links as absolute path",
hint: "for content made for Quartz 3 and Hugo",
},
{
value: "shortest",
label: "Treat links as shortest path",
hint: "for most Obsidian vaults",
},
{
value: "relative",
label: "Treat links as relative paths",
hint: "for just normal Markdown files",
},
],
}),
)
// now, do config changes
const configFilePath = path.join(cwd, "quartz.config.ts")
let configContent = await fs.promises.readFile(configFilePath, { encoding: "utf-8" })
configContent = configContent.replace(
/markdownLinkResolution: '(.+)'/,
`markdownLinkResolution: '${linkResolutionStrategy}'`,
)
await fs.promises.writeFile(configFilePath, configContent)
outro(`You're all set! Not sure what to do next? Try:
Customizing Quartz a bit more by editing \`quartz.config.ts\`
Running \`npx quartz build --serve\` to preview your Quartz locally
Hosting your Quartz online (see: https://quartz.jzhao.xyz/hosting)
`)
}) })
.command("update", "Get the latest Quartz updates", CommonArgv, async (argv) => { .command("update", "Get the latest Quartz updates", CommonArgv, async (argv) => {
const contentFolder = path.join(cwd, argv.directory) await handleUpdate(argv)
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!"))
}) })
.command( .command(
"restore", "restore",
"Try to restore your content folder from the cache", "Try to restore your content folder from the cache",
CommonArgv, CommonArgv,
async (argv) => { async (argv) => {
const contentFolder = path.join(cwd, argv.directory) await handleRestore(argv)
await popContentFolder(contentFolder)
}, },
) )
.command("sync", "Sync your Quartz to and from GitHub.", SyncArgv, async (argv) => { .command("sync", "Sync your Quartz to and from GitHub.", SyncArgv, async (argv) => {
const contentFolder = path.join(cwd, argv.directory) await handleSync(argv)
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!"))
}) })
.command("build", "Build Quartz into a bundle of static HTML files", BuildArgv, async (argv) => { .command("build", "Build Quartz into a bundle of static HTML files", BuildArgv, async (argv) => {
console.log(chalk.bgGreen.black(`\n Quartz v${version} \n`)) await handleBuild(argv)
const ctx = await esbuild.context({
entryPoints: [fp],
outfile: path.join("quartz", cacheFile),
bundle: true,
keepNames: true,
minifyWhitespace: true,
minifySyntax: true,
platform: "node",
format: "esm",
jsx: "automatic",
jsxImportSource: "preact",
packages: "external",
metafile: true,
sourcemap: true,
sourcesContent: false,
plugins: [
sassPlugin({
type: "css-text",
cssImports: true,
}),
{
name: "inline-script-loader",
setup(build) {
build.onLoad({ filter: /\.inline\.(ts|js)$/ }, async (args) => {
let text = await promises.readFile(args.path, "utf8")
// remove default exports that we manually inserted
text = text.replace("export default", "")
text = text.replace("export", "")
const sourcefile = path.relative(path.resolve("."), args.path)
const resolveDir = path.dirname(sourcefile)
const transpiled = await esbuild.build({
stdin: {
contents: text,
loader: "ts",
resolveDir,
sourcefile,
},
write: false,
bundle: true,
platform: "browser",
format: "esm",
})
const rawMod = transpiled.outputFiles[0].text
return {
contents: rawMod,
loader: "text",
}
})
},
},
],
})
const buildMutex = new Mutex()
let lastBuildMs = 0
let cleanupBuild = null
const build = async (clientRefresh) => {
const buildStart = new Date().getTime()
lastBuildMs = buildStart
const release = await buildMutex.acquire()
if (lastBuildMs > buildStart) {
release()
return
}
if (cleanupBuild) {
await cleanupBuild()
console.log(chalk.yellow("Detected a source code change, doing a hard rebuild..."))
}
const result = await ctx.rebuild().catch((err) => {
console.error(`${chalk.red("Couldn't parse Quartz configuration:")} ${fp}`)
console.log(`Reason: ${chalk.grey(err)}`)
process.exit(1)
})
release()
if (argv.bundleInfo) {
const outputFileName = "quartz/.quartz-cache/transpiled-build.mjs"
const meta = result.metafile.outputs[outputFileName]
console.log(
`Successfully transpiled ${Object.keys(meta.inputs).length} files (${prettyBytes(
meta.bytes,
)})`,
)
console.log(await esbuild.analyzeMetafile(result.metafile, { color: true }))
}
// bypass module cache
// https://github.com/nodejs/modules/issues/307
const { default: buildQuartz } = await import(cacheFile + `?update=${randomUUID()}`)
cleanupBuild = await buildQuartz(argv, buildMutex, clientRefresh)
clientRefresh()
}
if (argv.serve) {
const connections = []
const clientRefresh = () => connections.forEach((conn) => conn.send("rebuild"))
if (argv.baseDir !== "" && !argv.baseDir.startsWith("/")) {
argv.baseDir = "/" + argv.baseDir
}
await build(clientRefresh)
const server = http.createServer(async (req, res) => {
if (argv.baseDir && !req.url?.startsWith(argv.baseDir)) {
console.log(
chalk.red(
`[404] ${req.url} (warning: link outside of site, this is likely a Quartz bug)`,
),
)
res.writeHead(404)
res.end()
return
}
// strip baseDir prefix
req.url = req.url?.slice(argv.baseDir.length)
const serve = async () => {
const release = await buildMutex.acquire()
await serveHandler(req, res, {
public: argv.output,
directoryListing: false,
headers: [
{
source: "**/*.html",
headers: [{ key: "Content-Disposition", value: "inline" }],
},
],
})
const status = res.statusCode
const statusString =
status >= 200 && status < 300 ? chalk.green(`[${status}]`) : chalk.red(`[${status}]`)
console.log(statusString + chalk.grey(` ${argv.baseDir}${req.url}`))
release()
}
const redirect = (newFp) => {
newFp = argv.baseDir + newFp
res.writeHead(302, {
Location: newFp,
})
console.log(chalk.yellow("[302]") + chalk.grey(` ${argv.baseDir}${req.url} -> ${newFp}`))
res.end()
}
let fp = req.url?.split("?")[0] ?? "/"
// handle redirects
if (fp.endsWith("/")) {
// /trailing/
// does /trailing/index.html exist? if so, serve it
const indexFp = path.posix.join(fp, "index.html")
if (fs.existsSync(path.posix.join(argv.output, indexFp))) {
req.url = fp
return serve()
}
// does /trailing.html exist? if so, redirect to /trailing
let base = fp.slice(0, -1)
if (path.extname(base) === "") {
base += ".html"
}
if (fs.existsSync(path.posix.join(argv.output, base))) {
return redirect(fp.slice(0, -1))
}
} else {
// /regular
// does /regular.html exist? if so, serve it
let base = fp
if (path.extname(base) === "") {
base += ".html"
}
if (fs.existsSync(path.posix.join(argv.output, base))) {
req.url = fp
return serve()
}
// does /regular/index.html exist? if so, redirect to /regular/
let indexFp = path.posix.join(fp, "index.html")
if (fs.existsSync(path.posix.join(argv.output, indexFp))) {
return redirect(fp + "/")
}
}
return serve()
})
server.listen(argv.port)
const wss = new WebSocketServer({ port: 3001 })
wss.on("connection", (ws) => connections.push(ws))
console.log(
chalk.cyan(
`Started a Quartz server listening at http://localhost:${argv.port}${argv.baseDir}`,
),
)
console.log("hint: exit with ctrl+c")
chokidar
.watch(["**/*.ts", "**/*.tsx", "**/*.scss", "package.json"], {
ignoreInitial: true,
})
.on("all", async () => {
build(clientRefresh)
})
} else {
await build(() => {})
ctx.dispose()
}
}) })
.showHelpOnFail(false) .showHelpOnFail(false)
.help() .help()

View File

@ -12,6 +12,10 @@ export type Analytics =
provider: "google" provider: "google"
tagId: string tagId: string
} }
| {
provider: "umami"
websiteId: string
}
export interface GlobalConfiguration { export interface GlobalConfiguration {
pageTitle: string pageTitle: string

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

@ -0,0 +1,103 @@
export const CommonArgv = {
directory: {
string: true,
alias: ["d"],
default: "content",
describe: "directory to look for content files",
},
verbose: {
boolean: true,
alias: ["v"],
default: false,
describe: "print out extra logging information",
},
}
export const CreateArgv = {
...CommonArgv,
source: {
string: true,
alias: ["s"],
describe: "source directory to copy/create symlink from",
},
strategy: {
string: true,
alias: ["X"],
choices: ["new", "copy", "symlink"],
describe: "strategy for content folder setup",
},
links: {
string: true,
alias: ["l"],
choices: ["absolute", "shortest", "relative"],
describe: "strategy to resolve links",
},
}
export const SyncArgv = {
...CommonArgv,
commit: {
boolean: true,
default: true,
describe: "create a git commit for your unsaved changes",
},
message: {
string: true,
alias: ["m"],
describe: "option to override the default Quartz commit message",
},
push: {
boolean: true,
default: true,
describe: "push updates to your Quartz fork",
},
pull: {
boolean: true,
default: true,
describe: "pull updates from your Quartz fork",
},
}
export const BuildArgv = {
...CommonArgv,
output: {
string: true,
alias: ["o"],
default: "public",
describe: "output folder for files",
},
serve: {
boolean: true,
default: false,
describe: "run a local server to live-preview your Quartz",
},
baseDir: {
string: true,
default: "",
describe: "base path to serve your local server on",
},
port: {
number: true,
default: 8080,
describe: "port to serve Quartz on",
},
wsPort: {
number: true,
default: 3001,
describe: "port to use for WebSocket-based hot-reload notifications",
},
remoteDevHost: {
string: true,
default: "",
describe: "A URL override for the websocket connection if you are not developing on localhost",
},
bundleInfo: {
boolean: true,
default: false,
describe: "show detailed bundle information",
},
concurrency: {
number: true,
describe: "how many threads to use to parse notes",
},
}

15
quartz/cli/constants.js Normal file
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")

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

@ -0,0 +1,512 @@
import { promises } from "fs"
import path from "path"
import esbuild from "esbuild"
import chalk from "chalk"
import { sassPlugin } from "esbuild-sass-plugin"
import fs from "fs"
import { intro, outro, select, text } from "@clack/prompts"
import { rimraf } from "rimraf"
import chokidar from "chokidar"
import prettyBytes from "pretty-bytes"
import { execSync, spawnSync } from "child_process"
import http from "http"
import serveHandler from "serve-handler"
import { WebSocketServer } from "ws"
import { randomUUID } from "crypto"
import { Mutex } from "async-mutex"
import { CreateArgv } from "./args.js"
import {
exitIfCancel,
escapePath,
gitPull,
popContentFolder,
stashContentFolder,
} from "./helpers.js"
import {
UPSTREAM_NAME,
QUARTZ_SOURCE_BRANCH,
ORIGIN_NAME,
version,
fp,
cacheFile,
cwd,
} from "./constants.js"
/**
* Handles `npx quartz create`
* @param {*} argv arguments for `create`
*/
export async function handleCreate(argv) {
console.log()
intro(chalk.bgGreen.black(` Quartz v${version} `))
const contentFolder = path.join(cwd, argv.directory)
let setupStrategy = argv.strategy?.toLowerCase()
let linkResolutionStrategy = argv.links?.toLowerCase()
const sourceDirectory = argv.source
// If all cmd arguments were provided, check if theyre valid
if (setupStrategy && linkResolutionStrategy) {
// If setup isn't, "new", source argument is required
if (setupStrategy !== "new") {
// Error handling
if (!sourceDirectory) {
outro(
chalk.red(
`Setup strategies (arg '${chalk.yellow(
`-${CreateArgv.strategy.alias[0]}`,
)}') other than '${chalk.yellow(
"new",
)}' require content folder argument ('${chalk.yellow(
`-${CreateArgv.source.alias[0]}`,
)}') to be set`,
),
)
process.exit(1)
} else {
if (!fs.existsSync(sourceDirectory)) {
outro(
chalk.red(
`Input directory to copy/symlink 'content' from not found ('${chalk.yellow(
sourceDirectory,
)}', invalid argument "${chalk.yellow(`-${CreateArgv.source.alias[0]}`)})`,
),
)
process.exit(1)
} else if (!fs.lstatSync(sourceDirectory).isDirectory()) {
outro(
chalk.red(
`Source directory to copy/symlink 'content' from is not a directory (found file at '${chalk.yellow(
sourceDirectory,
)}', invalid argument ${chalk.yellow(`-${CreateArgv.source.alias[0]}`)}")`,
),
)
process.exit(1)
}
}
}
}
// Use cli process if cmd args werent provided
if (!setupStrategy) {
setupStrategy = exitIfCancel(
await select({
message: `Choose how to initialize the content in \`${contentFolder}\``,
options: [
{ value: "new", label: "Empty Quartz" },
{ value: "copy", label: "Copy an existing folder", hint: "overwrites `content`" },
{
value: "symlink",
label: "Symlink an existing folder",
hint: "don't select this unless you know what you are doing!",
},
],
}),
)
}
async function rmContentFolder() {
const contentStat = await fs.promises.lstat(contentFolder)
if (contentStat.isSymbolicLink()) {
await fs.promises.unlink(contentFolder)
} else {
await rimraf(contentFolder)
}
}
await fs.promises.unlink(path.join(contentFolder, ".gitkeep"))
if (setupStrategy === "copy" || setupStrategy === "symlink") {
let originalFolder = sourceDirectory
// If input directory was not passed, use cli
if (!sourceDirectory) {
originalFolder = escapePath(
exitIfCancel(
await text({
message: "Enter the full path to existing content folder",
placeholder:
"On most terminal emulators, you can drag and drop a folder into the window and it will paste the full path",
validate(fp) {
const fullPath = escapePath(fp)
if (!fs.existsSync(fullPath)) {
return "The given path doesn't exist"
} else if (!fs.lstatSync(fullPath).isDirectory()) {
return "The given path is not a folder"
}
},
}),
),
)
}
await rmContentFolder()
if (setupStrategy === "copy") {
await fs.promises.cp(originalFolder, contentFolder, {
recursive: true,
preserveTimestamps: true,
})
} else if (setupStrategy === "symlink") {
await fs.promises.symlink(originalFolder, contentFolder, "dir")
}
} else if (setupStrategy === "new") {
await fs.promises.writeFile(
path.join(contentFolder, "index.md"),
`---
title: Welcome to Quartz
---
This is a blank Quartz installation.
See the [documentation](https://quartz.jzhao.xyz) for how to get started.
`,
)
}
// Use cli process if cmd args werent provided
if (!linkResolutionStrategy) {
// get a preferred link resolution strategy
linkResolutionStrategy = exitIfCancel(
await select({
message: `Choose how Quartz should resolve links in your content. You can change this later in \`quartz.config.ts\`.`,
options: [
{
value: "absolute",
label: "Treat links as absolute path",
hint: "for content made for Quartz 3 and Hugo",
},
{
value: "shortest",
label: "Treat links as shortest path",
hint: "for most Obsidian vaults",
},
{
value: "relative",
label: "Treat links as relative paths",
hint: "for just normal Markdown files",
},
],
}),
)
}
// now, do config changes
const configFilePath = path.join(cwd, "quartz.config.ts")
let configContent = await fs.promises.readFile(configFilePath, { encoding: "utf-8" })
configContent = configContent.replace(
/markdownLinkResolution: '(.+)'/,
`markdownLinkResolution: '${linkResolutionStrategy}'`,
)
await fs.promises.writeFile(configFilePath, configContent)
outro(`You're all set! Not sure what to do next? Try:
Customizing Quartz a bit more by editing \`quartz.config.ts\`
Running \`npx quartz build --serve\` to preview your Quartz locally
Hosting your Quartz online (see: https://quartz.jzhao.xyz/hosting)
`)
}
/**
* Handles `npx quartz build`
* @param {*} argv arguments for `build`
*/
export async function handleBuild(argv) {
console.log(chalk.bgGreen.black(`\n Quartz v${version} \n`))
const ctx = await esbuild.context({
entryPoints: [fp],
outfile: cacheFile,
bundle: true,
keepNames: true,
minifyWhitespace: true,
minifySyntax: true,
platform: "node",
format: "esm",
jsx: "automatic",
jsxImportSource: "preact",
packages: "external",
metafile: true,
sourcemap: true,
sourcesContent: false,
plugins: [
sassPlugin({
type: "css-text",
cssImports: true,
}),
{
name: "inline-script-loader",
setup(build) {
build.onLoad({ filter: /\.inline\.(ts|js)$/ }, async (args) => {
let text = await promises.readFile(args.path, "utf8")
// remove default exports that we manually inserted
text = text.replace("export default", "")
text = text.replace("export", "")
const sourcefile = path.relative(path.resolve("."), args.path)
const resolveDir = path.dirname(sourcefile)
const transpiled = await esbuild.build({
stdin: {
contents: text,
loader: "ts",
resolveDir,
sourcefile,
},
write: false,
bundle: true,
platform: "browser",
format: "esm",
})
const rawMod = transpiled.outputFiles[0].text
return {
contents: rawMod,
loader: "text",
}
})
},
},
],
})
const buildMutex = new Mutex()
let lastBuildMs = 0
let cleanupBuild = null
const build = async (clientRefresh) => {
const buildStart = new Date().getTime()
lastBuildMs = buildStart
const release = await buildMutex.acquire()
if (lastBuildMs > buildStart) {
release()
return
}
if (cleanupBuild) {
await cleanupBuild()
console.log(chalk.yellow("Detected a source code change, doing a hard rebuild..."))
}
const result = await ctx.rebuild().catch((err) => {
console.error(`${chalk.red("Couldn't parse Quartz configuration:")} ${fp}`)
console.log(`Reason: ${chalk.grey(err)}`)
process.exit(1)
})
release()
if (argv.bundleInfo) {
const outputFileName = "quartz/.quartz-cache/transpiled-build.mjs"
const meta = result.metafile.outputs[outputFileName]
console.log(
`Successfully transpiled ${Object.keys(meta.inputs).length} files (${prettyBytes(
meta.bytes,
)})`,
)
console.log(await esbuild.analyzeMetafile(result.metafile, { color: true }))
}
// bypass module cache
// https://github.com/nodejs/modules/issues/307
const { default: buildQuartz } = await import(`../../${cacheFile}?update=${randomUUID()}`)
// ^ this import is relative, so base "cacheFile" path can't be used
cleanupBuild = await buildQuartz(argv, buildMutex, clientRefresh)
clientRefresh()
}
if (argv.serve) {
const connections = []
const clientRefresh = () => connections.forEach((conn) => conn.send("rebuild"))
if (argv.baseDir !== "" && !argv.baseDir.startsWith("/")) {
argv.baseDir = "/" + argv.baseDir
}
await build(clientRefresh)
const server = http.createServer(async (req, res) => {
if (argv.baseDir && !req.url?.startsWith(argv.baseDir)) {
console.log(
chalk.red(
`[404] ${req.url} (warning: link outside of site, this is likely a Quartz bug)`,
),
)
res.writeHead(404)
res.end()
return
}
// strip baseDir prefix
req.url = req.url?.slice(argv.baseDir.length)
const serve = async () => {
const release = await buildMutex.acquire()
await serveHandler(req, res, {
public: argv.output,
directoryListing: false,
headers: [
{
source: "**/*.html",
headers: [{ key: "Content-Disposition", value: "inline" }],
},
],
})
const status = res.statusCode
const statusString =
status >= 200 && status < 300 ? chalk.green(`[${status}]`) : chalk.red(`[${status}]`)
console.log(statusString + chalk.grey(` ${argv.baseDir}${req.url}`))
release()
}
const redirect = (newFp) => {
newFp = argv.baseDir + newFp
res.writeHead(302, {
Location: newFp,
})
console.log(chalk.yellow("[302]") + chalk.grey(` ${argv.baseDir}${req.url} -> ${newFp}`))
res.end()
}
let fp = req.url?.split("?")[0] ?? "/"
// handle redirects
if (fp.endsWith("/")) {
// /trailing/
// does /trailing/index.html exist? if so, serve it
const indexFp = path.posix.join(fp, "index.html")
if (fs.existsSync(path.posix.join(argv.output, indexFp))) {
req.url = fp
return serve()
}
// does /trailing.html exist? if so, redirect to /trailing
let base = fp.slice(0, -1)
if (path.extname(base) === "") {
base += ".html"
}
if (fs.existsSync(path.posix.join(argv.output, base))) {
return redirect(fp.slice(0, -1))
}
} else {
// /regular
// does /regular.html exist? if so, serve it
let base = fp
if (path.extname(base) === "") {
base += ".html"
}
if (fs.existsSync(path.posix.join(argv.output, base))) {
req.url = fp
return serve()
}
// does /regular/index.html exist? if so, redirect to /regular/
let indexFp = path.posix.join(fp, "index.html")
if (fs.existsSync(path.posix.join(argv.output, indexFp))) {
return redirect(fp + "/")
}
}
return serve()
})
server.listen(argv.port)
const wss = new WebSocketServer({ port: argv.wsPort })
wss.on("connection", (ws) => connections.push(ws))
console.log(
chalk.cyan(
`Started a Quartz server listening at http://localhost:${argv.port}${argv.baseDir}`,
),
)
console.log("hint: exit with ctrl+c")
chokidar
.watch(["**/*.ts", "**/*.tsx", "**/*.scss", "package.json"], {
ignoreInitial: true,
})
.on("all", async () => {
build(clientRefresh)
})
} else {
await build(() => {})
ctx.dispose()
}
}
/**
* Handles `npx quartz update`
* @param {*} argv arguments for `update`
*/
export async function handleUpdate(argv) {
const contentFolder = path.join(cwd, argv.directory)
console.log(chalk.bgGreen.black(`\n Quartz v${version} \n`))
console.log("Backing up your content")
execSync(
`git remote show upstream || git remote add upstream https://github.com/jackyzha0/quartz.git`,
)
await stashContentFolder(contentFolder)
console.log(
"Pulling updates... you may need to resolve some `git` conflicts if you've made changes to components or plugins.",
)
gitPull(UPSTREAM_NAME, QUARTZ_SOURCE_BRANCH)
await popContentFolder(contentFolder)
console.log("Ensuring dependencies are up to date")
spawnSync("npm", ["i"], { stdio: "inherit" })
console.log(chalk.green("Done!"))
}
/**
* Handles `npx quartz restore`
* @param {*} argv arguments for `restore`
*/
export async function handleRestore(argv) {
const contentFolder = path.join(cwd, argv.directory)
await popContentFolder(contentFolder)
}
/**
* Handles `npx quartz sync`
* @param {*} argv arguments for `sync`
*/
export async function handleSync(argv) {
const contentFolder = path.join(cwd, argv.directory)
console.log(chalk.bgGreen.black(`\n Quartz v${version} \n`))
console.log("Backing up your content")
if (argv.commit) {
const contentStat = await fs.promises.lstat(contentFolder)
if (contentStat.isSymbolicLink()) {
const linkTarg = await fs.promises.readlink(contentFolder)
console.log(chalk.yellow("Detected symlink, trying to dereference before committing"))
// stash symlink file
await stashContentFolder(contentFolder)
// follow symlink and copy content
await fs.promises.cp(linkTarg, contentFolder, {
recursive: true,
preserveTimestamps: true,
})
}
const currentTimestamp = new Date().toLocaleString("en-US", {
dateStyle: "medium",
timeStyle: "short",
})
const commitMessage = argv.message ?? `Quartz sync: ${currentTimestamp}`
spawnSync("git", ["add", "."], { stdio: "inherit" })
spawnSync("git", ["commit", "-m", commitMessage], { stdio: "inherit" })
if (contentStat.isSymbolicLink()) {
// put symlink back
await popContentFolder(contentFolder)
}
}
await stashContentFolder(contentFolder)
if (argv.pull) {
console.log(
"Pulling updates from your repository. You may need to resolve some `git` conflicts if you've made changes to components or plugins.",
)
gitPull(ORIGIN_NAME, QUARTZ_SOURCE_BRANCH)
}
await popContentFolder(contentFolder)
if (argv.push) {
console.log("Pushing your changes")
spawnSync("git", ["push", "-f", ORIGIN_NAME, QUARTZ_SOURCE_BRANCH], { stdio: "inherit" })
}
console.log(chalk.green("Done!"))
}

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

@ -1,9 +1,9 @@
import { QuartzComponentConstructor, QuartzComponentProps } from "./types" import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
function ArticleTitle({ fileData }: QuartzComponentProps) { function ArticleTitle({ fileData, displayClass }: QuartzComponentProps) {
const title = fileData.frontmatter?.title const title = fileData.frontmatter?.title
if (title) { if (title) {
return <h1 class="article-title">{title}</h1> return <h1 class={`article-title ${displayClass ?? ""}`}>{title}</h1>
} else { } else {
return null return null
} }

View File

@ -2,11 +2,11 @@ import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
import style from "./styles/backlinks.scss" import style from "./styles/backlinks.scss"
import { resolveRelative, simplifySlug } from "../util/path" import { resolveRelative, simplifySlug } from "../util/path"
function Backlinks({ fileData, allFiles }: QuartzComponentProps) { function Backlinks({ fileData, allFiles, displayClass }: QuartzComponentProps) {
const slug = simplifySlug(fileData.slug!) const slug = simplifySlug(fileData.slug!)
const backlinkFiles = allFiles.filter((file) => file.links?.includes(slug)) const backlinkFiles = allFiles.filter((file) => file.links?.includes(slug))
return ( return (
<div class="backlinks"> <div class={`backlinks ${displayClass ?? ""}`}>
<h3>Backlinks</h3> <h3>Backlinks</h3>
<ul class="overflow"> <ul class="overflow">
{backlinkFiles.length > 0 ? ( {backlinkFiles.length > 0 ? (

View File

@ -0,0 +1,116 @@
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
import breadcrumbsStyle from "./styles/breadcrumbs.scss"
import { FullSlug, SimpleSlug, resolveRelative } from "../util/path"
import { QuartzPluginData } from "../plugins/vfile"
type CrumbData = {
displayName: string
path: string
}
interface BreadcrumbOptions {
/**
* Symbol between crumbs
*/
spacerSymbol: string
/**
* Name of first crumb
*/
rootName: string
/**
* wether to look up frontmatter title for folders (could cause performance problems with big vaults)
*/
resolveFrontmatterTitle: boolean
/**
* Wether to display breadcrumbs on root `index.md`
*/
hideOnRoot: boolean
}
const defaultOptions: BreadcrumbOptions = {
spacerSymbol: "",
rootName: "Home",
resolveFrontmatterTitle: true,
hideOnRoot: true,
}
function formatCrumb(displayName: string, baseSlug: FullSlug, currentSlug: SimpleSlug): CrumbData {
return {
displayName: displayName.replaceAll("-", " "),
path: resolveRelative(baseSlug, currentSlug),
}
}
export default ((opts?: Partial<BreadcrumbOptions>) => {
// Merge options with defaults
const options: BreadcrumbOptions = { ...defaultOptions, ...opts }
// computed index of folder name to its associated file data
let folderIndex: Map<string, QuartzPluginData> | undefined
function Breadcrumbs({ fileData, allFiles, displayClass }: QuartzComponentProps) {
// Hide crumbs on root if enabled
if (options.hideOnRoot && fileData.slug === "index") {
return <></>
}
// Format entry for root element
const firstEntry = formatCrumb(options.rootName, fileData.slug!, "/" as SimpleSlug)
const crumbs: CrumbData[] = [firstEntry]
if (!folderIndex && options.resolveFrontmatterTitle) {
folderIndex = new Map()
// construct the index for the first time
for (const file of allFiles) {
if (file.slug?.endsWith("index")) {
const folderParts = file.filePath?.split("/")
if (folderParts) {
const folderName = folderParts[folderParts?.length - 2]
folderIndex.set(folderName, file)
}
}
}
}
// Split slug into hierarchy/parts
const slugParts = fileData.slug?.split("/")
if (slugParts) {
// full path until current part
let currentPath = ""
for (let i = 0; i < slugParts.length - 1; i++) {
let curPathSegment = slugParts[i]
// Try to resolve frontmatter folder title
const currentFile = folderIndex?.get(curPathSegment)
if (currentFile) {
curPathSegment = currentFile.frontmatter!.title
}
// Add current slug to full path
currentPath += slugParts[i] + "/"
// Format and add current crumb
const crumb = formatCrumb(curPathSegment, fileData.slug!, currentPath as SimpleSlug)
crumbs.push(crumb)
}
// Add current file to crumb (can directly use frontmatter title)
crumbs.push({
displayName: fileData.frontmatter!.title,
path: "",
})
}
return (
<nav class={`breadcrumb-container ${displayClass ?? ""}`} aria-label="breadcrumbs">
{crumbs.map((crumb, index) => (
<div class="breadcrumb-element">
<a href={crumb.path}>{crumb.displayName}</a>
{index !== crumbs.length - 1 && <p>{` ${options.spacerSymbol} `}</p>}
</div>
))}
</nav>
)
}
Breadcrumbs.css = breadcrumbsStyle
return Breadcrumbs
}) satisfies QuartzComponentConstructor

View File

@ -3,7 +3,7 @@ import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
import readingTime from "reading-time" import readingTime from "reading-time"
export default (() => { export default (() => {
function ContentMetadata({ cfg, fileData }: QuartzComponentProps) { function ContentMetadata({ cfg, fileData, displayClass }: QuartzComponentProps) {
const text = fileData.text const text = fileData.text
if (text) { if (text) {
const segments: string[] = [] const segments: string[] = []
@ -14,7 +14,7 @@ export default (() => {
} }
segments.push(timeTaken) segments.push(timeTaken)
return <p class="content-meta">{segments.join(", ")}</p> return <p class={`content-meta ${displayClass ?? ""}`}>{segments.join(", ")}</p>
} else { } else {
return null return null
} }

View File

@ -3,11 +3,11 @@
// see: https://v8.dev/features/modules#defer // see: https://v8.dev/features/modules#defer
import darkmodeScript from "./scripts/darkmode.inline" import darkmodeScript from "./scripts/darkmode.inline"
import styles from "./styles/darkmode.scss" import styles from "./styles/darkmode.scss"
import { QuartzComponentConstructor } from "./types" import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
function Darkmode() { function Darkmode({ displayClass }: QuartzComponentProps) {
return ( return (
<div class="darkmode"> <div class={`darkmode ${displayClass ?? ""}`}>
<input class="toggle" id="darkmode-toggle" type="checkbox" tabIndex={-1} /> <input class="toggle" id="darkmode-toggle" type="checkbox" tabIndex={-1} />
<label id="toggle-label-light" for="darkmode-toggle" tabIndex={-1}> <label id="toggle-label-light" for="darkmode-toggle" tabIndex={-1}>
<svg <svg

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

@ -1,4 +1,4 @@
import { QuartzComponentConstructor } from "./types" import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
import style from "./styles/footer.scss" import style from "./styles/footer.scss"
import { version } from "../../package.json" import { version } from "../../package.json"
@ -7,11 +7,11 @@ interface Options {
} }
export default ((opts?: Options) => { export default ((opts?: Options) => {
function Footer() { function Footer({ displayClass }: QuartzComponentProps) {
const year = new Date().getFullYear() const year = new Date().getFullYear()
const links = opts?.links ?? [] const links = opts?.links ?? []
return ( return (
<footer> <footer class={`${displayClass ?? ""}`}>
<hr /> <hr />
<p> <p>
Created with <a href="https://quartz.jzhao.xyz/">Quartz v{version}</a>, © {year} Created with <a href="https://quartz.jzhao.xyz/">Quartz v{version}</a>, © {year}

View File

@ -1,4 +1,4 @@
import { QuartzComponentConstructor } from "./types" import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
// @ts-ignore // @ts-ignore
import script from "./scripts/graph.inline" import script from "./scripts/graph.inline"
import style from "./styles/graph.scss" import style from "./styles/graph.scss"
@ -13,6 +13,8 @@ export interface D3Config {
linkDistance: number linkDistance: number
fontSize: number fontSize: number
opacityScale: number opacityScale: number
removeTags: string[]
showTags: boolean
} }
interface GraphOptions { interface GraphOptions {
@ -31,6 +33,8 @@ const defaultOptions: GraphOptions = {
linkDistance: 30, linkDistance: 30,
fontSize: 0.6, fontSize: 0.6,
opacityScale: 1, opacityScale: 1,
showTags: true,
removeTags: [],
}, },
globalGraph: { globalGraph: {
drag: true, drag: true,
@ -42,15 +46,17 @@ const defaultOptions: GraphOptions = {
linkDistance: 30, linkDistance: 30,
fontSize: 0.6, fontSize: 0.6,
opacityScale: 1, opacityScale: 1,
showTags: true,
removeTags: [],
}, },
} }
export default ((opts?: GraphOptions) => { export default ((opts?: GraphOptions) => {
function Graph() { function Graph({ displayClass }: QuartzComponentProps) {
const localGraph = { ...defaultOptions.localGraph, ...opts?.localGraph } const localGraph = { ...defaultOptions.localGraph, ...opts?.localGraph }
const globalGraph = { ...defaultOptions.globalGraph, ...opts?.globalGraph } const globalGraph = { ...defaultOptions.globalGraph, ...opts?.globalGraph }
return ( return (
<div class="graph"> <div class={`graph ${displayClass ?? ""}`}>
<h3>Graph View</h3> <h3>Graph View</h3>
<div class="graph-outer"> <div class="graph-outer">
<div id="graph-container" data-cfg={JSON.stringify(localGraph)}></div> <div id="graph-container" data-cfg={JSON.stringify(localGraph)}></div>

View File

@ -1,4 +1,4 @@
import { joinSegments, pathToRoot } from "../util/path" import { FullSlug, _stripSlashes, joinSegments, pathToRoot } from "../util/path"
import { JSResourceToScriptElement } from "../util/resources" import { JSResourceToScriptElement } from "../util/resources"
import { QuartzComponentConstructor, QuartzComponentProps } from "./types" import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
@ -7,7 +7,11 @@ export default (() => {
const title = fileData.frontmatter?.title ?? "Untitled" const title = fileData.frontmatter?.title ?? "Untitled"
const description = fileData.description?.trim() ?? "No description provided" const description = fileData.description?.trim() ?? "No description provided"
const { css, js } = externalResources const { css, js } = externalResources
const baseDir = pathToRoot(fileData.slug!)
const url = new URL(`https://${cfg.baseUrl ?? "example.com"}`)
const path = url.pathname as FullSlug
const baseDir = fileData.slug === "404" ? path : pathToRoot(fileData.slug!)
const iconPath = joinSegments(baseDir, "static/icon.png") const iconPath = joinSegments(baseDir, "static/icon.png")
const ogImagePath = `https://${cfg.baseUrl}/static/og-image.png` const ogImagePath = `https://${cfg.baseUrl}/static/og-image.png`

View File

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

View File

@ -23,13 +23,12 @@ const defaultOptions = (cfg: GlobalConfiguration): Options => ({
}) })
export default ((userOpts?: Partial<Options>) => { export default ((userOpts?: Partial<Options>) => {
function RecentNotes(props: QuartzComponentProps) { function RecentNotes({ allFiles, fileData, displayClass, cfg }: QuartzComponentProps) {
const { allFiles, fileData, displayClass, cfg } = props
const opts = { ...defaultOptions(cfg), ...userOpts } const opts = { ...defaultOptions(cfg), ...userOpts }
const pages = allFiles.filter(opts.filter).sort(opts.sort) const pages = allFiles.filter(opts.filter).sort(opts.sort)
const remaining = Math.max(0, pages.length - opts.limit) const remaining = Math.max(0, pages.length - opts.limit)
return ( return (
<div class={`recent-notes ${displayClass}`}> <div class={`recent-notes ${displayClass ?? ""}`}>
<h3>{opts.title}</h3> <h3>{opts.title}</h3>
<ul class="recent-ul"> <ul class="recent-ul">
{pages.slice(0, opts.limit).map((page) => { {pages.slice(0, opts.limit).map((page) => {

View File

@ -1,12 +1,12 @@
import { QuartzComponentConstructor } from "./types" import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
import style from "./styles/search.scss" import style from "./styles/search.scss"
// @ts-ignore // @ts-ignore
import script from "./scripts/search.inline" import script from "./scripts/search.inline"
export default (() => { export default (() => {
function Search() { function Search({ displayClass }: QuartzComponentProps) {
return ( return (
<div class="search"> <div class={`search ${displayClass ?? ""}`}>
<div id="search-icon"> <div id="search-icon">
<p>Search</p> <p>Search</p>
<div></div> <div></div>

View File

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

View File

@ -19,8 +19,8 @@ function TableOfContents({ fileData, displayClass }: QuartzComponentProps) {
} }
return ( return (
<div class={`toc ${displayClass}`}> <div class={`toc ${displayClass ?? ""}`}>
<button type="button" id="toc"> <button type="button" id="toc" class={fileData.collapseToc ? "collapsed" : ""}>
<h3>Table of Contents</h3> <h3>Table of Contents</h3>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@ -60,7 +60,7 @@ function LegacyTableOfContents({ fileData }: QuartzComponentProps) {
} }
return ( return (
<details id="toc" open> <details id="toc" open={!fileData.collapseToc}>
<summary> <summary>
<h3>Table of Contents</h3> <h3>Table of Contents</h3>
</summary> </summary>

View File

@ -1,12 +1,12 @@
import { pathToRoot, slugTag } from "../util/path" import { pathToRoot, slugTag } from "../util/path"
import { QuartzComponentConstructor, QuartzComponentProps } from "./types" import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
function TagList({ fileData }: QuartzComponentProps) { function TagList({ fileData, displayClass }: QuartzComponentProps) {
const tags = fileData.frontmatter?.tags const tags = fileData.frontmatter?.tags
const baseDir = pathToRoot(fileData.slug!) const baseDir = pathToRoot(fileData.slug!)
if (tags && tags.length > 0) { if (tags && tags.length > 0) {
return ( return (
<ul class="tags"> <ul class={`tags ${displayClass ?? ""}`}>
{tags.map((tag) => { {tags.map((tag) => {
const display = `#${tag}` const display = `#${tag}`
const linkDest = baseDir + `/tags/${slugTag(tag)}` const linkDest = baseDir + `/tags/${slugTag(tag)}`
@ -32,6 +32,12 @@ TagList.css = `
padding-left: 0; padding-left: 0;
gap: 0.4rem; gap: 0.4rem;
margin: 1rem 0; margin: 1rem 0;
flex-wrap: wrap;
justify-self: end;
}
.section-li > .section > .tags {
justify-content: flex-end;
} }
.tags > li { .tags > li {
@ -41,7 +47,7 @@ TagList.css = `
overflow-wrap: normal; overflow-wrap: normal;
} }
a.tag-link { a.internal.tag-link {
border-radius: 8px; border-radius: 8px;
background-color: var(--highlight); background-color: var(--highlight);
padding: 0.2rem 0.4rem; padding: 0.2rem 0.4rem;

View File

@ -1,13 +1,15 @@
import ArticleTitle from "./ArticleTitle"
import Content from "./pages/Content" import Content from "./pages/Content"
import TagContent from "./pages/TagContent" import TagContent from "./pages/TagContent"
import FolderContent from "./pages/FolderContent" import FolderContent from "./pages/FolderContent"
import NotFound from "./pages/404"
import ArticleTitle from "./ArticleTitle"
import Darkmode from "./Darkmode" import Darkmode from "./Darkmode"
import Head from "./Head" import Head from "./Head"
import PageTitle from "./PageTitle" import PageTitle from "./PageTitle"
import ContentMeta from "./ContentMeta" import ContentMeta from "./ContentMeta"
import Spacer from "./Spacer" import Spacer from "./Spacer"
import TableOfContents from "./TableOfContents" import TableOfContents from "./TableOfContents"
import Explorer from "./Explorer"
import TagList from "./TagList" import TagList from "./TagList"
import Graph from "./Graph" import Graph from "./Graph"
import Backlinks from "./Backlinks" import Backlinks from "./Backlinks"
@ -16,6 +18,7 @@ import Footer from "./Footer"
import DesktopOnly from "./DesktopOnly" import DesktopOnly from "./DesktopOnly"
import MobileOnly from "./MobileOnly" import MobileOnly from "./MobileOnly"
import RecentNotes from "./RecentNotes" import RecentNotes from "./RecentNotes"
import Breadcrumbs from "./Breadcrumbs"
export { export {
ArticleTitle, ArticleTitle,
@ -28,6 +31,7 @@ export {
ContentMeta, ContentMeta,
Spacer, Spacer,
TableOfContents, TableOfContents,
Explorer,
TagList, TagList,
Graph, Graph,
Backlinks, Backlinks,
@ -36,4 +40,6 @@ export {
DesktopOnly, DesktopOnly,
MobileOnly, MobileOnly,
RecentNotes, 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

@ -1,10 +1,8 @@
import { htmlToJsx } from "../../util/jsx"
import { QuartzComponentConstructor, QuartzComponentProps } from "../types" import { QuartzComponentConstructor, QuartzComponentProps } from "../types"
import { Fragment, jsx, jsxs } from "preact/jsx-runtime"
import { toJsxRuntime } from "hast-util-to-jsx-runtime"
function Content({ tree }: QuartzComponentProps) { function Content({ fileData, tree }: QuartzComponentProps) {
// @ts-ignore (preact makes it angry) const content = htmlToJsx(fileData.filePath!, tree)
const content = toJsxRuntime(tree, { Fragment, jsx, jsxs, elementAttributeNameCase: "html" })
return <article class="popover-hint">{content}</article> return <article class="popover-hint">{content}</article>
} }

View File

@ -1,12 +1,12 @@
import { QuartzComponentConstructor, QuartzComponentProps } from "../types" import { QuartzComponentConstructor, QuartzComponentProps } from "../types"
import { Fragment, jsx, jsxs } from "preact/jsx-runtime"
import { toJsxRuntime } from "hast-util-to-jsx-runtime"
import path from "path" import path from "path"
import style from "../styles/listPage.scss" import style from "../styles/listPage.scss"
import { PageList } from "../PageList" import { PageList } from "../PageList"
import { _stripSlashes, simplifySlug } from "../../util/path" import { _stripSlashes, simplifySlug } from "../../util/path"
import { Root } from "hast" import { Root } from "hast"
import { pluralize } from "../../util/lang"
import { htmlToJsx } from "../../util/jsx"
function FolderContent(props: QuartzComponentProps) { function FolderContent(props: QuartzComponentProps) {
const { tree, fileData, allFiles } = props const { tree, fileData, allFiles } = props
@ -28,15 +28,14 @@ function FolderContent(props: QuartzComponentProps) {
const content = const content =
(tree as Root).children.length === 0 (tree as Root).children.length === 0
? fileData.description ? fileData.description
: // @ts-ignore : htmlToJsx(fileData.filePath!, tree)
toJsxRuntime(tree, { Fragment, jsx, jsxs, elementAttributeNameCase: "html" })
return ( return (
<div class="popover-hint"> <div class="popover-hint">
<article> <article>
<p>{content}</p> <p>{content}</p>
</article> </article>
<p>{allPagesInFolder.length} items under this folder.</p> <p>{pluralize(allPagesInFolder.length, "item")} under this folder.</p>
<div> <div>
<PageList {...listProps} /> <PageList {...listProps} />
</div> </div>

View File

@ -1,11 +1,11 @@
import { QuartzComponentConstructor, QuartzComponentProps } from "../types" import { QuartzComponentConstructor, QuartzComponentProps } from "../types"
import { Fragment, jsx, jsxs } from "preact/jsx-runtime"
import { toJsxRuntime } from "hast-util-to-jsx-runtime"
import style from "../styles/listPage.scss" import style from "../styles/listPage.scss"
import { PageList } from "../PageList" import { PageList } from "../PageList"
import { FullSlug, getAllSegmentPrefixes, simplifySlug } from "../../util/path" import { FullSlug, getAllSegmentPrefixes, simplifySlug } from "../../util/path"
import { QuartzPluginData } from "../../plugins/vfile" import { QuartzPluginData } from "../../plugins/vfile"
import { Root } from "hast" import { Root } from "hast"
import { pluralize } from "../../util/lang"
import { htmlToJsx } from "../../util/jsx"
const numPages = 10 const numPages = 10
function TagContent(props: QuartzComponentProps) { function TagContent(props: QuartzComponentProps) {
@ -25,8 +25,7 @@ function TagContent(props: QuartzComponentProps) {
const content = const content =
(tree as Root).children.length === 0 (tree as Root).children.length === 0
? fileData.description ? fileData.description
: // @ts-ignore : htmlToJsx(fileData.filePath!, tree)
toJsxRuntime(tree, { Fragment, jsx, jsxs, elementAttributeNameCase: "html" })
if (tag === "") { if (tag === "") {
const tags = [...new Set(allFiles.flatMap((data) => data.frontmatter?.tags ?? []))] const tags = [...new Set(allFiles.flatMap((data) => data.frontmatter?.tags ?? []))]
@ -54,13 +53,13 @@ function TagContent(props: QuartzComponentProps) {
return ( return (
<div> <div>
<h2> <h2>
<a class="internal tag-link" href={`./${tag}`}> <a class="internal tag-link" href={`../tags/${tag}`}>
#{tag} #{tag}
</a> </a>
</h2> </h2>
{content && <p>{content}</p>} {content && <p>{content}</p>}
<p> <p>
{pages.length} items with this tag.{" "} {pluralize(pages.length, "item")} with this tag.{" "}
{pages.length > numPages && `Showing first ${numPages}.`} {pages.length > numPages && `Showing first ${numPages}.`}
</p> </p>
<PageList limit={numPages} {...listProps} /> <PageList limit={numPages} {...listProps} />
@ -80,7 +79,7 @@ function TagContent(props: QuartzComponentProps) {
return ( return (
<div class="popover-hint"> <div class="popover-hint">
<article>{content}</article> <article>{content}</article>
<p>{pages.length} items with this tag.</p> <p>{pluralize(pages.length, "item")} with this tag.</p>
<div> <div>
<PageList {...listProps} /> <PageList {...listProps} />
</div> </div>

View File

@ -3,7 +3,9 @@ import { QuartzComponent, QuartzComponentProps } from "./types"
import HeaderConstructor from "./Header" import HeaderConstructor from "./Header"
import BodyConstructor from "./Body" import BodyConstructor from "./Body"
import { JSResourceToScriptElement, StaticResources } from "../util/resources" import { JSResourceToScriptElement, StaticResources } from "../util/resources"
import { FullSlug, joinSegments, pathToRoot } from "../util/path" import { FullSlug, RelativeURL, joinSegments } from "../util/path"
import { visit } from "unist-util-visit"
import { Root, Element, ElementContent } from "hast"
interface RenderComponents { interface RenderComponents {
head: QuartzComponent head: QuartzComponent
@ -15,9 +17,10 @@ interface RenderComponents {
footer: QuartzComponent footer: QuartzComponent
} }
export function pageResources(slug: FullSlug, staticResources: StaticResources): StaticResources { export function pageResources(
const baseDir = pathToRoot(slug) baseDir: FullSlug | RelativeURL,
staticResources: StaticResources,
): StaticResources {
const contentIndexPath = joinSegments(baseDir, "static/contentIndex.json") const contentIndexPath = joinSegments(baseDir, "static/contentIndex.json")
const contentIndexScript = `const fetchData = fetch(\`${contentIndexPath}\`).then(data => data.json())` const contentIndexScript = `const fetchData = fetch(\`${contentIndexPath}\`).then(data => data.json())`
@ -52,6 +55,99 @@ export function renderPage(
components: RenderComponents, components: RenderComponents,
pageResources: StaticResources, pageResources: StaticResources,
): string { ): string {
// process transcludes in componentData
visit(componentData.tree as Root, "element", (node, _index, _parent) => {
if (node.tagName === "blockquote") {
const classNames = (node.properties?.className ?? []) as string[]
if (classNames.includes("transclude")) {
const inner = node.children[0] as Element
const transcludeTarget = inner.properties?.["data-slug"] as FullSlug
// TODO: avoid this expensive find operation and construct an index ahead of time
const page = componentData.allFiles.find((f) => f.slug === transcludeTarget)
if (!page) {
return
}
let blockRef = node.properties?.dataBlock as string | undefined
if (blockRef?.startsWith("^")) {
// block transclude
blockRef = blockRef.slice(1)
let blockNode = page.blocks?.[blockRef]
if (blockNode) {
if (blockNode.tagName === "li") {
blockNode = {
type: "element",
tagName: "ul",
children: [blockNode],
}
}
node.children = [
blockNode,
{
type: "element",
tagName: "a",
properties: { href: inner.properties?.href, class: ["internal"] },
children: [{ type: "text", value: `Link to original` }],
},
]
}
} else if (blockRef?.startsWith("#") && page.htmlAst) {
// header transclude
blockRef = blockRef.slice(1)
let startIdx = undefined
let endIdx = undefined
for (const [i, el] of page.htmlAst.children.entries()) {
if (el.type === "element" && el.tagName.match(/h[1-6]/)) {
if (endIdx) {
break
}
if (startIdx) {
endIdx = i
} else if (el.properties?.id === blockRef) {
startIdx = i
}
}
}
if (!startIdx) {
return
}
node.children = [
...(page.htmlAst.children.slice(startIdx, endIdx) as ElementContent[]),
{
type: "element",
tagName: "a",
properties: { href: inner.properties?.href, class: ["internal"] },
children: [{ type: "text", value: `Link to original` }],
},
]
} else if (page.htmlAst) {
// page transclude
node.children = [
{
type: "element",
tagName: "h1",
children: [
{ type: "text", value: page.frontmatter?.title ?? `Transclude of ${page.slug}` },
],
},
...(page.htmlAst.children as ElementContent[]),
{
type: "element",
tagName: "a",
properties: { href: inner.properties?.href, class: ["internal"] },
children: [{ type: "text", value: `Link to original` }],
},
]
}
}
}
})
const { const {
head: Head, head: Head,
header, header,

View File

@ -20,4 +20,13 @@ document.addEventListener("nav", () => {
if (currentTheme === "dark") { if (currentTheme === "dark") {
toggleSwitch.checked = true 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

@ -42,19 +42,38 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
linkDistance, linkDistance,
fontSize, fontSize,
opacityScale, opacityScale,
removeTags,
showTags,
} = JSON.parse(graph.dataset["cfg"]!) } = JSON.parse(graph.dataset["cfg"]!)
const data = await fetchData const data = await fetchData
const links: LinkData[] = [] 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)) { for (const [src, details] of Object.entries<ContentDetails>(data)) {
const source = simplifySlug(src as FullSlug) const source = simplifySlug(src as FullSlug)
const outgoing = details.links ?? [] const outgoing = details.links ?? []
for (const dest of outgoing) { for (const dest of outgoing) {
if (dest in data) { if (validLinks.has(dest)) {
links.push({ source, target: 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 neighbourhood = new Set<SimpleSlug>()
@ -75,14 +94,18 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
} }
} else { } else {
Object.keys(data).forEach((id) => neighbourhood.add(simplifySlug(id as FullSlug))) 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[] } = { const graphData: { nodes: NodeData[]; links: LinkData[] } = {
nodes: [...neighbourhood].map((url) => ({ nodes: [...neighbourhood].map((url) => {
const text = url.startsWith("tags/") ? "#" + url.substring(5) : data[url]?.title ?? url
return {
id: url, id: url,
text: data[url]?.title ?? url, text: text,
tags: data[url]?.tags ?? [], tags: data[url]?.tags ?? [],
})), }
}),
links: links.filter((l) => neighbourhood.has(l.source) && neighbourhood.has(l.target)), links: links.filter((l) => neighbourhood.has(l.source) && neighbourhood.has(l.target)),
} }
@ -126,7 +149,7 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
const isCurrent = d.id === slug const isCurrent = d.id === slug
if (isCurrent) { if (isCurrent) {
return "var(--secondary)" return "var(--secondary)"
} else if (visited.has(d.id)) { } else if (visited.has(d.id) || d.id.startsWith("tags/")) {
return "var(--tertiary)" return "var(--tertiary)"
} else { } else {
return "var(--gray)" return "var(--gray)"
@ -230,9 +253,7 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
.attr("dx", 0) .attr("dx", 0)
.attr("dy", (d) => -nodeRadius(d) + "px") .attr("dy", (d) => -nodeRadius(d) + "px")
.attr("text-anchor", "middle") .attr("text-anchor", "middle")
.text( .text((d) => d.text)
(d) => data[d.id]?.title || (d.id.charAt(1).toUpperCase() + d.id.slice(2)).replace("-", " "),
)
.style("opacity", (opacityScale - 1) / 3.75) .style("opacity", (opacityScale - 1) / 3.75)
.style("pointer-events", "none") .style("pointer-events", "none")
.style("font-size", fontSize + "em") .style("font-size", fontSize + "em")

View File

@ -28,8 +28,11 @@ async function mouseEnterHandler(
}) })
} }
const hasAlreadyBeenFetched = () =>
[...link.children].some((child) => child.classList.contains("popover"))
// dont refetch if there's already a popover // dont refetch if there's already a popover
if ([...link.children].some((child) => child.classList.contains("popover"))) { if (hasAlreadyBeenFetched()) {
return setPosition(link.lastChild as HTMLElement) return setPosition(link.lastChild as HTMLElement)
} }
@ -49,6 +52,11 @@ async function mouseEnterHandler(
console.error(err) console.error(err)
}) })
// bailout if another popover exists
if (hasAlreadyBeenFetched()) {
return
}
if (!contents) return if (!contents) return
const html = p.parseFromString(contents, "text/html") const html = p.parseFromString(contents, "text/html")
normalizeRelativeURLs(html, targetUrl) normalizeRelativeURLs(html, targetUrl)

View File

@ -1,4 +1,4 @@
import { Document } from "flexsearch" import { Document, SimpleDocumentSearchResultSetUnit } from "flexsearch"
import { ContentDetails } from "../../plugins/emitters/contentIndex" import { ContentDetails } from "../../plugins/emitters/contentIndex"
import { registerEscapeHandler, removeAllChildren } from "./util" import { registerEscapeHandler, removeAllChildren } from "./util"
import { FullSlug, resolveRelative } from "../../util/path" import { FullSlug, resolveRelative } from "../../util/path"
@ -8,12 +8,20 @@ interface Item {
slug: FullSlug slug: FullSlug
title: string title: string
content: string content: string
tags: string[]
} }
let index: Document<Item> | undefined = undefined 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 contextWindowWords = 30
const numSearchResults = 5 const numSearchResults = 5
const numTagResults = 3
function highlight(searchTerm: string, text: string, trim?: boolean) { function highlight(searchTerm: string, text: string, trim?: boolean) {
// try to highlight longest tokens first // try to highlight longest tokens first
const tokenizedTerms = searchTerm const tokenizedTerms = searchTerm
@ -74,6 +82,7 @@ document.addEventListener("nav", async (e: unknown) => {
const searchIcon = document.getElementById("search-icon") const searchIcon = document.getElementById("search-icon")
const searchBar = document.getElementById("search-bar") as HTMLInputElement | null const searchBar = document.getElementById("search-bar") as HTMLInputElement | null
const results = document.getElementById("results-container") const results = document.getElementById("results-container")
const resultCards = document.getElementsByClassName("result-card")
const idDataMap = Object.keys(data) as FullSlug[] const idDataMap = Object.keys(data) as FullSlug[]
function hideSearch() { function hideSearch() {
@ -87,9 +96,12 @@ document.addEventListener("nav", async (e: unknown) => {
if (results) { if (results) {
removeAllChildren(results) removeAllChildren(results)
} }
searchType = "basic" // reset search type after closing
} }
function showSearch() { function showSearch(searchTypeNew: SearchType) {
searchType = searchTypeNew
if (sidebar) { if (sidebar) {
sidebar.style.zIndex = "1" sidebar.style.zIndex = "1"
} }
@ -98,36 +110,123 @@ document.addEventListener("nav", async (e: unknown) => {
} }
function shortcutHandler(e: HTMLElementEventMap["keydown"]) { function shortcutHandler(e: HTMLElementEventMap["keydown"]) {
if (e.key === "k" && (e.ctrlKey || e.metaKey)) { if (e.key === "k" && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
e.preventDefault() e.preventDefault()
const searchBarOpen = container?.classList.contains("active") const searchBarOpen = container?.classList.contains("active")
searchBarOpen ? hideSearch() : showSearch() searchBarOpen ? hideSearch() : showSearch("basic")
} else if (e.shiftKey && (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
// Hotkey to open tag search
e.preventDefault()
const searchBarOpen = container?.classList.contains("active")
searchBarOpen ? hideSearch() : showSearch("tags")
// add "#" prefix for tag search
if (searchBar) searchBar.value = "#"
} else if (e.key === "Enter") { } 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 const anchor = document.getElementsByClassName("result-card")[0] as HTMLInputElement | null
if (anchor) { anchor?.click()
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 formatForDisplay = (term: string, id: number) => {
const slug = idDataMap[id] const slug = idDataMap[id]
return { return {
id, id,
slug, slug,
title: highlight(term, data[slug].title ?? ""), title: searchType === "tags" ? data[slug].title : highlight(term, data[slug].title ?? ""),
content: highlight(term, data[slug].content ?? "", true), // if searchType is tag, display context from start of file and trim, otherwise use regular highlight
content:
searchType === "tags"
? trimContent(data[slug].content)
: highlight(term, data[slug].content ?? "", true),
tags: highlightTags(term, data[slug].tags),
} }
} }
const resultToHTML = ({ slug, title, content }: Item) => { function highlightTags(term: string, tags: string[]) {
if (tags && searchType === "tags") {
// Find matching tags
const termLower = term.toLowerCase()
let matching = tags.filter((str) => str.includes(termLower))
// Substract matching from original tags, then push difference
if (matching.length > 0) {
let difference = tags.filter((x) => !matching.includes(x))
// Convert to html (cant be done later as matches/term dont get passed to `resultToHTML`)
matching = matching.map((tag) => `<li><p class="match-tag">#${tag}</p></li>`)
difference = difference.map((tag) => `<li><p>#${tag}</p></li>`)
matching.push(...difference)
}
// Only allow max of `numTagResults` in preview
if (tags.length > numTagResults) {
matching.splice(numTagResults)
}
return matching
} else {
return []
}
}
const resultToHTML = ({ slug, title, content, tags }: Item) => {
const htmlTags = tags.length > 0 ? `<ul>${tags.join("")}</ul>` : ``
const button = document.createElement("button") const button = document.createElement("button")
button.classList.add("result-card") button.classList.add("result-card")
button.id = slug button.id = slug
button.innerHTML = `<h3>${title}</h3><p>${content}</p>` button.innerHTML = `<h3>${title}</h3>${htmlTags}<p>${content}</p>`
button.addEventListener("click", () => { button.addEventListener("click", () => {
const targ = resolveRelative(currentSlug, slug) const targ = resolveRelative(currentSlug, slug)
window.spaNavigate(new URL(targ, window.location.toString())) window.spaNavigate(new URL(targ, window.location.toString()))
hideSearch()
}) })
return button return button
} }
@ -147,15 +246,45 @@ document.addEventListener("nav", async (e: unknown) => {
} }
async function onType(e: HTMLElementEventMap["input"]) { async function onType(e: HTMLElementEventMap["input"]) {
const term = (e.target as HTMLInputElement).value let term = (e.target as HTMLInputElement).value
const searchResults = (await index?.searchAsync(term, numSearchResults)) ?? [] 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 getByField = (field: string): number[] => {
const results = searchResults.filter((x) => x.field === field) const results = searchResults.filter((x) => x.field === field)
return results.length === 0 ? [] : ([...results[0].result] as number[]) return results.length === 0 ? [] : ([...results[0].result] as number[])
} }
// order titles ahead of content // order titles ahead of content
const allIds: Set<number> = new Set([...getByField("title"), ...getByField("content")]) const allIds: Set<number> = new Set([
...getByField("title"),
...getByField("content"),
...getByField("tags"),
])
const finalResults = [...allIds].map((id) => formatForDisplay(term, id)) const finalResults = [...allIds].map((id) => formatForDisplay(term, id))
displayResults(finalResults) displayResults(finalResults)
} }
@ -166,15 +295,14 @@ document.addEventListener("nav", async (e: unknown) => {
document.addEventListener("keydown", shortcutHandler) document.addEventListener("keydown", shortcutHandler)
prevShortcutHandler = shortcutHandler prevShortcutHandler = shortcutHandler
searchIcon?.removeEventListener("click", showSearch) searchIcon?.removeEventListener("click", () => showSearch("basic"))
searchIcon?.addEventListener("click", showSearch) searchIcon?.addEventListener("click", () => showSearch("basic"))
searchBar?.removeEventListener("input", onType) searchBar?.removeEventListener("input", onType)
searchBar?.addEventListener("input", onType) searchBar?.addEventListener("input", onType)
// setup index if it hasn't been already // setup index if it hasn't been already
if (!index) { if (!index) {
index = new Document({ index = new Document({
cache: true,
charset: "latin:extra", charset: "latin:extra",
optimize: true, optimize: true,
encode: encoder, encode: encoder,
@ -189,10 +317,27 @@ document.addEventListener("nav", async (e: unknown) => {
field: "content", field: "content",
tokenize: "reverse", 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 let id = 0
for (const [slug, fileData] of Object.entries<ContentDetails>(data)) { for (const [slug, fileData] of Object.entries<ContentDetails>(data)) {
await index.addAsync(id, { await index.addAsync(id, {
@ -200,11 +345,8 @@ document.addEventListener("nav", async (e: unknown) => {
slug: slug as FullSlug, slug: slug as FullSlug,
title: fileData.title, title: fileData.title,
content: fileData.content, content: fileData.content,
tags: fileData.tags,
}) })
id++ id++
} }
} }
// register handlers
registerEscapeHandler(container, hideSearch)
})

View File

@ -1,5 +1,6 @@
import micromorph from "micromorph" import micromorph from "micromorph"
import { FullSlug, RelativeURL, getFullSlug } from "../../util/path" import { FullSlug, RelativeURL, getFullSlug } from "../../util/path"
import { normalizeRelativeURLs } from "./popover.inline"
// adapted from `micromorph` // adapted from `micromorph`
// https://github.com/natemoo-re/micromorph // https://github.com/natemoo-re/micromorph
@ -18,8 +19,15 @@ const isLocalUrl = (href: string) => {
return false return false
} }
const isSamePage = (url: URL): boolean => {
const sameOrigin = url.origin === window.location.origin
const samePath = url.pathname === window.location.pathname
return sameOrigin && samePath
}
const getOpts = ({ target }: Event): { url: URL; scroll?: boolean } | undefined => { const getOpts = ({ target }: Event): { url: URL; scroll?: boolean } | undefined => {
if (!isElement(target)) return if (!isElement(target)) return
if (target.attributes.getNamedItem("target")?.value === "_blank") return
const a = target.closest("a") const a = target.closest("a")
if (!a) return if (!a) return
if ("routerIgnore" in a.dataset) return if ("routerIgnore" in a.dataset) return
@ -45,6 +53,8 @@ async function navigate(url: URL, isBack: boolean = false) {
if (!contents) return if (!contents) return
const html = p.parseFromString(contents, "text/html") const html = p.parseFromString(contents, "text/html")
normalizeRelativeURLs(html, url)
let title = html.querySelector("title")?.textContent let title = html.querySelector("title")?.textContent
if (title) { if (title) {
document.title = title document.title = title
@ -92,8 +102,16 @@ function createRouter() {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
window.addEventListener("click", async (event) => { window.addEventListener("click", async (event) => {
const { url } = getOpts(event) ?? {} const { url } = getOpts(event) ?? {}
if (!url) return // dont hijack behaviour, just let browser act normally
if (!url || event.ctrlKey || event.metaKey) return
event.preventDefault() event.preventDefault()
if (isSamePage(url) && url.hash) {
const el = document.getElementById(decodeURIComponent(url.hash.substring(1)))
el?.scrollIntoView()
return
}
try { try {
navigate(url, false) navigate(url, false)
} catch (e) { } catch (e) {

View File

@ -24,8 +24,9 @@ function toggleToc(this: HTMLElement) {
function setupToc() { function setupToc() {
const toc = document.getElementById("toc") const toc = document.getElementById("toc")
if (toc) { if (toc) {
const collapsed = toc.classList.contains("collapsed")
const content = toc.nextElementSibling as HTMLElement const content = toc.nextElementSibling as HTMLElement
content.style.maxHeight = content.scrollHeight + "px" content.style.maxHeight = collapsed ? "0px" : content.scrollHeight + "px"
toc.removeEventListener("click", toggleToc) toc.removeEventListener("click", toggleToc)
toc.addEventListener("click", toggleToc) toc.addEventListener("click", toggleToc)
} }

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

@ -10,7 +10,6 @@
background-color: var(--light); background-color: var(--light);
border: 1px solid; border: 1px solid;
border-radius: 5px; border-radius: 5px;
z-index: 1;
opacity: 0; opacity: 0;
transition: 0.2s; transition: 0.2s;

View File

@ -21,6 +21,14 @@
} }
} }
:root[saved-theme="dark"] {
color-scheme: dark;
}
:root[saved-theme="light"] {
color-scheme: light;
}
:root[saved-theme="dark"] .toggle ~ label { :root[saved-theme="dark"] .toggle ~ label {
& > #dayIcon { & > #dayIcon {
opacity: 0; 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

@ -19,11 +19,6 @@ li.section-li {
} }
} }
& > .tags {
justify-self: end;
margin-left: 1rem;
}
& > .desc > h3 > a { & > .desc > h3 > a {
background-color: transparent; background-color: transparent;
} }

View File

@ -130,6 +130,44 @@
margin: 0; 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 { & > p {
margin-bottom: 0; margin-bottom: 0;
} }

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

@ -1,4 +1,4 @@
import { FilePath, FullSlug, resolveRelative, simplifySlug } from "../../util/path" import { FilePath, FullSlug, joinSegments, resolveRelative, simplifySlug } from "../../util/path"
import { QuartzEmitterPlugin } from "../types" import { QuartzEmitterPlugin } from "../types"
import path from "path" import path from "path"
@ -12,15 +12,25 @@ export const AliasRedirects: QuartzEmitterPlugin = () => ({
for (const [_tree, file] of content) { for (const [_tree, file] of content) {
const ogSlug = simplifySlug(file.data.slug!) const ogSlug = simplifySlug(file.data.slug!)
const dir = path.posix.relative(argv.directory, file.dirname ?? argv.directory) const dir = path.posix.relative(argv.directory, path.dirname(file.data.filePath!))
let aliases: FullSlug[] = file.data.frontmatter?.aliases ?? file.data.frontmatter?.alias ?? [] let aliases: FullSlug[] = file.data.frontmatter?.aliases ?? file.data.frontmatter?.alias ?? []
if (typeof aliases === "string") { if (typeof aliases === "string") {
aliases = [aliases] aliases = [aliases]
} }
for (const alias of aliases) { const slugs: FullSlug[] = aliases.map((alias) => path.posix.join(dir, alias) as FullSlug)
const slug = path.posix.join(dir, alias) as FullSlug const permalink = file.data.frontmatter?.permalink
if (typeof permalink === "string") {
slugs.push(permalink as FullSlug)
}
for (let slug of slugs) {
// fix any slugs that have trailing slash
if (slug.endsWith("/")) {
slug = joinSegments(slug, "index") as FullSlug
}
const redirUrl = resolveRelative(slug, file.data.slug!) const redirUrl = resolveRelative(slug, file.data.slug!)
const fp = await emit({ const fp = await emit({
content: ` content: `

View File

@ -7,7 +7,7 @@ import spaRouterScript from "../../components/scripts/spa.inline"
import plausibleScript from "../../components/scripts/plausible.inline" import plausibleScript from "../../components/scripts/plausible.inline"
// @ts-ignore // @ts-ignore
import popoverScript from "../../components/scripts/popover.inline" import popoverScript from "../../components/scripts/popover.inline"
import styles from "../../styles/base.scss" import styles from "../../styles/custom.scss"
import popoverStyle from "../../components/styles/popover.scss" import popoverStyle from "../../components/styles/popover.scss"
import { BuildCtx } from "../../util/ctx" import { BuildCtx } from "../../util/ctx"
import { StaticResources } from "../../util/resources" import { StaticResources } from "../../util/resources"
@ -96,6 +96,15 @@ function addGlobalPageResources(
});`) });`)
} else if (cfg.analytics?.provider === "plausible") { } else if (cfg.analytics?.provider === "plausible") {
componentResources.afterDOMLoaded.push(plausibleScript) 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) { if (cfg.enableSPA) {
@ -107,12 +116,18 @@ function addGlobalPageResources(
document.dispatchEvent(event)`) document.dispatchEvent(event)`)
} }
let wsUrl = `ws://localhost:${ctx.argv.wsPort}`
if (ctx.argv.remoteDevHost) {
wsUrl = `wss://${ctx.argv.remoteDevHost}:${ctx.argv.wsPort}`
}
if (reloadScript) { if (reloadScript) {
staticResources.js.push({ staticResources.js.push({
loadTime: "afterDOMReady", loadTime: "afterDOMReady",
contentType: "inline", contentType: "inline",
script: ` script: `
const socket = new WebSocket('ws://localhost:3001') const socket = new WebSocket('${wsUrl}')
socket.addEventListener('message', () => document.location.reload()) socket.addEventListener('message', () => document.location.reload())
`, `,
}) })
@ -149,7 +164,7 @@ export const ComponentResources: QuartzEmitterPlugin<Options> = (opts?: Partial<
addGlobalPageResources(ctx, resources, componentResources) addGlobalPageResources(ctx, resources, componentResources)
const stylesheet = joinStyles(ctx.cfg.configuration.theme, styles, ...componentResources.css) const stylesheet = joinStyles(ctx.cfg.configuration.theme, ...componentResources.css, styles)
const prescript = joinScripts(componentResources.beforeDOMLoaded) const prescript = joinScripts(componentResources.beforeDOMLoaded)
const postscript = joinScripts(componentResources.afterDOMLoaded) const postscript = joinScripts(componentResources.afterDOMLoaded)
const fps = await Promise.all([ const fps = await Promise.all([

View File

@ -1,7 +1,10 @@
import { Root } from "hast"
import { GlobalConfiguration } from "../../cfg" import { GlobalConfiguration } from "../../cfg"
import { getDate } from "../../components/Date" import { getDate } from "../../components/Date"
import { escapeHTML } from "../../util/escape"
import { FilePath, FullSlug, SimpleSlug, simplifySlug } from "../../util/path" import { FilePath, FullSlug, SimpleSlug, simplifySlug } from "../../util/path"
import { QuartzEmitterPlugin } from "../types" import { QuartzEmitterPlugin } from "../types"
import { toHtml } from "hast-util-to-html"
import path from "path" import path from "path"
export type ContentIndex = Map<FullSlug, ContentDetails> export type ContentIndex = Map<FullSlug, ContentDetails>
@ -10,6 +13,7 @@ export type ContentDetails = {
links: SimpleSlug[] links: SimpleSlug[]
tags: string[] tags: string[]
content: string content: string
richContent?: string
date?: Date date?: Date
description?: string description?: string
} }
@ -17,19 +21,23 @@ export type ContentDetails = {
interface Options { interface Options {
enableSiteMap: boolean enableSiteMap: boolean
enableRSS: boolean enableRSS: boolean
rssLimit?: number
rssFullHtml: boolean
includeEmptyFiles: boolean includeEmptyFiles: boolean
} }
const defaultOptions: Options = { const defaultOptions: Options = {
enableSiteMap: true, enableSiteMap: true,
enableRSS: true, enableRSS: true,
rssLimit: 10,
rssFullHtml: false,
includeEmptyFiles: true, includeEmptyFiles: true,
} }
function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string { function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string {
const base = cfg.baseUrl ?? "" const base = cfg.baseUrl ?? ""
const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<url> const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<url>
<loc>https://${base}/${slug}</loc> <loc>https://${base}/${encodeURI(slug)}</loc>
<lastmod>${content.date?.toISOString()}</lastmod> <lastmod>${content.date?.toISOString()}</lastmod>
</url>` </url>`
const urls = Array.from(idx) const urls = Array.from(idx)
@ -38,27 +46,42 @@ function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string {
return `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">${urls}</urlset>` return `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">${urls}</urlset>`
} }
function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex): string { function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, limit?: number): string {
const base = cfg.baseUrl ?? "" const base = cfg.baseUrl ?? ""
const root = `https://${base}` const root = `https://${base}`
const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<item> const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<item>
<title>${content.title}</title> <title>${escapeHTML(content.title)}</title>
<link>${root}/${slug}</link> <link>${root}/${encodeURI(slug)}</link>
<guid>${root}/${slug}</guid> <guid>${root}/${encodeURI(slug)}</guid>
<description>${content.description}</description> <description>${content.richContent ?? content.description}</description>
<pubDate>${content.date?.toUTCString()}</pubDate> <pubDate>${content.date?.toUTCString()}</pubDate>
</item>` </item>`
const items = Array.from(idx) const items = Array.from(idx)
.sort(([_, f1], [__, f2]) => {
if (f1.date && f2.date) {
return f2.date.getTime() - f1.date.getTime()
} else if (f1.date && !f2.date) {
return -1
} else if (!f1.date && f2.date) {
return 1
}
return f1.title.localeCompare(f2.title)
})
.map(([slug, content]) => createURLEntry(simplifySlug(slug), content)) .map(([slug, content]) => createURLEntry(simplifySlug(slug), content))
.slice(0, limit ?? idx.size)
.join("") .join("")
return `<?xml version="1.0" encoding="UTF-8" ?> return `<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0"> <rss version="2.0">
<channel> <channel>
<title>${cfg.pageTitle}</title> <title>${escapeHTML(cfg.pageTitle)}</title>
<link>${root}</link> <link>${root}</link>
<description>Recent content on ${cfg.pageTitle}</description> <description>${!!limit ? `Last ${limit} notes` : "Recent notes"} on ${escapeHTML(
cfg.pageTitle,
)}</description>
<generator>Quartz -- quartz.jzhao.xyz</generator> <generator>Quartz -- quartz.jzhao.xyz</generator>
${items} ${items}
</channel> </channel>
@ -73,7 +96,7 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
const cfg = ctx.cfg.configuration const cfg = ctx.cfg.configuration
const emitted: FilePath[] = [] const emitted: FilePath[] = []
const linkIndex: ContentIndex = new Map() const linkIndex: ContentIndex = new Map()
for (const [_tree, file] of content) { for (const [tree, file] of content) {
const slug = file.data.slug! const slug = file.data.slug!
const date = getDate(ctx.cfg.configuration, file.data) ?? new Date() const date = getDate(ctx.cfg.configuration, file.data) ?? new Date()
if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) { if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) {
@ -82,6 +105,9 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
links: file.data.links ?? [], links: file.data.links ?? [],
tags: file.data.frontmatter?.tags ?? [], tags: file.data.frontmatter?.tags ?? [],
content: file.data.text ?? "", content: file.data.text ?? "",
richContent: opts?.rssFullHtml
? escapeHTML(toHtml(tree as Root, { allowDangerousHtml: true }))
: undefined,
date: date, date: date,
description: file.data.description ?? "", description: file.data.description ?? "",
}) })
@ -101,7 +127,7 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
if (opts?.enableRSS) { if (opts?.enableRSS) {
emitted.push( emitted.push(
await emit({ await emit({
content: generateRSSFeed(cfg, linkIndex), content: generateRSSFeed(cfg, linkIndex, opts.rssLimit),
slug: "index" as FullSlug, slug: "index" as FullSlug,
ext: ".xml", ext: ".xml",
}), }),

View File

@ -4,9 +4,10 @@ import HeaderConstructor from "../../components/Header"
import BodyConstructor from "../../components/Body" import BodyConstructor from "../../components/Body"
import { pageResources, renderPage } from "../../components/renderPage" import { pageResources, renderPage } from "../../components/renderPage"
import { FullPageLayout } from "../../cfg" import { FullPageLayout } from "../../cfg"
import { FilePath } from "../../util/path" import { FilePath, pathToRoot } from "../../util/path"
import { defaultContentPageLayout, sharedPageComponents } from "../../../quartz.layout" import { defaultContentPageLayout, sharedPageComponents } from "../../../quartz.layout"
import { Content } from "../../components" import { Content } from "../../components"
import chalk from "chalk"
export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpts) => { export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpts) => {
const opts: FullPageLayout = { const opts: FullPageLayout = {
@ -29,9 +30,15 @@ export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOp
const cfg = ctx.cfg.configuration const cfg = ctx.cfg.configuration
const fps: FilePath[] = [] const fps: FilePath[] = []
const allFiles = content.map((c) => c[1].data) const allFiles = content.map((c) => c[1].data)
let containsIndex = false
for (const [tree, file] of content) { for (const [tree, file] of content) {
const slug = file.data.slug! const slug = file.data.slug!
const externalResources = pageResources(slug, resources) if (slug === "index") {
containsIndex = true
}
const externalResources = pageResources(pathToRoot(slug), resources)
const componentData: QuartzComponentProps = { const componentData: QuartzComponentProps = {
fileData: file.data, fileData: file.data,
externalResources, externalResources,
@ -50,6 +57,15 @@ export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOp
fps.push(fp) 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 return fps
}, },
} }

View File

@ -12,6 +12,7 @@ import {
SimpleSlug, SimpleSlug,
_stripSlashes, _stripSlashes,
joinSegments, joinSegments,
pathToRoot,
simplifySlug, simplifySlug,
} from "../../util/path" } from "../../util/path"
import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout" import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
@ -69,7 +70,7 @@ export const FolderPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
for (const folder of folders) { for (const folder of folders) {
const slug = joinSegments(folder, "index") as FullSlug const slug = joinSegments(folder, "index") as FullSlug
const externalResources = pageResources(slug, resources) const externalResources = pageResources(pathToRoot(slug), resources)
const [tree, file] = folderDescriptions[folder] const [tree, file] = folderDescriptions[folder]
const componentData: QuartzComponentProps = { const componentData: QuartzComponentProps = {
fileData: file.data, fileData: file.data,

View File

@ -6,3 +6,4 @@ export { AliasRedirects } from "./aliases"
export { Assets } from "./assets" export { Assets } from "./assets"
export { Static } from "./static" export { Static } from "./static"
export { ComponentResources } from "./componentResources" export { ComponentResources } from "./componentResources"
export { NotFoundPage } from "./404"

View File

@ -5,7 +5,13 @@ import BodyConstructor from "../../components/Body"
import { pageResources, renderPage } from "../../components/renderPage" import { pageResources, renderPage } from "../../components/renderPage"
import { ProcessedContent, defaultProcessedContent } from "../vfile" import { ProcessedContent, defaultProcessedContent } from "../vfile"
import { FullPageLayout } from "../../cfg" import { FullPageLayout } from "../../cfg"
import { FilePath, FullSlug, getAllSegmentPrefixes, joinSegments } from "../../util/path" import {
FilePath,
FullSlug,
getAllSegmentPrefixes,
joinSegments,
pathToRoot,
} from "../../util/path"
import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout" import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
import { TagContent } from "../../components" import { TagContent } from "../../components"
@ -62,7 +68,7 @@ export const TagPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
for (const tag of tags) { for (const tag of tags) {
const slug = joinSegments("tags", tag) as FullSlug const slug = joinSegments("tags", tag) as FullSlug
const externalResources = pageResources(slug, resources) const externalResources = pageResources(pathToRoot(slug), resources)
const [tree, file] = tagDescriptions[tag] const [tree, file] = tagDescriptions[tag]
const componentData: QuartzComponentProps = { const componentData: QuartzComponentProps = {
fileData: file.data, fileData: file.data,

View File

@ -1,6 +1,7 @@
import { Root as HTMLRoot } from "hast" import { Root as HTMLRoot } from "hast"
import { toString } from "hast-util-to-string" import { toString } from "hast-util-to-string"
import { QuartzTransformerPlugin } from "../types" import { QuartzTransformerPlugin } from "../types"
import { escapeHTML } from "../../util/escape"
export interface Options { export interface Options {
descriptionLength: number descriptionLength: number
@ -10,15 +11,6 @@ const defaultOptions: Options = {
descriptionLength: 150, descriptionLength: 150,
} }
const escapeHTML = (unsafe: string) => {
return unsafe
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;")
}
export const Description: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => { export const Description: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
const opts = { ...defaultOptions, ...userOpts } const opts = { ...defaultOptions, ...userOpts }
return { return {

View File

@ -2,14 +2,17 @@ import matter from "gray-matter"
import remarkFrontmatter from "remark-frontmatter" import remarkFrontmatter from "remark-frontmatter"
import { QuartzTransformerPlugin } from "../types" import { QuartzTransformerPlugin } from "../types"
import yaml from "js-yaml" import yaml from "js-yaml"
import toml from "toml"
import { slugTag } from "../../util/path" import { slugTag } from "../../util/path"
export interface Options { export interface Options {
delims: string | string[] delims: string | string[]
language: "yaml" | "toml"
} }
const defaultOptions: Options = { const defaultOptions: Options = {
delims: "---", delims: "---",
language: "yaml",
} }
export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => { export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
@ -18,13 +21,14 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined>
name: "FrontMatter", name: "FrontMatter",
markdownPlugins() { markdownPlugins() {
return [ return [
remarkFrontmatter, [remarkFrontmatter, ["yaml", "toml"]],
() => { () => {
return (_, file) => { return (_, file) => {
const { data } = matter(file.value, { const { data } = matter(file.value, {
...opts, ...opts,
engines: { engines: {
yaml: (s) => yaml.load(s, { schema: yaml.JSON_SCHEMA }) as object, yaml: (s) => yaml.load(s, { schema: yaml.JSON_SCHEMA }) as object,
toml: (s) => toml.parse(s) as object,
}, },
}) })
@ -33,6 +37,11 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined>
data.tags = data.tag data.tags = data.tag
} }
// coerce title to string
if (data.title) {
data.title = data.title.toString()
}
if (data.tags && !Array.isArray(data.tags)) { if (data.tags && !Array.isArray(data.tags)) {
data.tags = data.tags data.tags = data.tags
.toString() .toString()

View File

@ -5,5 +5,7 @@ export { Latex } from "./latex"
export { Description } from "./description" export { Description } from "./description"
export { CrawlLinks } from "./links" export { CrawlLinks } from "./links"
export { ObsidianFlavoredMarkdown } from "./ofm" export { ObsidianFlavoredMarkdown } from "./ofm"
export { OxHugoFlavouredMarkdown } from "./oxhugofm"
export { SyntaxHighlighting } from "./syntax" export { SyntaxHighlighting } from "./syntax"
export { TableOfContents } from "./toc" export { TableOfContents } from "./toc"
export { HardLineBreaks } from "./linebreaks"

View File

@ -2,6 +2,7 @@ import fs from "fs"
import path from "path" import path from "path"
import { Repository } from "@napi-rs/simple-git" import { Repository } from "@napi-rs/simple-git"
import { QuartzTransformerPlugin } from "../types" import { QuartzTransformerPlugin } from "../types"
import chalk from "chalk"
export interface Options { export interface Options {
priority: ("frontmatter" | "git" | "filesystem")[] priority: ("frontmatter" | "git" | "filesystem")[]
@ -11,6 +12,20 @@ const defaultOptions: Options = {
priority: ["frontmatter", "git", "filesystem"], 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 type MaybeDate = undefined | string | number
export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options> | undefined> = ( export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options> | undefined> = (
userOpts, userOpts,
@ -27,10 +42,11 @@ export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options> | und
let modified: MaybeDate = undefined let modified: MaybeDate = undefined
let published: MaybeDate = undefined let published: MaybeDate = undefined
const fp = path.posix.join(file.cwd, file.data.filePath as string) const fp = file.data.filePath!
const fullFp = path.posix.join(file.cwd, fp)
for (const source of opts.priority) { for (const source of opts.priority) {
if (source === "filesystem") { if (source === "filesystem") {
const st = await fs.promises.stat(fp) const st = await fs.promises.stat(fullFp)
created ||= st.birthtimeMs created ||= st.birthtimeMs
modified ||= st.mtimeMs modified ||= st.mtimeMs
} else if (source === "frontmatter" && file.data.frontmatter) { } else if (source === "frontmatter" && file.data.frontmatter) {
@ -49,9 +65,9 @@ export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options> | und
} }
file.data.dates = { file.data.dates = {
created: created ? new Date(created) : new Date(), created: coerceDate(fp, created),
modified: modified ? new Date(modified) : new Date(), modified: coerceDate(fp, modified),
published: published ? new Date(published) : new Date(), published: coerceDate(fp, published),
} }
} }
}, },

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

@ -5,7 +5,6 @@ import {
SimpleSlug, SimpleSlug,
TransformOptions, TransformOptions,
_stripSlashes, _stripSlashes,
joinSegments,
simplifySlug, simplifySlug,
splitAnchor, splitAnchor,
transformLink, transformLink,
@ -19,11 +18,13 @@ interface Options {
markdownLinkResolution: TransformOptions["strategy"] markdownLinkResolution: TransformOptions["strategy"]
/** Strips folders from a link so that it looks nice */ /** Strips folders from a link so that it looks nice */
prettyLinks: boolean prettyLinks: boolean
openLinksInNewTab: boolean
} }
const defaultOptions: Options = { const defaultOptions: Options = {
markdownLinkResolution: "absolute", markdownLinkResolution: "absolute",
prettyLinks: true, prettyLinks: true,
openLinksInNewTab: false,
} }
export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => { export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
@ -53,8 +54,13 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> =
node.properties.className ??= [] node.properties.className ??= []
node.properties.className.push(isAbsoluteUrl(dest) ? "external" : "internal") node.properties.className.push(isAbsoluteUrl(dest) ? "external" : "internal")
if (opts.openLinksInNewTab) {
node.properties.target = "_blank"
}
// don't process external links or intra-document anchors // don't process external links or intra-document anchors
if (!(isAbsoluteUrl(dest) || dest.startsWith("#"))) { const isInternal = !(isAbsoluteUrl(dest) || dest.startsWith("#"))
if (isInternal) {
dest = node.properties.href = transformLink( dest = node.properties.href = transformLink(
file.data.slug!, file.data.slug!,
dest, dest,
@ -72,11 +78,13 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> =
simplifySlug(destCanonical as FullSlug), simplifySlug(destCanonical as FullSlug),
) as SimpleSlug ) as SimpleSlug
outgoing.add(simple) outgoing.add(simple)
node.properties["data-slug"] = simple
} }
// rewrite link internals if prettylinks is on // rewrite link internals if prettylinks is on
if ( if (
opts.prettyLinks && opts.prettyLinks &&
isInternal &&
node.children.length === 1 && node.children.length === 1 &&
node.children[0].type === "text" && node.children[0].type === "text" &&
!node.children[0].value.startsWith("#") !node.children[0].value.startsWith("#")

View File

@ -1,6 +1,7 @@
import { PluggableList } from "unified" import { PluggableList } from "unified"
import { QuartzTransformerPlugin } from "../types" import { QuartzTransformerPlugin } from "../types"
import { Root, HTML, BlockContent, DefinitionContent, Code, Paragraph } from "mdast" import { Root, HTML, BlockContent, DefinitionContent, Code, Paragraph } from "mdast"
import { Element, Literal, Root as HtmlRoot } from "hast"
import { Replace, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace" import { Replace, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace"
import { slug as slugAnchor } from "github-slugger" import { slug as slugAnchor } from "github-slugger"
import rehypeRaw from "rehype-raw" import rehypeRaw from "rehype-raw"
@ -13,6 +14,7 @@ import { FilePath, pathToRoot, slugTag, slugifyFilePath } from "../../util/path"
import { toHast } from "mdast-util-to-hast" import { toHast } from "mdast-util-to-hast"
import { toHtml } from "hast-util-to-html" import { toHtml } from "hast-util-to-html"
import { PhrasingContent } from "mdast-util-find-and-replace/lib" import { PhrasingContent } from "mdast-util-find-and-replace/lib"
import { capitalize } from "../../util/lang"
export interface Options { export interface Options {
comments: boolean comments: boolean
@ -21,6 +23,7 @@ export interface Options {
callouts: boolean callouts: boolean
mermaid: boolean mermaid: boolean
parseTags: boolean parseTags: boolean
parseBlockReferences: boolean
enableInHtmlEmbed: boolean enableInHtmlEmbed: boolean
} }
@ -31,6 +34,7 @@ const defaultOptions: Options = {
callouts: true, callouts: true,
mermaid: true, mermaid: true,
parseTags: true, parseTags: true,
parseBlockReferences: true,
enableInHtmlEmbed: false, enableInHtmlEmbed: false,
} }
@ -69,6 +73,8 @@ const callouts = {
const calloutMapping: Record<string, keyof typeof callouts> = { const calloutMapping: Record<string, keyof typeof callouts> = {
note: "note", note: "note",
abstract: "abstract", abstract: "abstract",
summary: "abstract",
tldr: "abstract",
info: "info", info: "info",
todo: "todo", todo: "todo",
tip: "tip", tip: "tip",
@ -96,11 +102,7 @@ const calloutMapping: Record<string, keyof typeof callouts> = {
function canonicalizeCallout(calloutName: string): keyof typeof callouts { function canonicalizeCallout(calloutName: string): keyof typeof callouts {
let callout = calloutName.toLowerCase() as keyof typeof calloutMapping let callout = calloutName.toLowerCase() as keyof typeof calloutMapping
return calloutMapping[callout] ?? calloutName return calloutMapping[callout] ?? "note"
}
const capitalize = (s: string): string => {
return s.substring(0, 1).toUpperCase() + s.substring(1)
} }
// !? -> optional embedding // !? -> optional embedding
@ -109,14 +111,17 @@ const capitalize = (s: string): string => {
// (#[^\[\]\|\#]+)? -> # then one or more non-special characters (heading link) // (#[^\[\]\|\#]+)? -> # then one or more non-special characters (heading link)
// (|[^\[\]\|\#]+)? -> | then one or more non-special characters (alias) // (|[^\[\]\|\#]+)? -> | then one or more non-special characters (alias)
const wikilinkRegex = new RegExp(/!?\[\[([^\[\]\|\#]+)?(#[^\[\]\|\#]+)?(\|[^\[\]\|\#]+)?\]\]/, "g") const wikilinkRegex = new RegExp(/!?\[\[([^\[\]\|\#]+)?(#[^\[\]\|\#]+)?(\|[^\[\]\|\#]+)?\]\]/, "g")
const highlightRegex = new RegExp(/==(.+)==/, "g") const highlightRegex = new RegExp(/==([^=]+)==/, "g")
const commentRegex = new RegExp(/%%(.+)%%/, "g") const commentRegex = new RegExp(/%%(.+)%%/, "g")
// from https://github.com/escwxyz/remark-obsidian-callout/blob/main/src/index.ts // from https://github.com/escwxyz/remark-obsidian-callout/blob/main/src/index.ts
const calloutRegex = new RegExp(/^\[\!(\w+)\]([+-]?)/) const calloutRegex = new RegExp(/^\[\!(\w+)\]([+-]?)/)
const calloutLineRegex = new RegExp(/^> *\[\!\w+\][+-]?.*$/, "gm") const calloutLineRegex = new RegExp(/^> *\[\!\w+\][+-]?.*$/, "gm")
// (?:^| ) -> non-capturing group, tag should start be separated by a space or be the start of the line // (?:^| ) -> non-capturing group, tag should start be separated by a space or be the start of the line
// #(\w+) -> tag itself is # followed by a string of alpha-numeric characters // #(...) -> capturing group, tag itself must start with #
const tagRegex = new RegExp(/(?:^| )#(\p{L}+)/, "gu") // (?:[-_\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> = ( export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> | undefined> = (
userOpts, userOpts,
@ -230,8 +235,16 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
value: `<iframe src="${url}"></iframe>`, value: `<iframe src="${url}"></iframe>`,
} }
} else if (ext === "") { } else if (ext === "") {
// TODO: note embed const block = anchor
return {
type: "html",
data: { hProperties: { transclude: true } },
value: `<blockquote class="transclude" data-url="${url}" data-block="${block}"><a href="${
url + anchor
}" class="transclude-inner">Transclude of ${url}${block}</a></blockquote>`,
} }
}
// otherwise, fall through to regular link // otherwise, fall through to regular link
} }
@ -383,13 +396,18 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
return (tree: Root, file) => { return (tree: Root, file) => {
const base = pathToRoot(file.data.slug!) const base = pathToRoot(file.data.slug!)
findAndReplace(tree, tagRegex, (_value: string, tag: string) => { 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)) { if (file.data.frontmatter && !file.data.frontmatter.tags.includes(tag)) {
file.data.frontmatter.tags.push(tag) file.data.frontmatter.tags.push(tag)
} }
return { return {
type: "link", type: "link",
url: base + `/tags/${slugTag(tag)}`, url: base + `/tags/${tag}`,
data: { data: {
hProperties: { hProperties: {
className: ["tag-link"], className: ["tag-link"],
@ -406,11 +424,64 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
} }
}) })
} }
return plugins return plugins
}, },
htmlPlugins() { htmlPlugins() {
return [rehypeRaw] const plugins = [rehypeRaw]
if (opts.parseBlockReferences) {
plugins.push(() => {
const inlineTagTypes = new Set(["p", "li"])
const blockTagTypes = new Set(["blockquote"])
return (tree, file) => {
file.data.blocks = {}
file.data.htmlAst = tree
visit(tree, "element", (node, index, parent) => {
if (blockTagTypes.has(node.tagName)) {
const nextChild = parent?.children.at(index! + 2) as Element
if (nextChild && nextChild.tagName === "p") {
const text = nextChild.children.at(0) as Literal
if (text && text.value && text.type === "text") {
const matches = text.value.match(blockReferenceRegex)
if (matches && matches.length >= 1) {
parent!.children.splice(index! + 2, 1)
const block = matches[0].slice(1)
if (!Object.keys(file.data.blocks!).includes(block)) {
node.properties = {
...node.properties,
id: block,
}
file.data.blocks![block] = node
}
}
}
}
} else if (inlineTagTypes.has(node.tagName)) {
const last = node.children.at(-1) as Literal
if (last && last.value && typeof last.value === "string") {
const matches = last.value.match(blockReferenceRegex)
if (matches && matches.length >= 1) {
last.value = last.value.slice(0, -matches[0].length)
const block = matches[0].slice(1)
if (!Object.keys(file.data.blocks!).includes(block)) {
node.properties = {
...node.properties,
id: block,
}
file.data.blocks![block] = node
}
}
}
}
})
}
})
}
return plugins
}, },
externalResources() { externalResources() {
const js: JSResource[] = [] const js: JSResource[] = []
@ -449,3 +520,10 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
}, },
} }
} }
declare module "vfile" {
interface DataMap {
blocks: Record<string, Element>
htmlAst: HtmlRoot
}
}

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

@ -8,12 +8,14 @@ export interface Options {
maxDepth: 1 | 2 | 3 | 4 | 5 | 6 maxDepth: 1 | 2 | 3 | 4 | 5 | 6
minEntries: 1 minEntries: 1
showByDefault: boolean showByDefault: boolean
collapseByDefault: boolean
} }
const defaultOptions: Options = { const defaultOptions: Options = {
maxDepth: 3, maxDepth: 3,
minEntries: 1, minEntries: 1,
showByDefault: true, showByDefault: true,
collapseByDefault: false,
} }
interface TocEntry { interface TocEntry {
@ -54,6 +56,7 @@ export const TableOfContents: QuartzTransformerPlugin<Partial<Options> | undefin
...entry, ...entry,
depth: entry.depth - highestDepth, depth: entry.depth - highestDepth,
})) }))
file.data.collapseToc = opts.collapseByDefault
} }
} }
} }
@ -66,5 +69,6 @@ export const TableOfContents: QuartzTransformerPlugin<Partial<Options> | undefin
declare module "vfile" { declare module "vfile" {
interface DataMap { interface DataMap {
toc: TocEntry[] toc: TocEntry[]
collapseToc: boolean
} }
} }

View File

@ -1,4 +1,3 @@
@use "./custom.scss";
@use "./syntax.scss"; @use "./syntax.scss";
@use "./callouts.scss"; @use "./callouts.scss";
@use "./variables.scss" as *; @use "./variables.scss" as *;
@ -65,7 +64,7 @@ a {
color: var(--tertiary) !important; color: var(--tertiary) !important;
} }
&.internal { &.internal:not(:has(> img)) {
text-decoration: none; text-decoration: none;
background-color: var(--highlight); background-color: var(--highlight);
padding: 0 0.1rem; padding: 0 0.1rem;
@ -95,6 +94,8 @@ a {
} }
& article { & article {
position: relative;
& > h1 { & > h1 {
font-size: 2rem; font-size: 2rem;
} }
@ -389,23 +390,33 @@ p {
line-height: 1.6rem; line-height: 1.6rem;
} }
table { .table-container {
overflow-x: auto;
& > table {
margin: 1rem; margin: 1rem;
padding: 1.5rem; padding: 1.5rem;
border-collapse: collapse; border-collapse: collapse;
th,
td {
min-width: 75px;
}
& > * { & > * {
line-height: 2rem; line-height: 2rem;
} }
}
} }
th { th {
text-align: left; text-align: left;
padding: 0.4rem 1rem; padding: 0.4rem 0.7rem;
border-bottom: 2px solid var(--gray); border-bottom: 2px solid var(--gray);
} }
td { td {
padding: 0.2rem 1rem; padding: 0.2rem 0.7rem;
} }
tr { tr {
@ -446,7 +457,7 @@ video {
ul.overflow, ul.overflow,
ol.overflow { ol.overflow {
height: 300px; max-height: 400;
overflow-y: auto; overflow-y: auto;
// clearfix // clearfix
@ -454,7 +465,7 @@ ol.overflow {
clear: both; clear: both;
& > li:last-of-type { & > li:last-of-type {
margin-bottom: 50px; margin-bottom: 30px;
} }
&:after { &:after {
@ -470,3 +481,9 @@ ol.overflow {
background: linear-gradient(transparent 0px, var(--light)); background: linear-gradient(transparent 0px, var(--light));
} }
} }
.transclude {
ul {
padding-left: 1rem;
}
}

View File

@ -82,7 +82,6 @@
.callout-title { .callout-title {
display: flex; display: flex;
align-items: center;
gap: 5px; gap: 5px;
padding: 1rem 0; padding: 1rem 0;
color: var(--color); color: var(--color);
@ -103,6 +102,8 @@
.callout-icon { .callout-icon {
width: 18px; width: 18px;
height: 18px; height: 18px;
flex: 0 0 18px;
padding-top: 4px;
} }
.callout-title-inner { .callout-title-inner {

View File

@ -11,3 +11,6 @@
--million-progress-bar-color: var(--secondary); --million-progress-bar-color: var(--secondary);
--highlighted: #f5dfaf88; --highlighted: #f5dfaf88;
} }
@use "./base.scss";
// put your custom CSS here!

View File

@ -7,6 +7,8 @@ export interface Argv {
output: string output: string
serve: boolean serve: boolean
port: number port: number
wsPort: number
remoteDevHost?: string
concurrency?: number concurrency?: number
} }

8
quartz/util/escape.ts Normal file
View File

@ -0,0 +1,8 @@
export const escapeHTML = (unsafe: string) => {
return unsafe
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;")
}

28
quartz/util/jsx.tsx Normal file
View File

@ -0,0 +1,28 @@
import { Components, Jsx, toJsxRuntime } from "hast-util-to-jsx-runtime"
import { QuartzPluginData } from "../plugins/vfile"
import { Node, Root } from "hast"
import { Fragment, jsx, jsxs } from "preact/jsx-runtime"
import { trace } from "./trace"
import { type FilePath } from "./path"
const customComponents: Components = {
table: (props) => (
<div class="table-container">
<table {...props} />
</div>
),
}
export function htmlToJsx(fp: FilePath, tree: Node<QuartzPluginData>) {
try {
return toJsxRuntime(tree as Root, {
Fragment,
jsx: jsx as Jsx,
jsxs: jsxs as Jsx,
elementAttributeNameCase: "html",
components: customComponents,
})
} catch (e) {
trace(`Failed to parse Markdown in \`${fp}\` into JSX`, e as Error)
}
}

11
quartz/util/lang.ts Normal file
View File

@ -0,0 +1,11 @@
export function pluralize(count: number, s: string): string {
if (count === 1) {
return `1 ${s}`
} else {
return `${count} ${s}s`
}
}
export function capitalize(s: string): string {
return s.substring(0, 1).toUpperCase() + s.substring(1)
}

View File

@ -52,7 +52,7 @@ export function slugifyFilePath(fp: FilePath, excludeExt?: boolean): FullSlug {
let slug = withoutFileExt let slug = withoutFileExt
.split("/") .split("/")
.map((segment) => segment.replace(/\s/g, "-").replace(/%/g, "-percent")) // slugify all segments .map((segment) => segment.replace(/\s/g, "-").replace(/%/g, "-percent").replace(/\?/g, "-q")) // slugify all segments
.join("/") // always use / as sep .join("/") // always use / as sep
.replace(/\/$/, "") // remove trailing slash .replace(/\/$/, "") // remove trailing slash
@ -123,7 +123,10 @@ export function slugTag(tag: string) {
} }
export function joinSegments(...args: string[]): string { export function joinSegments(...args: string[]): string {
return args.filter((segment) => segment !== "").join("/") return args
.filter((segment) => segment !== "")
.join("/")
.replace(/\/\/+/g, "/")
} }
export function getAllSegmentPrefixes(tags: string): string[] { export function getAllSegmentPrefixes(tags: string): string[] {

View File

@ -4,7 +4,7 @@ import { isMainThread } from "workerpool"
const rootFile = /.*at file:/ const rootFile = /.*at file:/
export function trace(msg: string, err: Error) { export function trace(msg: string, err: Error) {
const stack = err.stack let stack = err.stack ?? ""
const lines: string[] = [] const lines: string[] = []
@ -12,15 +12,11 @@ export function trace(msg: string, err: Error) {
lines.push( lines.push(
"\n" + "\n" +
chalk.bgRed.black.bold(" ERROR ") + chalk.bgRed.black.bold(" ERROR ") +
"\n" + "\n\n" +
chalk.red(` ${msg}`) + chalk.red(` ${msg}`) +
(err.message.length > 0 ? `: ${err.message}` : ""), (err.message.length > 0 ? `: ${err.message}` : ""),
) )
if (!stack) {
return
}
let reachedEndOfLegibleTrace = false let reachedEndOfLegibleTrace = false
for (const line of stack.split("\n").slice(1)) { for (const line of stack.split("\n").slice(1)) {
if (reachedEndOfLegibleTrace) { if (reachedEndOfLegibleTrace) {