diff --git a/quartz/build.ts b/quartz/build.ts index d72b8ddf4a934..0c0aba5e3bab2 100644 --- a/quartz/build.ts +++ b/quartz/build.ts @@ -1,24 +1,26 @@ -import sourceMapSupport from "source-map-support" -sourceMapSupport.install(options) -import path from "path" -import { PerfTimer } from "./util/perf" -import { rimraf } from "rimraf" -import { GlobbyFilterFunction, isGitIgnored } from "globby" -import chalk from "chalk" -import { parseMarkdown } from "./processors/parse" -import { filterContent } from "./processors/filter" -import { emitContent } from "./processors/emit" -import cfg from "../quartz.config" -import { FilePath, FullSlug, joinSegments, slugifyFilePath } from "./util/path" -import chokidar from "chokidar" -import { ProcessedContent } from "./plugins/vfile" -import { Argv, BuildCtx } from "./util/ctx" -import { glob, toPosixPath } from "./util/glob" -import { trace } from "./util/trace" -import { options } from "./util/sourcemap" -import { Mutex } from "async-mutex" -import DepGraph from "./depgraph" -import { getStaticResourcesFromPlugins } from "./plugins" +import { Mutex } from "async-mutex"; +import chalk from "chalk"; +import chokidar from "chokidar"; +import { GlobbyFilterFunction, isGitIgnored } from "globby"; +import path from "path"; +import { rimraf } from "rimraf"; +import sourceMapSupport from "source-map-support"; + +import cfg from "../quartz.config"; +import DepGraph from "./depgraph"; +import { getStaticResourcesFromPlugins } from "./plugins"; +import { ProcessedContent } from "./plugins/vfile"; +import { emitContent } from "./processors/emit"; +import { filterContent } from "./processors/filter"; +import { parseMarkdown } from "./processors/parse"; +import { Argv, BuildCtx } from "./util/ctx"; +import { glob, toPosixPath } from "./util/glob"; +import { FilePath, FullSlug, joinSegments, slugifyFilePath } from "./util/path"; +import { PerfTimer } from "./util/perf"; +import { options } from "./util/sourcemap"; +import { trace } from "./util/trace"; + +sourceMapSupport.install(options); type Dependencies = Record | null> @@ -39,371 +41,371 @@ type BuildData = { type FileEvent = "add" | "change" | "delete" async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) { - const ctx: BuildCtx = { - argv, - cfg, - allSlugs: [], - } - - const perf = new PerfTimer() - const output = argv.output - - const pluginCount = Object.values(cfg.plugins).flat().length - const pluginNames = (key: "transformers" | "filters" | "emitters") => - cfg.plugins[key].map((plugin) => plugin.name) - if (argv.verbose) { - console.log(`Loaded ${pluginCount} plugins`) - console.log(` Transformers: ${pluginNames("transformers").join(", ")}`) - console.log(` Filters: ${pluginNames("filters").join(", ")}`) - console.log(` Emitters: ${pluginNames("emitters").join(", ")}`) - } - - const release = await mut.acquire() - perf.addEvent("clean") - await rimraf(path.join(output, "*"), { glob: true }) - console.log(`Cleaned output directory \`${output}\` in ${perf.timeSince("clean")}`) - - perf.addEvent("glob") - const allFiles = await glob("**/*.*", argv.directory, cfg.configuration.ignorePatterns) - const fps = allFiles.filter((fp) => fp.endsWith(".md")).sort() - console.log( - `Found ${fps.length} input files from \`${argv.directory}\` in ${perf.timeSince("glob")}`, - ) - - const filePaths = fps.map((fp) => joinSegments(argv.directory, fp) as FilePath) - ctx.allSlugs = allFiles.map((fp) => slugifyFilePath(fp as FilePath)) - - const parsedFiles = await parseMarkdown(ctx, filePaths) - const filteredContent = filterContent(ctx, parsedFiles) - - const dependencies: Record | null> = {} - - // Only build dependency graphs if we're doing a fast rebuild - if (argv.fastRebuild) { - const staticResources = getStaticResourcesFromPlugins(ctx) - for (const emitter of cfg.plugins.emitters) { - dependencies[emitter.name] = - (await emitter.getDependencyGraph?.(ctx, filteredContent, staticResources)) ?? null - } - } - - await emitContent(ctx, filteredContent) - console.log(chalk.green(`Done processing ${fps.length} files in ${perf.timeSince()}`)) - release() - - if (argv.serve) { - return startServing(ctx, mut, parsedFiles, clientRefresh, dependencies) - } + const ctx: BuildCtx = { + argv, + cfg, + allSlugs: [], + }; + + const perf = new PerfTimer(); + const output = argv.output; + + const pluginCount = Object.values(cfg.plugins).flat().length; + const pluginNames = (key: "transformers" | "filters" | "emitters") => + cfg.plugins[key].map((plugin) => plugin.name); + if (argv.verbose) { + console.log(`Loaded ${pluginCount} plugins`); + console.log(` Transformers: ${pluginNames("transformers").join(", ")}`); + console.log(` Filters: ${pluginNames("filters").join(", ")}`); + console.log(` Emitters: ${pluginNames("emitters").join(", ")}`); + } + + const release = await mut.acquire(); + perf.addEvent("clean"); + await rimraf(path.join(output, "*"), { glob: true }); + console.log(`Cleaned output directory \`${output}\` in ${perf.timeSince("clean")}`); + + perf.addEvent("glob"); + const allFiles = await glob("**/*.*", argv.directory, cfg.configuration.ignorePatterns); + const fps = allFiles.filter((fp) => fp.endsWith(".md")).sort(); + console.log( + `Found ${fps.length} input files from \`${argv.directory}\` in ${perf.timeSince("glob")}`, + ); + + const filePaths = fps.map((fp) => joinSegments(argv.directory, fp) as FilePath); + ctx.allSlugs = allFiles.map((fp) => slugifyFilePath(fp as FilePath)); + + const parsedFiles = await parseMarkdown(ctx, filePaths); + const filteredContent = filterContent(ctx, parsedFiles); + + const dependencies: Record | null> = {}; + + // Only build dependency graphs if we're doing a fast rebuild + if (argv.fastRebuild) { + const staticResources = getStaticResourcesFromPlugins(ctx); + for (const emitter of cfg.plugins.emitters) { + dependencies[emitter.name] = + (await emitter.getDependencyGraph?.(ctx, filteredContent, staticResources)) ?? null; + } + } + + await emitContent(ctx, filteredContent); + console.log(chalk.green(`Done processing ${fps.length} files in ${perf.timeSince()}`)); + release(); + + if (argv.serve) { + return startServing(ctx, mut, parsedFiles, clientRefresh, dependencies); + } } // setup watcher for rebuilds async function startServing( - ctx: BuildCtx, - mut: Mutex, - initialContent: ProcessedContent[], - clientRefresh: () => void, - dependencies: Dependencies, // emitter name: dep graph + ctx: BuildCtx, + mut: Mutex, + initialContent: ProcessedContent[], + clientRefresh: () => void, + dependencies: Dependencies, // emitter name: dep graph ) { - const { argv } = ctx - - // cache file parse results - const contentMap = new Map() - for (const content of initialContent) { - const [_tree, vfile] = content - contentMap.set(vfile.data.filePath!, content) - } - - const buildData: BuildData = { - ctx, - mut, - dependencies, - contentMap, - ignored: await isGitIgnored(), - initialSlugs: ctx.allSlugs, - toRebuild: new Set(), - toRemove: new Set(), - trackedAssets: new Set(), - lastBuildMs: 0, - } - - const watcher = chokidar.watch(".", { - persistent: true, - cwd: argv.directory, - ignoreInitial: true, - }) - - const buildFromEntry = argv.fastRebuild ? partialRebuildFromEntrypoint : rebuildFromEntrypoint - watcher - .on("add", (fp) => buildFromEntry(fp, "add", clientRefresh, buildData)) - .on("change", (fp) => buildFromEntry(fp, "change", clientRefresh, buildData)) - .on("unlink", (fp) => buildFromEntry(fp, "delete", clientRefresh, buildData)) - - return async () => { - await watcher.close() - } + const { argv } = ctx; + + // cache file parse results + const contentMap = new Map(); + for (const content of initialContent) { + const [_tree, vfile] = content; + contentMap.set(vfile.data.filePath!, content); + } + + const buildData: BuildData = { + ctx, + mut, + dependencies, + contentMap, + ignored: await isGitIgnored(), + initialSlugs: ctx.allSlugs, + toRebuild: new Set(), + toRemove: new Set(), + trackedAssets: new Set(), + lastBuildMs: 0, + }; + + const watcher = chokidar.watch(".", { + persistent: true, + cwd: argv.directory, + ignoreInitial: true, + }); + + const buildFromEntry = argv.fastRebuild ? partialRebuildFromEntrypoint : rebuildFromEntrypoint; + watcher + .on("add", (fp) => buildFromEntry(fp, "add", clientRefresh, buildData)) + .on("change", (fp) => buildFromEntry(fp, "change", clientRefresh, buildData)) + .on("unlink", (fp) => buildFromEntry(fp, "delete", clientRefresh, buildData)); + + return async () => { + await watcher.close(); + }; } async function partialRebuildFromEntrypoint( - filepath: string, - action: FileEvent, - clientRefresh: () => void, - buildData: BuildData, // note: this function mutates buildData + filepath: string, + action: FileEvent, + clientRefresh: () => void, + buildData: BuildData, // note: this function mutates buildData ) { - const { ctx, ignored, dependencies, contentMap, mut, toRemove } = buildData - const { argv, cfg } = ctx - - // don't do anything for gitignored files - if (ignored(filepath)) { - return - } - - const buildStart = new Date().getTime() - buildData.lastBuildMs = buildStart - const release = await mut.acquire() - if (buildData.lastBuildMs > buildStart) { - release() - return - } - - const perf = new PerfTimer() - console.log(chalk.yellow("Detected change, rebuilding...")) - - // UPDATE DEP GRAPH - const fp = joinSegments(argv.directory, toPosixPath(filepath)) as FilePath - - const staticResources = getStaticResourcesFromPlugins(ctx) - let processedFiles: ProcessedContent[] = [] - - switch (action) { - case "add": - // add to cache when new file is added - processedFiles = await parseMarkdown(ctx, [fp]) - processedFiles.forEach(([tree, vfile]) => contentMap.set(vfile.data.filePath!, [tree, vfile])) - - // update the dep graph by asking all emitters whether they depend on this file - for (const emitter of cfg.plugins.emitters) { - const emitterGraph = - (await emitter.getDependencyGraph?.(ctx, processedFiles, staticResources)) ?? null - - if (emitterGraph) { - const existingGraph = dependencies[emitter.name] - if (existingGraph !== null) { - existingGraph.mergeGraph(emitterGraph) - } else { - // might be the first time we're adding a mardown file - dependencies[emitter.name] = emitterGraph - } - } - } - break - case "change": - // invalidate cache when file is changed - processedFiles = await parseMarkdown(ctx, [fp]) - processedFiles.forEach(([tree, vfile]) => contentMap.set(vfile.data.filePath!, [tree, vfile])) - - // only content files can have added/removed dependencies because of transclusions - if (path.extname(fp) === ".md") { - for (const emitter of cfg.plugins.emitters) { - // get new dependencies from all emitters for this file - const emitterGraph = - (await emitter.getDependencyGraph?.(ctx, processedFiles, staticResources)) ?? null - - // only update the graph if the emitter plugin uses the changed file - // eg. Assets plugin ignores md files, so we skip updating the graph - if (emitterGraph?.hasNode(fp)) { - // merge the new dependencies into the dep graph - dependencies[emitter.name]?.updateIncomingEdgesForNode(emitterGraph, fp) - } - } - } - break - case "delete": - toRemove.add(fp) - break - } - - if (argv.verbose) { - console.log(`Updated dependency graphs in ${perf.timeSince()}`) - } - - // EMIT - perf.addEvent("rebuild") - let emittedFiles = 0 - - for (const emitter of cfg.plugins.emitters) { - const depGraph = dependencies[emitter.name] - - // emitter hasn't defined a dependency graph. call it with all processed files - if (depGraph === null) { - if (argv.verbose) { - console.log( - `Emitter ${emitter.name} doesn't define a dependency graph. Calling it with all files...`, - ) - } - - const files = [...contentMap.values()].filter( - ([_node, vfile]) => !toRemove.has(vfile.data.filePath!), - ) - - const emittedFps = await emitter.emit(ctx, files, staticResources) - - if (ctx.argv.verbose) { - for (const file of emittedFps) { - console.log(`[emit:${emitter.name}] ${file}`) - } - } - - emittedFiles += emittedFps.length - continue - } - - // only call the emitter if it uses this file - if (depGraph.hasNode(fp)) { - // re-emit using all files that are needed for the downstream of this file - // eg. for ContentIndex, the dep graph could be: - // a.md --> contentIndex.json - // b.md ------^ - // - // if a.md changes, we need to re-emit contentIndex.json, - // and supply [a.md, b.md] to the emitter - const upstreams = [...depGraph.getLeafNodeAncestors(fp)] as FilePath[] - - const upstreamContent = upstreams - // filter out non-markdown files - .filter((file) => contentMap.has(file)) - // if file was deleted, don't give it to the emitter - .filter((file) => !toRemove.has(file)) - .map((file) => contentMap.get(file)!) - - const emittedFps = await emitter.emit(ctx, upstreamContent, staticResources) - - if (ctx.argv.verbose) { - for (const file of emittedFps) { - console.log(`[emit:${emitter.name}] ${file}`) - } - } - - emittedFiles += emittedFps.length - } - } - - console.log(`Emitted ${emittedFiles} files to \`${argv.output}\` in ${perf.timeSince("rebuild")}`) - - // CLEANUP - const destinationsToDelete = new Set() - for (const file of toRemove) { - // remove from cache - contentMap.delete(file) - Object.values(dependencies).forEach((depGraph) => { - // remove the node from dependency graphs - depGraph?.removeNode(file) - // remove any orphan nodes. eg if a.md is deleted, a.html is orphaned and should be removed - const orphanNodes = depGraph?.removeOrphanNodes() - orphanNodes?.forEach((node) => { - // only delete files that are in the output directory - if (node.startsWith(argv.output)) { - destinationsToDelete.add(node) - } - }) - }) - } - await rimraf([...destinationsToDelete]) - - toRemove.clear() - release() - clientRefresh() + const { ctx, ignored, dependencies, contentMap, mut, toRemove } = buildData; + const { argv, cfg } = ctx; + + // don't do anything for gitignored files + if (ignored(filepath)) { + return; + } + + const buildStart = new Date().getTime(); + buildData.lastBuildMs = buildStart; + const release = await mut.acquire(); + if (buildData.lastBuildMs > buildStart) { + release(); + return; + } + + const perf = new PerfTimer(); + console.log(chalk.yellow("Detected change, rebuilding...")); + + // UPDATE DEP GRAPH + const fp = joinSegments(argv.directory, toPosixPath(filepath)) as FilePath; + + const staticResources = getStaticResourcesFromPlugins(ctx); + let processedFiles: ProcessedContent[] = []; + + switch (action) { + case "add": + // add to cache when new file is added + processedFiles = await parseMarkdown(ctx, [fp]); + processedFiles.forEach(([tree, vfile]) => contentMap.set(vfile.data.filePath!, [tree, vfile])); + + // update the dep graph by asking all emitters whether they depend on this file + for (const emitter of cfg.plugins.emitters) { + const emitterGraph = + (await emitter.getDependencyGraph?.(ctx, processedFiles, staticResources)) ?? null; + + if (emitterGraph) { + const existingGraph = dependencies[emitter.name]; + if (existingGraph !== null) { + existingGraph.mergeGraph(emitterGraph); + } else { + // might be the first time we're adding a mardown file + dependencies[emitter.name] = emitterGraph; + } + } + } + break; + case "change": + // invalidate cache when file is changed + processedFiles = await parseMarkdown(ctx, [fp]); + processedFiles.forEach(([tree, vfile]) => contentMap.set(vfile.data.filePath!, [tree, vfile])); + + // only content files can have added/removed dependencies because of transclusions + if (path.extname(fp) === ".md") { + for (const emitter of cfg.plugins.emitters) { + // get new dependencies from all emitters for this file + const emitterGraph = + (await emitter.getDependencyGraph?.(ctx, processedFiles, staticResources)) ?? null; + + // only update the graph if the emitter plugin uses the changed file + // eg. Assets plugin ignores md files, so we skip updating the graph + if (emitterGraph?.hasNode(fp)) { + // merge the new dependencies into the dep graph + dependencies[emitter.name]?.updateIncomingEdgesForNode(emitterGraph, fp); + } + } + } + break; + case "delete": + toRemove.add(fp); + break; + } + + if (argv.verbose) { + console.log(`Updated dependency graphs in ${perf.timeSince()}`); + } + + // EMIT + perf.addEvent("rebuild"); + let emittedFiles = 0; + + for (const emitter of cfg.plugins.emitters) { + const depGraph = dependencies[emitter.name]; + + // emitter hasn't defined a dependency graph. call it with all processed files + if (depGraph === null) { + if (argv.verbose) { + console.log( + `Emitter ${emitter.name} doesn't define a dependency graph. Calling it with all files...`, + ); + } + + const files = [...contentMap.values()].filter( + ([_node, vfile]) => !toRemove.has(vfile.data.filePath!), + ); + + const emittedFps = await emitter.emit(ctx, files, staticResources); + + if (ctx.argv.verbose) { + for (const file of emittedFps) { + console.log(`[emit:${emitter.name}] ${file}`); + } + } + + emittedFiles += emittedFps.length; + continue; + } + + // only call the emitter if it uses this file + if (depGraph.hasNode(fp)) { + // re-emit using all files that are needed for the downstream of this file + // eg. for ContentIndex, the dep graph could be: + // a.md --> contentIndex.json + // b.md ------^ + // + // if a.md changes, we need to re-emit contentIndex.json, + // and supply [a.md, b.md] to the emitter + const upstreams = [...depGraph.getLeafNodeAncestors(fp)] as FilePath[]; + + const upstreamContent = upstreams + // filter out non-markdown files + .filter((file) => contentMap.has(file)) + // if file was deleted, don't give it to the emitter + .filter((file) => !toRemove.has(file)) + .map((file) => contentMap.get(file)!); + + const emittedFps = await emitter.emit(ctx, upstreamContent, staticResources); + + if (ctx.argv.verbose) { + for (const file of emittedFps) { + console.log(`[emit:${emitter.name}] ${file}`); + } + } + + emittedFiles += emittedFps.length; + } + } + + console.log(`Emitted ${emittedFiles} files to \`${argv.output}\` in ${perf.timeSince("rebuild")}`); + + // CLEANUP + const destinationsToDelete = new Set(); + for (const file of toRemove) { + // remove from cache + contentMap.delete(file); + Object.values(dependencies).forEach((depGraph) => { + // remove the node from dependency graphs + depGraph?.removeNode(file); + // remove any orphan nodes. eg if a.md is deleted, a.html is orphaned and should be removed + const orphanNodes = depGraph?.removeOrphanNodes(); + orphanNodes?.forEach((node) => { + // only delete files that are in the output directory + if (node.startsWith(argv.output)) { + destinationsToDelete.add(node); + } + }); + }); + } + await rimraf([...destinationsToDelete]); + + toRemove.clear(); + release(); + clientRefresh(); } async function rebuildFromEntrypoint( - fp: string, - action: FileEvent, - clientRefresh: () => void, - buildData: BuildData, // note: this function mutates buildData + fp: string, + action: FileEvent, + clientRefresh: () => void, + buildData: BuildData, // note: this function mutates buildData ) { - const { ctx, ignored, mut, initialSlugs, contentMap, toRebuild, toRemove, trackedAssets } = - buildData - - const { argv } = ctx - - // don't do anything for gitignored files - if (ignored(fp)) { - return - } - - // dont bother rebuilding for non-content files, just track and refresh - fp = toPosixPath(fp) - const filePath = joinSegments(argv.directory, fp) as FilePath - if (path.extname(fp) !== ".md") { - if (action === "add" || action === "change") { - trackedAssets.add(filePath) - } else if (action === "delete") { - trackedAssets.delete(filePath) - } - clientRefresh() - return - } - - if (action === "add" || action === "change") { - toRebuild.add(filePath) - } else if (action === "delete") { - toRemove.add(filePath) - } - - const buildStart = new Date().getTime() - buildData.lastBuildMs = buildStart - const release = await mut.acquire() - - // there's another build after us, release and let them do it - if (buildData.lastBuildMs > buildStart) { - release() - return - } - - const perf = new PerfTimer() - console.log(chalk.yellow("Detected change, rebuilding...")) - try { - const filesToRebuild = [...toRebuild].filter((fp) => !toRemove.has(fp)) - - const trackedSlugs = [...new Set([...contentMap.keys(), ...toRebuild, ...trackedAssets])] - .filter((fp) => !toRemove.has(fp)) - .map((fp) => slugifyFilePath(path.posix.relative(argv.directory, fp) as FilePath)) - - ctx.allSlugs = [...new Set([...initialSlugs, ...trackedSlugs])] - const parsedContent = await parseMarkdown(ctx, filesToRebuild) - for (const content of parsedContent) { - const [_tree, vfile] = content - contentMap.set(vfile.data.filePath!, content) - } - - for (const fp of toRemove) { - contentMap.delete(fp) - } - - const parsedFiles = [...contentMap.values()] - const filteredContent = filterContent(ctx, parsedFiles) - - // TODO: we can probably traverse the link graph to figure out what's safe to delete here - // instead of just deleting everything - await rimraf(path.join(argv.output, ".*"), { glob: true }) - await emitContent(ctx, filteredContent) - console.log(chalk.green(`Done rebuilding in ${perf.timeSince()}`)) - } catch (err) { - console.log(chalk.yellow(`Rebuild failed. Waiting on a change to fix the error...`)) - if (argv.verbose) { - console.log(chalk.red(err)) - } - } - - release() - clientRefresh() - toRebuild.clear() - toRemove.clear() + const { ctx, ignored, mut, initialSlugs, contentMap, toRebuild, toRemove, trackedAssets } = + buildData; + + const { argv } = ctx; + + // don't do anything for gitignored files + if (ignored(fp)) { + return; + } + + // dont bother rebuilding for non-content files, just track and refresh + fp = toPosixPath(fp); + const filePath = joinSegments(argv.directory, fp) as FilePath; + if (path.extname(fp) !== ".md") { + if (action === "add" || action === "change") { + trackedAssets.add(filePath); + } else if (action === "delete") { + trackedAssets.delete(filePath); + } + clientRefresh(); + return; + } + + if (action === "add" || action === "change") { + toRebuild.add(filePath); + } else if (action === "delete") { + toRemove.add(filePath); + } + + const buildStart = new Date().getTime(); + buildData.lastBuildMs = buildStart; + const release = await mut.acquire(); + + // there's another build after us, release and let them do it + if (buildData.lastBuildMs > buildStart) { + release(); + return; + } + + const perf = new PerfTimer(); + console.log(chalk.yellow("Detected change, rebuilding...")); + try { + const filesToRebuild = [...toRebuild].filter((fp) => !toRemove.has(fp)); + + const trackedSlugs = [...new Set([...contentMap.keys(), ...toRebuild, ...trackedAssets])] + .filter((fp) => !toRemove.has(fp)) + .map((fp) => slugifyFilePath(path.posix.relative(argv.directory, fp) as FilePath)); + + ctx.allSlugs = [...new Set([...initialSlugs, ...trackedSlugs])]; + const parsedContent = await parseMarkdown(ctx, filesToRebuild); + for (const content of parsedContent) { + const [_tree, vfile] = content; + contentMap.set(vfile.data.filePath!, content); + } + + for (const fp of toRemove) { + contentMap.delete(fp); + } + + const parsedFiles = [...contentMap.values()]; + const filteredContent = filterContent(ctx, parsedFiles); + + // TODO: we can probably traverse the link graph to figure out what's safe to delete here + // instead of just deleting everything + await rimraf(path.join(argv.output, ".*"), { glob: true }); + await emitContent(ctx, filteredContent); + console.log(chalk.green(`Done rebuilding in ${perf.timeSince()}`)); + } catch (err) { + console.log(chalk.yellow("Rebuild failed. Waiting on a change to fix the error...")); + if (argv.verbose) { + console.log(chalk.red(err)); + } + } + + release(); + clientRefresh(); + toRebuild.clear(); + toRemove.clear(); } export default async (argv: Argv, mut: Mutex, clientRefresh: () => void) => { - try { - return await buildQuartz(argv, mut, clientRefresh) - } catch (err) { - trace("\nExiting Quartz due to a fatal error", err as Error) - } -} + try { + return await buildQuartz(argv, mut, clientRefresh); + } catch (err) { + trace("\nExiting Quartz due to a fatal error", err as Error); + } +}; diff --git a/quartz/cfg.ts b/quartz/cfg.ts index 622198ee01781..45c0409b5797d 100644 --- a/quartz/cfg.ts +++ b/quartz/cfg.ts @@ -1,8 +1,8 @@ -import { ValidDateType } from "./components/Date" -import { QuartzComponent } from "./components/types" -import { ValidLocale } from "./i18n" -import { PluginTypes } from "./plugins/types" -import { Theme } from "./util/theme" +import { ValidDateType } from "./components/Date"; +import { QuartzComponent } from "./components/types"; +import { ValidLocale } from "./i18n"; +import { PluginTypes } from "./plugins/types"; +import { Theme } from "./util/theme"; export type Analytics = | null diff --git a/quartz/cli/args.js b/quartz/cli/args.js index 123d0ac552361..66ada5174ae4d 100644 --- a/quartz/cli/args.js +++ b/quartz/cli/args.js @@ -1,108 +1,108 @@ 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", - }, -} + 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", - }, -} + ...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", - }, -} + ...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", - }, - fastRebuild: { - boolean: true, - default: false, - describe: "[experimental] rebuild only the changed files", - }, - 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", - }, -} + ...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", + }, + fastRebuild: { + boolean: true, + default: false, + describe: "[experimental] rebuild only the changed files", + }, + 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", + }, +}; diff --git a/quartz/cli/constants.js b/quartz/cli/constants.js index f4a9ce52b3660..5786b635d3cad 100644 --- a/quartz/cli/constants.js +++ b/quartz/cli/constants.js @@ -1,15 +1,15 @@ -import path from "path" -import { readFileSync } from "fs" +import { readFileSync } from "fs"; +import path from "path"; /** * 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") +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"); diff --git a/quartz/cli/handlers.js b/quartz/cli/handlers.js index 12e7e8ec90156..816a9497a541b 100644 --- a/quartz/cli/handlers.js +++ b/quartz/cli/handlers.js @@ -1,213 +1,213 @@ -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 { intro, outro, select, text } from "@clack/prompts"; +import { Mutex } from "async-mutex"; +import chalk from "chalk"; +import { execSync, spawnSync } from "child_process"; +import chokidar from "chokidar"; +import { randomUUID } from "crypto"; +import esbuild from "esbuild"; +import { sassPlugin } from "esbuild-sass-plugin"; +import fs, { promises } from "fs"; +import http from "http"; +import path from "path"; +import prettyBytes from "pretty-bytes"; +import { rimraf } from "rimraf"; +import serveHandler from "serve-handler"; +import { WebSocketServer } from "ws"; + +import { CreateArgv } from "./args.js"; import { - exitIfCancel, - escapePath, - gitPull, - popContentFolder, - stashContentFolder, -} from "./helpers.js" + cacheFile, + cwd, + fp, + ORIGIN_NAME, + QUARTZ_SOURCE_BRANCH, + UPSTREAM_NAME, + version, +} from "./constants.js"; import { - UPSTREAM_NAME, - QUARTZ_SOURCE_BRANCH, - ORIGIN_NAME, - version, - fp, - cacheFile, - cwd, -} from "./constants.js" + escapePath, + exitIfCancel, + gitPull, + popContentFolder, + stashContentFolder, +} from "./helpers.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) - } - } - - const gitkeepPath = path.join(contentFolder, ".gitkeep") - if (fs.existsSync(gitkeepPath)) { - await fs.promises.unlink(gitkeepPath) - } - 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: + 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); + } + } + + const gitkeepPath = path.join(contentFolder, ".gitkeep"); + if (fs.existsSync(gitkeepPath)) { + await fs.promises.unlink(gitkeepPath); + } + 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"), - `--- + 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. This should match Obsidian's link format. You can change this later in \`quartz.config.ts\`.`, - options: [ - { - value: "shortest", - label: "Treat links as shortest path", - hint: "(default)", - }, - { - value: "absolute", - label: "Treat links as absolute path", - }, - { - value: "relative", - label: "Treat links as relative paths", - }, - ], - }), - ) - } - - // 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) - - // setup remote - execSync( - `git remote show upstream || git remote add upstream https://github.com/jackyzha0/quartz.git`, - { stdio: "ignore" }, - ) - - outro(`You're all set! Not sure what to do next? Try: + ); + } + + // 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. This should match Obsidian's link format. You can change this later in `quartz.config.ts`.", + options: [ + { + value: "shortest", + label: "Treat links as shortest path", + hint: "(default)", + }, + { + value: "absolute", + label: "Treat links as absolute path", + }, + { + value: "relative", + label: "Treat links as relative paths", + }, + ], + }), + ); + } + + // 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); + + // setup remote + execSync( + "git remote show upstream || git remote add upstream https://github.com/jackyzha0/quartz.git", + { stdio: "ignore" }, + ); + + 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) -`) +`); } /** @@ -215,220 +215,220 @@ See the [documentation](https://quartz.jzhao.xyz) for how to get started. * @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, - minify: 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: "**/*.*", - 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() - } + 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, + minify: 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: "**/*.*", + 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(); + } } /** @@ -436,33 +436,33 @@ export async function handleBuild(argv) { * @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.", - ) - - try { - gitPull(UPSTREAM_NAME, QUARTZ_SOURCE_BRANCH) - } catch { - console.log(chalk.red("An error occurred above while pulling updates.")) - await popContentFolder(contentFolder) - return - } - - await popContentFolder(contentFolder) - console.log("Ensuring dependencies are up to date") - const res = spawnSync("npm", ["i"], { stdio: "inherit" }) - if (res.status === 0) { - console.log(chalk.green("Done!")) - } else { - console.log(chalk.red("An error occurred above while installing dependencies.")) - } + 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.", + ); + + try { + gitPull(UPSTREAM_NAME, QUARTZ_SOURCE_BRANCH); + } catch { + console.log(chalk.red("An error occurred above while pulling updates.")); + await popContentFolder(contentFolder); + return; + } + + await popContentFolder(contentFolder); + console.log("Ensuring dependencies are up to date"); + const res = spawnSync("npm", ["i"], { stdio: "inherit" }); + if (res.status === 0) { + console.log(chalk.green("Done!")); + } else { + console.log(chalk.red("An error occurred above while installing dependencies.")); + } } /** @@ -470,8 +470,8 @@ export async function handleUpdate(argv) { * @param {*} argv arguments for `restore` */ export async function handleRestore(argv) { - const contentFolder = path.join(cwd, argv.directory) - await popContentFolder(contentFolder) + const contentFolder = path.join(cwd, argv.directory); + await popContentFolder(contentFolder); } /** @@ -479,66 +479,66 @@ export async function handleRestore(argv) { * @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.", - ) - try { - gitPull(ORIGIN_NAME, QUARTZ_SOURCE_BRANCH) - } catch { - console.log(chalk.red("An error occurred above while pulling updates.")) - await popContentFolder(contentFolder) - return - } - } - - await popContentFolder(contentFolder) - if (argv.push) { - console.log("Pushing your changes") - const res = spawnSync("git", ["push", "-uf", ORIGIN_NAME, QUARTZ_SOURCE_BRANCH], { - stdio: "inherit", - }) - if (res.status !== 0) { - console.log(chalk.red(`An error occurred above while pushing to remote ${ORIGIN_NAME}.`)) - return - } - } - - console.log(chalk.green("Done!")) + 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.", + ); + try { + gitPull(ORIGIN_NAME, QUARTZ_SOURCE_BRANCH); + } catch { + console.log(chalk.red("An error occurred above while pulling updates.")); + await popContentFolder(contentFolder); + return; + } + } + + await popContentFolder(contentFolder); + if (argv.push) { + console.log("Pushing your changes"); + const res = spawnSync("git", ["push", "-uf", ORIGIN_NAME, QUARTZ_SOURCE_BRANCH], { + stdio: "inherit", + }); + if (res.status !== 0) { + console.log(chalk.red(`An error occurred above while pushing to remote ${ORIGIN_NAME}.`)); + return; + } + } + + console.log(chalk.green("Done!")); } diff --git a/quartz/cli/helpers.js b/quartz/cli/helpers.js index 702a1b71de912..06c013aaa6f00 100644 --- a/quartz/cli/helpers.js +++ b/quartz/cli/helpers.js @@ -1,54 +1,55 @@ -import { isCancel, outro } from "@clack/prompts" -import chalk from "chalk" -import { contentCacheFolder } from "./constants.js" -import { spawnSync } from "child_process" -import fs from "fs" +import { isCancel, outro } from "@clack/prompts"; +import chalk from "chalk"; +import { spawnSync } from "child_process"; +import fs from "fs"; + +import { contentCacheFolder } from "./constants.js"; export function escapePath(fp) { - return fp - .replace(/\\ /g, " ") // unescape spaces - .replace(/^".*"$/, "$1") - .replace(/^'.*"$/, "$1") - .trim() + 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 - } + 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 }) + 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(chalk.red(`Error while pulling updates: ${out.stderr}`)) - } else if (out.status !== 0) { - throw new Error(chalk.red("Error while pulling updates")) - } + 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(chalk.red(`Error while pulling updates: ${out.stderr}`)); + } else if (out.status !== 0) { + throw new Error(chalk.red("Error while pulling updates")); + } } 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 }) + 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 }); } diff --git a/quartz/components/index.ts b/quartz/components/index.ts index 6e35fac781941..1bd78589a0ae1 100644 --- a/quartz/components/index.ts +++ b/quartz/components/index.ts @@ -1,47 +1,47 @@ -import Content from "./pages/Content" -import TagContent from "./pages/TagContent" -import FolderContent from "./pages/FolderContent" -import NotFound from "./pages/404" -import ArticleTitle from "./ArticleTitle" -import Darkmode from "./Darkmode" -import Head from "./Head" -import PageTitle from "./PageTitle" -import ContentMeta from "./ContentMeta" -import Spacer from "./Spacer" -import TableOfContents from "./TableOfContents" -import Explorer from "./Explorer" -import TagList from "./TagList" -import Graph from "./Graph" -import Backlinks from "./Backlinks" -import Search from "./Search" -import Footer from "./Footer" -import DesktopOnly from "./DesktopOnly" -import MobileOnly from "./MobileOnly" -import RecentNotes from "./RecentNotes" -import Breadcrumbs from "./Breadcrumbs" -import ExplorerBurger from "./ExplorerBurger" +import ArticleTitle from "./ArticleTitle"; +import Backlinks from "./Backlinks"; +import Breadcrumbs from "./Breadcrumbs"; +import ContentMeta from "./ContentMeta"; +import Darkmode from "./Darkmode"; +import DesktopOnly from "./DesktopOnly"; +import Explorer from "./Explorer"; +import ExplorerBurger from "./ExplorerBurger"; +import Footer from "./Footer"; +import Graph from "./Graph"; +import Head from "./Head"; +import MobileOnly from "./MobileOnly"; +import NotFound from "./pages/404"; +import Content from "./pages/Content"; +import FolderContent from "./pages/FolderContent"; +import TagContent from "./pages/TagContent"; +import PageTitle from "./PageTitle"; +import RecentNotes from "./RecentNotes"; +import Search from "./Search"; +import Spacer from "./Spacer"; +import TableOfContents from "./TableOfContents"; +import TagList from "./TagList"; export { - ArticleTitle, - Content, - TagContent, - FolderContent, - Darkmode, - Head, - PageTitle, - ContentMeta, - Spacer, - TableOfContents, - Explorer, - TagList, - Graph, - Backlinks, - Search, - Footer, - DesktopOnly, - MobileOnly, - RecentNotes, - NotFound, - Breadcrumbs, - ExplorerBurger, -} + ArticleTitle, + Backlinks, + Breadcrumbs, + Content, + ContentMeta, + Darkmode, + DesktopOnly, + Explorer, + ExplorerBurger, + FolderContent, + Footer, + Graph, + Head, + MobileOnly, + NotFound, + PageTitle, + RecentNotes, + Search, + Spacer, + TableOfContents, + TagContent, + TagList, +}; diff --git a/quartz/components/scripts/callout.inline.ts b/quartz/components/scripts/callout.inline.ts index 8f63df36f6d3e..b92c6a4207b07 100644 --- a/quartz/components/scripts/callout.inline.ts +++ b/quartz/components/scripts/callout.inline.ts @@ -1,44 +1,44 @@ function toggleCallout(this: HTMLElement) { - const outerBlock = this.parentElement! - outerBlock.classList.toggle("is-collapsed") - const collapsed = outerBlock.classList.contains("is-collapsed") - const height = collapsed ? this.scrollHeight : outerBlock.scrollHeight - outerBlock.style.maxHeight = height + "px" + const outerBlock = this.parentElement!; + outerBlock.classList.toggle("is-collapsed"); + const collapsed = outerBlock.classList.contains("is-collapsed"); + const height = collapsed ? this.scrollHeight : outerBlock.scrollHeight; + outerBlock.style.maxHeight = height + "px"; - // walk and adjust height of all parents - let current = outerBlock - let parent = outerBlock.parentElement - while (parent) { - if (!parent.classList.contains("callout")) { - return - } + // walk and adjust height of all parents + let current = outerBlock; + let parent = outerBlock.parentElement; + while (parent) { + if (!parent.classList.contains("callout")) { + return; + } - const collapsed = parent.classList.contains("is-collapsed") - const height = collapsed ? parent.scrollHeight : parent.scrollHeight + current.scrollHeight - parent.style.maxHeight = height + "px" + const collapsed = parent.classList.contains("is-collapsed"); + const height = collapsed ? parent.scrollHeight : parent.scrollHeight + current.scrollHeight; + parent.style.maxHeight = height + "px"; - current = parent - parent = parent.parentElement - } + current = parent; + parent = parent.parentElement; + } } function setupCallout() { - const collapsible = document.getElementsByClassName( - `callout is-collapsible`, - ) as HTMLCollectionOf - for (const div of collapsible) { - const title = div.firstElementChild + const collapsible = document.getElementsByClassName( + "callout is-collapsible", + ) as HTMLCollectionOf; + for (const div of collapsible) { + const title = div.firstElementChild; - if (title) { - title.addEventListener("click", toggleCallout) - window.addCleanup(() => title.removeEventListener("click", toggleCallout)) + if (title) { + title.addEventListener("click", toggleCallout); + window.addCleanup(() => title.removeEventListener("click", toggleCallout)); - const collapsed = div.classList.contains("is-collapsed") - const height = collapsed ? title.scrollHeight : div.scrollHeight - div.style.maxHeight = height + "px" - } - } + const collapsed = div.classList.contains("is-collapsed"); + const height = collapsed ? title.scrollHeight : div.scrollHeight; + div.style.maxHeight = height + "px"; + } + } } -document.addEventListener("nav", setupCallout) -window.addEventListener("resize", setupCallout) +document.addEventListener("nav", setupCallout); +window.addEventListener("resize", setupCallout); diff --git a/quartz/components/scripts/checkbox.inline.ts b/quartz/components/scripts/checkbox.inline.ts index 50ab0425a619e..54293ebe44d13 100644 --- a/quartz/components/scripts/checkbox.inline.ts +++ b/quartz/components/scripts/checkbox.inline.ts @@ -1,23 +1,23 @@ -import { getFullSlug } from "../../util/path" +import { getFullSlug } from "../../util/path"; -const checkboxId = (index: number) => `${getFullSlug(window)}-checkbox-${index}` +const checkboxId = (index: number) => `${getFullSlug(window)}-checkbox-${index}`; document.addEventListener("nav", () => { - const checkboxes = document.querySelectorAll( - "input.checkbox-toggle", - ) as NodeListOf - checkboxes.forEach((el, index) => { - const elId = checkboxId(index) + const checkboxes = document.querySelectorAll( + "input.checkbox-toggle", + ) as NodeListOf; + checkboxes.forEach((el, index) => { + const elId = checkboxId(index); - const switchState = (e: Event) => { - const newCheckboxState = (e.target as HTMLInputElement)?.checked ? "true" : "false" - localStorage.setItem(elId, newCheckboxState) - } + const switchState = (e: Event) => { + const newCheckboxState = (e.target as HTMLInputElement)?.checked ? "true" : "false"; + localStorage.setItem(elId, newCheckboxState); + }; - el.addEventListener("change", switchState) - window.addCleanup(() => el.removeEventListener("change", switchState)) - if (localStorage.getItem(elId) === "true") { - el.checked = true - } - }) -}) + el.addEventListener("change", switchState); + window.addCleanup(() => el.removeEventListener("change", switchState)); + if (localStorage.getItem(elId) === "true") { + el.checked = true; + } + }); +}); diff --git a/quartz/components/scripts/clipboard.inline.ts b/quartz/components/scripts/clipboard.inline.ts index 87182a154b45f..039336f97f270 100644 --- a/quartz/components/scripts/clipboard.inline.ts +++ b/quartz/components/scripts/clipboard.inline.ts @@ -1,35 +1,35 @@ const svgCopy = - '' + ""; const svgCheck = - '' + ""; document.addEventListener("nav", () => { - const els = document.getElementsByTagName("pre") - for (let i = 0; i < els.length; i++) { - const codeBlock = els[i].getElementsByTagName("code")[0] - if (codeBlock) { - const source = codeBlock.innerText.replace(/\n\n/g, "\n") - const button = document.createElement("button") - button.className = "clipboard-button" - button.type = "button" - button.innerHTML = svgCopy - button.ariaLabel = "Copy source" - function onClick() { - navigator.clipboard.writeText(source).then( - () => { - button.blur() - button.innerHTML = svgCheck - setTimeout(() => { - button.innerHTML = svgCopy - button.style.borderColor = "" - }, 2000) - }, - (error) => console.error(error), - ) - } - button.addEventListener("click", onClick) - window.addCleanup(() => button.removeEventListener("click", onClick)) - els[i].prepend(button) - } - } -}) + const els = document.getElementsByTagName("pre"); + for (let i = 0; i < els.length; i++) { + const codeBlock = els[i].getElementsByTagName("code")[0]; + if (codeBlock) { + const source = codeBlock.innerText.replace(/\n\n/g, "\n"); + const button = document.createElement("button"); + button.className = "clipboard-button"; + button.type = "button"; + button.innerHTML = svgCopy; + button.ariaLabel = "Copy source"; + function onClick() { + navigator.clipboard.writeText(source).then( + () => { + button.blur(); + button.innerHTML = svgCheck; + setTimeout(() => { + button.innerHTML = svgCopy; + button.style.borderColor = ""; + }, 2000); + }, + (error) => console.error(error), + ); + } + button.addEventListener("click", onClick); + window.addCleanup(() => button.removeEventListener("click", onClick)); + els[i].prepend(button); + } + } +}); diff --git a/quartz/components/scripts/darkmode.inline.ts b/quartz/components/scripts/darkmode.inline.ts index 48e0aa1f5df36..50b9c1ad72072 100644 --- a/quartz/components/scripts/darkmode.inline.ts +++ b/quartz/components/scripts/darkmode.inline.ts @@ -1,40 +1,40 @@ -const userPref = window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark" -const currentTheme = localStorage.getItem("theme") ?? userPref -document.documentElement.setAttribute("saved-theme", currentTheme) +const userPref = window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark"; +const currentTheme = localStorage.getItem("theme") ?? userPref; +document.documentElement.setAttribute("saved-theme", currentTheme); const emitThemeChangeEvent = (theme: "light" | "dark") => { - const event: CustomEventMap["themechange"] = new CustomEvent("themechange", { - detail: { theme }, - }) - document.dispatchEvent(event) -} + const event: CustomEventMap["themechange"] = new CustomEvent("themechange", { + detail: { theme }, + }); + document.dispatchEvent(event); +}; document.addEventListener("nav", () => { - const switchTheme = (e: Event) => { - const newTheme = (e.target as HTMLInputElement)?.checked ? "dark" : "light" - document.documentElement.setAttribute("saved-theme", newTheme) - localStorage.setItem("theme", newTheme) - emitThemeChangeEvent(newTheme) - } + const switchTheme = (e: Event) => { + const newTheme = (e.target as HTMLInputElement)?.checked ? "dark" : "light"; + document.documentElement.setAttribute("saved-theme", newTheme); + localStorage.setItem("theme", newTheme); + emitThemeChangeEvent(newTheme); + }; - const themeChange = (e: MediaQueryListEvent) => { - const newTheme = e.matches ? "dark" : "light" - document.documentElement.setAttribute("saved-theme", newTheme) - localStorage.setItem("theme", newTheme) - toggleSwitch.checked = e.matches - emitThemeChangeEvent(newTheme) - } + const themeChange = (e: MediaQueryListEvent) => { + const newTheme = e.matches ? "dark" : "light"; + document.documentElement.setAttribute("saved-theme", newTheme); + localStorage.setItem("theme", newTheme); + toggleSwitch.checked = e.matches; + emitThemeChangeEvent(newTheme); + }; - // Darkmode toggle - const toggleSwitch = document.querySelector("#darkmode-toggle") as HTMLInputElement - toggleSwitch.addEventListener("change", switchTheme) - window.addCleanup(() => toggleSwitch.removeEventListener("change", switchTheme)) - if (currentTheme === "dark") { - toggleSwitch.checked = true - } + // Darkmode toggle + const toggleSwitch = document.querySelector("#darkmode-toggle") as HTMLInputElement; + toggleSwitch.addEventListener("change", switchTheme); + window.addCleanup(() => toggleSwitch.removeEventListener("change", switchTheme)); + if (currentTheme === "dark") { + toggleSwitch.checked = true; + } - // Listen for changes in prefers-color-scheme - const colorSchemeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)") - colorSchemeMediaQuery.addEventListener("change", themeChange) - window.addCleanup(() => colorSchemeMediaQuery.removeEventListener("change", themeChange)) -}) + // Listen for changes in prefers-color-scheme + const colorSchemeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); + colorSchemeMediaQuery.addEventListener("change", themeChange); + window.addCleanup(() => colorSchemeMediaQuery.removeEventListener("change", themeChange)); +}); diff --git a/quartz/components/scripts/explorer-burger.inline.ts b/quartz/components/scripts/explorer-burger.inline.ts index 73eccab1009cc..92ef3c425ee5f 100644 --- a/quartz/components/scripts/explorer-burger.inline.ts +++ b/quartz/components/scripts/explorer-burger.inline.ts @@ -1,174 +1,174 @@ -import { FolderState } from "../ExplorerNode" +import { FolderState } from "../ExplorerNode"; // Current state of folders type MaybeHTMLElement = HTMLElement | undefined -let currentExplorerState: FolderState[] +let currentExplorerState: FolderState[]; function escapeCharacters(str: string) { - return str.replace(/'/g, "\\'").replace(/"/g, '\\"') + return str.replace(/'/g, "\\'").replace(/"/g, "\\\""); } 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") - } - } -}) + // 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 MaybeHTMLElement - if (!content) return; - content.classList.toggle("collapsed") - content.style.maxHeight = content.style.maxHeight === "0px" ? content.scrollHeight + "px" : "0px" - - //prevent scroll under - if (this.dataset.mobile === "true" && document.querySelector(".mobile-only #explorer")) { - const article = document.querySelectorAll( - ".popover-hint, footer, .backlinks, .graph, .toc, #progress", - ) - const header = document.querySelector(".page .page-header") - if (article) - article.forEach((element) => { - element.classList.toggle("no-scroll") - }) - if (header) header.classList.toggle("fixed") - } + // Toggle collapsed state of entire explorer + this.classList.toggle("collapsed"); + const content = this.nextElementSibling as MaybeHTMLElement; + if (!content) return; + content.classList.toggle("collapsed"); + content.style.maxHeight = content.style.maxHeight === "0px" ? content.scrollHeight + "px" : "0px"; + + //prevent scroll under + if (this.dataset.mobile === "true" && document.querySelector(".mobile-only #explorer")) { + const article = document.querySelectorAll( + ".popover-hint, footer, .backlinks, .graph, .toc, #progress", + ); + const header = document.querySelector(".page .page-header"); + if (article) + article.forEach((element) => { + element.classList.toggle("no-scroll"); + }); + if (header) header.classList.toggle("fixed"); + } } function toggleFolder(evt: MouseEvent) { - evt.stopPropagation() + evt.stopPropagation(); - // Element that was clicked - const target = evt.target as MaybeHTMLElement - if (!target) return + // Element that was clicked + const target = evt.target as MaybeHTMLElement; + if (!target) return; - // Check if target was svg icon or button - const isSvg = target.nodeName === "svg" + // Check if target was svg icon or button + const isSvg = target.nodeName === "svg"; - // corresponding
    element relative to clicked button/folder - const childFolderContainer = ( + // corresponding
      element relative to clicked button/folder + const childFolderContainer = ( isSvg - ? target.parentElement?.nextSibling - : target.parentElement?.parentElement?.nextElementSibling - ) as MaybeHTMLElement - const currentFolderParent = ( + ? target.parentElement?.nextSibling + : target.parentElement?.parentElement?.nextElementSibling + ) as MaybeHTMLElement; + const currentFolderParent = ( isSvg ? target.nextElementSibling : target.parentElement - ) as MaybeHTMLElement - if (!(childFolderContainer && currentFolderParent)) return - //
    • element of folder (stores folder-path dataset) - childFolderContainer.classList.toggle("open") - - // Collapse folder container - const isCollapsed = childFolderContainer.classList.contains("open") - setFolderState(childFolderContainer, !isCollapsed) - - // Save folder state to localStorage - const fullFolderPath = currentFolderParent.dataset.folderpath as string - toggleCollapsedByPath(currentExplorerState, fullFolderPath) - const stringifiedFileTree = JSON.stringify(currentExplorerState) - localStorage.setItem("fileTree", stringifiedFileTree) + ) as MaybeHTMLElement; + if (!(childFolderContainer && currentFolderParent)) return; + //
    • element of folder (stores folder-path dataset) + childFolderContainer.classList.toggle("open"); + + // Collapse folder container + const isCollapsed = childFolderContainer.classList.contains("open"); + setFolderState(childFolderContainer, !isCollapsed); + + // Save folder state to localStorage + const fullFolderPath = currentFolderParent.dataset.folderpath as string; + toggleCollapsedByPath(currentExplorerState, fullFolderPath); + const stringifiedFileTree = JSON.stringify(currentExplorerState); + localStorage.setItem("fileTree", stringifiedFileTree); } function setupExplorer() { - // Set click handler for collapsing entire explorer - const allExplorers = document.querySelectorAll("#explorer") as NodeListOf - for (const explorer of allExplorers) { - // 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") - for (const item of document.getElementsByClassName( - "folder-icon", - ) as HTMLCollectionOf) { - item.addEventListener("click", toggleFolder) - window.addCleanup(() => item.removeEventListener("click", toggleFolder)) - } - // Get folder state from local storage - const oldExplorerState: FolderState[] = - storageTree && useSavedFolderState ? JSON.parse(storageTree) : [] - const oldIndex = new Map(oldExplorerState.map((entry) => [entry.path, entry.collapsed])) - const newExplorerState: FolderState[] = explorer.dataset.tree - ? JSON.parse(explorer.dataset.tree) - : [] - currentExplorerState = [] - for (const { path, collapsed } of newExplorerState) { - currentExplorerState.push({ path, collapsed: oldIndex.get(path) ?? collapsed }) - } - - currentExplorerState.map((folderState) => { - const folderLi = document.querySelector( - `[data-folderpath='${folderState.path.replace("'", "-")}']`, - ) as MaybeHTMLElement - const folderUl = folderLi?.parentElement?.nextElementSibling as MaybeHTMLElement - if (folderUl) { - setFolderState(folderUl, folderState.collapsed) - } - }) + // Set click handler for collapsing entire explorer + const allExplorers = document.querySelectorAll("#explorer") as NodeListOf; + for (const explorer of allExplorers) { + // 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") + for (const item of document.getElementsByClassName( + "folder-icon", + ) as HTMLCollectionOf) { + item.addEventListener("click", toggleFolder); + window.addCleanup(() => item.removeEventListener("click", toggleFolder)); + } + // Get folder state from local storage + const oldExplorerState: FolderState[] = + storageTree && useSavedFolderState ? JSON.parse(storageTree) : []; + const oldIndex = new Map(oldExplorerState.map((entry) => [entry.path, entry.collapsed])); + const newExplorerState: FolderState[] = explorer.dataset.tree + ? JSON.parse(explorer.dataset.tree) + : []; + currentExplorerState = []; + for (const { path, collapsed } of newExplorerState) { + currentExplorerState.push({ path, collapsed: oldIndex.get(path) ?? collapsed }); + } + + currentExplorerState.map((folderState) => { + const folderLi = document.querySelector( + `[data-folderpath='${folderState.path.replace("'", "-")}']`, + ) as MaybeHTMLElement; + const folderUl = folderLi?.parentElement?.nextElementSibling as MaybeHTMLElement; + if (folderUl) { + setFolderState(folderUl, folderState.collapsed); + } + }); - } + } } -window.addEventListener("resize", setupExplorer) +window.addEventListener("resize", setupExplorer); document.addEventListener("DOMContentLoaded", () => { - const explorer = document.querySelector(".mobile-only #explorer") - if (explorer) { - explorer.classList.add("collapsed") - const content = explorer.nextElementSibling as HTMLElement - content.classList.add("collapsed") - content.style.maxHeight = "0px" - } -}) + const explorer = document.querySelector(".mobile-only #explorer"); + if (explorer) { + explorer.classList.add("collapsed"); + const content = explorer.nextElementSibling as HTMLElement; + content.classList.add("collapsed"); + content.style.maxHeight = "0px"; + } +}); document.addEventListener("nav", () => { - const explorer = document.querySelector(".mobile-only #explorer") - if (explorer) { - explorer.classList.add("collapsed") - const content = explorer.nextElementSibling as HTMLElement - content.classList.add("collapsed") - content.style.maxHeight = "0px" - } - setupExplorer() - //add collapsed class to all folders - - observer.disconnect() - - // select pseudo element at end of list - const lastItem = document.getElementById("explorer-end") - if (lastItem) { - observer.observe(lastItem) - } -}) + const explorer = document.querySelector(".mobile-only #explorer"); + if (explorer) { + explorer.classList.add("collapsed"); + const content = explorer.nextElementSibling as HTMLElement; + content.classList.add("collapsed"); + content.style.maxHeight = "0px"; + } + setupExplorer(); + //add collapsed class to all folders + + observer.disconnect(); + + // select pseudo element at end of list + const lastItem = document.getElementById("explorer-end"); + if (lastItem) { + observer.observe(lastItem); + } +}); /** * Toggles the state of a given folder @@ -176,7 +176,7 @@ document.addEventListener("nav", () => { * @param collapsed if folder should be set to collapsed or not */ function setFolderState(folderElement: HTMLElement, collapsed: boolean) { - return collapsed ? folderElement.classList.remove("open") : folderElement.classList.add("open") + return collapsed ? folderElement.classList.remove("open") : folderElement.classList.add("open"); } /** @@ -185,8 +185,8 @@ function setFolderState(folderElement: HTMLElement, collapsed: boolean) { * @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 - } + const entry = array.find((item) => item.path === path); + if (entry) { + entry.collapsed = !entry.collapsed; + } } diff --git a/quartz/components/scripts/explorer.inline.ts b/quartz/components/scripts/explorer.inline.ts index 3eb25ead42e40..bd111d44230a6 100644 --- a/quartz/components/scripts/explorer.inline.ts +++ b/quartz/components/scripts/explorer.inline.ts @@ -1,114 +1,114 @@ -import { FolderState } from "../ExplorerNode" +import { FolderState } from "../ExplorerNode"; type MaybeHTMLElement = HTMLElement | undefined -let currentExplorerState: FolderState[] +let currentExplorerState: FolderState[]; const observer = new IntersectionObserver((entries) => { - // If last element is observed, remove gradient of "overflow" class so element is visible - const explorerUl = document.getElementById("explorer-ul") - if (!explorerUl) return - for (const entry of entries) { - if (entry.isIntersecting) { - explorerUl.classList.add("no-background") - } else { - explorerUl.classList.remove("no-background") - } - } -}) + // If last element is observed, remove gradient of "overflow" class so element is visible + const explorerUl = document.getElementById("explorer-ul"); + if (!explorerUl) return; + for (const entry of entries) { + if (entry.isIntersecting) { + explorerUl.classList.add("no-background"); + } else { + explorerUl.classList.remove("no-background"); + } + } +}); function toggleExplorer(this: HTMLElement) { - this.classList.toggle("collapsed") - const content = this.nextElementSibling as MaybeHTMLElement - if (!content) return + this.classList.toggle("collapsed"); + const content = this.nextElementSibling as MaybeHTMLElement; + if (!content) return; - content.classList.toggle("collapsed") - content.style.maxHeight = content.style.maxHeight === "0px" ? content.scrollHeight + "px" : "0px" + content.classList.toggle("collapsed"); + content.style.maxHeight = content.style.maxHeight === "0px" ? content.scrollHeight + "px" : "0px"; } function toggleFolder(evt: MouseEvent) { - evt.stopPropagation() - const target = evt.target as MaybeHTMLElement - if (!target) return + evt.stopPropagation(); + const target = evt.target as MaybeHTMLElement; + if (!target) return; - const isSvg = target.nodeName === "svg" - const childFolderContainer = ( + const isSvg = target.nodeName === "svg"; + const childFolderContainer = ( isSvg - ? target.parentElement?.nextSibling - : target.parentElement?.parentElement?.nextElementSibling - ) as MaybeHTMLElement - const currentFolderParent = ( + ? target.parentElement?.nextSibling + : target.parentElement?.parentElement?.nextElementSibling + ) as MaybeHTMLElement; + const currentFolderParent = ( isSvg ? target.nextElementSibling : target.parentElement - ) as MaybeHTMLElement - if (!(childFolderContainer && currentFolderParent)) return - - childFolderContainer.classList.toggle("open") - const isCollapsed = childFolderContainer.classList.contains("open") - setFolderState(childFolderContainer, !isCollapsed) - const fullFolderPath = currentFolderParent.dataset.folderpath as string - toggleCollapsedByPath(currentExplorerState, fullFolderPath) - const stringifiedFileTree = JSON.stringify(currentExplorerState) - localStorage.setItem("fileTree", stringifiedFileTree) + ) as MaybeHTMLElement; + if (!(childFolderContainer && currentFolderParent)) return; + + childFolderContainer.classList.toggle("open"); + const isCollapsed = childFolderContainer.classList.contains("open"); + setFolderState(childFolderContainer, !isCollapsed); + const fullFolderPath = currentFolderParent.dataset.folderpath as string; + toggleCollapsedByPath(currentExplorerState, fullFolderPath); + const stringifiedFileTree = JSON.stringify(currentExplorerState); + localStorage.setItem("fileTree", stringifiedFileTree); } function setupExplorer() { - const explorer = document.getElementById("explorer") - if (!explorer) return - - if (explorer.dataset.behavior === "collapse") { - for (const item of document.getElementsByClassName( - "folder-button", - ) as HTMLCollectionOf) { - item.addEventListener("click", toggleFolder) - window.addCleanup(() => item.removeEventListener("click", toggleFolder)) - } - } - - explorer.addEventListener("click", toggleExplorer) - window.addCleanup(() => explorer.removeEventListener("click", toggleExplorer)) - - // Set up click handlers for each folder (click handler on folder "icon") - for (const item of document.getElementsByClassName( - "folder-icon", - ) as HTMLCollectionOf) { - item.addEventListener("click", toggleFolder) - window.addCleanup(() => item.removeEventListener("click", toggleFolder)) - } - - // Get folder state from local storage - const storageTree = localStorage.getItem("fileTree") - const useSavedFolderState = explorer?.dataset.savestate === "true" - const oldExplorerState: FolderState[] = - storageTree && useSavedFolderState ? JSON.parse(storageTree) : [] - const oldIndex = new Map(oldExplorerState.map((entry) => [entry.path, entry.collapsed])) - const newExplorerState: FolderState[] = explorer.dataset.tree - ? JSON.parse(explorer.dataset.tree) - : [] - currentExplorerState = [] - for (const { path, collapsed } of newExplorerState) { - currentExplorerState.push({ path, collapsed: oldIndex.get(path) ?? collapsed }) - } - - currentExplorerState.map((folderState) => { - const folderLi = document.querySelector( - `[data-folderpath='${folderState.path}']`, - ) as MaybeHTMLElement - const folderUl = folderLi?.parentElement?.nextElementSibling as MaybeHTMLElement - if (folderUl) { - setFolderState(folderUl, folderState.collapsed) - } - }) + const explorer = document.getElementById("explorer"); + if (!explorer) return; + + if (explorer.dataset.behavior === "collapse") { + for (const item of document.getElementsByClassName( + "folder-button", + ) as HTMLCollectionOf) { + item.addEventListener("click", toggleFolder); + window.addCleanup(() => item.removeEventListener("click", toggleFolder)); + } + } + + explorer.addEventListener("click", toggleExplorer); + window.addCleanup(() => explorer.removeEventListener("click", toggleExplorer)); + + // Set up click handlers for each folder (click handler on folder "icon") + for (const item of document.getElementsByClassName( + "folder-icon", + ) as HTMLCollectionOf) { + item.addEventListener("click", toggleFolder); + window.addCleanup(() => item.removeEventListener("click", toggleFolder)); + } + + // Get folder state from local storage + const storageTree = localStorage.getItem("fileTree"); + const useSavedFolderState = explorer?.dataset.savestate === "true"; + const oldExplorerState: FolderState[] = + storageTree && useSavedFolderState ? JSON.parse(storageTree) : []; + const oldIndex = new Map(oldExplorerState.map((entry) => [entry.path, entry.collapsed])); + const newExplorerState: FolderState[] = explorer.dataset.tree + ? JSON.parse(explorer.dataset.tree) + : []; + currentExplorerState = []; + for (const { path, collapsed } of newExplorerState) { + currentExplorerState.push({ path, collapsed: oldIndex.get(path) ?? collapsed }); + } + + currentExplorerState.map((folderState) => { + const folderLi = document.querySelector( + `[data-folderpath='${folderState.path}']`, + ) as MaybeHTMLElement; + const folderUl = folderLi?.parentElement?.nextElementSibling as MaybeHTMLElement; + if (folderUl) { + setFolderState(folderUl, folderState.collapsed); + } + }); } -window.addEventListener("resize", setupExplorer) +window.addEventListener("resize", setupExplorer); document.addEventListener("nav", () => { - setupExplorer() - observer.disconnect() + setupExplorer(); + observer.disconnect(); - // select pseudo element at end of list - const lastItem = document.getElementById("explorer-end") - if (lastItem) { - observer.observe(lastItem) - } -}) + // select pseudo element at end of list + const lastItem = document.getElementById("explorer-end"); + if (lastItem) { + observer.observe(lastItem); + } +}); /** * Toggles the state of a given folder @@ -116,7 +116,7 @@ document.addEventListener("nav", () => { * @param collapsed if folder should be set to collapsed or not */ function setFolderState(folderElement: HTMLElement, collapsed: boolean) { - return collapsed ? folderElement.classList.remove("open") : folderElement.classList.add("open") + return collapsed ? folderElement.classList.remove("open") : folderElement.classList.add("open"); } /** @@ -125,8 +125,8 @@ function setFolderState(folderElement: HTMLElement, collapsed: boolean) { * @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 - } + const entry = array.find((item) => item.path === path); + if (entry) { + entry.collapsed = !entry.collapsed; + } } diff --git a/quartz/components/scripts/graph.inline.ts b/quartz/components/scripts/graph.inline.ts index c991e163ef0c8..c8bc2b8dd4296 100644 --- a/quartz/components/scripts/graph.inline.ts +++ b/quartz/components/scripts/graph.inline.ts @@ -1,7 +1,8 @@ -import type { ContentDetails, ContentIndex } from "../../plugins/emitters/contentIndex" -import * as d3 from "d3" -import { registerEscapeHandler, removeAllChildren } from "./util" -import { FullSlug, SimpleSlug, getFullSlug, resolveRelative, simplifySlug } from "../../util/path" +import * as d3 from "d3"; + +import type { ContentDetails } from "../../plugins/emitters/contentIndex"; +import { FullSlug, getFullSlug, resolveRelative, SimpleSlug, simplifySlug } from "../../util/path"; +import { registerEscapeHandler, removeAllChildren } from "./util"; type NodeData = { id: SimpleSlug @@ -14,317 +15,317 @@ type LinkData = { target: SimpleSlug } -const localStorageKey = "graph-visited" +const localStorageKey = "graph-visited"; function getVisited(): Set { - return new Set(JSON.parse(localStorage.getItem(localStorageKey) ?? "[]")) + return new Set(JSON.parse(localStorage.getItem(localStorageKey) ?? "[]")); } function addToVisited(slug: SimpleSlug) { - const visited = getVisited() - visited.add(slug) - localStorage.setItem(localStorageKey, JSON.stringify([...visited])) + const visited = getVisited(); + visited.add(slug); + localStorage.setItem(localStorageKey, JSON.stringify([...visited])); } async function renderGraph(container: string, fullSlug: FullSlug) { - const slug = simplifySlug(fullSlug) - const visited = getVisited() - const graph = document.getElementById(container) - if (!graph) return - removeAllChildren(graph) - - let { - drag: enableDrag, - zoom: enableZoom, - depth, - scale, - repelForce, - centerForce, - linkDistance, - fontSize, - opacityScale, - removeTags, - showTags, - } = JSON.parse(graph.dataset["cfg"]!) - - const data: Map = new Map( - Object.entries(await fetchData).map(([k, v]) => [ - simplifySlug(k as FullSlug), - v, - ]), - ) - const links: LinkData[] = [] - const tags: SimpleSlug[] = [] - - const validLinks = new Set(data.keys()) - for (const [source, details] of data.entries()) { - const outgoing = details.links ?? [] - - for (const dest of outgoing) { - if (validLinks.has(dest)) { - links.push({ source: 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: source, target: tag }) - } - } - } - - const neighbourhood = new Set() - const wl: (SimpleSlug | "__SENTINEL")[] = [slug, "__SENTINEL"] - if (depth >= 0) { - while (depth >= 0 && wl.length > 0) { - // compute neighbours - const cur = wl.shift()! - if (cur === "__SENTINEL") { - depth-- - wl.push("__SENTINEL") - } else { - neighbourhood.add(cur) - const outgoing = links.filter((l) => l.source === cur) - const incoming = links.filter((l) => l.target === cur) - wl.push(...outgoing.map((l) => l.target), ...incoming.map((l) => l.source)) - } - } - } else { - validLinks.forEach((id) => neighbourhood.add(id)) - if (showTags) tags.forEach((tag) => neighbourhood.add(tag)) - } - - const graphData: { nodes: NodeData[]; links: LinkData[] } = { - nodes: [...neighbourhood].map((url) => { - const text = url.startsWith("tags/") ? "#" + url.substring(5) : data.get(url)?.title ?? url - return { - id: url, - text: text, - tags: data.get(url)?.tags ?? [], - } - }), - links: links.filter((l) => neighbourhood.has(l.source) && neighbourhood.has(l.target)), - } - - const simulation: d3.Simulation = d3 - .forceSimulation(graphData.nodes) - .force("charge", d3.forceManyBody().strength(-100 * repelForce)) - .force( - "link", - d3 - .forceLink(graphData.links) - .id((d: any) => d.id) - .distance(linkDistance), - ) - .force("center", d3.forceCenter().strength(centerForce)) - - const height = Math.max(graph.offsetHeight, 250) - const width = graph.offsetWidth - - const svg = d3 - .select("#" + container) - .append("svg") - .attr("width", width) - .attr("height", height) - .attr("viewBox", [-width / 2 / scale, -height / 2 / scale, width / scale, height / scale]) - - // draw links between nodes - const link = svg - .append("g") - .selectAll("line") - .data(graphData.links) - .join("line") - .attr("class", "link") - .attr("stroke", "var(--lightgray)") - .attr("stroke-width", 1) - - // svg groups - const graphNode = svg.append("g").selectAll("g").data(graphData.nodes).enter().append("g") - - // calculate color - const color = (d: NodeData) => { - const isCurrent = d.id === slug - if (isCurrent) { - return "var(--secondary)" - } else if (visited.has(d.id) || d.id.startsWith("tags/")) { - return "var(--tertiary)" - } else { - return "var(--gray)" - } - } - - const drag = (simulation: d3.Simulation) => { - function dragstarted(event: any, d: NodeData) { - if (!event.active) simulation.alphaTarget(1).restart() - d.fx = d.x - d.fy = d.y - } - - function dragged(event: any, d: NodeData) { - d.fx = event.x - d.fy = event.y - } - - function dragended(event: any, d: NodeData) { - if (!event.active) simulation.alphaTarget(0) - d.fx = null - d.fy = null - } - - const noop = () => {} - return d3 - .drag() - .on("start", enableDrag ? dragstarted : noop) - .on("drag", enableDrag ? dragged : noop) - .on("end", enableDrag ? dragended : noop) - } - - function nodeRadius(d: NodeData) { - const numLinks = links.filter((l: any) => l.source.id === d.id || l.target.id === d.id).length - return 2 + Math.sqrt(numLinks) - } - - // draw individual nodes - const node = graphNode - .append("circle") - .attr("class", "node") - .attr("id", (d) => d.id) - .attr("r", nodeRadius) - .attr("fill", color) - .style("cursor", "pointer") - .on("click", (_, d) => { - const targ = resolveRelative(fullSlug, d.id) - window.spaNavigate(new URL(targ, window.location.toString())) - }) - .on("mouseover", function (_, d) { - const neighbours: SimpleSlug[] = data.get(slug)?.links ?? [] - const neighbourNodes = d3 - .selectAll(".node") - .filter((d) => neighbours.includes(d.id)) - const currentId = d.id - const linkNodes = d3 - .selectAll(".link") - .filter((d: any) => d.source.id === currentId || d.target.id === currentId) - - // highlight neighbour nodes - neighbourNodes.transition().duration(200).attr("fill", color) - - // highlight links - linkNodes.transition().duration(200).attr("stroke", "var(--gray)").attr("stroke-width", 1) - - const bigFont = fontSize * 1.5 - - // show text for self - const parent = this.parentNode as HTMLElement - d3.select(parent) - .raise() - .select("text") - .transition() - .duration(200) - .attr("opacityOld", d3.select(parent).select("text").style("opacity")) - .style("opacity", 1) - .style("font-size", bigFont + "em") - }) - .on("mouseleave", function (_, d) { - const currentId = d.id - const linkNodes = d3 - .selectAll(".link") - .filter((d: any) => d.source.id === currentId || d.target.id === currentId) - - linkNodes.transition().duration(200).attr("stroke", "var(--lightgray)") - - const parent = this.parentNode as HTMLElement - d3.select(parent) - .select("text") - .transition() - .duration(200) - .style("opacity", d3.select(parent).select("text").attr("opacityOld")) - .style("font-size", fontSize + "em") - }) - // @ts-ignore - .call(drag(simulation)) - - // draw labels - const labels = graphNode - .append("text") - .attr("dx", 0) - .attr("dy", (d) => -nodeRadius(d) + "px") - .attr("text-anchor", "middle") - .text((d) => d.text) - .style("opacity", (opacityScale - 1) / 3.75) - .style("pointer-events", "none") - .style("font-size", fontSize + "em") - .raise() - // @ts-ignore - .call(drag(simulation)) - - // set panning - if (enableZoom) { - svg.call( - d3 - .zoom() - .extent([ - [0, 0], - [width, height], - ]) - .scaleExtent([0.25, 4]) - .on("zoom", ({ transform }) => { - link.attr("transform", transform) - node.attr("transform", transform) - const scale = transform.k * opacityScale - const scaledOpacity = Math.max((scale - 1) / 3.75, 0) - labels.attr("transform", transform).style("opacity", scaledOpacity) - }), - ) - } - - // progress the simulation - simulation.on("tick", () => { - link - .attr("x1", (d: any) => d.source.x) - .attr("y1", (d: any) => d.source.y) - .attr("x2", (d: any) => d.target.x) - .attr("y2", (d: any) => d.target.y) - node.attr("cx", (d: any) => d.x).attr("cy", (d: any) => d.y) - labels.attr("x", (d: any) => d.x).attr("y", (d: any) => d.y) - }) + const slug = simplifySlug(fullSlug); + const visited = getVisited(); + const graph = document.getElementById(container); + if (!graph) return; + removeAllChildren(graph); + + let { + drag: enableDrag, + zoom: enableZoom, + depth, + scale, + repelForce, + centerForce, + linkDistance, + fontSize, + opacityScale, + removeTags, + showTags, + } = JSON.parse(graph.dataset["cfg"]!); + + const data: Map = new Map( + Object.entries(await fetchData).map(([k, v]) => [ + simplifySlug(k as FullSlug), + v, + ]), + ); + const links: LinkData[] = []; + const tags: SimpleSlug[] = []; + + const validLinks = new Set(data.keys()); + for (const [source, details] of data.entries()) { + const outgoing = details.links ?? []; + + for (const dest of outgoing) { + if (validLinks.has(dest)) { + links.push({ source: 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: source, target: tag }); + } + } + } + + const neighbourhood = new Set(); + const wl: (SimpleSlug | "__SENTINEL")[] = [slug, "__SENTINEL"]; + if (depth >= 0) { + while (depth >= 0 && wl.length > 0) { + // compute neighbours + const cur = wl.shift()!; + if (cur === "__SENTINEL") { + depth--; + wl.push("__SENTINEL"); + } else { + neighbourhood.add(cur); + const outgoing = links.filter((l) => l.source === cur); + const incoming = links.filter((l) => l.target === cur); + wl.push(...outgoing.map((l) => l.target), ...incoming.map((l) => l.source)); + } + } + } else { + validLinks.forEach((id) => neighbourhood.add(id)); + if (showTags) tags.forEach((tag) => neighbourhood.add(tag)); + } + + const graphData: { nodes: NodeData[]; links: LinkData[] } = { + nodes: [...neighbourhood].map((url) => { + const text = url.startsWith("tags/") ? "#" + url.substring(5) : data.get(url)?.title ?? url; + return { + id: url, + text: text, + tags: data.get(url)?.tags ?? [], + }; + }), + links: links.filter((l) => neighbourhood.has(l.source) && neighbourhood.has(l.target)), + }; + + const simulation: d3.Simulation = d3 + .forceSimulation(graphData.nodes) + .force("charge", d3.forceManyBody().strength(-100 * repelForce)) + .force( + "link", + d3 + .forceLink(graphData.links) + .id((d: any) => d.id) + .distance(linkDistance), + ) + .force("center", d3.forceCenter().strength(centerForce)); + + const height = Math.max(graph.offsetHeight, 250); + const width = graph.offsetWidth; + + const svg = d3 + .select("#" + container) + .append("svg") + .attr("width", width) + .attr("height", height) + .attr("viewBox", [-width / 2 / scale, -height / 2 / scale, width / scale, height / scale]); + + // draw links between nodes + const link = svg + .append("g") + .selectAll("line") + .data(graphData.links) + .join("line") + .attr("class", "link") + .attr("stroke", "var(--lightgray)") + .attr("stroke-width", 1); + + // svg groups + const graphNode = svg.append("g").selectAll("g").data(graphData.nodes).enter().append("g"); + + // calculate color + const color = (d: NodeData) => { + const isCurrent = d.id === slug; + if (isCurrent) { + return "var(--secondary)"; + } else if (visited.has(d.id) || d.id.startsWith("tags/")) { + return "var(--tertiary)"; + } else { + return "var(--gray)"; + } + }; + + const drag = (simulation: d3.Simulation) => { + function dragstarted(event: any, d: NodeData) { + if (!event.active) simulation.alphaTarget(1).restart(); + d.fx = d.x; + d.fy = d.y; + } + + function dragged(event: any, d: NodeData) { + d.fx = event.x; + d.fy = event.y; + } + + function dragended(event: any, d: NodeData) { + if (!event.active) simulation.alphaTarget(0); + d.fx = null; + d.fy = null; + } + + const noop = () => {}; + return d3 + .drag() + .on("start", enableDrag ? dragstarted : noop) + .on("drag", enableDrag ? dragged : noop) + .on("end", enableDrag ? dragended : noop); + }; + + function nodeRadius(d: NodeData) { + const numLinks = links.filter((l: any) => l.source.id === d.id || l.target.id === d.id).length; + return 2 + Math.sqrt(numLinks); + } + + // draw individual nodes + const node = graphNode + .append("circle") + .attr("class", "node") + .attr("id", (d) => d.id) + .attr("r", nodeRadius) + .attr("fill", color) + .style("cursor", "pointer") + .on("click", (_, d) => { + const targ = resolveRelative(fullSlug, d.id); + window.spaNavigate(new URL(targ, window.location.toString())); + }) + .on("mouseover", function (_, d) { + const neighbours: SimpleSlug[] = data.get(slug)?.links ?? []; + const neighbourNodes = d3 + .selectAll(".node") + .filter((d) => neighbours.includes(d.id)); + const currentId = d.id; + const linkNodes = d3 + .selectAll(".link") + .filter((d: any) => d.source.id === currentId || d.target.id === currentId); + + // highlight neighbour nodes + neighbourNodes.transition().duration(200).attr("fill", color); + + // highlight links + linkNodes.transition().duration(200).attr("stroke", "var(--gray)").attr("stroke-width", 1); + + const bigFont = fontSize * 1.5; + + // show text for self + const parent = this.parentNode as HTMLElement; + d3.select(parent) + .raise() + .select("text") + .transition() + .duration(200) + .attr("opacityOld", d3.select(parent).select("text").style("opacity")) + .style("opacity", 1) + .style("font-size", bigFont + "em"); + }) + .on("mouseleave", function (_, d) { + const currentId = d.id; + const linkNodes = d3 + .selectAll(".link") + .filter((d: any) => d.source.id === currentId || d.target.id === currentId); + + linkNodes.transition().duration(200).attr("stroke", "var(--lightgray)"); + + const parent = this.parentNode as HTMLElement; + d3.select(parent) + .select("text") + .transition() + .duration(200) + .style("opacity", d3.select(parent).select("text").attr("opacityOld")) + .style("font-size", fontSize + "em"); + }) + // @ts-ignore + .call(drag(simulation)); + + // draw labels + const labels = graphNode + .append("text") + .attr("dx", 0) + .attr("dy", (d) => -nodeRadius(d) + "px") + .attr("text-anchor", "middle") + .text((d) => d.text) + .style("opacity", (opacityScale - 1) / 3.75) + .style("pointer-events", "none") + .style("font-size", fontSize + "em") + .raise() + // @ts-ignore + .call(drag(simulation)); + + // set panning + if (enableZoom) { + svg.call( + d3 + .zoom() + .extent([ + [0, 0], + [width, height], + ]) + .scaleExtent([0.25, 4]) + .on("zoom", ({ transform }) => { + link.attr("transform", transform); + node.attr("transform", transform); + const scale = transform.k * opacityScale; + const scaledOpacity = Math.max((scale - 1) / 3.75, 0); + labels.attr("transform", transform).style("opacity", scaledOpacity); + }), + ); + } + + // progress the simulation + simulation.on("tick", () => { + link + .attr("x1", (d: any) => d.source.x) + .attr("y1", (d: any) => d.source.y) + .attr("x2", (d: any) => d.target.x) + .attr("y2", (d: any) => d.target.y); + node.attr("cx", (d: any) => d.x).attr("cy", (d: any) => d.y); + labels.attr("x", (d: any) => d.x).attr("y", (d: any) => d.y); + }); } function renderGlobalGraph() { - const slug = getFullSlug(window) - const container = document.getElementById("global-graph-outer") - const sidebar = container?.closest(".sidebar") as HTMLElement - container?.classList.add("active") - if (sidebar) { - sidebar.style.zIndex = "1" - } - - renderGraph("global-graph-container", slug) - - function hideGlobalGraph() { - container?.classList.remove("active") - const graph = document.getElementById("global-graph-container") - if (sidebar) { - sidebar.style.zIndex = "unset" - } - if (!graph) return - removeAllChildren(graph) - } - - registerEscapeHandler(container, hideGlobalGraph) + const slug = getFullSlug(window); + const container = document.getElementById("global-graph-outer"); + const sidebar = container?.closest(".sidebar") as HTMLElement; + container?.classList.add("active"); + if (sidebar) { + sidebar.style.zIndex = "1"; + } + + renderGraph("global-graph-container", slug); + + function hideGlobalGraph() { + container?.classList.remove("active"); + const graph = document.getElementById("global-graph-container"); + if (sidebar) { + sidebar.style.zIndex = "unset"; + } + if (!graph) return; + removeAllChildren(graph); + } + + registerEscapeHandler(container, hideGlobalGraph); } document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { - const slug = e.detail.url - addToVisited(slug) - await renderGraph("graph-container", slug) - - const containerIcon = document.getElementById("global-graph-icon") - containerIcon?.addEventListener("click", renderGlobalGraph) - window.addCleanup(() => containerIcon?.removeEventListener("click", renderGlobalGraph)) -}) + const slug = e.detail.url; + addToVisited(slug); + await renderGraph("graph-container", slug); + + const containerIcon = document.getElementById("global-graph-icon"); + containerIcon?.addEventListener("click", renderGlobalGraph); + window.addCleanup(() => containerIcon?.removeEventListener("click", renderGlobalGraph)); +}); diff --git a/quartz/components/scripts/popover.inline.ts b/quartz/components/scripts/popover.inline.ts index 972d3c638819c..ba04535430d7b 100644 --- a/quartz/components/scripts/popover.inline.ts +++ b/quartz/components/scripts/popover.inline.ts @@ -1,108 +1,109 @@ -import { computePosition, flip, inline, shift } from "@floating-ui/dom" -import { normalizeRelativeURLs } from "../../util/path" +import { computePosition, flip, inline, shift } from "@floating-ui/dom"; -const p = new DOMParser() +import { normalizeRelativeURLs } from "../../util/path"; + +const p = new DOMParser(); async function mouseEnterHandler( - this: HTMLLinkElement, - { clientX, clientY }: { clientX: number; clientY: number }, + this: HTMLLinkElement, + { clientX, clientY }: { clientX: number; clientY: number }, ) { - const link = this - if (link.dataset.noPopover === "true") { - return - } - - async function setPosition(popoverElement: HTMLElement) { - const { x, y } = await computePosition(link, popoverElement, { - middleware: [inline({ x: clientX, y: clientY }), shift(), flip()], - }) - Object.assign(popoverElement.style, { - left: `${x}px`, - top: `${y}px`, - }) - } - - const hasAlreadyBeenFetched = () => - [...link.children].some((child) => child.classList.contains("popover")) - - // dont refetch if there's already a popover - if (hasAlreadyBeenFetched()) { - return setPosition(link.lastChild as HTMLElement) - } - - const thisUrl = new URL(document.location.href) - thisUrl.hash = "" - thisUrl.search = "" - const targetUrl = new URL(link.href) - const hash = targetUrl.hash - targetUrl.hash = "" - targetUrl.search = "" - - const response = await fetch(`${targetUrl}`).catch((err) => { - console.error(err) - }) - - // bailout if another popover exists - if (hasAlreadyBeenFetched()) { - return - } - - if (!response) return - const [contentType] = response.headers.get("Content-Type")!.split(";") - const [contentTypeCategory, typeInfo] = contentType.split("/") - - const popoverElement = document.createElement("div") - popoverElement.classList.add("popover") - const popoverInner = document.createElement("div") - popoverInner.classList.add("popover-inner") - popoverElement.appendChild(popoverInner) - - popoverInner.dataset.contentType = contentType ?? undefined - - switch (contentTypeCategory) { - case "image": - const img = document.createElement("img") - img.src = targetUrl.toString() - img.alt = targetUrl.pathname - - popoverInner.appendChild(img) - break - case "application": - switch (typeInfo) { - case "pdf": - const pdf = document.createElement("iframe") - pdf.src = targetUrl.toString() - popoverInner.appendChild(pdf) - break - default: - break - } - break - default: - const contents = await response.text() - const html = p.parseFromString(contents, "text/html") - normalizeRelativeURLs(html, targetUrl) - const elts = [...html.getElementsByClassName("popover-hint")] - if (elts.length === 0) return - - elts.forEach((elt) => popoverInner.appendChild(elt)) - } - - setPosition(popoverElement) - link.appendChild(popoverElement) - - if (hash !== "") { - const heading = popoverInner.querySelector(hash) as HTMLElement | null - if (heading) { - // leave ~12px of buffer when scrolling to a heading - popoverInner.scroll({ top: heading.offsetTop - 12, behavior: "instant" }) - } - } + const link = this; + if (link.dataset.noPopover === "true") { + return; + } + + async function setPosition(popoverElement: HTMLElement) { + const { x, y } = await computePosition(link, popoverElement, { + middleware: [inline({ x: clientX, y: clientY }), shift(), flip()], + }); + Object.assign(popoverElement.style, { + left: `${x}px`, + top: `${y}px`, + }); + } + + const hasAlreadyBeenFetched = () => + [...link.children].some((child) => child.classList.contains("popover")); + + // dont refetch if there's already a popover + if (hasAlreadyBeenFetched()) { + return setPosition(link.lastChild as HTMLElement); + } + + const thisUrl = new URL(document.location.href); + thisUrl.hash = ""; + thisUrl.search = ""; + const targetUrl = new URL(link.href); + const hash = targetUrl.hash; + targetUrl.hash = ""; + targetUrl.search = ""; + + const response = await fetch(`${targetUrl}`).catch((err) => { + console.error(err); + }); + + // bailout if another popover exists + if (hasAlreadyBeenFetched()) { + return; + } + + if (!response) return; + const [contentType] = response.headers.get("Content-Type")!.split(";"); + const [contentTypeCategory, typeInfo] = contentType.split("/"); + + const popoverElement = document.createElement("div"); + popoverElement.classList.add("popover"); + const popoverInner = document.createElement("div"); + popoverInner.classList.add("popover-inner"); + popoverElement.appendChild(popoverInner); + + popoverInner.dataset.contentType = contentType ?? undefined; + + switch (contentTypeCategory) { + case "image": + const img = document.createElement("img"); + img.src = targetUrl.toString(); + img.alt = targetUrl.pathname; + + popoverInner.appendChild(img); + break; + case "application": + switch (typeInfo) { + case "pdf": + const pdf = document.createElement("iframe"); + pdf.src = targetUrl.toString(); + popoverInner.appendChild(pdf); + break; + default: + break; + } + break; + default: + const contents = await response.text(); + const html = p.parseFromString(contents, "text/html"); + normalizeRelativeURLs(html, targetUrl); + const elts = [...html.getElementsByClassName("popover-hint")]; + if (elts.length === 0) return; + + elts.forEach((elt) => popoverInner.appendChild(elt)); + } + + setPosition(popoverElement); + link.appendChild(popoverElement); + + if (hash !== "") { + const heading = popoverInner.querySelector(hash) as HTMLElement | null; + if (heading) { + // leave ~12px of buffer when scrolling to a heading + popoverInner.scroll({ top: heading.offsetTop - 12, behavior: "instant" }); + } + } } document.addEventListener("nav", () => { - const links = [...document.getElementsByClassName("internal")] as HTMLLinkElement[] - for (const link of links) { - link.addEventListener("mouseenter", mouseEnterHandler) - window.addCleanup(() => link.removeEventListener("mouseenter", mouseEnterHandler)) - } -}) + const links = [...document.getElementsByClassName("internal")] as HTMLLinkElement[]; + for (const link of links) { + link.addEventListener("mouseenter", mouseEnterHandler); + window.addCleanup(() => link.removeEventListener("mouseenter", mouseEnterHandler)); + } +}); diff --git a/quartz/components/scripts/search.inline.ts b/quartz/components/scripts/search.inline.ts index a75f4ff460eb9..f41e5500e5c54 100644 --- a/quartz/components/scripts/search.inline.ts +++ b/quartz/components/scripts/search.inline.ts @@ -1,7 +1,8 @@ -import FlexSearch from "flexsearch" -import { ContentDetails } from "../../plugins/emitters/contentIndex" -import { registerEscapeHandler, removeAllChildren } from "./util" -import { FullSlug, normalizeRelativeURLs, resolveRelative } from "../../util/path" +import FlexSearch from "flexsearch"; + +import { ContentDetails } from "../../plugins/emitters/contentIndex"; +import { FullSlug, normalizeRelativeURLs, resolveRelative } from "../../util/path"; +import { registerEscapeHandler, removeAllChildren } from "./util"; interface Item { id: number @@ -13,436 +14,436 @@ interface Item { // Can be expanded with things like "term" in the future type SearchType = "basic" | "tags" -let searchType: SearchType = "basic" -let currentSearchTerm: string = "" -const encoder = (str: string) => str.toLowerCase().split(/([^a-z]|[^\x00-\x7F])/) -let index = new FlexSearch.Document({ - charset: "latin:extra", - encode: encoder, - document: { - id: "id", - index: [ - { - field: "title", - tokenize: "forward", - }, - { - field: "content", - tokenize: "forward", - }, - { - field: "tags", - tokenize: "forward", - }, - ], - }, -}) - -const p = new DOMParser() -const fetchContentCache: Map = new Map() -const contextWindowWords = 30 -const numSearchResults = 8 -const numTagResults = 5 +let searchType: SearchType = "basic"; +let currentSearchTerm: string = ""; +const encoder = (str: string) => str.toLowerCase().split(/([^a-z]|[^\x00-\x7F])/); +const index = new FlexSearch.Document({ + charset: "latin:extra", + encode: encoder, + document: { + id: "id", + index: [ + { + field: "title", + tokenize: "forward", + }, + { + field: "content", + tokenize: "forward", + }, + { + field: "tags", + tokenize: "forward", + }, + ], + }, +}); + +const p = new DOMParser(); +const fetchContentCache: Map = new Map(); +const contextWindowWords = 30; +const numSearchResults = 8; +const numTagResults = 5; const tokenizeTerm = (term: string) => { - const tokens = term.split(/\s+/).filter((t) => t.trim() !== "") - const tokenLen = tokens.length - if (tokenLen > 1) { - for (let i = 1; i < tokenLen; i++) { - tokens.push(tokens.slice(0, i + 1).join(" ")) - } - } - - return tokens.sort((a, b) => b.length - a.length) // always highlight longest terms first -} + const tokens = term.split(/\s+/).filter((t) => t.trim() !== ""); + const tokenLen = tokens.length; + if (tokenLen > 1) { + for (let i = 1; i < tokenLen; i++) { + tokens.push(tokens.slice(0, i + 1).join(" ")); + } + } + + return tokens.sort((a, b) => b.length - a.length); // always highlight longest terms first +}; function highlight(searchTerm: string, text: string, trim?: boolean) { - const tokenizedTerms = tokenizeTerm(searchTerm) - let tokenizedText = text.split(/\s+/).filter((t) => t !== "") - - let startIndex = 0 - let endIndex = tokenizedText.length - 1 - if (trim) { - const includesCheck = (tok: string) => - tokenizedTerms.some((term) => tok.toLowerCase().startsWith(term.toLowerCase())) - const occurrencesIndices = tokenizedText.map(includesCheck) - - let bestSum = 0 - let bestIndex = 0 - for (let i = 0; i < Math.max(tokenizedText.length - contextWindowWords, 0); i++) { - const window = occurrencesIndices.slice(i, i + contextWindowWords) - const windowSum = window.reduce((total, cur) => total + (cur ? 1 : 0), 0) - if (windowSum >= bestSum) { - bestSum = windowSum - bestIndex = i - } - } - - startIndex = Math.max(bestIndex - contextWindowWords, 0) - endIndex = Math.min(startIndex + 2 * contextWindowWords, tokenizedText.length - 1) - tokenizedText = tokenizedText.slice(startIndex, endIndex) - } - - const slice = tokenizedText - .map((tok) => { - // see if this tok is prefixed by any search terms - for (const searchTok of tokenizedTerms) { - if (tok.toLowerCase().includes(searchTok.toLowerCase())) { - const regex = new RegExp(searchTok.toLowerCase(), "gi") - return tok.replace(regex, `$&`) - } - } - return tok - }) - .join(" ") - - return `${startIndex === 0 ? "" : "..."}${slice}${ - endIndex === tokenizedText.length - 1 ? "" : "..." - }` + const tokenizedTerms = tokenizeTerm(searchTerm); + let tokenizedText = text.split(/\s+/).filter((t) => t !== ""); + + let startIndex = 0; + let endIndex = tokenizedText.length - 1; + if (trim) { + const includesCheck = (tok: string) => + tokenizedTerms.some((term) => tok.toLowerCase().startsWith(term.toLowerCase())); + const occurrencesIndices = tokenizedText.map(includesCheck); + + let bestSum = 0; + let bestIndex = 0; + for (let i = 0; i < Math.max(tokenizedText.length - contextWindowWords, 0); i++) { + const window = occurrencesIndices.slice(i, i + contextWindowWords); + const windowSum = window.reduce((total, cur) => total + (cur ? 1 : 0), 0); + if (windowSum >= bestSum) { + bestSum = windowSum; + bestIndex = i; + } + } + + startIndex = Math.max(bestIndex - contextWindowWords, 0); + endIndex = Math.min(startIndex + 2 * contextWindowWords, tokenizedText.length - 1); + tokenizedText = tokenizedText.slice(startIndex, endIndex); + } + + const slice = tokenizedText + .map((tok) => { + // see if this tok is prefixed by any search terms + for (const searchTok of tokenizedTerms) { + if (tok.toLowerCase().includes(searchTok.toLowerCase())) { + const regex = new RegExp(searchTok.toLowerCase(), "gi"); + return tok.replace(regex, "$&"); + } + } + return tok; + }) + .join(" "); + + return `${startIndex === 0 ? "" : "..."}${slice}${ + endIndex === tokenizedText.length - 1 ? "" : "..." + }`; } function highlightHTML(searchTerm: string, el: HTMLElement) { - const p = new DOMParser() - const tokenizedTerms = tokenizeTerm(searchTerm) - const html = p.parseFromString(el.innerHTML, "text/html") - - const createHighlightSpan = (text: string) => { - const span = document.createElement("span") - span.className = "highlight" - span.textContent = text - return span - } - - const highlightTextNodes = (node: Node, term: string) => { - if (node.nodeType === Node.TEXT_NODE) { - const nodeText = node.nodeValue ?? "" - const regex = new RegExp(term.toLowerCase(), "gi") - const matches = nodeText.match(regex) - if (!matches || matches.length === 0) return - const spanContainer = document.createElement("span") - let lastIndex = 0 - for (const match of matches) { - const matchIndex = nodeText.indexOf(match, lastIndex) - spanContainer.appendChild(document.createTextNode(nodeText.slice(lastIndex, matchIndex))) - spanContainer.appendChild(createHighlightSpan(match)) - lastIndex = matchIndex + match.length - } - spanContainer.appendChild(document.createTextNode(nodeText.slice(lastIndex))) - node.parentNode?.replaceChild(spanContainer, node) - } else if (node.nodeType === Node.ELEMENT_NODE) { - if ((node as HTMLElement).classList.contains("highlight")) return - Array.from(node.childNodes).forEach((child) => highlightTextNodes(child, term)) - } - } - - for (const term of tokenizedTerms) { - highlightTextNodes(html.body, term) - } - - return html.body + const p = new DOMParser(); + const tokenizedTerms = tokenizeTerm(searchTerm); + const html = p.parseFromString(el.innerHTML, "text/html"); + + const createHighlightSpan = (text: string) => { + const span = document.createElement("span"); + span.className = "highlight"; + span.textContent = text; + return span; + }; + + const highlightTextNodes = (node: Node, term: string) => { + if (node.nodeType === Node.TEXT_NODE) { + const nodeText = node.nodeValue ?? ""; + const regex = new RegExp(term.toLowerCase(), "gi"); + const matches = nodeText.match(regex); + if (!matches || matches.length === 0) return; + const spanContainer = document.createElement("span"); + let lastIndex = 0; + for (const match of matches) { + const matchIndex = nodeText.indexOf(match, lastIndex); + spanContainer.appendChild(document.createTextNode(nodeText.slice(lastIndex, matchIndex))); + spanContainer.appendChild(createHighlightSpan(match)); + lastIndex = matchIndex + match.length; + } + spanContainer.appendChild(document.createTextNode(nodeText.slice(lastIndex))); + node.parentNode?.replaceChild(spanContainer, node); + } else if (node.nodeType === Node.ELEMENT_NODE) { + if ((node as HTMLElement).classList.contains("highlight")) return; + Array.from(node.childNodes).forEach((child) => highlightTextNodes(child, term)); + } + }; + + for (const term of tokenizedTerms) { + highlightTextNodes(html.body, term); + } + + return html.body; } document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { - const currentSlug = e.detail.url - const data = await fetchData - const container = document.getElementById("search-container") - const sidebar = container?.closest(".sidebar") as HTMLElement - const searchIcon = document.getElementById("search-icon") - const searchBar = document.getElementById("search-bar") as HTMLInputElement | null - const searchLayout = document.getElementById("search-layout") - const idDataMap = Object.keys(data) as FullSlug[] - - const appendLayout = (el: HTMLElement) => { - if (searchLayout?.querySelector(`#${el.id}`) === null) { - searchLayout?.appendChild(el) - } - } - - const enablePreview = searchLayout?.dataset?.preview === "true" - let preview: HTMLDivElement | undefined = undefined - let previewInner: HTMLDivElement | undefined = undefined - const results = document.createElement("div") - results.id = "results-container" - appendLayout(results) - - if (enablePreview) { - preview = document.createElement("div") - preview.id = "preview-container" - appendLayout(preview) - } - - function hideSearch() { - container?.classList.remove("active") - if (searchBar) { - searchBar.value = "" // clear the input when we dismiss the search - } - if (sidebar) { - sidebar.style.zIndex = "unset" - } - if (results) { - removeAllChildren(results) - } - if (preview) { - removeAllChildren(preview) - } - if (searchLayout) { - searchLayout.classList.remove("display-results") - } - - searchType = "basic" // reset search type after closing - } - - function showSearch(searchTypeNew: SearchType) { - searchType = searchTypeNew - if (sidebar) { - sidebar.style.zIndex = "1" - } - container?.classList.add("active") - searchBar?.focus() - } - - let currentHover: HTMLInputElement | null = null - - async function shortcutHandler(e: HTMLElementEventMap["keydown"]) { - if (e.key === "k" && (e.ctrlKey || e.metaKey) && !e.shiftKey) { - e.preventDefault() - const searchBarOpen = container?.classList.contains("active") - searchBarOpen ? hideSearch() : showSearch("basic") - return - } 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 = "#" - return - } - - if (currentHover) { - currentHover.classList.remove("focus") - } - - // If search is active, then we will render the first result and display accordingly - if (!container?.classList.contains("active")) return - 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 - if (active.classList.contains("no-match")) return - await displayPreview(active) - active.click() - } else { - const anchor = document.getElementsByClassName("result-card")[0] as HTMLInputElement | null - if (!anchor || anchor?.classList.contains("no-match")) return - await displayPreview(anchor) - anchor.click() - } - } else if (e.key === "ArrowUp" || (e.shiftKey && e.key === "Tab")) { - e.preventDefault() - if (results?.contains(document.activeElement)) { - // If an element in results-container already has focus, focus previous one - const currentResult = currentHover - ? currentHover - : (document.activeElement as HTMLInputElement | null) - const prevResult = currentResult?.previousElementSibling as HTMLInputElement | null - currentResult?.classList.remove("focus") - prevResult?.focus() - if (prevResult) currentHover = prevResult - await displayPreview(prevResult) - } - } else if (e.key === "ArrowDown" || e.key === "Tab") { - e.preventDefault() - // The results should already been focused, so we need to find the next one. - // The activeElement is the search bar, so we need to find the first result and focus it. - if (document.activeElement === searchBar || currentHover !== null) { - const firstResult = currentHover - ? currentHover - : (document.getElementsByClassName("result-card")[0] as HTMLInputElement | null) - const secondResult = firstResult?.nextElementSibling as HTMLInputElement | null - firstResult?.classList.remove("focus") - secondResult?.focus() - if (secondResult) currentHover = secondResult - await displayPreview(secondResult) - } - } - } - - const formatForDisplay = (term: string, id: number) => { - const slug = idDataMap[id] - return { - id, - slug, - title: searchType === "tags" ? data[slug].title : highlight(term, data[slug].title ?? ""), - content: highlight(term, data[slug].content ?? "", true), - tags: highlightTags(term.substring(1), data[slug].tags), - } - } - - function highlightTags(term: string, tags: string[]) { - if (!tags || searchType !== "tags") { - return [] - } - - return tags - .map((tag) => { - if (tag.toLowerCase().includes(term.toLowerCase())) { - return `
    • #${tag}

    • ` - } else { - return `
    • #${tag}

    • ` - } - }) - .slice(0, numTagResults) - } - - function resolveUrl(slug: FullSlug): URL { - return new URL(resolveRelative(currentSlug, slug), location.toString()) - } - - const resultToHTML = ({ slug, title, content, tags }: Item) => { - const htmlTags = tags.length > 0 ? `
        ${tags.join("")}
      ` : `` - const itemTile = document.createElement("a") - itemTile.classList.add("result-card") - itemTile.id = slug - itemTile.href = resolveUrl(slug).toString() - itemTile.innerHTML = `

      ${title}

      ${htmlTags}${ - enablePreview && window.innerWidth > 600 ? "" : `

      ${content}

      ` - }` - itemTile.addEventListener("click", (event) => { - if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return - hideSearch() - }) - - const handler = (event: MouseEvent) => { - if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return - hideSearch() - } - - async function onMouseEnter(ev: MouseEvent) { - if (!ev.target) return - const target = ev.target as HTMLInputElement - await displayPreview(target) - } - - itemTile.addEventListener("mouseenter", onMouseEnter) - window.addCleanup(() => itemTile.removeEventListener("mouseenter", onMouseEnter)) - itemTile.addEventListener("click", handler) - window.addCleanup(() => itemTile.removeEventListener("click", handler)) - - return itemTile - } - - async function displayResults(finalResults: Item[]) { - if (!results) return - - removeAllChildren(results) - if (finalResults.length === 0) { - results.innerHTML = ` + const currentSlug = e.detail.url; + const data = await fetchData; + const container = document.getElementById("search-container"); + const sidebar = container?.closest(".sidebar") as HTMLElement; + const searchIcon = document.getElementById("search-icon"); + const searchBar = document.getElementById("search-bar") as HTMLInputElement | null; + const searchLayout = document.getElementById("search-layout"); + const idDataMap = Object.keys(data) as FullSlug[]; + + const appendLayout = (el: HTMLElement) => { + if (searchLayout?.querySelector(`#${el.id}`) === null) { + searchLayout?.appendChild(el); + } + }; + + const enablePreview = searchLayout?.dataset?.preview === "true"; + let preview: HTMLDivElement | undefined = undefined; + let previewInner: HTMLDivElement | undefined = undefined; + const results = document.createElement("div"); + results.id = "results-container"; + appendLayout(results); + + if (enablePreview) { + preview = document.createElement("div"); + preview.id = "preview-container"; + appendLayout(preview); + } + + function hideSearch() { + container?.classList.remove("active"); + if (searchBar) { + searchBar.value = ""; // clear the input when we dismiss the search + } + if (sidebar) { + sidebar.style.zIndex = "unset"; + } + if (results) { + removeAllChildren(results); + } + if (preview) { + removeAllChildren(preview); + } + if (searchLayout) { + searchLayout.classList.remove("display-results"); + } + + searchType = "basic"; // reset search type after closing + } + + function showSearch(searchTypeNew: SearchType) { + searchType = searchTypeNew; + if (sidebar) { + sidebar.style.zIndex = "1"; + } + container?.classList.add("active"); + searchBar?.focus(); + } + + let currentHover: HTMLInputElement | null = null; + + async function shortcutHandler(e: HTMLElementEventMap["keydown"]) { + if (e.key === "k" && (e.ctrlKey || e.metaKey) && !e.shiftKey) { + e.preventDefault(); + const searchBarOpen = container?.classList.contains("active"); + searchBarOpen ? hideSearch() : showSearch("basic"); + return; + } 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 = "#"; + return; + } + + if (currentHover) { + currentHover.classList.remove("focus"); + } + + // If search is active, then we will render the first result and display accordingly + if (!container?.classList.contains("active")) return; + 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; + if (active.classList.contains("no-match")) return; + await displayPreview(active); + active.click(); + } else { + const anchor = document.getElementsByClassName("result-card")[0] as HTMLInputElement | null; + if (!anchor || anchor?.classList.contains("no-match")) return; + await displayPreview(anchor); + anchor.click(); + } + } else if (e.key === "ArrowUp" || (e.shiftKey && e.key === "Tab")) { + e.preventDefault(); + if (results?.contains(document.activeElement)) { + // If an element in results-container already has focus, focus previous one + const currentResult = currentHover + ? currentHover + : (document.activeElement as HTMLInputElement | null); + const prevResult = currentResult?.previousElementSibling as HTMLInputElement | null; + currentResult?.classList.remove("focus"); + prevResult?.focus(); + if (prevResult) currentHover = prevResult; + await displayPreview(prevResult); + } + } else if (e.key === "ArrowDown" || e.key === "Tab") { + e.preventDefault(); + // The results should already been focused, so we need to find the next one. + // The activeElement is the search bar, so we need to find the first result and focus it. + if (document.activeElement === searchBar || currentHover !== null) { + const firstResult = currentHover + ? currentHover + : (document.getElementsByClassName("result-card")[0] as HTMLInputElement | null); + const secondResult = firstResult?.nextElementSibling as HTMLInputElement | null; + firstResult?.classList.remove("focus"); + secondResult?.focus(); + if (secondResult) currentHover = secondResult; + await displayPreview(secondResult); + } + } + } + + const formatForDisplay = (term: string, id: number) => { + const slug = idDataMap[id]; + return { + id, + slug, + title: searchType === "tags" ? data[slug].title : highlight(term, data[slug].title ?? ""), + content: highlight(term, data[slug].content ?? "", true), + tags: highlightTags(term.substring(1), data[slug].tags), + }; + }; + + function highlightTags(term: string, tags: string[]) { + if (!tags || searchType !== "tags") { + return []; + } + + return tags + .map((tag) => { + if (tag.toLowerCase().includes(term.toLowerCase())) { + return `
    • #${tag}

    • `; + } else { + return `
    • #${tag}

    • `; + } + }) + .slice(0, numTagResults); + } + + function resolveUrl(slug: FullSlug): URL { + return new URL(resolveRelative(currentSlug, slug), location.toString()); + } + + const resultToHTML = ({ slug, title, content, tags }: Item) => { + const htmlTags = tags.length > 0 ? `
        ${tags.join("")}
      ` : ""; + const itemTile = document.createElement("a"); + itemTile.classList.add("result-card"); + itemTile.id = slug; + itemTile.href = resolveUrl(slug).toString(); + itemTile.innerHTML = `

      ${title}

      ${htmlTags}${ + enablePreview && window.innerWidth > 600 ? "" : `

      ${content}

      ` + }`; + itemTile.addEventListener("click", (event) => { + if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return; + hideSearch(); + }); + + const handler = (event: MouseEvent) => { + if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return; + hideSearch(); + }; + + async function onMouseEnter(ev: MouseEvent) { + if (!ev.target) return; + const target = ev.target as HTMLInputElement; + await displayPreview(target); + } + + itemTile.addEventListener("mouseenter", onMouseEnter); + window.addCleanup(() => itemTile.removeEventListener("mouseenter", onMouseEnter)); + itemTile.addEventListener("click", handler); + window.addCleanup(() => itemTile.removeEventListener("click", handler)); + + return itemTile; + }; + + async function displayResults(finalResults: Item[]) { + if (!results) return; + + removeAllChildren(results); + if (finalResults.length === 0) { + results.innerHTML = `

      No results.

      Try another search term?

      -
      ` - } else { - results.append(...finalResults.map(resultToHTML)) - } - - if (finalResults.length === 0 && preview) { - // no results, clear previous preview - removeAllChildren(preview) - } else { - // focus on first result, then also dispatch preview immediately - const firstChild = results.firstElementChild as HTMLElement - firstChild.classList.add("focus") - currentHover = firstChild as HTMLInputElement - await displayPreview(firstChild) - } - } - - async function fetchContent(slug: FullSlug): Promise { - if (fetchContentCache.has(slug)) { - return fetchContentCache.get(slug) as Element[] - } - - const targetUrl = resolveUrl(slug).toString() - const contents = await fetch(targetUrl) - .then((res) => res.text()) - .then((contents) => { - if (contents === undefined) { - throw new Error(`Could not fetch ${targetUrl}`) - } - const html = p.parseFromString(contents ?? "", "text/html") - normalizeRelativeURLs(html, targetUrl) - return [...html.getElementsByClassName("popover-hint")] - }) - - fetchContentCache.set(slug, contents) - return contents - } - - async function displayPreview(el: HTMLElement | null) { - if (!searchLayout || !enablePreview || !el || !preview) return - const slug = el.id as FullSlug - const innerDiv = await fetchContent(slug).then((contents) => - contents.flatMap((el) => [...highlightHTML(currentSearchTerm, el as HTMLElement).children]), - ) - previewInner = document.createElement("div") - previewInner.classList.add("preview-inner") - previewInner.append(...innerDiv) - preview.replaceChildren(previewInner) - - // scroll to longest - const highlights = [...preview.querySelectorAll(".highlight")].sort( - (a, b) => b.innerHTML.length - a.innerHTML.length, - ) - highlights[0]?.scrollIntoView({ block: "start" }) - } - - async function onType(e: HTMLElementEventMap["input"]) { - if (!searchLayout || !index) return - currentSearchTerm = (e.target as HTMLInputElement).value - searchLayout.classList.toggle("display-results", currentSearchTerm !== "") - searchType = currentSearchTerm.startsWith("#") ? "tags" : "basic" - - let searchResults: FlexSearch.SimpleDocumentSearchResultSetUnit[] - if (searchType === "tags") { - searchResults = await index.searchAsync({ - query: currentSearchTerm.substring(1), - limit: numSearchResults, - index: ["tags"], - }) - } else if (searchType === "basic") { - searchResults = await index.searchAsync({ - query: currentSearchTerm, - limit: numSearchResults, - index: ["title", "content"], - }) - } - - const getByField = (field: string): number[] => { - const results = searchResults.filter((x) => x.field === field) - return results.length === 0 ? [] : ([...results[0].result] as number[]) - } - - // order titles ahead of content - const allIds: Set = new Set([ - ...getByField("title"), - ...getByField("content"), - ...getByField("tags"), - ]) - const finalResults = [...allIds].map((id) => formatForDisplay(currentSearchTerm, id)) - await displayResults(finalResults) - } - - document.addEventListener("keydown", shortcutHandler) - window.addCleanup(() => document.removeEventListener("keydown", shortcutHandler)) - searchIcon?.addEventListener("click", () => showSearch("basic")) - window.addCleanup(() => searchIcon?.removeEventListener("click", () => showSearch("basic"))) - searchBar?.addEventListener("input", onType) - window.addCleanup(() => searchBar?.removeEventListener("input", onType)) - - registerEscapeHandler(container, hideSearch) - await fillDocument(data) -}) + `; + } else { + results.append(...finalResults.map(resultToHTML)); + } + + if (finalResults.length === 0 && preview) { + // no results, clear previous preview + removeAllChildren(preview); + } else { + // focus on first result, then also dispatch preview immediately + const firstChild = results.firstElementChild as HTMLElement; + firstChild.classList.add("focus"); + currentHover = firstChild as HTMLInputElement; + await displayPreview(firstChild); + } + } + + async function fetchContent(slug: FullSlug): Promise { + if (fetchContentCache.has(slug)) { + return fetchContentCache.get(slug) as Element[]; + } + + const targetUrl = resolveUrl(slug).toString(); + const contents = await fetch(targetUrl) + .then((res) => res.text()) + .then((contents) => { + if (contents === undefined) { + throw new Error(`Could not fetch ${targetUrl}`); + } + const html = p.parseFromString(contents ?? "", "text/html"); + normalizeRelativeURLs(html, targetUrl); + return [...html.getElementsByClassName("popover-hint")]; + }); + + fetchContentCache.set(slug, contents); + return contents; + } + + async function displayPreview(el: HTMLElement | null) { + if (!searchLayout || !enablePreview || !el || !preview) return; + const slug = el.id as FullSlug; + const innerDiv = await fetchContent(slug).then((contents) => + contents.flatMap((el) => [...highlightHTML(currentSearchTerm, el as HTMLElement).children]), + ); + previewInner = document.createElement("div"); + previewInner.classList.add("preview-inner"); + previewInner.append(...innerDiv); + preview.replaceChildren(previewInner); + + // scroll to longest + const highlights = [...preview.querySelectorAll(".highlight")].sort( + (a, b) => b.innerHTML.length - a.innerHTML.length, + ); + highlights[0]?.scrollIntoView({ block: "start" }); + } + + async function onType(e: HTMLElementEventMap["input"]) { + if (!searchLayout || !index) return; + currentSearchTerm = (e.target as HTMLInputElement).value; + searchLayout.classList.toggle("display-results", currentSearchTerm !== ""); + searchType = currentSearchTerm.startsWith("#") ? "tags" : "basic"; + + let searchResults: FlexSearch.SimpleDocumentSearchResultSetUnit[]; + if (searchType === "tags") { + searchResults = await index.searchAsync({ + query: currentSearchTerm.substring(1), + limit: numSearchResults, + index: ["tags"], + }); + } else if (searchType === "basic") { + searchResults = await index.searchAsync({ + query: currentSearchTerm, + limit: numSearchResults, + index: ["title", "content"], + }); + } + + const getByField = (field: string): number[] => { + const results = searchResults.filter((x) => x.field === field); + return results.length === 0 ? [] : ([...results[0].result] as number[]); + }; + + // order titles ahead of content + const allIds: Set = new Set([ + ...getByField("title"), + ...getByField("content"), + ...getByField("tags"), + ]); + const finalResults = [...allIds].map((id) => formatForDisplay(currentSearchTerm, id)); + await displayResults(finalResults); + } + + document.addEventListener("keydown", shortcutHandler); + window.addCleanup(() => document.removeEventListener("keydown", shortcutHandler)); + searchIcon?.addEventListener("click", () => showSearch("basic")); + window.addCleanup(() => searchIcon?.removeEventListener("click", () => showSearch("basic"))); + searchBar?.addEventListener("input", onType); + window.addCleanup(() => searchBar?.removeEventListener("input", onType)); + + registerEscapeHandler(container, hideSearch); + await fillDocument(data); +}); /** * Fills flexsearch document with data @@ -450,19 +451,19 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { * @param data data to fill index with */ async function fillDocument(data: { [key: FullSlug]: ContentDetails }) { - let id = 0 - const promises: Array> = [] - for (const [slug, fileData] of Object.entries(data)) { - promises.push( - index.addAsync(id++, { - id, - slug: slug as FullSlug, - title: fileData.title, - content: fileData.content, - tags: fileData.tags, - }), - ) - } - - return await Promise.all(promises) + let id = 0; + const promises: Array> = []; + for (const [slug, fileData] of Object.entries(data)) { + promises.push( + index.addAsync(id++, { + id, + slug: slug as FullSlug, + title: fileData.title, + content: fileData.content, + tags: fileData.tags, + }), + ); + } + + return await Promise.all(promises); } diff --git a/quartz/components/scripts/spa.inline.ts b/quartz/components/scripts/spa.inline.ts index 1790bcabccebd..9682a0195ad7f 100644 --- a/quartz/components/scripts/spa.inline.ts +++ b/quartz/components/scripts/spa.inline.ts @@ -1,187 +1,188 @@ -import micromorph from "micromorph" -import { FullSlug, RelativeURL, getFullSlug, normalizeRelativeURLs } from "../../util/path" +import micromorph from "micromorph"; + +import { FullSlug, getFullSlug, normalizeRelativeURLs,RelativeURL } from "../../util/path"; // adapted from `micromorph` // https://github.com/natemoo-re/micromorph -const NODE_TYPE_ELEMENT = 1 -let announcer = document.createElement("route-announcer") +const NODE_TYPE_ELEMENT = 1; +const announcer = document.createElement("route-announcer"); const isElement = (target: EventTarget | null): target is Element => - (target as Node)?.nodeType === NODE_TYPE_ELEMENT + (target as Node)?.nodeType === NODE_TYPE_ELEMENT; const isLocalUrl = (href: string) => { - try { - const url = new URL(href) - if (window.location.origin === url.origin) { - return true - } - } catch (e) {} - return false -} + try { + const url = new URL(href); + if (window.location.origin === url.origin) { + return true; + } + } catch (e) {} + 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 sameOrigin = url.origin === window.location.origin; + const samePath = url.pathname === window.location.pathname; + return sameOrigin && samePath; +}; const getOpts = ({ target }: Event): { url: URL; scroll?: boolean } | undefined => { - if (!isElement(target)) return - if (target.attributes.getNamedItem("target")?.value === "_blank") return - const a = target.closest("a") - if (!a) return - if ("routerIgnore" in a.dataset) return - const { href } = a - if (!isLocalUrl(href)) return - return { url: new URL(href), scroll: "routerNoscroll" in a.dataset ? false : undefined } -} + if (!isElement(target)) return; + if (target.attributes.getNamedItem("target")?.value === "_blank") return; + const a = target.closest("a"); + if (!a) return; + if ("routerIgnore" in a.dataset) return; + const { href } = a; + if (!isLocalUrl(href)) return; + return { url: new URL(href), scroll: "routerNoscroll" in a.dataset ? false : undefined }; +}; function notifyNav(url: FullSlug) { - const event: CustomEventMap["nav"] = new CustomEvent("nav", { detail: { url } }) - document.dispatchEvent(event) + const event: CustomEventMap["nav"] = new CustomEvent("nav", { detail: { url } }); + document.dispatchEvent(event); } -const cleanupFns: Set<(...args: any[]) => void> = new Set() -window.addCleanup = (fn) => cleanupFns.add(fn) +const cleanupFns: Set<(...args: any[]) => void> = new Set(); +window.addCleanup = (fn) => cleanupFns.add(fn); -let p: DOMParser +let p: DOMParser; async function navigate(url: URL, isBack: boolean = false) { - p = p || new DOMParser() - const contents = await fetch(`${url}`) - .then((res) => { - const contentType = res.headers.get("content-type") - if (contentType?.startsWith("text/html")) { - return res.text() - } else { - window.location.assign(url) - } - }) - .catch(() => { - window.location.assign(url) - }) - - if (!contents) return - - // cleanup old - cleanupFns.forEach((fn) => fn()) - cleanupFns.clear() - - const html = p.parseFromString(contents, "text/html") - normalizeRelativeURLs(html, url) - - let title = html.querySelector("title")?.textContent - if (title) { - document.title = title - } else { - const h1 = document.querySelector("h1") - title = h1?.innerText ?? h1?.textContent ?? url.pathname - } - if (announcer.textContent !== title) { - announcer.textContent = title - } - announcer.dataset.persist = "" - html.body.appendChild(announcer) - - // morph body - micromorph(document.body, html.body) - - // scroll into place and add history - if (!isBack) { - if (url.hash) { - const el = document.getElementById(decodeURIComponent(url.hash.substring(1))) - el?.scrollIntoView() - } else { - window.scrollTo({ top: 0 }) - } - } - - // now, patch head - const elementsToRemove = document.head.querySelectorAll(":not([spa-preserve])") - elementsToRemove.forEach((el) => el.remove()) - const elementsToAdd = html.head.querySelectorAll(":not([spa-preserve])") - elementsToAdd.forEach((el) => document.head.appendChild(el)) - - // delay setting the url until now - // at this point everything is loaded so changing the url should resolve to the correct addresses - if (!isBack) { - history.pushState({}, "", url) - } - notifyNav(getFullSlug(window)) - delete announcer.dataset.persist + p = p || new DOMParser(); + const contents = await fetch(`${url}`) + .then((res) => { + const contentType = res.headers.get("content-type"); + if (contentType?.startsWith("text/html")) { + return res.text(); + } else { + window.location.assign(url); + } + }) + .catch(() => { + window.location.assign(url); + }); + + if (!contents) return; + + // cleanup old + cleanupFns.forEach((fn) => fn()); + cleanupFns.clear(); + + const html = p.parseFromString(contents, "text/html"); + normalizeRelativeURLs(html, url); + + let title = html.querySelector("title")?.textContent; + if (title) { + document.title = title; + } else { + const h1 = document.querySelector("h1"); + title = h1?.innerText ?? h1?.textContent ?? url.pathname; + } + if (announcer.textContent !== title) { + announcer.textContent = title; + } + announcer.dataset.persist = ""; + html.body.appendChild(announcer); + + // morph body + micromorph(document.body, html.body); + + // scroll into place and add history + if (!isBack) { + if (url.hash) { + const el = document.getElementById(decodeURIComponent(url.hash.substring(1))); + el?.scrollIntoView(); + } else { + window.scrollTo({ top: 0 }); + } + } + + // now, patch head + const elementsToRemove = document.head.querySelectorAll(":not([spa-preserve])"); + elementsToRemove.forEach((el) => el.remove()); + const elementsToAdd = html.head.querySelectorAll(":not([spa-preserve])"); + elementsToAdd.forEach((el) => document.head.appendChild(el)); + + // delay setting the url until now + // at this point everything is loaded so changing the url should resolve to the correct addresses + if (!isBack) { + history.pushState({}, "", url); + } + notifyNav(getFullSlug(window)); + delete announcer.dataset.persist; } -window.spaNavigate = navigate +window.spaNavigate = navigate; function createRouter() { - if (typeof window !== "undefined") { - window.addEventListener("click", async (event) => { - const { url } = getOpts(event) ?? {} - // dont hijack behaviour, just let browser act normally - if (!url || event.ctrlKey || event.metaKey) return - event.preventDefault() - - if (isSamePage(url) && url.hash) { - const el = document.getElementById(decodeURIComponent(url.hash.substring(1))) - el?.scrollIntoView() - history.pushState({}, "", url) - return - } - - try { - navigate(url, false) - } catch (e) { - window.location.assign(url) - } - }) - - window.addEventListener("popstate", (event) => { - const { url } = getOpts(event) ?? {} - if (window.location.hash && window.location.pathname === url?.pathname) return - try { - navigate(new URL(window.location.toString()), true) - } catch (e) { - window.location.reload() - } - return - }) - } - - return new (class Router { - go(pathname: RelativeURL) { - const url = new URL(pathname, window.location.toString()) - return navigate(url, false) - } - - back() { - return window.history.back() - } - - forward() { - return window.history.forward() - } - })() + if (typeof window !== "undefined") { + window.addEventListener("click", async (event) => { + const { url } = getOpts(event) ?? {}; + // dont hijack behaviour, just let browser act normally + if (!url || event.ctrlKey || event.metaKey) return; + event.preventDefault(); + + if (isSamePage(url) && url.hash) { + const el = document.getElementById(decodeURIComponent(url.hash.substring(1))); + el?.scrollIntoView(); + history.pushState({}, "", url); + return; + } + + try { + navigate(url, false); + } catch (e) { + window.location.assign(url); + } + }); + + window.addEventListener("popstate", (event) => { + const { url } = getOpts(event) ?? {}; + if (window.location.hash && window.location.pathname === url?.pathname) return; + try { + navigate(new URL(window.location.toString()), true); + } catch (e) { + window.location.reload(); + } + return; + }); + } + + return new (class Router { + go(pathname: RelativeURL) { + const url = new URL(pathname, window.location.toString()); + return navigate(url, false); + } + + back() { + return window.history.back(); + } + + forward() { + return window.history.forward(); + } + })(); } -createRouter() -notifyNav(getFullSlug(window)) +createRouter(); +notifyNav(getFullSlug(window)); if (!customElements.get("route-announcer")) { - const attrs = { - "aria-live": "assertive", - "aria-atomic": "true", - style: + const attrs = { + "aria-live": "assertive", + "aria-atomic": "true", + style: "position: absolute; left: 0; top: 0; clip: rect(0 0 0 0); clip-path: inset(50%); overflow: hidden; white-space: nowrap; width: 1px; height: 1px", - } - - customElements.define( - "route-announcer", - class RouteAnnouncer extends HTMLElement { - constructor() { - super() - } - connectedCallback() { - for (const [key, value] of Object.entries(attrs)) { - this.setAttribute(key, value) - } - } - }, - ) + }; + + customElements.define( + "route-announcer", + class RouteAnnouncer extends HTMLElement { + constructor() { + super(); + } + connectedCallback() { + for (const [key, value] of Object.entries(attrs)) { + this.setAttribute(key, value); + } + } + }, + ); } diff --git a/quartz/components/scripts/toc.inline.ts b/quartz/components/scripts/toc.inline.ts index 546859ed3261b..14e6ab9e3441c 100644 --- a/quartz/components/scripts/toc.inline.ts +++ b/quartz/components/scripts/toc.inline.ts @@ -1,45 +1,45 @@ -const bufferPx = 150 +const bufferPx = 150; const observer = new IntersectionObserver((entries) => { - for (const entry of entries) { - const slug = entry.target.id - const tocEntryElement = document.querySelector(`a[data-for="${slug}"]`) - const windowHeight = entry.rootBounds?.height - if (windowHeight && tocEntryElement) { - if (entry.boundingClientRect.y < windowHeight) { - tocEntryElement.classList.add("in-view") - } else { - tocEntryElement.classList.remove("in-view") - } - } - } -}) + for (const entry of entries) { + const slug = entry.target.id; + const tocEntryElement = document.querySelector(`a[data-for="${slug}"]`); + const windowHeight = entry.rootBounds?.height; + if (windowHeight && tocEntryElement) { + if (entry.boundingClientRect.y < windowHeight) { + tocEntryElement.classList.add("in-view"); + } else { + tocEntryElement.classList.remove("in-view"); + } + } + } +}); function toggleToc(this: HTMLElement) { - this.classList.toggle("collapsed") - const content = this.nextElementSibling as HTMLElement | undefined - if (!content) return - content.classList.toggle("collapsed") - content.style.maxHeight = content.style.maxHeight === "0px" ? content.scrollHeight + "px" : "0px" + this.classList.toggle("collapsed"); + const content = this.nextElementSibling as HTMLElement | undefined; + if (!content) return; + content.classList.toggle("collapsed"); + content.style.maxHeight = content.style.maxHeight === "0px" ? content.scrollHeight + "px" : "0px"; } function setupToc() { - const toc = document.getElementById("toc") - if (toc) { - const collapsed = toc.classList.contains("collapsed") - const content = toc.nextElementSibling as HTMLElement | undefined - if (!content) return - content.style.maxHeight = collapsed ? "0px" : content.scrollHeight + "px" - toc.addEventListener("click", toggleToc) - window.addCleanup(() => toc.removeEventListener("click", toggleToc)) - } + const toc = document.getElementById("toc"); + if (toc) { + const collapsed = toc.classList.contains("collapsed"); + const content = toc.nextElementSibling as HTMLElement | undefined; + if (!content) return; + content.style.maxHeight = collapsed ? "0px" : content.scrollHeight + "px"; + toc.addEventListener("click", toggleToc); + window.addCleanup(() => toc.removeEventListener("click", toggleToc)); + } } -window.addEventListener("resize", setupToc) +window.addEventListener("resize", setupToc); document.addEventListener("nav", () => { - setupToc() + setupToc(); - // update toc entry highlighting - observer.disconnect() - const headers = document.querySelectorAll("h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]") - headers.forEach((header) => observer.observe(header)) -}) + // update toc entry highlighting + observer.disconnect(); + const headers = document.querySelectorAll("h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]"); + headers.forEach((header) => observer.observe(header)); +}); diff --git a/quartz/components/scripts/util.ts b/quartz/components/scripts/util.ts index 4ffff29e28e46..909eb43d3a3fe 100644 --- a/quartz/components/scripts/util.ts +++ b/quartz/components/scripts/util.ts @@ -1,25 +1,25 @@ export function registerEscapeHandler(outsideContainer: HTMLElement | null, cb: () => void) { - if (!outsideContainer) return - function click(this: HTMLElement, e: HTMLElementEventMap["click"]) { - if (e.target !== this) return - e.preventDefault() - cb() - } + if (!outsideContainer) return; + function click(this: HTMLElement, e: HTMLElementEventMap["click"]) { + if (e.target !== this) return; + e.preventDefault(); + cb(); + } - function esc(e: HTMLElementEventMap["keydown"]) { - if (!e.key.startsWith("Esc")) return - e.preventDefault() - cb() - } + function esc(e: HTMLElementEventMap["keydown"]) { + if (!e.key.startsWith("Esc")) return; + e.preventDefault(); + cb(); + } - outsideContainer?.addEventListener("click", click) - window.addCleanup(() => outsideContainer?.removeEventListener("click", click)) - document.addEventListener("keydown", esc) - window.addCleanup(() => document.removeEventListener("keydown", esc)) + outsideContainer?.addEventListener("click", click); + window.addCleanup(() => outsideContainer?.removeEventListener("click", click)); + document.addEventListener("keydown", esc); + window.addCleanup(() => document.removeEventListener("keydown", esc)); } export function removeAllChildren(node: HTMLElement) { - while (node.firstChild) { - node.removeChild(node.firstChild) - } + while (node.firstChild) { + node.removeChild(node.firstChild); + } } diff --git a/quartz/components/types.ts b/quartz/components/types.ts index d238bff2370af..cfa2e81f67e66 100644 --- a/quartz/components/types.ts +++ b/quartz/components/types.ts @@ -1,9 +1,10 @@ -import { ComponentType, JSX } from "preact" -import { StaticResources } from "../util/resources" -import { QuartzPluginData } from "../plugins/vfile" -import { GlobalConfiguration } from "../cfg" -import { Node } from "hast" -import { BuildCtx } from "../util/ctx" +import { Node } from "hast"; +import { ComponentType, JSX } from "preact"; + +import { GlobalConfiguration } from "../cfg"; +import { QuartzPluginData } from "../plugins/vfile"; +import { BuildCtx } from "../util/ctx"; +import { StaticResources } from "../util/resources"; export type QuartzComponentProps = { ctx: BuildCtx diff --git a/quartz/depgraph.test.ts b/quartz/depgraph.test.ts index 062f13e35c00b..9a4347b408f5e 100644 --- a/quartz/depgraph.test.ts +++ b/quartz/depgraph.test.ts @@ -1,118 +1,119 @@ -import test, { describe } from "node:test" -import DepGraph from "./depgraph" -import assert from "node:assert" +import assert from "node:assert"; +import test, { describe } from "node:test"; + +import DepGraph from "./depgraph"; describe("DepGraph", () => { - test("getLeafNodes", () => { - const graph = new DepGraph() - graph.addEdge("A", "B") - graph.addEdge("B", "C") - graph.addEdge("D", "C") - assert.deepStrictEqual(graph.getLeafNodes("A"), new Set(["C"])) - assert.deepStrictEqual(graph.getLeafNodes("B"), new Set(["C"])) - assert.deepStrictEqual(graph.getLeafNodes("C"), new Set(["C"])) - assert.deepStrictEqual(graph.getLeafNodes("D"), new Set(["C"])) - }) - - describe("getLeafNodeAncestors", () => { - test("gets correct ancestors in a graph without cycles", () => { - const graph = new DepGraph() - graph.addEdge("A", "B") - graph.addEdge("B", "C") - graph.addEdge("D", "B") - assert.deepStrictEqual(graph.getLeafNodeAncestors("A"), new Set(["A", "B", "D"])) - assert.deepStrictEqual(graph.getLeafNodeAncestors("B"), new Set(["A", "B", "D"])) - assert.deepStrictEqual(graph.getLeafNodeAncestors("C"), new Set(["A", "B", "D"])) - assert.deepStrictEqual(graph.getLeafNodeAncestors("D"), new Set(["A", "B", "D"])) - }) - - test("gets correct ancestors in a graph with cycles", () => { - const graph = new DepGraph() - graph.addEdge("A", "B") - graph.addEdge("B", "C") - graph.addEdge("C", "A") - graph.addEdge("C", "D") - assert.deepStrictEqual(graph.getLeafNodeAncestors("A"), new Set(["A", "B", "C"])) - assert.deepStrictEqual(graph.getLeafNodeAncestors("B"), new Set(["A", "B", "C"])) - assert.deepStrictEqual(graph.getLeafNodeAncestors("C"), new Set(["A", "B", "C"])) - assert.deepStrictEqual(graph.getLeafNodeAncestors("D"), new Set(["A", "B", "C"])) - }) - }) - - describe("mergeGraph", () => { - test("merges two graphs", () => { - const graph = new DepGraph() - graph.addEdge("A.md", "A.html") - - const other = new DepGraph() - other.addEdge("B.md", "B.html") - - graph.mergeGraph(other) - - const expected = { - nodes: ["A.md", "A.html", "B.md", "B.html"], - edges: [ - ["A.md", "A.html"], - ["B.md", "B.html"], - ], - } - - assert.deepStrictEqual(graph.export(), expected) - }) - }) - - describe("updateIncomingEdgesForNode", () => { - test("merges when node exists", () => { - // A.md -> B.md -> B.html - const graph = new DepGraph() - graph.addEdge("A.md", "B.md") - graph.addEdge("B.md", "B.html") - - // B.md is edited so it removes the A.md transclusion - // and adds C.md transclusion - // C.md -> B.md - const other = new DepGraph() - other.addEdge("C.md", "B.md") - other.addEdge("B.md", "B.html") - - // A.md -> B.md removed, C.md -> B.md added - // C.md -> B.md -> B.html - graph.updateIncomingEdgesForNode(other, "B.md") - - const expected = { - nodes: ["A.md", "B.md", "B.html", "C.md"], - edges: [ - ["B.md", "B.html"], - ["C.md", "B.md"], - ], - } - - assert.deepStrictEqual(graph.export(), expected) - }) - - test("adds node if it does not exist", () => { - // A.md -> B.md - const graph = new DepGraph() - graph.addEdge("A.md", "B.md") - - // Add a new file C.md that transcludes B.md - // B.md -> C.md - const other = new DepGraph() - other.addEdge("B.md", "C.md") - - // B.md -> C.md added - // A.md -> B.md -> C.md - graph.updateIncomingEdgesForNode(other, "C.md") - - const expected = { - nodes: ["A.md", "B.md", "C.md"], - edges: [ - ["A.md", "B.md"], - ["B.md", "C.md"], - ], - } - - assert.deepStrictEqual(graph.export(), expected) - }) - }) -}) + test("getLeafNodes", () => { + const graph = new DepGraph(); + graph.addEdge("A", "B"); + graph.addEdge("B", "C"); + graph.addEdge("D", "C"); + assert.deepStrictEqual(graph.getLeafNodes("A"), new Set(["C"])); + assert.deepStrictEqual(graph.getLeafNodes("B"), new Set(["C"])); + assert.deepStrictEqual(graph.getLeafNodes("C"), new Set(["C"])); + assert.deepStrictEqual(graph.getLeafNodes("D"), new Set(["C"])); + }); + + describe("getLeafNodeAncestors", () => { + test("gets correct ancestors in a graph without cycles", () => { + const graph = new DepGraph(); + graph.addEdge("A", "B"); + graph.addEdge("B", "C"); + graph.addEdge("D", "B"); + assert.deepStrictEqual(graph.getLeafNodeAncestors("A"), new Set(["A", "B", "D"])); + assert.deepStrictEqual(graph.getLeafNodeAncestors("B"), new Set(["A", "B", "D"])); + assert.deepStrictEqual(graph.getLeafNodeAncestors("C"), new Set(["A", "B", "D"])); + assert.deepStrictEqual(graph.getLeafNodeAncestors("D"), new Set(["A", "B", "D"])); + }); + + test("gets correct ancestors in a graph with cycles", () => { + const graph = new DepGraph(); + graph.addEdge("A", "B"); + graph.addEdge("B", "C"); + graph.addEdge("C", "A"); + graph.addEdge("C", "D"); + assert.deepStrictEqual(graph.getLeafNodeAncestors("A"), new Set(["A", "B", "C"])); + assert.deepStrictEqual(graph.getLeafNodeAncestors("B"), new Set(["A", "B", "C"])); + assert.deepStrictEqual(graph.getLeafNodeAncestors("C"), new Set(["A", "B", "C"])); + assert.deepStrictEqual(graph.getLeafNodeAncestors("D"), new Set(["A", "B", "C"])); + }); + }); + + describe("mergeGraph", () => { + test("merges two graphs", () => { + const graph = new DepGraph(); + graph.addEdge("A.md", "A.html"); + + const other = new DepGraph(); + other.addEdge("B.md", "B.html"); + + graph.mergeGraph(other); + + const expected = { + nodes: ["A.md", "A.html", "B.md", "B.html"], + edges: [ + ["A.md", "A.html"], + ["B.md", "B.html"], + ], + }; + + assert.deepStrictEqual(graph.export(), expected); + }); + }); + + describe("updateIncomingEdgesForNode", () => { + test("merges when node exists", () => { + // A.md -> B.md -> B.html + const graph = new DepGraph(); + graph.addEdge("A.md", "B.md"); + graph.addEdge("B.md", "B.html"); + + // B.md is edited so it removes the A.md transclusion + // and adds C.md transclusion + // C.md -> B.md + const other = new DepGraph(); + other.addEdge("C.md", "B.md"); + other.addEdge("B.md", "B.html"); + + // A.md -> B.md removed, C.md -> B.md added + // C.md -> B.md -> B.html + graph.updateIncomingEdgesForNode(other, "B.md"); + + const expected = { + nodes: ["A.md", "B.md", "B.html", "C.md"], + edges: [ + ["B.md", "B.html"], + ["C.md", "B.md"], + ], + }; + + assert.deepStrictEqual(graph.export(), expected); + }); + + test("adds node if it does not exist", () => { + // A.md -> B.md + const graph = new DepGraph(); + graph.addEdge("A.md", "B.md"); + + // Add a new file C.md that transcludes B.md + // B.md -> C.md + const other = new DepGraph(); + other.addEdge("B.md", "C.md"); + + // B.md -> C.md added + // A.md -> B.md -> C.md + graph.updateIncomingEdgesForNode(other, "C.md"); + + const expected = { + nodes: ["A.md", "B.md", "C.md"], + edges: [ + ["A.md", "B.md"], + ["B.md", "C.md"], + ], + }; + + assert.deepStrictEqual(graph.export(), expected); + }); + }); +}); diff --git a/quartz/depgraph.ts b/quartz/depgraph.ts index 3d048cd83250d..9969c8f8b4ba6 100644 --- a/quartz/depgraph.ts +++ b/quartz/depgraph.ts @@ -1,228 +1,228 @@ export default class DepGraph { - // node: incoming and outgoing edges - _graph = new Map; outgoing: Set }>() - - constructor() { - this._graph = new Map() - } - - export(): Object { - return { - nodes: this.nodes, - edges: this.edges, - } - } - - toString(): string { - return JSON.stringify(this.export(), null, 2) - } - - // BASIC GRAPH OPERATIONS - - get nodes(): T[] { - return Array.from(this._graph.keys()) - } - - get edges(): [T, T][] { - let edges: [T, T][] = [] - this.forEachEdge((edge) => edges.push(edge)) - return edges - } - - hasNode(node: T): boolean { - return this._graph.has(node) - } - - addNode(node: T): void { - if (!this._graph.has(node)) { - this._graph.set(node, { incoming: new Set(), outgoing: new Set() }) - } - } - - // Remove node and all edges connected to it - removeNode(node: T): void { - if (this._graph.has(node)) { - // first remove all edges so other nodes don't have references to this node - for (const target of this._graph.get(node)!.outgoing) { - this.removeEdge(node, target) - } - for (const source of this._graph.get(node)!.incoming) { - this.removeEdge(source, node) - } - this._graph.delete(node) - } - } - - forEachNode(callback: (node: T) => void): void { - for (const node of this._graph.keys()) { - callback(node) - } - } - - hasEdge(from: T, to: T): boolean { - return Boolean(this._graph.get(from)?.outgoing.has(to)) - } - - addEdge(from: T, to: T): void { - this.addNode(from) - this.addNode(to) - - this._graph.get(from)!.outgoing.add(to) - this._graph.get(to)!.incoming.add(from) - } - - removeEdge(from: T, to: T): void { - if (this._graph.has(from) && this._graph.has(to)) { - this._graph.get(from)!.outgoing.delete(to) - this._graph.get(to)!.incoming.delete(from) - } - } - - // returns -1 if node does not exist - outDegree(node: T): number { - return this.hasNode(node) ? this._graph.get(node)!.outgoing.size : -1 - } - - // returns -1 if node does not exist - inDegree(node: T): number { - return this.hasNode(node) ? this._graph.get(node)!.incoming.size : -1 - } - - forEachOutNeighbor(node: T, callback: (neighbor: T) => void): void { - this._graph.get(node)?.outgoing.forEach(callback) - } - - forEachInNeighbor(node: T, callback: (neighbor: T) => void): void { - this._graph.get(node)?.incoming.forEach(callback) - } - - forEachEdge(callback: (edge: [T, T]) => void): void { - for (const [source, { outgoing }] of this._graph.entries()) { - for (const target of outgoing) { - callback([source, target]) - } - } - } - - // DEPENDENCY ALGORITHMS - - // Add all nodes and edges from other graph to this graph - mergeGraph(other: DepGraph): void { - other.forEachEdge(([source, target]) => { - this.addNode(source) - this.addNode(target) - this.addEdge(source, target) - }) - } - - // For the node provided: - // If node does not exist, add it - // If an incoming edge was added in other, it is added in this graph - // If an incoming edge was deleted in other, it is deleted in this graph - updateIncomingEdgesForNode(other: DepGraph, node: T): void { - this.addNode(node) - - // Add edge if it is present in other - other.forEachInNeighbor(node, (neighbor) => { - this.addEdge(neighbor, node) - }) - - // For node provided, remove incoming edge if it is absent in other - this.forEachEdge(([source, target]) => { - if (target === node && !other.hasEdge(source, target)) { - this.removeEdge(source, target) - } - }) - } - - // Remove all nodes that do not have any incoming or outgoing edges - // A node may be orphaned if the only node pointing to it was removed - removeOrphanNodes(): Set { - let orphanNodes = new Set() - - this.forEachNode((node) => { - if (this.inDegree(node) === 0 && this.outDegree(node) === 0) { - orphanNodes.add(node) - } - }) - - orphanNodes.forEach((node) => { - this.removeNode(node) - }) - - return orphanNodes - } - - // Get all leaf nodes (i.e. destination paths) reachable from the node provided - // Eg. if the graph is A -> B -> C - // D ---^ - // and the node is B, this function returns [C] - getLeafNodes(node: T): Set { - let stack: T[] = [node] - let visited = new Set() - let leafNodes = new Set() - - // DFS - while (stack.length > 0) { - let node = stack.pop()! - - // If the node is already visited, skip it - if (visited.has(node)) { - continue - } - visited.add(node) - - // Check if the node is a leaf node (i.e. destination path) - if (this.outDegree(node) === 0) { - leafNodes.add(node) - } - - // Add all unvisited neighbors to the stack - this.forEachOutNeighbor(node, (neighbor) => { - if (!visited.has(neighbor)) { - stack.push(neighbor) - } - }) - } - - return leafNodes - } - - // Get all ancestors of the leaf nodes reachable from the node provided - // Eg. if the graph is A -> B -> C - // D ---^ - // and the node is B, this function returns [A, B, D] - getLeafNodeAncestors(node: T): Set { - const leafNodes = this.getLeafNodes(node) - let visited = new Set() - let upstreamNodes = new Set() - - // Backwards DFS for each leaf node - leafNodes.forEach((leafNode) => { - let stack: T[] = [leafNode] - - while (stack.length > 0) { - let node = stack.pop()! - - if (visited.has(node)) { - continue - } - visited.add(node) - // Add node if it's not a leaf node (i.e. destination path) - // Assumes destination file cannot depend on another destination file - if (this.outDegree(node) !== 0) { - upstreamNodes.add(node) - } - - // Add all unvisited parents to the stack - this.forEachInNeighbor(node, (parentNode) => { - if (!visited.has(parentNode)) { - stack.push(parentNode) - } - }) - } - }) - - return upstreamNodes - } + // node: incoming and outgoing edges + _graph = new Map; outgoing: Set }>(); + + constructor() { + this._graph = new Map(); + } + + export(): Object { + return { + nodes: this.nodes, + edges: this.edges, + }; + } + + toString(): string { + return JSON.stringify(this.export(), null, 2); + } + + // BASIC GRAPH OPERATIONS + + get nodes(): T[] { + return Array.from(this._graph.keys()); + } + + get edges(): [T, T][] { + const edges: [T, T][] = []; + this.forEachEdge((edge) => edges.push(edge)); + return edges; + } + + hasNode(node: T): boolean { + return this._graph.has(node); + } + + addNode(node: T): void { + if (!this._graph.has(node)) { + this._graph.set(node, { incoming: new Set(), outgoing: new Set() }); + } + } + + // Remove node and all edges connected to it + removeNode(node: T): void { + if (this._graph.has(node)) { + // first remove all edges so other nodes don't have references to this node + for (const target of this._graph.get(node)!.outgoing) { + this.removeEdge(node, target); + } + for (const source of this._graph.get(node)!.incoming) { + this.removeEdge(source, node); + } + this._graph.delete(node); + } + } + + forEachNode(callback: (node: T) => void): void { + for (const node of this._graph.keys()) { + callback(node); + } + } + + hasEdge(from: T, to: T): boolean { + return Boolean(this._graph.get(from)?.outgoing.has(to)); + } + + addEdge(from: T, to: T): void { + this.addNode(from); + this.addNode(to); + + this._graph.get(from)!.outgoing.add(to); + this._graph.get(to)!.incoming.add(from); + } + + removeEdge(from: T, to: T): void { + if (this._graph.has(from) && this._graph.has(to)) { + this._graph.get(from)!.outgoing.delete(to); + this._graph.get(to)!.incoming.delete(from); + } + } + + // returns -1 if node does not exist + outDegree(node: T): number { + return this.hasNode(node) ? this._graph.get(node)!.outgoing.size : -1; + } + + // returns -1 if node does not exist + inDegree(node: T): number { + return this.hasNode(node) ? this._graph.get(node)!.incoming.size : -1; + } + + forEachOutNeighbor(node: T, callback: (neighbor: T) => void): void { + this._graph.get(node)?.outgoing.forEach(callback); + } + + forEachInNeighbor(node: T, callback: (neighbor: T) => void): void { + this._graph.get(node)?.incoming.forEach(callback); + } + + forEachEdge(callback: (edge: [T, T]) => void): void { + for (const [source, { outgoing }] of this._graph.entries()) { + for (const target of outgoing) { + callback([source, target]); + } + } + } + + // DEPENDENCY ALGORITHMS + + // Add all nodes and edges from other graph to this graph + mergeGraph(other: DepGraph): void { + other.forEachEdge(([source, target]) => { + this.addNode(source); + this.addNode(target); + this.addEdge(source, target); + }); + } + + // For the node provided: + // If node does not exist, add it + // If an incoming edge was added in other, it is added in this graph + // If an incoming edge was deleted in other, it is deleted in this graph + updateIncomingEdgesForNode(other: DepGraph, node: T): void { + this.addNode(node); + + // Add edge if it is present in other + other.forEachInNeighbor(node, (neighbor) => { + this.addEdge(neighbor, node); + }); + + // For node provided, remove incoming edge if it is absent in other + this.forEachEdge(([source, target]) => { + if (target === node && !other.hasEdge(source, target)) { + this.removeEdge(source, target); + } + }); + } + + // Remove all nodes that do not have any incoming or outgoing edges + // A node may be orphaned if the only node pointing to it was removed + removeOrphanNodes(): Set { + const orphanNodes = new Set(); + + this.forEachNode((node) => { + if (this.inDegree(node) === 0 && this.outDegree(node) === 0) { + orphanNodes.add(node); + } + }); + + orphanNodes.forEach((node) => { + this.removeNode(node); + }); + + return orphanNodes; + } + + // Get all leaf nodes (i.e. destination paths) reachable from the node provided + // Eg. if the graph is A -> B -> C + // D ---^ + // and the node is B, this function returns [C] + getLeafNodes(node: T): Set { + const stack: T[] = [node]; + const visited = new Set(); + const leafNodes = new Set(); + + // DFS + while (stack.length > 0) { + const node = stack.pop()!; + + // If the node is already visited, skip it + if (visited.has(node)) { + continue; + } + visited.add(node); + + // Check if the node is a leaf node (i.e. destination path) + if (this.outDegree(node) === 0) { + leafNodes.add(node); + } + + // Add all unvisited neighbors to the stack + this.forEachOutNeighbor(node, (neighbor) => { + if (!visited.has(neighbor)) { + stack.push(neighbor); + } + }); + } + + return leafNodes; + } + + // Get all ancestors of the leaf nodes reachable from the node provided + // Eg. if the graph is A -> B -> C + // D ---^ + // and the node is B, this function returns [A, B, D] + getLeafNodeAncestors(node: T): Set { + const leafNodes = this.getLeafNodes(node); + const visited = new Set(); + const upstreamNodes = new Set(); + + // Backwards DFS for each leaf node + leafNodes.forEach((leafNode) => { + const stack: T[] = [leafNode]; + + while (stack.length > 0) { + const node = stack.pop()!; + + if (visited.has(node)) { + continue; + } + visited.add(node); + // Add node if it's not a leaf node (i.e. destination path) + // Assumes destination file cannot depend on another destination file + if (this.outDegree(node) !== 0) { + upstreamNodes.add(node); + } + + // Add all unvisited parents to the stack + this.forEachInNeighbor(node, (parentNode) => { + if (!visited.has(parentNode)) { + stack.push(parentNode); + } + }); + } + }); + + return upstreamNodes; + } } diff --git a/quartz/i18n/index.ts b/quartz/i18n/index.ts index 38d356280a9ef..ec2a757dab176 100644 --- a/quartz/i18n/index.ts +++ b/quartz/i18n/index.ts @@ -1,56 +1,56 @@ -import { Translation, CalloutTranslation } from "./locales/definition" -import en from "./locales/en-US" -import fr from "./locales/fr-FR" -import it from "./locales/it-IT" -import ja from "./locales/ja-JP" -import de from "./locales/de-DE" -import nl from "./locales/nl-NL" -import ro from "./locales/ro-RO" -import es from "./locales/es-ES" -import ar from "./locales/ar-SA" -import uk from "./locales/uk-UA" -import ru from "./locales/ru-RU" -import ko from "./locales/ko-KR" -import zh from "./locales/zh-CN" +import ar from "./locales/ar-SA"; +import de from "./locales/de-DE"; +import { CalloutTranslation,Translation } from "./locales/definition"; +import en from "./locales/en-US"; +import es from "./locales/es-ES"; +import fr from "./locales/fr-FR"; +import it from "./locales/it-IT"; +import ja from "./locales/ja-JP"; +import ko from "./locales/ko-KR"; +import nl from "./locales/nl-NL"; +import ro from "./locales/ro-RO"; +import ru from "./locales/ru-RU"; +import uk from "./locales/uk-UA"; +import zh from "./locales/zh-CN"; export const TRANSLATIONS = { - "en-US": en, - "fr-FR": fr, - "it-IT": it, - "ja-JP": ja, - "de-DE": de, - "nl-NL": nl, - "nl-BE": nl, - "ro-RO": ro, - "ro-MD": ro, - "es-ES": es, - "ar-SA": ar, - "ar-AE": ar, - "ar-QA": ar, - "ar-BH": ar, - "ar-KW": ar, - "ar-OM": ar, - "ar-YE": ar, - "ar-IR": ar, - "ar-SY": ar, - "ar-IQ": ar, - "ar-JO": ar, - "ar-PL": ar, - "ar-LB": ar, - "ar-EG": ar, - "ar-SD": ar, - "ar-LY": ar, - "ar-MA": ar, - "ar-TN": ar, - "ar-DZ": ar, - "ar-MR": ar, - "uk-UA": uk, - "ru-RU": ru, - "ko-KR": ko, - "zh-CN": zh, -} as const + "en-US": en, + "fr-FR": fr, + "it-IT": it, + "ja-JP": ja, + "de-DE": de, + "nl-NL": nl, + "nl-BE": nl, + "ro-RO": ro, + "ro-MD": ro, + "es-ES": es, + "ar-SA": ar, + "ar-AE": ar, + "ar-QA": ar, + "ar-BH": ar, + "ar-KW": ar, + "ar-OM": ar, + "ar-YE": ar, + "ar-IR": ar, + "ar-SY": ar, + "ar-IQ": ar, + "ar-JO": ar, + "ar-PL": ar, + "ar-LB": ar, + "ar-EG": ar, + "ar-SD": ar, + "ar-LY": ar, + "ar-MA": ar, + "ar-TN": ar, + "ar-DZ": ar, + "ar-MR": ar, + "uk-UA": uk, + "ru-RU": ru, + "ko-KR": ko, + "zh-CN": zh, +} as const; -export const defaultTranslation = "en-US" -export const i18n = (locale: ValidLocale): Translation => TRANSLATIONS[locale ?? defaultTranslation] +export const defaultTranslation = "en-US"; +export const i18n = (locale: ValidLocale): Translation => TRANSLATIONS[locale ?? defaultTranslation]; export type ValidLocale = keyof typeof TRANSLATIONS export type ValidCallout = keyof CalloutTranslation diff --git a/quartz/i18n/locales/ar-SA.ts b/quartz/i18n/locales/ar-SA.ts index f7048103fc616..c54aa5045da93 100644 --- a/quartz/i18n/locales/ar-SA.ts +++ b/quartz/i18n/locales/ar-SA.ts @@ -1,88 +1,88 @@ -import { Translation } from "./definition" +import { Translation } from "./definition"; export default { - propertyDefaults: { - title: "غير معنون", - description: "لم يتم تقديم أي وصف", - }, - components: { - callout: { - note: "ملاحظة", - abstract: "ملخص", - info: "معلومات", - todo: "للقيام", - tip: "نصيحة", - success: "نجاح", - question: "سؤال", - warning: "تحذير", - failure: "فشل", - danger: "خطر", - bug: "خلل", - example: "مثال", - quote: "اقتباس", - }, - backlinks: { - title: "وصلات العودة", - noBacklinksFound: "لا يوجد وصلات عودة", - }, - themeToggle: { - lightMode: "الوضع النهاري", - darkMode: "الوضع الليلي", - }, - explorer: { - title: "المستعرض", - }, - footer: { - createdWith: "أُنشئ باستخدام", - }, - graph: { - title: "التمثيل التفاعلي", - }, - recentNotes: { - title: "آخر الملاحظات", - seeRemainingMore: ({ remaining }) => `تصفح ${remaining} أكثر →`, - }, - transcludes: { - transcludeOf: ({ targetSlug }) => `مقتبس من ${targetSlug}`, - linkToOriginal: "وصلة للملاحظة الرئيسة", - }, - search: { - title: "بحث", - searchBarPlaceholder: "ابحث عن شيء ما", - }, - tableOfContents: { - title: "فهرس المحتويات", - }, - contentMeta: { - readingTime: ({ minutes }) => - minutes == 1 - ? `دقيقة أو أقل للقراءة` - : minutes == 2 - ? `دقيقتان للقراءة` - : `${minutes} دقائق للقراءة`, - }, - }, - pages: { - rss: { - recentNotes: "آخر الملاحظات", - lastFewNotes: ({ count }) => `آخر ${count} ملاحظة`, - }, - error: { - title: "غير موجود", - notFound: "إما أن هذه الصفحة خاصة أو غير موجودة.", - }, - folderContent: { - folder: "مجلد", - itemsUnderFolder: ({ count }) => - count === 1 ? "يوجد عنصر واحد فقط تحت هذا المجلد" : `يوجد ${count} عناصر تحت هذا المجلد.`, - }, - tagContent: { - tag: "الوسم", - tagIndex: "مؤشر الوسم", - itemsUnderTag: ({ count }) => - count === 1 ? "يوجد عنصر واحد فقط تحت هذا الوسم" : `يوجد ${count} عناصر تحت هذا الوسم.`, - showingFirst: ({ count }) => `إظهار أول ${count} أوسمة.`, - totalTags: ({ count }) => `يوجد ${count} أوسمة.`, - }, - }, -} as const satisfies Translation + propertyDefaults: { + title: "غير معنون", + description: "لم يتم تقديم أي وصف", + }, + components: { + callout: { + note: "ملاحظة", + abstract: "ملخص", + info: "معلومات", + todo: "للقيام", + tip: "نصيحة", + success: "نجاح", + question: "سؤال", + warning: "تحذير", + failure: "فشل", + danger: "خطر", + bug: "خلل", + example: "مثال", + quote: "اقتباس", + }, + backlinks: { + title: "وصلات العودة", + noBacklinksFound: "لا يوجد وصلات عودة", + }, + themeToggle: { + lightMode: "الوضع النهاري", + darkMode: "الوضع الليلي", + }, + explorer: { + title: "المستعرض", + }, + footer: { + createdWith: "أُنشئ باستخدام", + }, + graph: { + title: "التمثيل التفاعلي", + }, + recentNotes: { + title: "آخر الملاحظات", + seeRemainingMore: ({ remaining }) => `تصفح ${remaining} أكثر →`, + }, + transcludes: { + transcludeOf: ({ targetSlug }) => `مقتبس من ${targetSlug}`, + linkToOriginal: "وصلة للملاحظة الرئيسة", + }, + search: { + title: "بحث", + searchBarPlaceholder: "ابحث عن شيء ما", + }, + tableOfContents: { + title: "فهرس المحتويات", + }, + contentMeta: { + readingTime: ({ minutes }) => + minutes == 1 + ? "دقيقة أو أقل للقراءة" + : minutes == 2 + ? "دقيقتان للقراءة" + : `${minutes} دقائق للقراءة`, + }, + }, + pages: { + rss: { + recentNotes: "آخر الملاحظات", + lastFewNotes: ({ count }) => `آخر ${count} ملاحظة`, + }, + error: { + title: "غير موجود", + notFound: "إما أن هذه الصفحة خاصة أو غير موجودة.", + }, + folderContent: { + folder: "مجلد", + itemsUnderFolder: ({ count }) => + count === 1 ? "يوجد عنصر واحد فقط تحت هذا المجلد" : `يوجد ${count} عناصر تحت هذا المجلد.`, + }, + tagContent: { + tag: "الوسم", + tagIndex: "مؤشر الوسم", + itemsUnderTag: ({ count }) => + count === 1 ? "يوجد عنصر واحد فقط تحت هذا الوسم" : `يوجد ${count} عناصر تحت هذا الوسم.`, + showingFirst: ({ count }) => `إظهار أول ${count} أوسمة.`, + totalTags: ({ count }) => `يوجد ${count} أوسمة.`, + }, + }, +} as const satisfies Translation; diff --git a/quartz/i18n/locales/de-DE.ts b/quartz/i18n/locales/de-DE.ts index 64c9ba9df5d4d..a00ee8f6b148b 100644 --- a/quartz/i18n/locales/de-DE.ts +++ b/quartz/i18n/locales/de-DE.ts @@ -1,83 +1,83 @@ -import { Translation } from "./definition" +import { Translation } from "./definition"; export default { - propertyDefaults: { - title: "Unbenannt", - description: "Keine Beschreibung angegeben", - }, - components: { - callout: { - note: "Hinweis", - abstract: "Zusammenfassung", - info: "Info", - todo: "Zu erledigen", - tip: "Tipp", - success: "Erfolg", - question: "Frage", - warning: "Warnung", - failure: "Misserfolg", - danger: "Gefahr", - bug: "Fehler", - example: "Beispiel", - quote: "Zitat", - }, - backlinks: { - title: "Backlinks", - noBacklinksFound: "Keine Backlinks gefunden", - }, - themeToggle: { - lightMode: "Light Mode", - darkMode: "Dark Mode", - }, - explorer: { - title: "Explorer", - }, - footer: { - createdWith: "Erstellt mit", - }, - graph: { - title: "Graphansicht", - }, - recentNotes: { - title: "Zuletzt bearbeitete Seiten", - seeRemainingMore: ({ remaining }) => `${remaining} weitere ansehen →`, - }, - transcludes: { - transcludeOf: ({ targetSlug }) => `Transklusion von ${targetSlug}`, - linkToOriginal: "Link zum Original", - }, - search: { - title: "Suche", - searchBarPlaceholder: "Suche nach etwas", - }, - tableOfContents: { - title: "Inhaltsverzeichnis", - }, - contentMeta: { - readingTime: ({ minutes }) => `${minutes} min read`, - }, - }, - pages: { - rss: { - recentNotes: "Zuletzt bearbeitete Seiten", - lastFewNotes: ({ count }) => `Letzte ${count} Seiten`, - }, - error: { - title: "Nicht gefunden", - notFound: "Diese Seite ist entweder nicht öffentlich oder existiert nicht.", - }, - folderContent: { - folder: "Ordner", - itemsUnderFolder: ({ count }) => - count === 1 ? "1 Datei in diesem Ordner." : `${count} Dateien in diesem Ordner.`, - }, - tagContent: { - tag: "Tag", - tagIndex: "Tag-Übersicht", - itemsUnderTag: ({ count }) => - count === 1 ? "1 Datei mit diesem Tag." : `${count} Dateien mit diesem Tag.`, - showingFirst: ({ count }) => `Die ersten ${count} Tags werden angezeigt.`, - totalTags: ({ count }) => `${count} Tags insgesamt.`, - }, - }, -} as const satisfies Translation + propertyDefaults: { + title: "Unbenannt", + description: "Keine Beschreibung angegeben", + }, + components: { + callout: { + note: "Hinweis", + abstract: "Zusammenfassung", + info: "Info", + todo: "Zu erledigen", + tip: "Tipp", + success: "Erfolg", + question: "Frage", + warning: "Warnung", + failure: "Misserfolg", + danger: "Gefahr", + bug: "Fehler", + example: "Beispiel", + quote: "Zitat", + }, + backlinks: { + title: "Backlinks", + noBacklinksFound: "Keine Backlinks gefunden", + }, + themeToggle: { + lightMode: "Light Mode", + darkMode: "Dark Mode", + }, + explorer: { + title: "Explorer", + }, + footer: { + createdWith: "Erstellt mit", + }, + graph: { + title: "Graphansicht", + }, + recentNotes: { + title: "Zuletzt bearbeitete Seiten", + seeRemainingMore: ({ remaining }) => `${remaining} weitere ansehen →`, + }, + transcludes: { + transcludeOf: ({ targetSlug }) => `Transklusion von ${targetSlug}`, + linkToOriginal: "Link zum Original", + }, + search: { + title: "Suche", + searchBarPlaceholder: "Suche nach etwas", + }, + tableOfContents: { + title: "Inhaltsverzeichnis", + }, + contentMeta: { + readingTime: ({ minutes }) => `${minutes} min read`, + }, + }, + pages: { + rss: { + recentNotes: "Zuletzt bearbeitete Seiten", + lastFewNotes: ({ count }) => `Letzte ${count} Seiten`, + }, + error: { + title: "Nicht gefunden", + notFound: "Diese Seite ist entweder nicht öffentlich oder existiert nicht.", + }, + folderContent: { + folder: "Ordner", + itemsUnderFolder: ({ count }) => + count === 1 ? "1 Datei in diesem Ordner." : `${count} Dateien in diesem Ordner.`, + }, + tagContent: { + tag: "Tag", + tagIndex: "Tag-Übersicht", + itemsUnderTag: ({ count }) => + count === 1 ? "1 Datei mit diesem Tag." : `${count} Dateien mit diesem Tag.`, + showingFirst: ({ count }) => `Die ersten ${count} Tags werden angezeigt.`, + totalTags: ({ count }) => `${count} Tags insgesamt.`, + }, + }, +} as const satisfies Translation; diff --git a/quartz/i18n/locales/definition.ts b/quartz/i18n/locales/definition.ts index 1d5d3dda6f3dc..7c8d3d788c39a 100644 --- a/quartz/i18n/locales/definition.ts +++ b/quartz/i18n/locales/definition.ts @@ -1,4 +1,4 @@ -import { FullSlug } from "../../util/path" +import { FullSlug } from "../../util/path"; export interface CalloutTranslation { note: string diff --git a/quartz/i18n/locales/en-US.ts b/quartz/i18n/locales/en-US.ts index ac283fdafaeba..ba128dc170710 100644 --- a/quartz/i18n/locales/en-US.ts +++ b/quartz/i18n/locales/en-US.ts @@ -1,83 +1,83 @@ -import { Translation } from "./definition" +import { Translation } from "./definition"; export default { - propertyDefaults: { - title: "Untitled", - description: "No description provided", - }, - components: { - callout: { - note: "Note", - abstract: "Abstract", - info: "Info", - todo: "Todo", - tip: "Tip", - success: "Success", - question: "Question", - warning: "Warning", - failure: "Failure", - danger: "Danger", - bug: "Bug", - example: "Example", - quote: "Quote", - }, - backlinks: { - title: "Backlinks", - noBacklinksFound: "No backlinks found", - }, - themeToggle: { - lightMode: "Light mode", - darkMode: "Dark mode", - }, - explorer: { - title: "Explorer", - }, - footer: { - createdWith: "Created with", - }, - graph: { - title: "Graph View", - }, - recentNotes: { - title: "Recent Notes", - seeRemainingMore: ({ remaining }) => `See ${remaining} more →`, - }, - transcludes: { - transcludeOf: ({ targetSlug }) => `Transclude of ${targetSlug}`, - linkToOriginal: "Link to original", - }, - search: { - title: "Search", - searchBarPlaceholder: "Search for something", - }, - tableOfContents: { - title: "Table of Contents", - }, - contentMeta: { - readingTime: ({ minutes }) => `${minutes} min read`, - }, - }, - pages: { - rss: { - recentNotes: "Recent notes", - lastFewNotes: ({ count }) => `Last ${count} notes`, - }, - error: { - title: "Not Found", - notFound: "Either this page is private or doesn't exist.", - }, - folderContent: { - folder: "Folder", - itemsUnderFolder: ({ count }) => - count === 1 ? "1 item under this folder." : `${count} items under this folder.`, - }, - tagContent: { - tag: "Tag", - tagIndex: "Tag Index", - itemsUnderTag: ({ count }) => - count === 1 ? "1 item with this tag." : `${count} items with this tag.`, - showingFirst: ({ count }) => `Showing first ${count} tags.`, - totalTags: ({ count }) => `Found ${count} total tags.`, - }, - }, -} as const satisfies Translation + propertyDefaults: { + title: "Untitled", + description: "No description provided", + }, + components: { + callout: { + note: "Note", + abstract: "Abstract", + info: "Info", + todo: "Todo", + tip: "Tip", + success: "Success", + question: "Question", + warning: "Warning", + failure: "Failure", + danger: "Danger", + bug: "Bug", + example: "Example", + quote: "Quote", + }, + backlinks: { + title: "Backlinks", + noBacklinksFound: "No backlinks found", + }, + themeToggle: { + lightMode: "Light mode", + darkMode: "Dark mode", + }, + explorer: { + title: "Explorer", + }, + footer: { + createdWith: "Created with", + }, + graph: { + title: "Graph View", + }, + recentNotes: { + title: "Recent Notes", + seeRemainingMore: ({ remaining }) => `See ${remaining} more →`, + }, + transcludes: { + transcludeOf: ({ targetSlug }) => `Transclude of ${targetSlug}`, + linkToOriginal: "Link to original", + }, + search: { + title: "Search", + searchBarPlaceholder: "Search for something", + }, + tableOfContents: { + title: "Table of Contents", + }, + contentMeta: { + readingTime: ({ minutes }) => `${minutes} min read`, + }, + }, + pages: { + rss: { + recentNotes: "Recent notes", + lastFewNotes: ({ count }) => `Last ${count} notes`, + }, + error: { + title: "Not Found", + notFound: "Either this page is private or doesn't exist.", + }, + folderContent: { + folder: "Folder", + itemsUnderFolder: ({ count }) => + count === 1 ? "1 item under this folder." : `${count} items under this folder.`, + }, + tagContent: { + tag: "Tag", + tagIndex: "Tag Index", + itemsUnderTag: ({ count }) => + count === 1 ? "1 item with this tag." : `${count} items with this tag.`, + showingFirst: ({ count }) => `Showing first ${count} tags.`, + totalTags: ({ count }) => `Found ${count} total tags.`, + }, + }, +} as const satisfies Translation; diff --git a/quartz/i18n/locales/es-ES.ts b/quartz/i18n/locales/es-ES.ts index 37a2a79c721f6..931668c6a8e72 100644 --- a/quartz/i18n/locales/es-ES.ts +++ b/quartz/i18n/locales/es-ES.ts @@ -1,83 +1,83 @@ -import { Translation } from "./definition" +import { Translation } from "./definition"; export default { - propertyDefaults: { - title: "Sin título", - description: "Sin descripción", - }, - components: { - callout: { - note: "Nota", - abstract: "Resumen", - info: "Información", - todo: "Por hacer", - tip: "Consejo", - success: "Éxito", - question: "Pregunta", - warning: "Advertencia", - failure: "Fallo", - danger: "Peligro", - bug: "Error", - example: "Ejemplo", - quote: "Cita", - }, - backlinks: { - title: "Enlaces de Retroceso", - noBacklinksFound: "No se han encontrado enlaces traseros", - }, - themeToggle: { - lightMode: "Modo claro", - darkMode: "Modo oscuro", - }, - explorer: { - title: "Explorador", - }, - footer: { - createdWith: "Creado con", - }, - graph: { - title: "Vista Gráfica", - }, - recentNotes: { - title: "Notas Recientes", - seeRemainingMore: ({ remaining }) => `Vea ${remaining} más →`, - }, - transcludes: { - transcludeOf: ({ targetSlug }) => `Transcluido de ${targetSlug}`, - linkToOriginal: "Enlace al original", - }, - search: { - title: "Buscar", - searchBarPlaceholder: "Busca algo", - }, - tableOfContents: { - title: "Tabla de Contenidos", - }, - contentMeta: { - readingTime: ({ minutes }) => `${minutes} min read`, - }, - }, - pages: { - rss: { - recentNotes: "Notas recientes", - lastFewNotes: ({ count }) => `Últimás ${count} notas`, - }, - error: { - title: "No se encontró.", - notFound: "Esta página es privada o no existe.", - }, - folderContent: { - folder: "Carpeta", - itemsUnderFolder: ({ count }) => - count === 1 ? "1 artículo en esta carpeta." : `${count} artículos en esta carpeta.`, - }, - tagContent: { - tag: "Etiqueta", - tagIndex: "Índice de Etiquetas", - itemsUnderTag: ({ count }) => - count === 1 ? "1 artículo con esta etiqueta." : `${count} artículos con esta etiqueta.`, - showingFirst: ({ count }) => `Mostrando las primeras ${count} etiquetas.`, - totalTags: ({ count }) => `Se encontraron ${count} etiquetas en total.`, - }, - }, -} as const satisfies Translation + propertyDefaults: { + title: "Sin título", + description: "Sin descripción", + }, + components: { + callout: { + note: "Nota", + abstract: "Resumen", + info: "Información", + todo: "Por hacer", + tip: "Consejo", + success: "Éxito", + question: "Pregunta", + warning: "Advertencia", + failure: "Fallo", + danger: "Peligro", + bug: "Error", + example: "Ejemplo", + quote: "Cita", + }, + backlinks: { + title: "Enlaces de Retroceso", + noBacklinksFound: "No se han encontrado enlaces traseros", + }, + themeToggle: { + lightMode: "Modo claro", + darkMode: "Modo oscuro", + }, + explorer: { + title: "Explorador", + }, + footer: { + createdWith: "Creado con", + }, + graph: { + title: "Vista Gráfica", + }, + recentNotes: { + title: "Notas Recientes", + seeRemainingMore: ({ remaining }) => `Vea ${remaining} más →`, + }, + transcludes: { + transcludeOf: ({ targetSlug }) => `Transcluido de ${targetSlug}`, + linkToOriginal: "Enlace al original", + }, + search: { + title: "Buscar", + searchBarPlaceholder: "Busca algo", + }, + tableOfContents: { + title: "Tabla de Contenidos", + }, + contentMeta: { + readingTime: ({ minutes }) => `${minutes} min read`, + }, + }, + pages: { + rss: { + recentNotes: "Notas recientes", + lastFewNotes: ({ count }) => `Últimás ${count} notas`, + }, + error: { + title: "No se encontró.", + notFound: "Esta página es privada o no existe.", + }, + folderContent: { + folder: "Carpeta", + itemsUnderFolder: ({ count }) => + count === 1 ? "1 artículo en esta carpeta." : `${count} artículos en esta carpeta.`, + }, + tagContent: { + tag: "Etiqueta", + tagIndex: "Índice de Etiquetas", + itemsUnderTag: ({ count }) => + count === 1 ? "1 artículo con esta etiqueta." : `${count} artículos con esta etiqueta.`, + showingFirst: ({ count }) => `Mostrando las primeras ${count} etiquetas.`, + totalTags: ({ count }) => `Se encontraron ${count} etiquetas en total.`, + }, + }, +} as const satisfies Translation; diff --git a/quartz/i18n/locales/fr-FR.ts b/quartz/i18n/locales/fr-FR.ts index b485d2b6e5a01..100c84a05380e 100644 --- a/quartz/i18n/locales/fr-FR.ts +++ b/quartz/i18n/locales/fr-FR.ts @@ -1,83 +1,83 @@ -import { Translation } from "./definition" +import { Translation } from "./definition"; export default { - propertyDefaults: { - title: "Sans titre", - description: "Aucune description fournie", - }, - components: { - callout: { - note: "Note", - abstract: "Résumé", - info: "Info", - todo: "À faire", - tip: "Conseil", - success: "Succès", - question: "Question", - warning: "Avertissement", - failure: "Échec", - danger: "Danger", - bug: "Bogue", - example: "Exemple", - quote: "Citation", - }, - backlinks: { - title: "Liens retour", - noBacklinksFound: "Aucun lien retour trouvé", - }, - themeToggle: { - lightMode: "Mode clair", - darkMode: "Mode sombre", - }, - explorer: { - title: "Explorateur", - }, - footer: { - createdWith: "Créé avec", - }, - graph: { - title: "Vue Graphique", - }, - recentNotes: { - title: "Notes Récentes", - seeRemainingMore: ({ remaining }) => `Voir ${remaining} de plus →`, - }, - transcludes: { - transcludeOf: ({ targetSlug }) => `Transclusion de ${targetSlug}`, - linkToOriginal: "Lien vers l'original", - }, - search: { - title: "Recherche", - searchBarPlaceholder: "Rechercher quelque chose", - }, - tableOfContents: { - title: "Table des Matières", - }, - contentMeta: { - readingTime: ({ minutes }) => `${minutes} min read`, - }, - }, - pages: { - rss: { - recentNotes: "Notes récentes", - lastFewNotes: ({ count }) => `Les dernières ${count} notes`, - }, - error: { - title: "Pas trouvé", - notFound: "Cette page est soit privée, soit elle n'existe pas.", - }, - folderContent: { - folder: "Dossier", - itemsUnderFolder: ({ count }) => - count === 1 ? "1 élément sous ce dossier." : `${count} éléments sous ce dossier.`, - }, - tagContent: { - tag: "Étiquette", - tagIndex: "Index des étiquettes", - itemsUnderTag: ({ count }) => - count === 1 ? "1 élément avec cette étiquette." : `${count} éléments avec cette étiquette.`, - showingFirst: ({ count }) => `Affichage des premières ${count} étiquettes.`, - totalTags: ({ count }) => `Trouvé ${count} étiquettes au total.`, - }, - }, -} as const satisfies Translation + propertyDefaults: { + title: "Sans titre", + description: "Aucune description fournie", + }, + components: { + callout: { + note: "Note", + abstract: "Résumé", + info: "Info", + todo: "À faire", + tip: "Conseil", + success: "Succès", + question: "Question", + warning: "Avertissement", + failure: "Échec", + danger: "Danger", + bug: "Bogue", + example: "Exemple", + quote: "Citation", + }, + backlinks: { + title: "Liens retour", + noBacklinksFound: "Aucun lien retour trouvé", + }, + themeToggle: { + lightMode: "Mode clair", + darkMode: "Mode sombre", + }, + explorer: { + title: "Explorateur", + }, + footer: { + createdWith: "Créé avec", + }, + graph: { + title: "Vue Graphique", + }, + recentNotes: { + title: "Notes Récentes", + seeRemainingMore: ({ remaining }) => `Voir ${remaining} de plus →`, + }, + transcludes: { + transcludeOf: ({ targetSlug }) => `Transclusion de ${targetSlug}`, + linkToOriginal: "Lien vers l'original", + }, + search: { + title: "Recherche", + searchBarPlaceholder: "Rechercher quelque chose", + }, + tableOfContents: { + title: "Table des Matières", + }, + contentMeta: { + readingTime: ({ minutes }) => `${minutes} min read`, + }, + }, + pages: { + rss: { + recentNotes: "Notes récentes", + lastFewNotes: ({ count }) => `Les dernières ${count} notes`, + }, + error: { + title: "Pas trouvé", + notFound: "Cette page est soit privée, soit elle n'existe pas.", + }, + folderContent: { + folder: "Dossier", + itemsUnderFolder: ({ count }) => + count === 1 ? "1 élément sous ce dossier." : `${count} éléments sous ce dossier.`, + }, + tagContent: { + tag: "Étiquette", + tagIndex: "Index des étiquettes", + itemsUnderTag: ({ count }) => + count === 1 ? "1 élément avec cette étiquette." : `${count} éléments avec cette étiquette.`, + showingFirst: ({ count }) => `Affichage des premières ${count} étiquettes.`, + totalTags: ({ count }) => `Trouvé ${count} étiquettes au total.`, + }, + }, +} as const satisfies Translation; diff --git a/quartz/i18n/locales/it-IT.ts b/quartz/i18n/locales/it-IT.ts index ca8818a650f35..62322b15fde25 100644 --- a/quartz/i18n/locales/it-IT.ts +++ b/quartz/i18n/locales/it-IT.ts @@ -1,83 +1,83 @@ -import { Translation } from "./definition" +import { Translation } from "./definition"; export default { - propertyDefaults: { - title: "Senza titolo", - description: "Nessuna descrizione", - }, - components: { - callout: { - note: "Nota", - abstract: "Astratto", - info: "Info", - todo: "Da fare", - tip: "Consiglio", - success: "Completato", - question: "Domanda", - warning: "Attenzione", - failure: "Errore", - danger: "Pericolo", - bug: "Bug", - example: "Esempio", - quote: "Citazione", - }, - backlinks: { - title: "Link entranti", - noBacklinksFound: "Nessun link entrante", - }, - themeToggle: { - lightMode: "Tema chiaro", - darkMode: "Tema scuro", - }, - explorer: { - title: "Esplora", - }, - footer: { - createdWith: "Creato con", - }, - graph: { - title: "Vista grafico", - }, - recentNotes: { - title: "Note recenti", - seeRemainingMore: ({ remaining }) => `Vedi ${remaining} altro →`, - }, - transcludes: { - transcludeOf: ({ targetSlug }) => `Transclusione di ${targetSlug}`, - linkToOriginal: "Link all'originale", - }, - search: { - title: "Cerca", - searchBarPlaceholder: "Cerca qualcosa", - }, - tableOfContents: { - title: "Tabella dei contenuti", - }, - contentMeta: { - readingTime: ({ minutes }) => `${minutes} minuti`, - }, - }, - pages: { - rss: { - recentNotes: "Note recenti", - lastFewNotes: ({ count }) => `Ultime ${count} note`, - }, - error: { - title: "Non trovato", - notFound: "Questa pagina è privata o non esiste.", - }, - folderContent: { - folder: "Cartella", - itemsUnderFolder: ({ count }) => - count === 1 ? "1 oggetto in questa cartella." : `${count} oggetti in questa cartella.`, - }, - tagContent: { - tag: "Etichetta", - tagIndex: "Indice etichette", - itemsUnderTag: ({ count }) => - count === 1 ? "1 oggetto con questa etichetta." : `${count} oggetti con questa etichetta.`, - showingFirst: ({ count }) => `Prime ${count} etichette.`, - totalTags: ({ count }) => `Trovate ${count} etichette totali.`, - }, - }, -} as const satisfies Translation + propertyDefaults: { + title: "Senza titolo", + description: "Nessuna descrizione", + }, + components: { + callout: { + note: "Nota", + abstract: "Astratto", + info: "Info", + todo: "Da fare", + tip: "Consiglio", + success: "Completato", + question: "Domanda", + warning: "Attenzione", + failure: "Errore", + danger: "Pericolo", + bug: "Bug", + example: "Esempio", + quote: "Citazione", + }, + backlinks: { + title: "Link entranti", + noBacklinksFound: "Nessun link entrante", + }, + themeToggle: { + lightMode: "Tema chiaro", + darkMode: "Tema scuro", + }, + explorer: { + title: "Esplora", + }, + footer: { + createdWith: "Creato con", + }, + graph: { + title: "Vista grafico", + }, + recentNotes: { + title: "Note recenti", + seeRemainingMore: ({ remaining }) => `Vedi ${remaining} altro →`, + }, + transcludes: { + transcludeOf: ({ targetSlug }) => `Transclusione di ${targetSlug}`, + linkToOriginal: "Link all'originale", + }, + search: { + title: "Cerca", + searchBarPlaceholder: "Cerca qualcosa", + }, + tableOfContents: { + title: "Tabella dei contenuti", + }, + contentMeta: { + readingTime: ({ minutes }) => `${minutes} minuti`, + }, + }, + pages: { + rss: { + recentNotes: "Note recenti", + lastFewNotes: ({ count }) => `Ultime ${count} note`, + }, + error: { + title: "Non trovato", + notFound: "Questa pagina è privata o non esiste.", + }, + folderContent: { + folder: "Cartella", + itemsUnderFolder: ({ count }) => + count === 1 ? "1 oggetto in questa cartella." : `${count} oggetti in questa cartella.`, + }, + tagContent: { + tag: "Etichetta", + tagIndex: "Indice etichette", + itemsUnderTag: ({ count }) => + count === 1 ? "1 oggetto con questa etichetta." : `${count} oggetti con questa etichetta.`, + showingFirst: ({ count }) => `Prime ${count} etichette.`, + totalTags: ({ count }) => `Trovate ${count} etichette totali.`, + }, + }, +} as const satisfies Translation; diff --git a/quartz/i18n/locales/ja-JP.ts b/quartz/i18n/locales/ja-JP.ts index d429db411a934..61327eb70d7fc 100644 --- a/quartz/i18n/locales/ja-JP.ts +++ b/quartz/i18n/locales/ja-JP.ts @@ -1,81 +1,81 @@ -import { Translation } from "./definition" +import { Translation } from "./definition"; export default { - propertyDefaults: { - title: "無題", - description: "説明なし", - }, - components: { - callout: { - note: "ノート", - abstract: "抄録", - info: "情報", - todo: "やるべきこと", - tip: "ヒント", - success: "成功", - question: "質問", - warning: "警告", - failure: "失敗", - danger: "危険", - bug: "バグ", - example: "例", - quote: "引用", - }, - backlinks: { - title: "バックリンク", - noBacklinksFound: "バックリンクはありません", - }, - themeToggle: { - lightMode: "ライトモード", - darkMode: "ダークモード", - }, - explorer: { - title: "エクスプローラー", - }, - footer: { - createdWith: "作成", - }, - graph: { - title: "グラフビュー", - }, - recentNotes: { - title: "最近の記事", - seeRemainingMore: ({ remaining }) => `さらに${remaining}件 →`, - }, - transcludes: { - transcludeOf: ({ targetSlug }) => `${targetSlug}のまとめ`, - linkToOriginal: "元記事へのリンク", - }, - search: { - title: "検索", - searchBarPlaceholder: "検索ワードを入力", - }, - tableOfContents: { - title: "目次", - }, - contentMeta: { - readingTime: ({ minutes }) => `${minutes} min read`, - }, - }, - pages: { - rss: { - recentNotes: "最近の記事", - lastFewNotes: ({ count }) => `最新の${count}件`, - }, - error: { - title: "Not Found", - notFound: "ページが存在しないか、非公開設定になっています。", - }, - folderContent: { - folder: "フォルダ", - itemsUnderFolder: ({ count }) => `${count}件のページ`, - }, - tagContent: { - tag: "タグ", - tagIndex: "タグ一覧", - itemsUnderTag: ({ count }) => `${count}件のページ`, - showingFirst: ({ count }) => `のうち最初の${count}件を表示しています`, - totalTags: ({ count }) => `全${count}個のタグを表示中`, - }, - }, -} as const satisfies Translation + propertyDefaults: { + title: "無題", + description: "説明なし", + }, + components: { + callout: { + note: "ノート", + abstract: "抄録", + info: "情報", + todo: "やるべきこと", + tip: "ヒント", + success: "成功", + question: "質問", + warning: "警告", + failure: "失敗", + danger: "危険", + bug: "バグ", + example: "例", + quote: "引用", + }, + backlinks: { + title: "バックリンク", + noBacklinksFound: "バックリンクはありません", + }, + themeToggle: { + lightMode: "ライトモード", + darkMode: "ダークモード", + }, + explorer: { + title: "エクスプローラー", + }, + footer: { + createdWith: "作成", + }, + graph: { + title: "グラフビュー", + }, + recentNotes: { + title: "最近の記事", + seeRemainingMore: ({ remaining }) => `さらに${remaining}件 →`, + }, + transcludes: { + transcludeOf: ({ targetSlug }) => `${targetSlug}のまとめ`, + linkToOriginal: "元記事へのリンク", + }, + search: { + title: "検索", + searchBarPlaceholder: "検索ワードを入力", + }, + tableOfContents: { + title: "目次", + }, + contentMeta: { + readingTime: ({ minutes }) => `${minutes} min read`, + }, + }, + pages: { + rss: { + recentNotes: "最近の記事", + lastFewNotes: ({ count }) => `最新の${count}件`, + }, + error: { + title: "Not Found", + notFound: "ページが存在しないか、非公開設定になっています。", + }, + folderContent: { + folder: "フォルダ", + itemsUnderFolder: ({ count }) => `${count}件のページ`, + }, + tagContent: { + tag: "タグ", + tagIndex: "タグ一覧", + itemsUnderTag: ({ count }) => `${count}件のページ`, + showingFirst: ({ count }) => `のうち最初の${count}件を表示しています`, + totalTags: ({ count }) => `全${count}個のタグを表示中`, + }, + }, +} as const satisfies Translation; diff --git a/quartz/i18n/locales/ko-KR.ts b/quartz/i18n/locales/ko-KR.ts index ea735b00c961f..617e80836c163 100644 --- a/quartz/i18n/locales/ko-KR.ts +++ b/quartz/i18n/locales/ko-KR.ts @@ -1,81 +1,81 @@ -import { Translation } from "./definition" +import { Translation } from "./definition"; export default { - propertyDefaults: { - title: "제목 없음", - description: "설명 없음", - }, - components: { - callout: { - note: "노트", - abstract: "개요", - info: "정보", - todo: "할일", - tip: "팁", - success: "성공", - question: "질문", - warning: "주의", - failure: "실패", - danger: "위험", - bug: "버그", - example: "예시", - quote: "인용", - }, - backlinks: { - title: "백링크", - noBacklinksFound: "백링크가 없습니다.", - }, - themeToggle: { - lightMode: "라이트 모드", - darkMode: "다크 모드", - }, - explorer: { - title: "탐색기", - }, - footer: { - createdWith: "Created with", - }, - graph: { - title: "그래프 뷰", - }, - recentNotes: { - title: "최근 게시글", - seeRemainingMore: ({ remaining }) => `${remaining}건 더보기 →`, - }, - transcludes: { - transcludeOf: ({ targetSlug }) => `${targetSlug}의 포함`, - linkToOriginal: "원본 링크", - }, - search: { - title: "검색", - searchBarPlaceholder: "검색어를 입력하세요", - }, - tableOfContents: { - title: "목차", - }, - contentMeta: { - readingTime: ({ minutes }) => `${minutes} min read`, - }, - }, - pages: { - rss: { - recentNotes: "최근 게시글", - lastFewNotes: ({ count }) => `최근 ${count} 건`, - }, - error: { - title: "Not Found", - notFound: "페이지가 존재하지 않거나 비공개 설정이 되어 있습니다.", - }, - folderContent: { - folder: "폴더", - itemsUnderFolder: ({ count }) => `${count}건의 항목`, - }, - tagContent: { - tag: "태그", - tagIndex: "태그 목록", - itemsUnderTag: ({ count }) => `${count}건의 항목`, - showingFirst: ({ count }) => `처음 ${count}개의 태그`, - totalTags: ({ count }) => `총 ${count}개의 태그를 찾았습니다.`, - }, - }, -} as const satisfies Translation + propertyDefaults: { + title: "제목 없음", + description: "설명 없음", + }, + components: { + callout: { + note: "노트", + abstract: "개요", + info: "정보", + todo: "할일", + tip: "팁", + success: "성공", + question: "질문", + warning: "주의", + failure: "실패", + danger: "위험", + bug: "버그", + example: "예시", + quote: "인용", + }, + backlinks: { + title: "백링크", + noBacklinksFound: "백링크가 없습니다.", + }, + themeToggle: { + lightMode: "라이트 모드", + darkMode: "다크 모드", + }, + explorer: { + title: "탐색기", + }, + footer: { + createdWith: "Created with", + }, + graph: { + title: "그래프 뷰", + }, + recentNotes: { + title: "최근 게시글", + seeRemainingMore: ({ remaining }) => `${remaining}건 더보기 →`, + }, + transcludes: { + transcludeOf: ({ targetSlug }) => `${targetSlug}의 포함`, + linkToOriginal: "원본 링크", + }, + search: { + title: "검색", + searchBarPlaceholder: "검색어를 입력하세요", + }, + tableOfContents: { + title: "목차", + }, + contentMeta: { + readingTime: ({ minutes }) => `${minutes} min read`, + }, + }, + pages: { + rss: { + recentNotes: "최근 게시글", + lastFewNotes: ({ count }) => `최근 ${count} 건`, + }, + error: { + title: "Not Found", + notFound: "페이지가 존재하지 않거나 비공개 설정이 되어 있습니다.", + }, + folderContent: { + folder: "폴더", + itemsUnderFolder: ({ count }) => `${count}건의 항목`, + }, + tagContent: { + tag: "태그", + tagIndex: "태그 목록", + itemsUnderTag: ({ count }) => `${count}건의 항목`, + showingFirst: ({ count }) => `처음 ${count}개의 태그`, + totalTags: ({ count }) => `총 ${count}개의 태그를 찾았습니다.`, + }, + }, +} as const satisfies Translation; diff --git a/quartz/i18n/locales/nl-NL.ts b/quartz/i18n/locales/nl-NL.ts index d075d584a45be..26783e15d4d36 100644 --- a/quartz/i18n/locales/nl-NL.ts +++ b/quartz/i18n/locales/nl-NL.ts @@ -1,85 +1,85 @@ -import { Translation } from "./definition" +import { Translation } from "./definition"; export default { - propertyDefaults: { - title: "Naamloos", - description: "Geen beschrijving gegeven.", - }, - components: { - callout: { - note: "Notitie", - abstract: "Samenvatting", - info: "Info", - todo: "Te doen", - tip: "Tip", - success: "Succes", - question: "Vraag", - warning: "Waarschuwing", - failure: "Mislukking", - danger: "Gevaar", - bug: "Bug", - example: "Voorbeeld", - quote: "Citaat", - }, - backlinks: { - title: "Backlinks", - noBacklinksFound: "Geen backlinks gevonden", - }, - themeToggle: { - lightMode: "Lichte modus", - darkMode: "Donkere modus", - }, - explorer: { - title: "Verkenner", - }, - footer: { - createdWith: "Gemaakt met", - }, - graph: { - title: "Grafiekweergave", - }, - recentNotes: { - title: "Recente notities", - seeRemainingMore: ({ remaining }) => `Zie ${remaining} meer →`, - }, - transcludes: { - transcludeOf: ({ targetSlug }) => `Invoeging van ${targetSlug}`, - linkToOriginal: "Link naar origineel", - }, - search: { - title: "Zoeken", - searchBarPlaceholder: "Doorzoek de website", - }, - tableOfContents: { - title: "Inhoudsopgave", - }, - contentMeta: { - readingTime: ({ minutes }) => - minutes === 1 ? "1 minuut leestijd" : `${minutes} minuten leestijd`, - }, - }, - pages: { - rss: { - recentNotes: "Recente notities", - lastFewNotes: ({ count }) => `Laatste ${count} notities`, - }, - error: { - title: "Niet gevonden", - notFound: "Deze pagina is niet zichtbaar of bestaat niet.", - }, - folderContent: { - folder: "Map", - itemsUnderFolder: ({ count }) => - count === 1 ? "1 item in deze map." : `${count} items in deze map.`, - }, - tagContent: { - tag: "Label", - tagIndex: "Label-index", - itemsUnderTag: ({ count }) => - count === 1 ? "1 item met dit label." : `${count} items met dit label.`, - showingFirst: ({ count }) => - count === 1 ? "Eerste label tonen." : `Eerste ${count} labels tonen.`, - totalTags: ({ count }) => `${count} labels gevonden.`, - }, - }, -} as const satisfies Translation + propertyDefaults: { + title: "Naamloos", + description: "Geen beschrijving gegeven.", + }, + components: { + callout: { + note: "Notitie", + abstract: "Samenvatting", + info: "Info", + todo: "Te doen", + tip: "Tip", + success: "Succes", + question: "Vraag", + warning: "Waarschuwing", + failure: "Mislukking", + danger: "Gevaar", + bug: "Bug", + example: "Voorbeeld", + quote: "Citaat", + }, + backlinks: { + title: "Backlinks", + noBacklinksFound: "Geen backlinks gevonden", + }, + themeToggle: { + lightMode: "Lichte modus", + darkMode: "Donkere modus", + }, + explorer: { + title: "Verkenner", + }, + footer: { + createdWith: "Gemaakt met", + }, + graph: { + title: "Grafiekweergave", + }, + recentNotes: { + title: "Recente notities", + seeRemainingMore: ({ remaining }) => `Zie ${remaining} meer →`, + }, + transcludes: { + transcludeOf: ({ targetSlug }) => `Invoeging van ${targetSlug}`, + linkToOriginal: "Link naar origineel", + }, + search: { + title: "Zoeken", + searchBarPlaceholder: "Doorzoek de website", + }, + tableOfContents: { + title: "Inhoudsopgave", + }, + contentMeta: { + readingTime: ({ minutes }) => + minutes === 1 ? "1 minuut leestijd" : `${minutes} minuten leestijd`, + }, + }, + pages: { + rss: { + recentNotes: "Recente notities", + lastFewNotes: ({ count }) => `Laatste ${count} notities`, + }, + error: { + title: "Niet gevonden", + notFound: "Deze pagina is niet zichtbaar of bestaat niet.", + }, + folderContent: { + folder: "Map", + itemsUnderFolder: ({ count }) => + count === 1 ? "1 item in deze map." : `${count} items in deze map.`, + }, + tagContent: { + tag: "Label", + tagIndex: "Label-index", + itemsUnderTag: ({ count }) => + count === 1 ? "1 item met dit label." : `${count} items met dit label.`, + showingFirst: ({ count }) => + count === 1 ? "Eerste label tonen." : `Eerste ${count} labels tonen.`, + totalTags: ({ count }) => `${count} labels gevonden.`, + }, + }, +} as const satisfies Translation; diff --git a/quartz/i18n/locales/ro-RO.ts b/quartz/i18n/locales/ro-RO.ts index 556b1899503fe..c70f1218c4e82 100644 --- a/quartz/i18n/locales/ro-RO.ts +++ b/quartz/i18n/locales/ro-RO.ts @@ -1,84 +1,84 @@ -import { Translation } from "./definition" +import { Translation } from "./definition"; export default { - propertyDefaults: { - title: "Fără titlu", - description: "Nici o descriere furnizată", - }, - components: { - callout: { - note: "Notă", - abstract: "Rezumat", - info: "Informație", - todo: "De făcut", - tip: "Sfat", - success: "Succes", - question: "Întrebare", - warning: "Avertisment", - failure: "Eșec", - danger: "Pericol", - bug: "Bug", - example: "Exemplu", - quote: "Citat", - }, - backlinks: { - title: "Legături înapoi", - noBacklinksFound: "Nu s-au găsit legături înapoi", - }, - themeToggle: { - lightMode: "Modul luminos", - darkMode: "Modul întunecat", - }, - explorer: { - title: "Explorator", - }, - footer: { - createdWith: "Creat cu", - }, - graph: { - title: "Graf", - }, - recentNotes: { - title: "Notițe recente", - seeRemainingMore: ({ remaining }) => `Vezi încă ${remaining} →`, - }, - transcludes: { - transcludeOf: ({ targetSlug }) => `Extras din ${targetSlug}`, - linkToOriginal: "Legătură către original", - }, - search: { - title: "Căutare", - searchBarPlaceholder: "Introduceți termenul de căutare...", - }, - tableOfContents: { - title: "Cuprins", - }, - contentMeta: { - readingTime: ({ minutes }) => - minutes == 1 ? `lectură de 1 minut` : `lectură de ${minutes} minute`, - }, - }, - pages: { - rss: { - recentNotes: "Notițe recente", - lastFewNotes: ({ count }) => `Ultimele ${count} notițe`, - }, - error: { - title: "Pagina nu a fost găsită", - notFound: "Fie această pagină este privată, fie nu există.", - }, - folderContent: { - folder: "Dosar", - itemsUnderFolder: ({ count }) => - count === 1 ? "1 articol în acest dosar." : `${count} elemente în acest dosar.`, - }, - tagContent: { - tag: "Etichetă", - tagIndex: "Indexul etichetelor", - itemsUnderTag: ({ count }) => - count === 1 ? "1 articol cu această etichetă." : `${count} articole cu această etichetă.`, - showingFirst: ({ count }) => `Se afișează primele ${count} etichete.`, - totalTags: ({ count }) => `Au fost găsite ${count} etichete în total.`, - }, - }, -} as const satisfies Translation + propertyDefaults: { + title: "Fără titlu", + description: "Nici o descriere furnizată", + }, + components: { + callout: { + note: "Notă", + abstract: "Rezumat", + info: "Informație", + todo: "De făcut", + tip: "Sfat", + success: "Succes", + question: "Întrebare", + warning: "Avertisment", + failure: "Eșec", + danger: "Pericol", + bug: "Bug", + example: "Exemplu", + quote: "Citat", + }, + backlinks: { + title: "Legături înapoi", + noBacklinksFound: "Nu s-au găsit legături înapoi", + }, + themeToggle: { + lightMode: "Modul luminos", + darkMode: "Modul întunecat", + }, + explorer: { + title: "Explorator", + }, + footer: { + createdWith: "Creat cu", + }, + graph: { + title: "Graf", + }, + recentNotes: { + title: "Notițe recente", + seeRemainingMore: ({ remaining }) => `Vezi încă ${remaining} →`, + }, + transcludes: { + transcludeOf: ({ targetSlug }) => `Extras din ${targetSlug}`, + linkToOriginal: "Legătură către original", + }, + search: { + title: "Căutare", + searchBarPlaceholder: "Introduceți termenul de căutare...", + }, + tableOfContents: { + title: "Cuprins", + }, + contentMeta: { + readingTime: ({ minutes }) => + minutes == 1 ? "lectură de 1 minut" : `lectură de ${minutes} minute`, + }, + }, + pages: { + rss: { + recentNotes: "Notițe recente", + lastFewNotes: ({ count }) => `Ultimele ${count} notițe`, + }, + error: { + title: "Pagina nu a fost găsită", + notFound: "Fie această pagină este privată, fie nu există.", + }, + folderContent: { + folder: "Dosar", + itemsUnderFolder: ({ count }) => + count === 1 ? "1 articol în acest dosar." : `${count} elemente în acest dosar.`, + }, + tagContent: { + tag: "Etichetă", + tagIndex: "Indexul etichetelor", + itemsUnderTag: ({ count }) => + count === 1 ? "1 articol cu această etichetă." : `${count} articole cu această etichetă.`, + showingFirst: ({ count }) => `Se afișează primele ${count} etichete.`, + totalTags: ({ count }) => `Au fost găsite ${count} etichete în total.`, + }, + }, +} as const satisfies Translation; diff --git a/quartz/i18n/locales/ru-RU.ts b/quartz/i18n/locales/ru-RU.ts index 8ead3cabe8b63..25ba7aba8606d 100644 --- a/quartz/i18n/locales/ru-RU.ts +++ b/quartz/i18n/locales/ru-RU.ts @@ -1,95 +1,95 @@ -import { Translation } from "./definition" +import { Translation } from "./definition"; export default { - propertyDefaults: { - title: "Без названия", - description: "Описание отсутствует", - }, - components: { - callout: { - note: "Заметка", - abstract: "Резюме", - info: "Инфо", - todo: "Сделать", - tip: "Подсказка", - success: "Успех", - question: "Вопрос", - warning: "Предупреждение", - failure: "Неудача", - danger: "Опасность", - bug: "Баг", - example: "Пример", - quote: "Цитата", - }, - backlinks: { - title: "Обратные ссылки", - noBacklinksFound: "Обратные ссылки отсутствуют", - }, - themeToggle: { - lightMode: "Светлый режим", - darkMode: "Тёмный режим", - }, - explorer: { - title: "Проводник", - }, - footer: { - createdWith: "Создано с помощью", - }, - graph: { - title: "Вид графа", - }, - recentNotes: { - title: "Недавние заметки", - seeRemainingMore: ({ remaining }) => - `Посмотреть оставш${getForm(remaining, "уюся", "иеся", "иеся")} ${remaining} →`, - }, - transcludes: { - transcludeOf: ({ targetSlug }) => `Переход из ${targetSlug}`, - linkToOriginal: "Ссылка на оригинал", - }, - search: { - title: "Поиск", - searchBarPlaceholder: "Найти что-нибудь", - }, - tableOfContents: { - title: "Оглавление", - }, - contentMeta: { - readingTime: ({ minutes }) => `время чтения ~${minutes} мин.`, - }, - }, - pages: { - rss: { - recentNotes: "Недавние заметки", - lastFewNotes: ({ count }) => - `Последн${getForm(count, "яя", "ие", "ие")} ${count} замет${getForm(count, "ка", "ки", "ок")}`, - }, - error: { - title: "Страница не найдена", - notFound: "Эта страница приватная или не существует", - }, - folderContent: { - folder: "Папка", - itemsUnderFolder: ({ count }) => - `в этой папке ${count} элемент${getForm(count, "", "а", "ов")}`, - }, - tagContent: { - tag: "Тег", - tagIndex: "Индекс тегов", - itemsUnderTag: ({ count }) => `с этим тегом ${count} элемент${getForm(count, "", "а", "ов")}`, - showingFirst: ({ count }) => - `Показыва${getForm(count, "ется", "ются", "ются")} ${count} тег${getForm(count, "", "а", "ов")}`, - totalTags: ({ count }) => `Всего ${count} тег${getForm(count, "", "а", "ов")}`, - }, - }, -} as const satisfies Translation + propertyDefaults: { + title: "Без названия", + description: "Описание отсутствует", + }, + components: { + callout: { + note: "Заметка", + abstract: "Резюме", + info: "Инфо", + todo: "Сделать", + tip: "Подсказка", + success: "Успех", + question: "Вопрос", + warning: "Предупреждение", + failure: "Неудача", + danger: "Опасность", + bug: "Баг", + example: "Пример", + quote: "Цитата", + }, + backlinks: { + title: "Обратные ссылки", + noBacklinksFound: "Обратные ссылки отсутствуют", + }, + themeToggle: { + lightMode: "Светлый режим", + darkMode: "Тёмный режим", + }, + explorer: { + title: "Проводник", + }, + footer: { + createdWith: "Создано с помощью", + }, + graph: { + title: "Вид графа", + }, + recentNotes: { + title: "Недавние заметки", + seeRemainingMore: ({ remaining }) => + `Посмотреть оставш${getForm(remaining, "уюся", "иеся", "иеся")} ${remaining} →`, + }, + transcludes: { + transcludeOf: ({ targetSlug }) => `Переход из ${targetSlug}`, + linkToOriginal: "Ссылка на оригинал", + }, + search: { + title: "Поиск", + searchBarPlaceholder: "Найти что-нибудь", + }, + tableOfContents: { + title: "Оглавление", + }, + contentMeta: { + readingTime: ({ minutes }) => `время чтения ~${minutes} мин.`, + }, + }, + pages: { + rss: { + recentNotes: "Недавние заметки", + lastFewNotes: ({ count }) => + `Последн${getForm(count, "яя", "ие", "ие")} ${count} замет${getForm(count, "ка", "ки", "ок")}`, + }, + error: { + title: "Страница не найдена", + notFound: "Эта страница приватная или не существует", + }, + folderContent: { + folder: "Папка", + itemsUnderFolder: ({ count }) => + `в этой папке ${count} элемент${getForm(count, "", "а", "ов")}`, + }, + tagContent: { + tag: "Тег", + tagIndex: "Индекс тегов", + itemsUnderTag: ({ count }) => `с этим тегом ${count} элемент${getForm(count, "", "а", "ов")}`, + showingFirst: ({ count }) => + `Показыва${getForm(count, "ется", "ются", "ются")} ${count} тег${getForm(count, "", "а", "ов")}`, + totalTags: ({ count }) => `Всего ${count} тег${getForm(count, "", "а", "ов")}`, + }, + }, +} as const satisfies Translation; function getForm(number: number, form1: string, form2: string, form5: string): string { - const remainder100 = number % 100 - const remainder10 = remainder100 % 10 + const remainder100 = number % 100; + const remainder10 = remainder100 % 10; - if (remainder100 >= 10 && remainder100 <= 20) return form5 - if (remainder10 > 1 && remainder10 < 5) return form2 - if (remainder10 == 1) return form1 - return form5 + if (remainder100 >= 10 && remainder100 <= 20) return form5; + if (remainder10 > 1 && remainder10 < 5) return form2; + if (remainder10 == 1) return form1; + return form5; } diff --git a/quartz/i18n/locales/uk-UA.ts b/quartz/i18n/locales/uk-UA.ts index b636938373c37..2f40122f1d311 100644 --- a/quartz/i18n/locales/uk-UA.ts +++ b/quartz/i18n/locales/uk-UA.ts @@ -1,83 +1,83 @@ -import { Translation } from "./definition" +import { Translation } from "./definition"; export default { - propertyDefaults: { - title: "Без назви", - description: "Опис не надано", - }, - components: { - callout: { - note: "Примітка", - abstract: "Абстракт", - info: "Інформація", - todo: "Завдання", - tip: "Порада", - success: "Успіх", - question: "Питання", - warning: "Попередження", - failure: "Невдача", - danger: "Небезпека", - bug: "Баг", - example: "Приклад", - quote: "Цитата", - }, - backlinks: { - title: "Зворотні посилання", - noBacklinksFound: "Зворотних посилань не знайдено", - }, - themeToggle: { - lightMode: "Світлий режим", - darkMode: "Темний режим", - }, - explorer: { - title: "Провідник", - }, - footer: { - createdWith: "Створено за допомогою", - }, - graph: { - title: "Вигляд графа", - }, - recentNotes: { - title: "Останні нотатки", - seeRemainingMore: ({ remaining }) => `Переглянути ще ${remaining} →`, - }, - transcludes: { - transcludeOf: ({ targetSlug }) => `Видобуто з ${targetSlug}`, - linkToOriginal: "Посилання на оригінал", - }, - search: { - title: "Пошук", - searchBarPlaceholder: "Шукати щось", - }, - tableOfContents: { - title: "Зміст", - }, - contentMeta: { - readingTime: ({ minutes }) => `${minutes} min read`, - }, - }, - pages: { - rss: { - recentNotes: "Останні нотатки", - lastFewNotes: ({ count }) => `Останні нотатки: ${count}`, - }, - error: { - title: "Не знайдено", - notFound: "Ця сторінка або приватна, або не існує.", - }, - folderContent: { - folder: "Папка", - itemsUnderFolder: ({ count }) => - count === 1 ? "У цій папці 1 елемент." : `Елементів у цій папці: ${count}.`, - }, - tagContent: { - tag: "Тег", - tagIndex: "Індекс тегу", - itemsUnderTag: ({ count }) => - count === 1 ? "1 елемент з цим тегом." : `Елементів з цим тегом: ${count}.`, - showingFirst: ({ count }) => `Показ перших ${count} тегів.`, - totalTags: ({ count }) => `Всього знайдено тегів: ${count}.`, - }, - }, -} as const satisfies Translation + propertyDefaults: { + title: "Без назви", + description: "Опис не надано", + }, + components: { + callout: { + note: "Примітка", + abstract: "Абстракт", + info: "Інформація", + todo: "Завдання", + tip: "Порада", + success: "Успіх", + question: "Питання", + warning: "Попередження", + failure: "Невдача", + danger: "Небезпека", + bug: "Баг", + example: "Приклад", + quote: "Цитата", + }, + backlinks: { + title: "Зворотні посилання", + noBacklinksFound: "Зворотних посилань не знайдено", + }, + themeToggle: { + lightMode: "Світлий режим", + darkMode: "Темний режим", + }, + explorer: { + title: "Провідник", + }, + footer: { + createdWith: "Створено за допомогою", + }, + graph: { + title: "Вигляд графа", + }, + recentNotes: { + title: "Останні нотатки", + seeRemainingMore: ({ remaining }) => `Переглянути ще ${remaining} →`, + }, + transcludes: { + transcludeOf: ({ targetSlug }) => `Видобуто з ${targetSlug}`, + linkToOriginal: "Посилання на оригінал", + }, + search: { + title: "Пошук", + searchBarPlaceholder: "Шукати щось", + }, + tableOfContents: { + title: "Зміст", + }, + contentMeta: { + readingTime: ({ minutes }) => `${minutes} min read`, + }, + }, + pages: { + rss: { + recentNotes: "Останні нотатки", + lastFewNotes: ({ count }) => `Останні нотатки: ${count}`, + }, + error: { + title: "Не знайдено", + notFound: "Ця сторінка або приватна, або не існує.", + }, + folderContent: { + folder: "Папка", + itemsUnderFolder: ({ count }) => + count === 1 ? "У цій папці 1 елемент." : `Елементів у цій папці: ${count}.`, + }, + tagContent: { + tag: "Тег", + tagIndex: "Індекс тегу", + itemsUnderTag: ({ count }) => + count === 1 ? "1 елемент з цим тегом." : `Елементів з цим тегом: ${count}.`, + showingFirst: ({ count }) => `Показ перших ${count} тегів.`, + totalTags: ({ count }) => `Всього знайдено тегів: ${count}.`, + }, + }, +} as const satisfies Translation; diff --git a/quartz/i18n/locales/zh-CN.ts b/quartz/i18n/locales/zh-CN.ts index 43d011197bd99..eb4345ffe7af7 100644 --- a/quartz/i18n/locales/zh-CN.ts +++ b/quartz/i18n/locales/zh-CN.ts @@ -1,81 +1,81 @@ -import { Translation } from "./definition" +import { Translation } from "./definition"; export default { - propertyDefaults: { - title: "无题", - description: "无描述", - }, - components: { - callout: { - note: "笔记", - abstract: "摘要", - info: "提示", - todo: "待办", - tip: "提示", - success: "成功", - question: "问题", - warning: "警告", - failure: "失败", - danger: "危险", - bug: "错误", - example: "示例", - quote: "引用", - }, - backlinks: { - title: "反向链接", - noBacklinksFound: "无法找到反向链接", - }, - themeToggle: { - lightMode: "亮色模式", - darkMode: "暗色模式", - }, - explorer: { - title: "探索", - }, - footer: { - createdWith: "Created with", - }, - graph: { - title: "关系图谱", - }, - recentNotes: { - title: "最近的笔记", - seeRemainingMore: ({ remaining }) => `查看更多${remaining}篇笔记 →`, - }, - transcludes: { - transcludeOf: ({ targetSlug }) => `包含${targetSlug}`, - linkToOriginal: "指向原始笔记的链接", - }, - search: { - title: "搜索", - searchBarPlaceholder: "搜索些什么", - }, - tableOfContents: { - title: "目录", - }, - contentMeta: { - readingTime: ({ minutes }) => `${minutes}分钟阅读`, - }, - }, - pages: { - rss: { - recentNotes: "最近的笔记", - lastFewNotes: ({ count }) => `最近的${count}条笔记`, - }, - error: { - title: "无法找到", - notFound: "私有笔记或笔记不存在。", - }, - folderContent: { - folder: "文件夹", - itemsUnderFolder: ({ count }) => `此文件夹下有${count}条笔记。`, - }, - tagContent: { - tag: "标签", - tagIndex: "标签索引", - itemsUnderTag: ({ count }) => `此标签下有${count}条笔记。`, - showingFirst: ({ count }) => `显示前${count}个标签。`, - totalTags: ({ count }) => `总共有${count}个标签。`, - }, - }, -} as const satisfies Translation + propertyDefaults: { + title: "无题", + description: "无描述", + }, + components: { + callout: { + note: "笔记", + abstract: "摘要", + info: "提示", + todo: "待办", + tip: "提示", + success: "成功", + question: "问题", + warning: "警告", + failure: "失败", + danger: "危险", + bug: "错误", + example: "示例", + quote: "引用", + }, + backlinks: { + title: "反向链接", + noBacklinksFound: "无法找到反向链接", + }, + themeToggle: { + lightMode: "亮色模式", + darkMode: "暗色模式", + }, + explorer: { + title: "探索", + }, + footer: { + createdWith: "Created with", + }, + graph: { + title: "关系图谱", + }, + recentNotes: { + title: "最近的笔记", + seeRemainingMore: ({ remaining }) => `查看更多${remaining}篇笔记 →`, + }, + transcludes: { + transcludeOf: ({ targetSlug }) => `包含${targetSlug}`, + linkToOriginal: "指向原始笔记的链接", + }, + search: { + title: "搜索", + searchBarPlaceholder: "搜索些什么", + }, + tableOfContents: { + title: "目录", + }, + contentMeta: { + readingTime: ({ minutes }) => `${minutes}分钟阅读`, + }, + }, + pages: { + rss: { + recentNotes: "最近的笔记", + lastFewNotes: ({ count }) => `最近的${count}条笔记`, + }, + error: { + title: "无法找到", + notFound: "私有笔记或笔记不存在。", + }, + folderContent: { + folder: "文件夹", + itemsUnderFolder: ({ count }) => `此文件夹下有${count}条笔记。`, + }, + tagContent: { + tag: "标签", + tagIndex: "标签索引", + itemsUnderTag: ({ count }) => `此标签下有${count}条笔记。`, + showingFirst: ({ count }) => `显示前${count}个标签。`, + totalTags: ({ count }) => `总共有${count}个标签。`, + }, + }, +} as const satisfies Translation; diff --git a/quartz/plugins/emitters/aliases.ts b/quartz/plugins/emitters/aliases.ts index af3578ebe52f3..388656b5a137b 100644 --- a/quartz/plugins/emitters/aliases.ts +++ b/quartz/plugins/emitters/aliases.ts @@ -1,63 +1,64 @@ -import { FilePath, FullSlug, joinSegments, resolveRelative, simplifySlug } from "../../util/path" -import { QuartzEmitterPlugin } from "../types" -import path from "path" -import { write } from "./helpers" -import DepGraph from "../../depgraph" +import path from "path"; + +import DepGraph from "../../depgraph"; +import { FilePath, FullSlug, joinSegments, resolveRelative, simplifySlug } from "../../util/path"; +import { QuartzEmitterPlugin } from "../types"; +import { write } from "./helpers"; export const AliasRedirects: QuartzEmitterPlugin = () => ({ - name: "AliasRedirects", - getQuartzComponents() { - return [] - }, - async getDependencyGraph(ctx, content, _resources) { - const graph = new DepGraph() + name: "AliasRedirects", + getQuartzComponents() { + return []; + }, + async getDependencyGraph(ctx, content, _resources) { + const graph = new DepGraph(); - const { argv } = ctx - for (const [_tree, file] of content) { - const dir = path.posix.relative(argv.directory, path.dirname(file.data.filePath!)) - const aliases = file.data.frontmatter?.aliases ?? [] - const slugs = aliases.map((alias) => path.posix.join(dir, alias) as FullSlug) - const permalink = file.data.frontmatter?.permalink - if (typeof permalink === "string") { - slugs.push(permalink as FullSlug) - } + const { argv } = ctx; + for (const [_tree, file] of content) { + const dir = path.posix.relative(argv.directory, path.dirname(file.data.filePath!)); + const aliases = file.data.frontmatter?.aliases ?? []; + const slugs = aliases.map((alias) => path.posix.join(dir, alias) as FullSlug); + const permalink = file.data.frontmatter?.permalink; + if (typeof permalink === "string") { + slugs.push(permalink as FullSlug); + } - for (let slug of slugs) { - // fix any slugs that have trailing slash - if (slug.endsWith("/")) { - slug = joinSegments(slug, "index") as FullSlug - } + for (let slug of slugs) { + // fix any slugs that have trailing slash + if (slug.endsWith("/")) { + slug = joinSegments(slug, "index") as FullSlug; + } - graph.addEdge(file.data.filePath!, joinSegments(argv.output, slug + ".html") as FilePath) - } - } + graph.addEdge(file.data.filePath!, joinSegments(argv.output, slug + ".html") as FilePath); + } + } - return graph - }, - async emit(ctx, content, _resources): Promise { - const { argv } = ctx - const fps: FilePath[] = [] + return graph; + }, + async emit(ctx, content, _resources): Promise { + const { argv } = ctx; + const fps: FilePath[] = []; - for (const [_tree, file] of content) { - const ogSlug = simplifySlug(file.data.slug!) - const dir = path.posix.relative(argv.directory, path.dirname(file.data.filePath!)) - const aliases = file.data.frontmatter?.aliases ?? [] - const slugs: FullSlug[] = aliases.map((alias) => path.posix.join(dir, alias) as FullSlug) - const permalink = file.data.frontmatter?.permalink - if (typeof permalink === "string") { - slugs.push(permalink as FullSlug) - } + for (const [_tree, file] of content) { + const ogSlug = simplifySlug(file.data.slug!); + const dir = path.posix.relative(argv.directory, path.dirname(file.data.filePath!)); + const aliases = file.data.frontmatter?.aliases ?? []; + const slugs: FullSlug[] = aliases.map((alias) => path.posix.join(dir, alias) as FullSlug); + const permalink = file.data.frontmatter?.permalink; + if (typeof permalink === "string") { + slugs.push(permalink as FullSlug); + } - for (let slug of slugs) { - // fix any slugs that have trailing slash - if (slug.endsWith("/")) { - slug = joinSegments(slug, "index") as FullSlug - } + for (let slug of slugs) { + // fix any slugs that have trailing slash + if (slug.endsWith("/")) { + slug = joinSegments(slug, "index") as FullSlug; + } - const redirUrl = resolveRelative(slug, file.data.slug!) - const fp = await write({ - ctx, - content: ` + const redirUrl = resolveRelative(slug, file.data.slug!); + const fp = await write({ + ctx, + content: ` @@ -69,13 +70,13 @@ export const AliasRedirects: QuartzEmitterPlugin = () => ({ `, - slug, - ext: ".html", - }) + slug, + ext: ".html", + }); - fps.push(fp) - } - } - return fps - }, -}) + fps.push(fp); + } + } + return fps; + }, +}); diff --git a/quartz/plugins/emitters/assets.ts b/quartz/plugins/emitters/assets.ts index 036b27da43f43..e9ef8e42e4ec3 100644 --- a/quartz/plugins/emitters/assets.ts +++ b/quartz/plugins/emitters/assets.ts @@ -1,58 +1,59 @@ -import { FilePath, joinSegments, slugifyFilePath } from "../../util/path" -import { QuartzEmitterPlugin } from "../types" -import path from "path" -import fs from "fs" -import { glob } from "../../util/glob" -import DepGraph from "../../depgraph" -import { Argv } from "../../util/ctx" -import { QuartzConfig } from "../../cfg" +import fs from "fs"; +import path from "path"; + +import { QuartzConfig } from "../../cfg"; +import DepGraph from "../../depgraph"; +import { Argv } from "../../util/ctx"; +import { glob } from "../../util/glob"; +import { FilePath, joinSegments, slugifyFilePath } from "../../util/path"; +import { QuartzEmitterPlugin } from "../types"; const filesToCopy = async (argv: Argv, cfg: QuartzConfig) => { - // glob all non MD files in content folder and copy it over - return await glob("**", argv.directory, ["**/*.md", ...cfg.configuration.ignorePatterns]) -} + // glob all non MD files in content folder and copy it over + return await glob("**", argv.directory, ["**/*.md", ...cfg.configuration.ignorePatterns]); +}; export const Assets: QuartzEmitterPlugin = () => { - return { - name: "Assets", - getQuartzComponents() { - return [] - }, - async getDependencyGraph(ctx, _content, _resources) { - const { argv, cfg } = ctx - const graph = new DepGraph() - - const fps = await filesToCopy(argv, cfg) - - for (const fp of fps) { - const ext = path.extname(fp) - const src = joinSegments(argv.directory, fp) as FilePath - const name = (slugifyFilePath(fp as FilePath, true) + ext) as FilePath - - const dest = joinSegments(argv.output, name) as FilePath - - graph.addEdge(src, dest) - } - - return graph - }, - async emit({ argv, cfg }, _content, _resources): Promise { - const assetsPath = argv.output - const fps = await filesToCopy(argv, cfg) - const res: FilePath[] = [] - for (const fp of fps) { - const ext = path.extname(fp) - const src = joinSegments(argv.directory, fp) as FilePath - const name = (slugifyFilePath(fp as FilePath, true) + ext) as FilePath - - const dest = joinSegments(assetsPath, name) as FilePath - const dir = path.dirname(dest) as FilePath - await fs.promises.mkdir(dir, { recursive: true }) // ensure dir exists - await fs.promises.copyFile(src, dest) - res.push(dest) - } - - return res - }, - } -} + return { + name: "Assets", + getQuartzComponents() { + return []; + }, + async getDependencyGraph(ctx, _content, _resources) { + const { argv, cfg } = ctx; + const graph = new DepGraph(); + + const fps = await filesToCopy(argv, cfg); + + for (const fp of fps) { + const ext = path.extname(fp); + const src = joinSegments(argv.directory, fp) as FilePath; + const name = (slugifyFilePath(fp as FilePath, true) + ext) as FilePath; + + const dest = joinSegments(argv.output, name) as FilePath; + + graph.addEdge(src, dest); + } + + return graph; + }, + async emit({ argv, cfg }, _content, _resources): Promise { + const assetsPath = argv.output; + const fps = await filesToCopy(argv, cfg); + const res: FilePath[] = []; + for (const fp of fps) { + const ext = path.extname(fp); + const src = joinSegments(argv.directory, fp) as FilePath; + const name = (slugifyFilePath(fp as FilePath, true) + ext) as FilePath; + + const dest = joinSegments(assetsPath, name) as FilePath; + const dir = path.dirname(dest) as FilePath; + await fs.promises.mkdir(dir, { recursive: true }); // ensure dir exists + await fs.promises.copyFile(src, dest); + res.push(dest); + } + + return res; + }, + }; +}; diff --git a/quartz/plugins/emitters/cname.ts b/quartz/plugins/emitters/cname.ts index cbed2a8b4bb24..ca601b87b4bdc 100644 --- a/quartz/plugins/emitters/cname.ts +++ b/quartz/plugins/emitters/cname.ts @@ -1,33 +1,34 @@ -import { FilePath, joinSegments } from "../../util/path" -import { QuartzEmitterPlugin } from "../types" -import fs from "fs" -import chalk from "chalk" -import DepGraph from "../../depgraph" +import chalk from "chalk"; +import fs from "fs"; + +import DepGraph from "../../depgraph"; +import { FilePath, joinSegments } from "../../util/path"; +import { QuartzEmitterPlugin } from "../types"; export function extractDomainFromBaseUrl(baseUrl: string) { - const url = new URL(`https://${baseUrl}`) - return url.hostname + const url = new URL(`https://${baseUrl}`); + return url.hostname; } export const CNAME: QuartzEmitterPlugin = () => ({ - name: "CNAME", - getQuartzComponents() { - return [] - }, - async getDependencyGraph(_ctx, _content, _resources) { - return new DepGraph() - }, - async emit({ argv, cfg }, _content, _resources): Promise { - if (!cfg.configuration.baseUrl) { - console.warn(chalk.yellow("CNAME emitter requires `baseUrl` to be set in your configuration")) - return [] - } - const path = joinSegments(argv.output, "CNAME") - const content = extractDomainFromBaseUrl(cfg.configuration.baseUrl) - if (!content) { - return [] - } - fs.writeFileSync(path, content) - return [path] as FilePath[] - }, -}) + name: "CNAME", + getQuartzComponents() { + return []; + }, + async getDependencyGraph(_ctx, _content, _resources) { + return new DepGraph(); + }, + async emit({ argv, cfg }, _content, _resources): Promise { + if (!cfg.configuration.baseUrl) { + console.warn(chalk.yellow("CNAME emitter requires `baseUrl` to be set in your configuration")); + return []; + } + const path = joinSegments(argv.output, "CNAME"); + const content = extractDomainFromBaseUrl(cfg.configuration.baseUrl); + if (!content) { + return []; + } + fs.writeFileSync(path, content); + return [path] as FilePath[]; + }, +}); diff --git a/quartz/plugins/emitters/componentResources.ts b/quartz/plugins/emitters/componentResources.ts index 1b6e13a40acf8..75c2a7a720647 100644 --- a/quartz/plugins/emitters/componentResources.ts +++ b/quartz/plugins/emitters/componentResources.ts @@ -1,20 +1,20 @@ -import { FilePath, FullSlug, joinSegments } from "../../util/path" -import { QuartzEmitterPlugin } from "../types" +import { transform as transpile } from "esbuild"; +import { Features, transform } from "lightningcss"; // @ts-ignore -import spaRouterScript from "../../components/scripts/spa.inline" +import popoverScript from "../../components/scripts/popover.inline"; // @ts-ignore -import popoverScript from "../../components/scripts/popover.inline" -import styles from "../../styles/custom.scss" -import popoverStyle from "../../components/styles/popover.scss" -import { BuildCtx } from "../../util/ctx" -import { StaticResources } from "../../util/resources" -import { QuartzComponent } from "../../components/types" -import { googleFontHref, joinStyles } from "../../util/theme" -import { Features, transform } from "lightningcss" -import { transform as transpile } from "esbuild" -import { write } from "./helpers" -import DepGraph from "../../depgraph" +import spaRouterScript from "../../components/scripts/spa.inline"; +import popoverStyle from "../../components/styles/popover.scss"; +import { QuartzComponent } from "../../components/types"; +import DepGraph from "../../depgraph"; +import styles from "../../styles/custom.scss"; +import { BuildCtx } from "../../util/ctx"; +import { FilePath, FullSlug, joinSegments } from "../../util/path"; +import { StaticResources } from "../../util/resources"; +import { googleFontHref, joinStyles } from "../../util/theme"; +import { QuartzEmitterPlugin } from "../types"; +import { write } from "./helpers"; type ComponentResources = { css: string[] @@ -23,74 +23,74 @@ type ComponentResources = { } function getComponentResources(ctx: BuildCtx): ComponentResources { - const allComponents: Set = new Set() - for (const emitter of ctx.cfg.plugins.emitters) { - const components = emitter.getQuartzComponents(ctx) - for (const component of components) { - allComponents.add(component) - } - } - - const componentResources = { - css: new Set(), - beforeDOMLoaded: new Set(), - afterDOMLoaded: new Set(), - } - - for (const component of allComponents) { - const { css, beforeDOMLoaded, afterDOMLoaded } = component - if (css) { - componentResources.css.add(css) - } - if (beforeDOMLoaded) { - componentResources.beforeDOMLoaded.add(beforeDOMLoaded) - } - if (afterDOMLoaded) { - componentResources.afterDOMLoaded.add(afterDOMLoaded) - } - } - - return { - css: [...componentResources.css], - beforeDOMLoaded: [...componentResources.beforeDOMLoaded], - afterDOMLoaded: [...componentResources.afterDOMLoaded], - } + const allComponents: Set = new Set(); + for (const emitter of ctx.cfg.plugins.emitters) { + const components = emitter.getQuartzComponents(ctx); + for (const component of components) { + allComponents.add(component); + } + } + + const componentResources = { + css: new Set(), + beforeDOMLoaded: new Set(), + afterDOMLoaded: new Set(), + }; + + for (const component of allComponents) { + const { css, beforeDOMLoaded, afterDOMLoaded } = component; + if (css) { + componentResources.css.add(css); + } + if (beforeDOMLoaded) { + componentResources.beforeDOMLoaded.add(beforeDOMLoaded); + } + if (afterDOMLoaded) { + componentResources.afterDOMLoaded.add(afterDOMLoaded); + } + } + + return { + css: [...componentResources.css], + beforeDOMLoaded: [...componentResources.beforeDOMLoaded], + afterDOMLoaded: [...componentResources.afterDOMLoaded], + }; } async function joinScripts(scripts: string[]): Promise { - // wrap with iife to prevent scope collision - const script = scripts.map((script) => `(function () {${script}})();`).join("\n") + // wrap with iife to prevent scope collision + const script = scripts.map((script) => `(function () {${script}})();`).join("\n"); - // minify with esbuild - const res = await transpile(script, { - minify: true, - }) + // minify with esbuild + const res = await transpile(script, { + minify: true, + }); - return res.code + return res.code; } function addGlobalPageResources( - ctx: BuildCtx, - staticResources: StaticResources, - componentResources: ComponentResources, + ctx: BuildCtx, + staticResources: StaticResources, + componentResources: ComponentResources, ) { - const cfg = ctx.cfg.configuration - const reloadScript = ctx.argv.serve - - // popovers - if (cfg.enablePopovers) { - componentResources.afterDOMLoaded.push(popoverScript) - componentResources.css.push(popoverStyle) - } - - if (cfg.analytics?.provider === "google") { - const tagId = cfg.analytics.tagId - staticResources.js.push({ - src: `https://www.googletagmanager.com/gtag/js?id=${tagId}`, - contentType: "external", - loadTime: "afterDOMReady", - }) - componentResources.afterDOMLoaded.push(` + const cfg = ctx.cfg.configuration; + const reloadScript = ctx.argv.serve; + + // popovers + if (cfg.enablePopovers) { + componentResources.afterDOMLoaded.push(popoverScript); + componentResources.css.push(popoverStyle); + } + + if (cfg.analytics?.provider === "google") { + const tagId = cfg.analytics.tagId; + staticResources.js.push({ + src: `https://www.googletagmanager.com/gtag/js?id=${tagId}`, + contentType: "external", + loadTime: "afterDOMReady", + }); + componentResources.afterDOMLoaded.push(` window.dataLayer = window.dataLayer || []; function gtag() { dataLayer.push(arguments); } gtag("js", new Date()); @@ -101,10 +101,10 @@ function addGlobalPageResources( page_title: document.title, page_location: location.href, }); - });`) - } else if (cfg.analytics?.provider === "plausible") { - const plausibleHost = cfg.analytics.host ?? "https://plausible.io" - componentResources.afterDOMLoaded.push(` + });`); + } else if (cfg.analytics?.provider === "plausible") { + const plausibleHost = cfg.analytics.host ?? "https://plausible.io"; + componentResources.afterDOMLoaded.push(` const plausibleScript = document.createElement("script") plausibleScript.src = "${plausibleHost}/js/script.manual.js" plausibleScript.setAttribute("data-domain", location.hostname) @@ -116,46 +116,46 @@ function addGlobalPageResources( document.addEventListener("nav", () => { plausible("pageview") }) - `) - } else if (cfg.analytics?.provider === "umami") { - componentResources.afterDOMLoaded.push(` + `); + } else if (cfg.analytics?.provider === "umami") { + componentResources.afterDOMLoaded.push(` const umamiScript = document.createElement("script") umamiScript.src = "${cfg.analytics.host ?? "https://analytics.umami.is"}/script.js" umamiScript.setAttribute("data-website-id", "${cfg.analytics.websiteId}") umamiScript.async = true document.head.appendChild(umamiScript) - `) - } + `); + } - if (cfg.enableSPA) { - componentResources.afterDOMLoaded.push(spaRouterScript) - } else { - componentResources.afterDOMLoaded.push(` + if (cfg.enableSPA) { + componentResources.afterDOMLoaded.push(spaRouterScript); + } else { + componentResources.afterDOMLoaded.push(` window.spaNavigate = (url, _) => window.location.assign(url) window.addCleanup = () => {} const event = new CustomEvent("nav", { detail: { url: document.body.dataset.slug } }) document.dispatchEvent(event) - `) - } + `); + } - let wsUrl = `ws://localhost:${ctx.argv.wsPort}` + let wsUrl = `ws://localhost:${ctx.argv.wsPort}`; - if (ctx.argv.remoteDevHost) { - wsUrl = `wss://${ctx.argv.remoteDevHost}:${ctx.argv.wsPort}` - } + if (ctx.argv.remoteDevHost) { + wsUrl = `wss://${ctx.argv.remoteDevHost}:${ctx.argv.wsPort}`; + } - if (reloadScript) { - staticResources.js.push({ - loadTime: "afterDOMReady", - contentType: "inline", - script: ` + if (reloadScript) { + staticResources.js.push({ + loadTime: "afterDOMReady", + contentType: "inline", + script: ` const socket = new WebSocket('${wsUrl}') // reload(true) ensures resources like images and scripts are fetched again in firefox socket.addEventListener('message', () => document.location.reload(true)) `, - }) - } + }); + } } interface Options { @@ -163,136 +163,136 @@ interface Options { } const defaultOptions: Options = { - fontOrigin: "googleFonts", -} + fontOrigin: "googleFonts", +}; export const ComponentResources: QuartzEmitterPlugin = (opts?: Partial) => { - const { fontOrigin } = { ...defaultOptions, ...opts } - return { - name: "ComponentResources", - getQuartzComponents() { - return [] - }, - async getDependencyGraph(ctx, content, _resources) { - // This emitter adds static resources to the `resources` parameter. One - // important resource this emitter adds is the code to start a websocket - // connection and listen to rebuild messages, which triggers a page reload. - // The resources parameter with the reload logic is later used by the - // ContentPage emitter while creating the final html page. In order for - // the reload logic to be included, and so for partial rebuilds to work, - // we need to run this emitter for all markdown files. - const graph = new DepGraph() - - for (const [_tree, file] of content) { - const sourcePath = file.data.filePath! - const slug = file.data.slug! - graph.addEdge(sourcePath, joinSegments(ctx.argv.output, slug + ".html") as FilePath) - } - - return graph - }, - async emit(ctx, _content, resources): Promise { - const promises: Promise[] = [] - const cfg = ctx.cfg.configuration - // component specific scripts and styles - const componentResources = getComponentResources(ctx) - let googleFontsStyleSheet = "" - if (fontOrigin === "local") { - // let the user do it themselves in css - } else if (fontOrigin === "googleFonts") { - if (cfg.theme.cdnCaching) { - resources.css.push(googleFontHref(cfg.theme)) - } else { - let match - - const fontSourceRegex = /url\((https:\/\/fonts.gstatic.com\/s\/[^)]+\.(woff2|ttf))\)/g - - googleFontsStyleSheet = await ( - await fetch(googleFontHref(ctx.cfg.configuration.theme)) - ).text() - - while ((match = fontSourceRegex.exec(googleFontsStyleSheet)) !== null) { - // match[0] is the `url(path)`, match[1] is the `path` - const url = match[1] - // the static name of this file. - const [filename, ext] = url.split("/").pop()!.split(".") - - googleFontsStyleSheet = googleFontsStyleSheet.replace( - url, - `/static/fonts/${filename}.ttf`, - ) - - promises.push( - fetch(url) - .then((res) => { - if (!res.ok) { - throw new Error(`Failed to fetch font`) - } - return res.arrayBuffer() - }) - .then((buf) => - write({ - ctx, - slug: joinSegments("static", "fonts", filename) as FullSlug, - ext: `.${ext}`, - content: Buffer.from(buf), - }), - ), - ) - } - } - } - - // important that this goes *after* component scripts - // as the "nav" event gets triggered here and we should make sure - // that everyone else had the chance to register a listener for it - addGlobalPageResources(ctx, resources, componentResources) - - const stylesheet = joinStyles( - ctx.cfg.configuration.theme, - googleFontsStyleSheet, - ...componentResources.css, - styles, - ) - const [prescript, postscript] = await Promise.all([ - joinScripts(componentResources.beforeDOMLoaded), - joinScripts(componentResources.afterDOMLoaded), - ]) - - promises.push( - write({ - ctx, - slug: "index" as FullSlug, - ext: ".css", - content: transform({ - filename: "index.css", - code: Buffer.from(stylesheet), - minify: true, - targets: { - safari: (15 << 16) | (6 << 8), // 15.6 - ios_saf: (15 << 16) | (6 << 8), // 15.6 - edge: 115 << 16, - firefox: 102 << 16, - chrome: 109 << 16, - }, - include: Features.MediaQueries, - }).code.toString(), - }), - write({ - ctx, - slug: "prescript" as FullSlug, - ext: ".js", - content: prescript, - }), - write({ - ctx, - slug: "postscript" as FullSlug, - ext: ".js", - content: postscript, - }), - ) - - return await Promise.all(promises) - }, - } -} + const { fontOrigin } = { ...defaultOptions, ...opts }; + return { + name: "ComponentResources", + getQuartzComponents() { + return []; + }, + async getDependencyGraph(ctx, content, _resources) { + // This emitter adds static resources to the `resources` parameter. One + // important resource this emitter adds is the code to start a websocket + // connection and listen to rebuild messages, which triggers a page reload. + // The resources parameter with the reload logic is later used by the + // ContentPage emitter while creating the final html page. In order for + // the reload logic to be included, and so for partial rebuilds to work, + // we need to run this emitter for all markdown files. + const graph = new DepGraph(); + + for (const [_tree, file] of content) { + const sourcePath = file.data.filePath!; + const slug = file.data.slug!; + graph.addEdge(sourcePath, joinSegments(ctx.argv.output, slug + ".html") as FilePath); + } + + return graph; + }, + async emit(ctx, _content, resources): Promise { + const promises: Promise[] = []; + const cfg = ctx.cfg.configuration; + // component specific scripts and styles + const componentResources = getComponentResources(ctx); + let googleFontsStyleSheet = ""; + if (fontOrigin === "local") { + // let the user do it themselves in css + } else if (fontOrigin === "googleFonts") { + if (cfg.theme.cdnCaching) { + resources.css.push(googleFontHref(cfg.theme)); + } else { + let match; + + const fontSourceRegex = /url\((https:\/\/fonts.gstatic.com\/s\/[^)]+\.(woff2|ttf))\)/g; + + googleFontsStyleSheet = await ( + await fetch(googleFontHref(ctx.cfg.configuration.theme)) + ).text(); + + while ((match = fontSourceRegex.exec(googleFontsStyleSheet)) !== null) { + // match[0] is the `url(path)`, match[1] is the `path` + const url = match[1]; + // the static name of this file. + const [filename, ext] = url.split("/").pop()!.split("."); + + googleFontsStyleSheet = googleFontsStyleSheet.replace( + url, + `/static/fonts/${filename}.ttf`, + ); + + promises.push( + fetch(url) + .then((res) => { + if (!res.ok) { + throw new Error("Failed to fetch font"); + } + return res.arrayBuffer(); + }) + .then((buf) => + write({ + ctx, + slug: joinSegments("static", "fonts", filename) as FullSlug, + ext: `.${ext}`, + content: Buffer.from(buf), + }), + ), + ); + } + } + } + + // important that this goes *after* component scripts + // as the "nav" event gets triggered here and we should make sure + // that everyone else had the chance to register a listener for it + addGlobalPageResources(ctx, resources, componentResources); + + const stylesheet = joinStyles( + ctx.cfg.configuration.theme, + googleFontsStyleSheet, + ...componentResources.css, + styles, + ); + const [prescript, postscript] = await Promise.all([ + joinScripts(componentResources.beforeDOMLoaded), + joinScripts(componentResources.afterDOMLoaded), + ]); + + promises.push( + write({ + ctx, + slug: "index" as FullSlug, + ext: ".css", + content: transform({ + filename: "index.css", + code: Buffer.from(stylesheet), + minify: true, + targets: { + safari: (15 << 16) | (6 << 8), // 15.6 + ios_saf: (15 << 16) | (6 << 8), // 15.6 + edge: 115 << 16, + firefox: 102 << 16, + chrome: 109 << 16, + }, + include: Features.MediaQueries, + }).code.toString(), + }), + write({ + ctx, + slug: "prescript" as FullSlug, + ext: ".js", + content: prescript, + }), + write({ + ctx, + slug: "postscript" as FullSlug, + ext: ".js", + content: postscript, + }), + ); + + return await Promise.all(promises); + }, + }; +}; diff --git a/quartz/plugins/emitters/contentIndex.ts b/quartz/plugins/emitters/contentIndex.ts index c0fef86d27100..d9c7ec47a6c5d 100644 --- a/quartz/plugins/emitters/contentIndex.ts +++ b/quartz/plugins/emitters/contentIndex.ts @@ -1,13 +1,14 @@ -import { Root } from "hast" -import { GlobalConfiguration } from "../../cfg" -import { getDate } from "../../components/Date" -import { escapeHTML } from "../../util/escape" -import { FilePath, FullSlug, SimpleSlug, joinSegments, simplifySlug } from "../../util/path" -import { QuartzEmitterPlugin } from "../types" -import { toHtml } from "hast-util-to-html" -import { write } from "./helpers" -import { i18n } from "../../i18n" -import DepGraph from "../../depgraph" +import { Root } from "hast"; +import { toHtml } from "hast-util-to-html"; + +import { GlobalConfiguration } from "../../cfg"; +import { getDate } from "../../components/Date"; +import DepGraph from "../../depgraph"; +import { i18n } from "../../i18n"; +import { escapeHTML } from "../../util/escape"; +import { FilePath, FullSlug, joinSegments, SimpleSlug, simplifySlug } from "../../util/path"; +import { QuartzEmitterPlugin } from "../types"; +import { write } from "./helpers"; export type ContentIndex = Map export type ContentDetails = { @@ -29,157 +30,157 @@ interface Options { } const defaultOptions: Options = { - enableSiteMap: true, - enableRSS: true, - rssLimit: 10, - rssFullHtml: false, - includeEmptyFiles: true, -} + enableSiteMap: true, + enableRSS: true, + rssLimit: 10, + rssFullHtml: false, + includeEmptyFiles: true, +}; function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string { - const base = cfg.baseUrl ?? "" - const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => ` + const base = cfg.baseUrl ?? ""; + const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => ` https://${joinSegments(base, encodeURI(slug))} ${content.date && `${content.date.toISOString()}`} - ` - const urls = Array.from(idx) - .map(([slug, content]) => createURLEntry(simplifySlug(slug), content)) - .join("") - return `${urls}` + `; + const urls = Array.from(idx) + .map(([slug, content]) => createURLEntry(simplifySlug(slug), content)) + .join(""); + return `${urls}`; } function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, limit?: number): string { - const base = cfg.baseUrl ?? "" + const base = cfg.baseUrl ?? ""; - const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => ` + const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => ` ${escapeHTML(content.title)} https://${joinSegments(base, encodeURI(slug))} https://${joinSegments(base, encodeURI(slug))} ${content.richContent ?? content.description} ${content.date?.toUTCString()} - ` - - const items = Array.from(idx) - .sort(([_, f1], [__, f2]) => { - if (f1.date && f2.date) { - return f2.date.getTime() - f1.date.getTime() - } else if (f1.date && !f2.date) { - return -1 - } else if (!f1.date && f2.date) { - return 1 - } - - return f1.title.localeCompare(f2.title) - }) - .map(([slug, content]) => createURLEntry(simplifySlug(slug), content)) - .slice(0, limit ?? idx.size) - .join("") - - return ` + `; + + const items = Array.from(idx) + .sort(([_, f1], [__, f2]) => { + if (f1.date && f2.date) { + return f2.date.getTime() - f1.date.getTime(); + } else if (f1.date && !f2.date) { + return -1; + } else if (!f1.date && f2.date) { + return 1; + } + + return f1.title.localeCompare(f2.title); + }) + .map(([slug, content]) => createURLEntry(simplifySlug(slug), content)) + .slice(0, limit ?? idx.size) + .join(""); + + return ` ${escapeHTML(cfg.pageTitle)} https://${base} - ${!!limit ? i18n(cfg.locale).pages.rss.lastFewNotes({ count: limit }) : i18n(cfg.locale).pages.rss.recentNotes} on ${escapeHTML( - cfg.pageTitle, - )} + ${limit ? i18n(cfg.locale).pages.rss.lastFewNotes({ count: limit }) : i18n(cfg.locale).pages.rss.recentNotes} on ${escapeHTML( + cfg.pageTitle, +)} Quartz -- quartz.jzhao.xyz ${items} - ` + `; } export const ContentIndex: QuartzEmitterPlugin> = (opts) => { - opts = { ...defaultOptions, ...opts } - return { - name: "ContentIndex", - async getDependencyGraph(ctx, content, _resources) { - const graph = new DepGraph() + opts = { ...defaultOptions, ...opts }; + return { + name: "ContentIndex", + async getDependencyGraph(ctx, content, _resources) { + const graph = new DepGraph(); - for (const [_tree, file] of content) { - const sourcePath = file.data.filePath! + for (const [_tree, file] of content) { + const sourcePath = file.data.filePath!; - graph.addEdge( - sourcePath, + graph.addEdge( + sourcePath, joinSegments(ctx.argv.output, "static/contentIndex.json") as FilePath, - ) - if (opts?.enableSiteMap) { - graph.addEdge(sourcePath, joinSegments(ctx.argv.output, "sitemap.xml") as FilePath) - } - if (opts?.enableRSS) { - graph.addEdge(sourcePath, joinSegments(ctx.argv.output, "index.xml") as FilePath) - } - } - - return graph - }, - async emit(ctx, content, _resources) { - const cfg = ctx.cfg.configuration - const emitted: FilePath[] = [] - const linkIndex: ContentIndex = new Map() - for (const [tree, file] of content) { - const slug = file.data.slug! - const date = getDate(ctx.cfg.configuration, file.data) ?? new Date() - if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) { - linkIndex.set(slug, { - title: file.data.frontmatter?.title!, - links: file.data.links ?? [], - tags: file.data.frontmatter?.tags ?? [], - content: file.data.text ?? "", - richContent: opts?.rssFullHtml - ? escapeHTML(toHtml(tree as Root, { allowDangerousHtml: true })) - : undefined, - date: date, - description: file.data.description ?? "", - }) - } - } - - if (opts?.enableSiteMap) { - emitted.push( - await write({ - ctx, - content: generateSiteMap(cfg, linkIndex), - slug: "sitemap" as FullSlug, - ext: ".xml", - }), - ) - } - - if (opts?.enableRSS) { - emitted.push( - await write({ - ctx, - content: generateRSSFeed(cfg, linkIndex, opts.rssLimit), - slug: "index" as FullSlug, - ext: ".xml", - }), - ) - } - - const fp = joinSegments("static", "contentIndex") as FullSlug - const simplifiedIndex = Object.fromEntries( - Array.from(linkIndex).map(([slug, content]) => { - // remove description and from content index as nothing downstream - // actually uses it. we only keep it in the index as we need it - // for the RSS feed - delete content.description - delete content.date - return [slug, content] - }), - ) - - emitted.push( - await write({ - ctx, - content: JSON.stringify(simplifiedIndex), - slug: fp, - ext: ".json", - }), - ) - - return emitted - }, - getQuartzComponents: () => [], - } -} + ); + if (opts?.enableSiteMap) { + graph.addEdge(sourcePath, joinSegments(ctx.argv.output, "sitemap.xml") as FilePath); + } + if (opts?.enableRSS) { + graph.addEdge(sourcePath, joinSegments(ctx.argv.output, "index.xml") as FilePath); + } + } + + return graph; + }, + async emit(ctx, content, _resources) { + const cfg = ctx.cfg.configuration; + const emitted: FilePath[] = []; + const linkIndex: ContentIndex = new Map(); + for (const [tree, file] of content) { + const slug = file.data.slug!; + const date = getDate(ctx.cfg.configuration, file.data) ?? new Date(); + if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) { + linkIndex.set(slug, { + title: file.data.frontmatter?.title!, + links: file.data.links ?? [], + tags: file.data.frontmatter?.tags ?? [], + content: file.data.text ?? "", + richContent: opts?.rssFullHtml + ? escapeHTML(toHtml(tree as Root, { allowDangerousHtml: true })) + : undefined, + date: date, + description: file.data.description ?? "", + }); + } + } + + if (opts?.enableSiteMap) { + emitted.push( + await write({ + ctx, + content: generateSiteMap(cfg, linkIndex), + slug: "sitemap" as FullSlug, + ext: ".xml", + }), + ); + } + + if (opts?.enableRSS) { + emitted.push( + await write({ + ctx, + content: generateRSSFeed(cfg, linkIndex, opts.rssLimit), + slug: "index" as FullSlug, + ext: ".xml", + }), + ); + } + + const fp = joinSegments("static", "contentIndex") as FullSlug; + const simplifiedIndex = Object.fromEntries( + Array.from(linkIndex).map(([slug, content]) => { + // remove description and from content index as nothing downstream + // actually uses it. we only keep it in the index as we need it + // for the RSS feed + delete content.description; + delete content.date; + return [slug, content]; + }), + ); + + emitted.push( + await write({ + ctx, + content: JSON.stringify(simplifiedIndex), + slug: fp, + ext: ".json", + }), + ); + + return emitted; + }, + getQuartzComponents: () => [], + }; +}; diff --git a/quartz/plugins/emitters/helpers.ts b/quartz/plugins/emitters/helpers.ts index 523151c2cd85e..8276cf21b897d 100644 --- a/quartz/plugins/emitters/helpers.ts +++ b/quartz/plugins/emitters/helpers.ts @@ -1,7 +1,8 @@ -import path from "path" -import fs from "fs" -import { BuildCtx } from "../../util/ctx" -import { FilePath, FullSlug, joinSegments } from "../../util/path" +import fs from "fs"; +import path from "path"; + +import { BuildCtx } from "../../util/ctx"; +import { FilePath, FullSlug, joinSegments } from "../../util/path"; type WriteOptions = { ctx: BuildCtx @@ -11,9 +12,9 @@ type WriteOptions = { } export const write = async ({ ctx, slug, ext, content }: WriteOptions): Promise => { - const pathToPage = joinSegments(ctx.argv.output, slug + ext) as FilePath - const dir = path.dirname(pathToPage) - await fs.promises.mkdir(dir, { recursive: true }) - await fs.promises.writeFile(pathToPage, content) - return pathToPage -} + const pathToPage = joinSegments(ctx.argv.output, slug + ext) as FilePath; + const dir = path.dirname(pathToPage); + await fs.promises.mkdir(dir, { recursive: true }); + await fs.promises.writeFile(pathToPage, content); + return pathToPage; +}; diff --git a/quartz/plugins/emitters/index.ts b/quartz/plugins/emitters/index.ts index bc378c47bedec..011da9a040653 100644 --- a/quartz/plugins/emitters/index.ts +++ b/quartz/plugins/emitters/index.ts @@ -1,10 +1,10 @@ -export { ContentPage } from "./contentPage" -export { TagPage } from "./tagPage" -export { FolderPage } from "./folderPage" -export { ContentIndex } from "./contentIndex" -export { AliasRedirects } from "./aliases" -export { Assets } from "./assets" -export { Static } from "./static" -export { ComponentResources } from "./componentResources" -export { NotFoundPage } from "./404" -export { CNAME } from "./cname" +export { NotFoundPage } from "./404"; +export { AliasRedirects } from "./aliases"; +export { Assets } from "./assets"; +export { CNAME } from "./cname"; +export { ComponentResources } from "./componentResources"; +export { ContentIndex } from "./contentIndex"; +export { ContentPage } from "./contentPage"; +export { FolderPage } from "./folderPage"; +export { Static } from "./static"; +export { TagPage } from "./tagPage"; diff --git a/quartz/plugins/emitters/static.ts b/quartz/plugins/emitters/static.ts index c52c6287968a0..7a6e31d22f6c4 100644 --- a/quartz/plugins/emitters/static.ts +++ b/quartz/plugins/emitters/static.ts @@ -1,35 +1,36 @@ -import { FilePath, QUARTZ, joinSegments } from "../../util/path" -import { QuartzEmitterPlugin } from "../types" -import fs from "fs" -import { glob } from "../../util/glob" -import DepGraph from "../../depgraph" +import fs from "fs"; + +import DepGraph from "../../depgraph"; +import { glob } from "../../util/glob"; +import { FilePath, joinSegments,QUARTZ } from "../../util/path"; +import { QuartzEmitterPlugin } from "../types"; export const Static: QuartzEmitterPlugin = () => ({ - name: "Static", - getQuartzComponents() { - return [] - }, - async getDependencyGraph({ argv, cfg }, _content, _resources) { - const graph = new DepGraph() + name: "Static", + getQuartzComponents() { + return []; + }, + async getDependencyGraph({ argv, cfg }, _content, _resources) { + const graph = new DepGraph(); - const staticPath = joinSegments(QUARTZ, "static") - const fps = await glob("**", staticPath, cfg.configuration.ignorePatterns) - for (const fp of fps) { - graph.addEdge( + const staticPath = joinSegments(QUARTZ, "static"); + const fps = await glob("**", staticPath, cfg.configuration.ignorePatterns); + for (const fp of fps) { + graph.addEdge( joinSegments("static", fp) as FilePath, joinSegments(argv.output, "static", fp) as FilePath, - ) - } + ); + } - return graph - }, - async emit({ argv, cfg }, _content, _resources): Promise { - const staticPath = joinSegments(QUARTZ, "static") - const fps = await glob("**", staticPath, cfg.configuration.ignorePatterns) - await fs.promises.cp(staticPath, joinSegments(argv.output, "static"), { - recursive: true, - dereference: true, - }) - return fps.map((fp) => joinSegments(argv.output, "static", fp)) as FilePath[] - }, -}) + return graph; + }, + async emit({ argv, cfg }, _content, _resources): Promise { + const staticPath = joinSegments(QUARTZ, "static"); + const fps = await glob("**", staticPath, cfg.configuration.ignorePatterns); + await fs.promises.cp(staticPath, joinSegments(argv.output, "static"), { + recursive: true, + dereference: true, + }); + return fps.map((fp) => joinSegments(argv.output, "static", fp)) as FilePath[]; + }, +}); diff --git a/quartz/plugins/filters/draft.ts b/quartz/plugins/filters/draft.ts index 65e2d6b619203..8a5d0a9e6f311 100644 --- a/quartz/plugins/filters/draft.ts +++ b/quartz/plugins/filters/draft.ts @@ -1,9 +1,9 @@ -import { QuartzFilterPlugin } from "../types" +import { QuartzFilterPlugin } from "../types"; export const RemoveDrafts: QuartzFilterPlugin<{}> = () => ({ - name: "RemoveDrafts", - shouldPublish(_ctx, [_tree, vfile]) { - const draftFlag: boolean = vfile.data?.frontmatter?.draft ?? false - return !draftFlag - }, -}) + name: "RemoveDrafts", + shouldPublish(_ctx, [_tree, vfile]) { + const draftFlag: boolean = vfile.data?.frontmatter?.draft ?? false; + return !draftFlag; + }, +}); diff --git a/quartz/plugins/filters/explicit.ts b/quartz/plugins/filters/explicit.ts index 79a46a81c1472..80bea5c50262c 100644 --- a/quartz/plugins/filters/explicit.ts +++ b/quartz/plugins/filters/explicit.ts @@ -1,8 +1,8 @@ -import { QuartzFilterPlugin } from "../types" +import { QuartzFilterPlugin } from "../types"; export const ExplicitPublish: QuartzFilterPlugin = () => ({ - name: "ExplicitPublish", - shouldPublish(_ctx, [_tree, vfile]) { - return vfile.data?.frontmatter?.publish ?? false - }, -}) + name: "ExplicitPublish", + shouldPublish(_ctx, [_tree, vfile]) { + return vfile.data?.frontmatter?.publish ?? false; + }, +}); diff --git a/quartz/plugins/filters/index.ts b/quartz/plugins/filters/index.ts index d937143424b29..46a9a2b7d18d7 100644 --- a/quartz/plugins/filters/index.ts +++ b/quartz/plugins/filters/index.ts @@ -1,2 +1,2 @@ -export { RemoveDrafts } from "./draft" -export { ExplicitPublish } from "./explicit" +export { RemoveDrafts } from "./draft"; +export { ExplicitPublish } from "./explicit"; diff --git a/quartz/plugins/index.ts b/quartz/plugins/index.ts index f35d05353a5da..d52243b95f1cd 100644 --- a/quartz/plugins/index.ts +++ b/quartz/plugins/index.ts @@ -1,29 +1,29 @@ -import { StaticResources } from "../util/resources" -import { FilePath, FullSlug } from "../util/path" -import { BuildCtx } from "../util/ctx" +import { BuildCtx } from "../util/ctx"; +import { FilePath, FullSlug } from "../util/path"; +import { StaticResources } from "../util/resources"; export function getStaticResourcesFromPlugins(ctx: BuildCtx) { - const staticResources: StaticResources = { - css: [], - js: [], - } + const staticResources: StaticResources = { + css: [], + js: [], + }; - for (const transformer of ctx.cfg.plugins.transformers) { - const res = transformer.externalResources ? transformer.externalResources(ctx) : {} - if (res?.js) { - staticResources.js.push(...res.js) - } - if (res?.css) { - staticResources.css.push(...res.css) - } - } + for (const transformer of ctx.cfg.plugins.transformers) { + const res = transformer.externalResources ? transformer.externalResources(ctx) : {}; + if (res?.js) { + staticResources.js.push(...res.js); + } + if (res?.css) { + staticResources.css.push(...res.css); + } + } - return staticResources + return staticResources; } -export * from "./transformers" -export * from "./filters" -export * from "./emitters" +export * from "./emitters"; +export * from "./filters"; +export * from "./transformers"; declare module "vfile" { // inserted in processors.ts diff --git a/quartz/plugins/transformers/description.ts b/quartz/plugins/transformers/description.ts index 884d5b1893041..2531c2c06b222 100644 --- a/quartz/plugins/transformers/description.ts +++ b/quartz/plugins/transformers/description.ts @@ -1,47 +1,48 @@ -import { Root as HTMLRoot } from "hast" -import { toString } from "hast-util-to-string" -import { QuartzTransformerPlugin } from "../types" -import { escapeHTML } from "../../util/escape" +import { Root as HTMLRoot } from "hast"; +import { toString } from "hast-util-to-string"; + +import { escapeHTML } from "../../util/escape"; +import { QuartzTransformerPlugin } from "../types"; export interface Options { descriptionLength: number } const defaultOptions: Options = { - descriptionLength: 150, -} + descriptionLength: 150, +}; export const Description: QuartzTransformerPlugin | undefined> = (userOpts) => { - const opts = { ...defaultOptions, ...userOpts } - return { - name: "Description", - htmlPlugins() { - return [ - () => { - return async (tree: HTMLRoot, file) => { - const frontMatterDescription = file.data.frontmatter?.description - const text = escapeHTML(toString(tree)) + const opts = { ...defaultOptions, ...userOpts }; + return { + name: "Description", + htmlPlugins() { + return [ + () => { + return async (tree: HTMLRoot, file) => { + const frontMatterDescription = file.data.frontmatter?.description; + const text = escapeHTML(toString(tree)); - const desc = frontMatterDescription ?? text - const sentences = desc.replace(/\s+/g, " ").split(".") - let finalDesc = "" - let sentenceIdx = 0 - const len = opts.descriptionLength - while (finalDesc.length < len) { - const sentence = sentences[sentenceIdx] - if (!sentence) break - finalDesc += sentence + "." - sentenceIdx++ - } + const desc = frontMatterDescription ?? text; + const sentences = desc.replace(/\s+/g, " ").split("."); + let finalDesc = ""; + let sentenceIdx = 0; + const len = opts.descriptionLength; + while (finalDesc.length < len) { + const sentence = sentences[sentenceIdx]; + if (!sentence) break; + finalDesc += sentence + "."; + sentenceIdx++; + } - file.data.description = finalDesc - file.data.text = text - } - }, - ] - }, - } -} + file.data.description = finalDesc; + file.data.text = text; + }; + }, + ]; + }, + }; +}; declare module "vfile" { interface DataMap { diff --git a/quartz/plugins/transformers/frontmatter.ts b/quartz/plugins/transformers/frontmatter.ts index 79aa5f3134989..d45df5f42337f 100644 --- a/quartz/plugins/transformers/frontmatter.ts +++ b/quartz/plugins/transformers/frontmatter.ts @@ -1,11 +1,12 @@ -import matter from "gray-matter" -import remarkFrontmatter from "remark-frontmatter" -import { QuartzTransformerPlugin } from "../types" -import yaml from "js-yaml" -import toml from "toml" -import { slugTag } from "../../util/path" -import { QuartzPluginData } from "../vfile" -import { i18n } from "../../i18n" +import matter from "gray-matter"; +import yaml from "js-yaml"; +import remarkFrontmatter from "remark-frontmatter"; +import toml from "toml"; + +import { i18n } from "../../i18n"; +import { slugTag } from "../../util/path"; +import { QuartzTransformerPlugin } from "../types"; +import { QuartzPluginData } from "../vfile"; export interface Options { delimiters: string | [string, string] @@ -13,72 +14,72 @@ export interface Options { } const defaultOptions: Options = { - delimiters: "---", - language: "yaml", -} + delimiters: "---", + language: "yaml", +}; function coalesceAliases(data: { [key: string]: any }, aliases: string[]) { - for (const alias of aliases) { - if (data[alias] !== undefined && data[alias] !== null) return data[alias] - } + for (const alias of aliases) { + if (data[alias] !== undefined && data[alias] !== null) return data[alias]; + } } function coerceToArray(input: string | string[]): string[] | undefined { - if (input === undefined || input === null) return undefined + if (input === undefined || input === null) return undefined; - // coerce to array - if (!Array.isArray(input)) { - input = input - .toString() - .split(",") - .map((tag: string) => tag.trim()) - } + // coerce to array + if (!Array.isArray(input)) { + input = input + .toString() + .split(",") + .map((tag: string) => tag.trim()); + } - // remove all non-strings - return input - .filter((tag: unknown) => typeof tag === "string" || typeof tag === "number") - .map((tag: string | number) => tag.toString()) + // remove all non-strings + return input + .filter((tag: unknown) => typeof tag === "string" || typeof tag === "number") + .map((tag: string | number) => tag.toString()); } export const FrontMatter: QuartzTransformerPlugin | undefined> = (userOpts) => { - const opts = { ...defaultOptions, ...userOpts } - return { - name: "FrontMatter", - markdownPlugins({ cfg }) { - return [ - [remarkFrontmatter, ["yaml", "toml"]], - () => { - return (_, file) => { - const { data } = matter(Buffer.from(file.value), { - ...opts, - engines: { - yaml: (s) => yaml.load(s, { schema: yaml.JSON_SCHEMA }) as object, - toml: (s) => toml.parse(s) as object, - }, - }) + const opts = { ...defaultOptions, ...userOpts }; + return { + name: "FrontMatter", + markdownPlugins({ cfg }) { + return [ + [remarkFrontmatter, ["yaml", "toml"]], + () => { + return (_, file) => { + const { data } = matter(Buffer.from(file.value), { + ...opts, + engines: { + yaml: (s) => yaml.load(s, { schema: yaml.JSON_SCHEMA }) as object, + toml: (s) => toml.parse(s) as object, + }, + }); - if (data.title != null && data.title.toString() !== "") { - data.title = data.title.toString() - } else { - data.title = file.stem ?? i18n(cfg.configuration.locale).propertyDefaults.title - } + if (data.title != null && data.title.toString() !== "") { + data.title = data.title.toString(); + } else { + data.title = file.stem ?? i18n(cfg.configuration.locale).propertyDefaults.title; + } - const tags = coerceToArray(coalesceAliases(data, ["tags", "tag"])) - if (tags) data.tags = [...new Set(tags.map((tag: string) => slugTag(tag)))] + const tags = coerceToArray(coalesceAliases(data, ["tags", "tag"])); + if (tags) data.tags = [...new Set(tags.map((tag: string) => slugTag(tag)))]; - const aliases = coerceToArray(coalesceAliases(data, ["aliases", "alias"])) - if (aliases) data.aliases = aliases - const cssclasses = coerceToArray(coalesceAliases(data, ["cssclasses", "cssclass"])) - if (cssclasses) data.cssclasses = cssclasses + const aliases = coerceToArray(coalesceAliases(data, ["aliases", "alias"])); + if (aliases) data.aliases = aliases; + const cssclasses = coerceToArray(coalesceAliases(data, ["cssclasses", "cssclass"])); + if (cssclasses) data.cssclasses = cssclasses; - // fill in frontmatter - file.data.frontmatter = data as QuartzPluginData["frontmatter"] - } - }, - ] - }, - } -} + // fill in frontmatter + file.data.frontmatter = data as QuartzPluginData["frontmatter"]; + }; + }, + ]; + }, + }; +}; declare module "vfile" { interface DataMap { diff --git a/quartz/plugins/transformers/gfm.ts b/quartz/plugins/transformers/gfm.ts index 48681ff77999d..bd5ec597d7837 100644 --- a/quartz/plugins/transformers/gfm.ts +++ b/quartz/plugins/transformers/gfm.ts @@ -1,8 +1,9 @@ -import remarkGfm from "remark-gfm" -import smartypants from "remark-smartypants" -import { QuartzTransformerPlugin } from "../types" -import rehypeSlug from "rehype-slug" -import rehypeAutolinkHeadings from "rehype-autolink-headings" +import rehypeAutolinkHeadings from "rehype-autolink-headings"; +import rehypeSlug from "rehype-slug"; +import remarkGfm from "remark-gfm"; +import smartypants from "remark-smartypants"; + +import { QuartzTransformerPlugin } from "../types"; export interface Options { enableSmartyPants: boolean @@ -10,71 +11,71 @@ export interface Options { } const defaultOptions: Options = { - enableSmartyPants: true, - linkHeadings: true, -} + enableSmartyPants: true, + linkHeadings: true, +}; export const GitHubFlavoredMarkdown: QuartzTransformerPlugin | undefined> = ( - userOpts, + userOpts, ) => { - const opts = { ...defaultOptions, ...userOpts } - return { - name: "GitHubFlavoredMarkdown", - markdownPlugins() { - return opts.enableSmartyPants ? [remarkGfm, smartypants] : [remarkGfm] - }, - htmlPlugins() { - if (opts.linkHeadings) { - return [ - rehypeSlug, - [ - rehypeAutolinkHeadings, - { - behavior: "append", - properties: { - role: "anchor", - ariaHidden: true, - tabIndex: -1, - "data-no-popover": true, - }, - content: { - type: "element", - tagName: "svg", - properties: { - width: 18, - height: 18, - viewBox: "0 0 24 24", - fill: "none", - stroke: "currentColor", - "stroke-width": "2", - "stroke-linecap": "round", - "stroke-linejoin": "round", - }, - children: [ - { - type: "element", - tagName: "path", - properties: { - d: "M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71", - }, - children: [], - }, - { - type: "element", - tagName: "path", - properties: { - d: "M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71", - }, - children: [], - }, - ], - }, - }, - ], - ] - } else { - return [] - } - }, - } -} + const opts = { ...defaultOptions, ...userOpts }; + return { + name: "GitHubFlavoredMarkdown", + markdownPlugins() { + return opts.enableSmartyPants ? [remarkGfm, smartypants] : [remarkGfm]; + }, + htmlPlugins() { + if (opts.linkHeadings) { + return [ + rehypeSlug, + [ + rehypeAutolinkHeadings, + { + behavior: "append", + properties: { + role: "anchor", + ariaHidden: true, + tabIndex: -1, + "data-no-popover": true, + }, + content: { + type: "element", + tagName: "svg", + properties: { + width: 18, + height: 18, + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + }, + children: [ + { + type: "element", + tagName: "path", + properties: { + d: "M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71", + }, + children: [], + }, + { + type: "element", + tagName: "path", + properties: { + d: "M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71", + }, + children: [], + }, + ], + }, + }, + ], + ]; + } else { + return []; + } + }, + }; +}; diff --git a/quartz/plugins/transformers/index.ts b/quartz/plugins/transformers/index.ts index e340f10e799fe..af85af7d5e5e9 100644 --- a/quartz/plugins/transformers/index.ts +++ b/quartz/plugins/transformers/index.ts @@ -1,11 +1,11 @@ -export { FrontMatter } from "./frontmatter" -export { GitHubFlavoredMarkdown } from "./gfm" -export { CreatedModifiedDate } from "./lastmod" -export { Latex } from "./latex" -export { Description } from "./description" -export { CrawlLinks } from "./links" -export { ObsidianFlavoredMarkdown } from "./ofm" -export { OxHugoFlavouredMarkdown } from "./oxhugofm" -export { SyntaxHighlighting } from "./syntax" -export { TableOfContents } from "./toc" -export { HardLineBreaks } from "./linebreaks" +export { Description } from "./description"; +export { FrontMatter } from "./frontmatter"; +export { GitHubFlavoredMarkdown } from "./gfm"; +export { CreatedModifiedDate } from "./lastmod"; +export { Latex } from "./latex"; +export { HardLineBreaks } from "./linebreaks"; +export { CrawlLinks } from "./links"; +export { ObsidianFlavoredMarkdown } from "./ofm"; +export { OxHugoFlavouredMarkdown } from "./oxhugofm"; +export { SyntaxHighlighting } from "./syntax"; +export { TableOfContents } from "./toc"; diff --git a/quartz/plugins/transformers/lastmod.ts b/quartz/plugins/transformers/lastmod.ts index 2c7b9ce47fa8a..c760cef0a0de0 100644 --- a/quartz/plugins/transformers/lastmod.ts +++ b/quartz/plugins/transformers/lastmod.ts @@ -1,92 +1,93 @@ -import fs from "fs" -import path from "path" -import { Repository } from "@napi-rs/simple-git" -import { QuartzTransformerPlugin } from "../types" -import chalk from "chalk" +import { Repository } from "@napi-rs/simple-git"; +import chalk from "chalk"; +import fs from "fs"; +import path from "path"; + +import { QuartzTransformerPlugin } from "../types"; export interface Options { priority: ("frontmatter" | "git" | "filesystem")[] } 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`, - ), - ) - } + 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 + return invalidDate ? new Date() : dt; } type MaybeDate = undefined | string | number export const CreatedModifiedDate: QuartzTransformerPlugin | undefined> = ( - userOpts, + userOpts, ) => { - const opts = { ...defaultOptions, ...userOpts } - return { - name: "CreatedModifiedDate", - markdownPlugins() { - return [ - () => { - let repo: Repository | undefined = undefined - return async (_tree, file) => { - let created: MaybeDate = undefined - let modified: MaybeDate = undefined - let published: MaybeDate = undefined + const opts = { ...defaultOptions, ...userOpts }; + return { + name: "CreatedModifiedDate", + markdownPlugins() { + return [ + () => { + let repo: Repository | undefined = undefined; + return async (_tree, file) => { + let created: MaybeDate = undefined; + let modified: MaybeDate = undefined; + let published: MaybeDate = undefined; - const fp = file.data.filePath! - const fullFp = path.isAbsolute(fp) ? fp : path.posix.join(file.cwd, fp) - for (const source of opts.priority) { - if (source === "filesystem") { - const st = await fs.promises.stat(fullFp) - created ||= st.birthtimeMs - modified ||= st.mtimeMs - } else if (source === "frontmatter" && file.data.frontmatter) { - created ||= file.data.frontmatter.date as MaybeDate - modified ||= file.data.frontmatter.lastmod as MaybeDate - modified ||= file.data.frontmatter.updated as MaybeDate - modified ||= file.data.frontmatter["last-modified"] as MaybeDate - published ||= file.data.frontmatter.publishDate as MaybeDate - } else if (source === "git") { - if (!repo) { - // Get a reference to the main git repo. - // It's either the same as the workdir, - // or 1+ level higher in case of a submodule/subtree setup - repo = Repository.discover(file.cwd) - } + const fp = file.data.filePath!; + const fullFp = path.isAbsolute(fp) ? fp : path.posix.join(file.cwd, fp); + for (const source of opts.priority) { + if (source === "filesystem") { + const st = await fs.promises.stat(fullFp); + created ||= st.birthtimeMs; + modified ||= st.mtimeMs; + } else if (source === "frontmatter" && file.data.frontmatter) { + created ||= file.data.frontmatter.date as MaybeDate; + modified ||= file.data.frontmatter.lastmod as MaybeDate; + modified ||= file.data.frontmatter.updated as MaybeDate; + modified ||= file.data.frontmatter["last-modified"] as MaybeDate; + published ||= file.data.frontmatter.publishDate as MaybeDate; + } else if (source === "git") { + if (!repo) { + // Get a reference to the main git repo. + // It's either the same as the workdir, + // or 1+ level higher in case of a submodule/subtree setup + repo = Repository.discover(file.cwd); + } - try { - modified ||= await repo.getFileLatestModifiedDateAsync(file.data.filePath!) - } catch { - console.log( - chalk.yellow( - `\nWarning: ${file.data - .filePath!} isn't yet tracked by git, last modification date is not available for this file`, - ), - ) - } - } - } + try { + modified ||= await repo.getFileLatestModifiedDateAsync(file.data.filePath!); + } catch { + console.log( + chalk.yellow( + `\nWarning: ${file.data + .filePath!} isn't yet tracked by git, last modification date is not available for this file`, + ), + ); + } + } + } - file.data.dates = { - created: coerceDate(fp, created), - modified: coerceDate(fp, modified), - published: coerceDate(fp, published), - } - } - }, - ] - }, - } -} + file.data.dates = { + created: coerceDate(fp, created), + modified: coerceDate(fp, modified), + published: coerceDate(fp, published), + }; + }; + }, + ]; + }, + }; +}; declare module "vfile" { interface DataMap { diff --git a/quartz/plugins/transformers/latex.ts b/quartz/plugins/transformers/latex.ts index c9f6bff0d07ba..697452c12ad4e 100644 --- a/quartz/plugins/transformers/latex.ts +++ b/quartz/plugins/transformers/latex.ts @@ -1,45 +1,46 @@ -import remarkMath from "remark-math" -import rehypeKatex from "rehype-katex" -import rehypeMathjax from "rehype-mathjax/svg" -import { QuartzTransformerPlugin } from "../types" +import rehypeKatex from "rehype-katex"; +import rehypeMathjax from "rehype-mathjax/svg"; +import remarkMath from "remark-math"; + +import { QuartzTransformerPlugin } from "../types"; interface Options { renderEngine: "katex" | "mathjax" } export const Latex: QuartzTransformerPlugin = (opts?: Options) => { - const engine = opts?.renderEngine ?? "katex" - return { - name: "Latex", - markdownPlugins() { - return [remarkMath] - }, - htmlPlugins() { - if (engine === "katex") { - return [[rehypeKatex, { output: "html" }]] - } else { - return [rehypeMathjax] - } - }, - externalResources() { - if (engine === "katex") { - return { - css: [ - // base css - "https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css", - ], - js: [ - { - // fix copy behaviour: https://github.com/KaTeX/KaTeX/blob/main/contrib/copy-tex/README.md - src: "https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/contrib/copy-tex.min.js", - loadTime: "afterDOMReady", - contentType: "external", - }, - ], - } - } else { - return {} - } - }, - } -} + const engine = opts?.renderEngine ?? "katex"; + return { + name: "Latex", + markdownPlugins() { + return [remarkMath]; + }, + htmlPlugins() { + if (engine === "katex") { + return [[rehypeKatex, { output: "html" }]]; + } else { + return [rehypeMathjax]; + } + }, + externalResources() { + if (engine === "katex") { + return { + css: [ + // base css + "https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css", + ], + js: [ + { + // fix copy behaviour: https://github.com/KaTeX/KaTeX/blob/main/contrib/copy-tex/README.md + src: "https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/contrib/copy-tex.min.js", + loadTime: "afterDOMReady", + contentType: "external", + }, + ], + }; + } else { + return {}; + } + }, + }; +}; diff --git a/quartz/plugins/transformers/linebreaks.ts b/quartz/plugins/transformers/linebreaks.ts index a8a066fc19529..d99a5e181d801 100644 --- a/quartz/plugins/transformers/linebreaks.ts +++ b/quartz/plugins/transformers/linebreaks.ts @@ -1,11 +1,12 @@ -import { QuartzTransformerPlugin } from "../types" -import remarkBreaks from "remark-breaks" +import remarkBreaks from "remark-breaks"; + +import { QuartzTransformerPlugin } from "../types"; export const HardLineBreaks: QuartzTransformerPlugin = () => { - return { - name: "HardLineBreaks", - markdownPlugins() { - return [remarkBreaks] - }, - } -} + return { + name: "HardLineBreaks", + markdownPlugins() { + return [remarkBreaks]; + }, + }; +}; diff --git a/quartz/plugins/transformers/links.ts b/quartz/plugins/transformers/links.ts index f89d367d75fb3..4bb29fa45c93a 100644 --- a/quartz/plugins/transformers/links.ts +++ b/quartz/plugins/transformers/links.ts @@ -1,19 +1,19 @@ -import { QuartzTransformerPlugin } from "../types" +import { Root } from "hast"; +import isAbsoluteUrl from "is-absolute-url"; +import path from "path"; +import { visit } from "unist-util-visit"; + import { - FullSlug, - RelativeURL, - SimpleSlug, - TransformOptions, - stripSlashes, - simplifySlug, - splitAnchor, - transformLink, - joinSegments, -} from "../../util/path" -import path from "path" -import { visit } from "unist-util-visit" -import isAbsoluteUrl from "is-absolute-url" -import { Root } from "hast" + FullSlug, + RelativeURL, + SimpleSlug, + simplifySlug, + splitAnchor, + stripSlashes, + transformLink, + TransformOptions, +} from "../../util/path"; +import { QuartzTransformerPlugin } from "../types"; interface Options { /** How to resolve Markdown paths */ @@ -26,143 +26,143 @@ interface Options { } const defaultOptions: Options = { - markdownLinkResolution: "absolute", - prettyLinks: true, - openLinksInNewTab: false, - lazyLoad: false, - externalLinkIcon: true, -} + markdownLinkResolution: "absolute", + prettyLinks: true, + openLinksInNewTab: false, + lazyLoad: false, + externalLinkIcon: true, +}; export const CrawlLinks: QuartzTransformerPlugin | undefined> = (userOpts) => { - const opts = { ...defaultOptions, ...userOpts } - return { - name: "LinkProcessing", - htmlPlugins(ctx) { - return [ - () => { - return (tree: Root, file) => { - const curSlug = simplifySlug(file.data.slug!) - const outgoing: Set = new Set() - - const transformOptions: TransformOptions = { - strategy: opts.markdownLinkResolution, - allSlugs: ctx.allSlugs, - } - - visit(tree, "element", (node, _index, _parent) => { - // rewrite all links - if ( - node.tagName === "a" && + const opts = { ...defaultOptions, ...userOpts }; + return { + name: "LinkProcessing", + htmlPlugins(ctx) { + return [ + () => { + return (tree: Root, file) => { + const curSlug = simplifySlug(file.data.slug!); + const outgoing: Set = new Set(); + + const transformOptions: TransformOptions = { + strategy: opts.markdownLinkResolution, + allSlugs: ctx.allSlugs, + }; + + visit(tree, "element", (node, _index, _parent) => { + // rewrite all links + if ( + node.tagName === "a" && node.properties && typeof node.properties.href === "string" - ) { - let dest = node.properties.href as RelativeURL - const classes = (node.properties.className ?? []) as string[] - const isExternal = isAbsoluteUrl(dest) - classes.push(isExternal ? "external" : "internal") - - if (isExternal && opts.externalLinkIcon) { - node.children.push({ - type: "element", - tagName: "svg", - properties: { - class: "external-icon", - viewBox: "0 0 512 512", - }, - children: [ - { - type: "element", - tagName: "path", - properties: { - d: "M320 0H288V64h32 82.7L201.4 265.4 178.7 288 224 333.3l22.6-22.6L448 109.3V192v32h64V192 32 0H480 320zM32 32H0V64 480v32H32 456h32V480 352 320H424v32 96H64V96h96 32V32H160 32z", - }, - children: [], - }, - ], - }) - } - - // Check if the link has alias text - if ( - node.children.length === 1 && + ) { + let dest = node.properties.href as RelativeURL; + const classes = (node.properties.className ?? []) as string[]; + const isExternal = isAbsoluteUrl(dest); + classes.push(isExternal ? "external" : "internal"); + + if (isExternal && opts.externalLinkIcon) { + node.children.push({ + type: "element", + tagName: "svg", + properties: { + class: "external-icon", + viewBox: "0 0 512 512", + }, + children: [ + { + type: "element", + tagName: "path", + properties: { + d: "M320 0H288V64h32 82.7L201.4 265.4 178.7 288 224 333.3l22.6-22.6L448 109.3V192v32h64V192 32 0H480 320zM32 32H0V64 480v32H32 456h32V480 352 320H424v32 96H64V96h96 32V32H160 32z", + }, + children: [], + }, + ], + }); + } + + // Check if the link has alias text + if ( + node.children.length === 1 && node.children[0].type === "text" && node.children[0].value !== dest - ) { - // Add the 'alias' class if the text content is not the same as the href - classes.push("alias") - } - node.properties.className = classes - - if (opts.openLinksInNewTab) { - node.properties.target = "_blank" - } - - // don't process external links or intra-document anchors - const isInternal = !(isAbsoluteUrl(dest) || dest.startsWith("#")) - if (isInternal) { - dest = node.properties.href = transformLink( + ) { + // Add the 'alias' class if the text content is not the same as the href + classes.push("alias"); + } + node.properties.className = classes; + + if (opts.openLinksInNewTab) { + node.properties.target = "_blank"; + } + + // don't process external links or intra-document anchors + const isInternal = !(isAbsoluteUrl(dest) || dest.startsWith("#")); + if (isInternal) { + dest = node.properties.href = transformLink( file.data.slug!, dest, transformOptions, - ) - - // url.resolve is considered legacy - // WHATWG equivalent https://nodejs.dev/en/api/v18/url/#urlresolvefrom-to - const url = new URL(dest, "https://base.com/" + stripSlashes(curSlug, true)) - const canonicalDest = url.pathname - let [destCanonical, _destAnchor] = splitAnchor(canonicalDest) - if (destCanonical.endsWith("/")) { - destCanonical += "index" - } - - // need to decodeURIComponent here as WHATWG URL percent-encodes everything - const full = decodeURIComponent(stripSlashes(destCanonical, true)) as FullSlug - const simple = simplifySlug(full) - outgoing.add(simple) - node.properties["data-slug"] = full - } - - // rewrite link internals if prettylinks is on - if ( - opts.prettyLinks && + ); + + // url.resolve is considered legacy + // WHATWG equivalent https://nodejs.dev/en/api/v18/url/#urlresolvefrom-to + const url = new URL(dest, "https://base.com/" + stripSlashes(curSlug, true)); + const canonicalDest = url.pathname; + let [destCanonical, _destAnchor] = splitAnchor(canonicalDest); + if (destCanonical.endsWith("/")) { + destCanonical += "index"; + } + + // need to decodeURIComponent here as WHATWG URL percent-encodes everything + const full = decodeURIComponent(stripSlashes(destCanonical, true)) as FullSlug; + const simple = simplifySlug(full); + outgoing.add(simple); + node.properties["data-slug"] = full; + } + + // rewrite link internals if prettylinks is on + if ( + opts.prettyLinks && isInternal && node.children.length === 1 && node.children[0].type === "text" && !node.children[0].value.startsWith("#") - ) { - node.children[0].value = path.basename(node.children[0].value) - } - } - - // transform all other resources that may use links - if ( - ["img", "video", "audio", "iframe"].includes(node.tagName) && + ) { + node.children[0].value = path.basename(node.children[0].value); + } + } + + // transform all other resources that may use links + if ( + ["img", "video", "audio", "iframe"].includes(node.tagName) && node.properties && typeof node.properties.src === "string" - ) { - if (opts.lazyLoad) { - node.properties.loading = "lazy" - } - - if (!isAbsoluteUrl(node.properties.src)) { - let dest = node.properties.src as RelativeURL - dest = node.properties.src = transformLink( + ) { + if (opts.lazyLoad) { + node.properties.loading = "lazy"; + } + + if (!isAbsoluteUrl(node.properties.src)) { + let dest = node.properties.src as RelativeURL; + dest = node.properties.src = transformLink( file.data.slug!, dest, transformOptions, - ) - node.properties.src = dest - } - } - }) - - file.data.links = [...outgoing] - } - }, - ] - }, - } -} + ); + node.properties.src = dest; + } + } + }); + + file.data.links = [...outgoing]; + }; + }, + ]; + }, + }; +}; declare module "vfile" { interface DataMap { diff --git a/quartz/plugins/transformers/ofm.ts b/quartz/plugins/transformers/ofm.ts index fab7cf894740d..8689865924c9e 100644 --- a/quartz/plugins/transformers/ofm.ts +++ b/quartz/plugins/transformers/ofm.ts @@ -1,23 +1,23 @@ -import { QuartzTransformerPlugin } from "../types" -import { Blockquote, Root, Html, BlockContent, DefinitionContent, Paragraph, Code } from "mdast" -import { Element, Literal, Root as HtmlRoot } from "hast" -import { ReplaceFunction, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace" -import { slug as slugAnchor } from "github-slugger" -import rehypeRaw from "rehype-raw" -import { SKIP, visit } from "unist-util-visit" -import path from "path" -import { JSResource } from "../../util/resources" +import { slug as slugAnchor } from "github-slugger"; +import { Element, Literal, Root as HtmlRoot } from "hast"; +import { toHtml } from "hast-util-to-html"; +import { BlockContent, Code,DefinitionContent, Html, Paragraph, Root } from "mdast"; +import { findAndReplace as mdastFindReplace,ReplaceFunction } from "mdast-util-find-and-replace"; +import { PhrasingContent } from "mdast-util-find-and-replace/lib"; +import { toHast } from "mdast-util-to-hast"; +import path from "path"; +import rehypeRaw from "rehype-raw"; +import { PluggableList } from "unified"; +import { SKIP, visit } from "unist-util-visit"; + // @ts-ignore -import calloutScript from "../../components/scripts/callout.inline.ts" +import calloutScript from "../../components/scripts/callout.inline.ts"; // @ts-ignore -import checkboxScript from "../../components/scripts/checkbox.inline.ts" -import { FilePath, pathToRoot, slugTag, slugifyFilePath } from "../../util/path" -import { toHast } from "mdast-util-to-hast" -import { toHtml } from "hast-util-to-html" -import { PhrasingContent } from "mdast-util-find-and-replace/lib" -import { capitalize } from "../../util/lang" -import { PluggableList } from "unified" -import { ValidCallout, i18n } from "../../i18n" +import checkboxScript from "../../components/scripts/checkbox.inline.ts"; +import { capitalize } from "../../util/lang"; +import { FilePath, pathToRoot, slugifyFilePath,slugTag } from "../../util/path"; +import { JSResource } from "../../util/resources"; +import { QuartzTransformerPlugin } from "../types"; export interface Options { comments: boolean @@ -35,70 +35,70 @@ export interface Options { } const defaultOptions: Options = { - comments: true, - highlight: true, - wikilinks: true, - callouts: true, - mermaid: true, - parseTags: true, - parseArrows: true, - parseBlockReferences: true, - enableInHtmlEmbed: false, - enableYouTubeEmbed: true, - enableVideoEmbed: true, - enableCheckbox: false, -} + comments: true, + highlight: true, + wikilinks: true, + callouts: true, + mermaid: true, + parseTags: true, + parseArrows: true, + parseBlockReferences: true, + enableInHtmlEmbed: false, + enableYouTubeEmbed: true, + enableVideoEmbed: true, + enableCheckbox: false, +}; const calloutMapping = { - note: "note", - abstract: "abstract", - summary: "abstract", - tldr: "abstract", - info: "info", - todo: "todo", - tip: "tip", - hint: "tip", - important: "tip", - success: "success", - check: "success", - done: "success", - question: "question", - help: "question", - faq: "question", - warning: "warning", - attention: "warning", - caution: "warning", - failure: "failure", - missing: "failure", - fail: "failure", - danger: "danger", - error: "danger", - bug: "bug", - example: "example", - quote: "quote", - cite: "quote", -} as const + note: "note", + abstract: "abstract", + summary: "abstract", + tldr: "abstract", + info: "info", + todo: "todo", + tip: "tip", + hint: "tip", + important: "tip", + success: "success", + check: "success", + done: "success", + question: "question", + help: "question", + faq: "question", + warning: "warning", + attention: "warning", + caution: "warning", + failure: "failure", + missing: "failure", + fail: "failure", + danger: "danger", + error: "danger", + bug: "bug", + example: "example", + quote: "quote", + cite: "quote", +} as const; const arrowMapping: Record = { - "->": "→", - "-->": "⇒", - "=>": "⇒", - "==>": "⇒", - "<-": "←", - "<--": "⇐", - "<=": "⇐", - "<==": "⇐", -} + "->": "→", + "-->": "⇒", + "=>": "⇒", + "==>": "⇒", + "<-": "←", + "<--": "⇐", + "<=": "⇐", + "<==": "⇐", +}; function canonicalizeCallout(calloutName: string): keyof typeof calloutMapping { - const normalizedCallout = calloutName.toLowerCase() as keyof typeof calloutMapping - // if callout is not recognized, make it a custom one - return calloutMapping[normalizedCallout] ?? calloutName + const normalizedCallout = calloutName.toLowerCase() as keyof typeof calloutMapping; + // if callout is not recognized, make it a custom one + return calloutMapping[normalizedCallout] ?? calloutName; } -export const externalLinkRegex = /^https?:\/\//i +export const externalLinkRegex = /^https?:\/\//i; -export const arrowRegex = new RegExp(/(-{1,2}>|={1,2}>|<-{1,2}|<={1,2})/, "g") +export const arrowRegex = new RegExp(/(-{1,2}>|={1,2}>|<-{1,2}|<={1,2})/, "g"); // !? -> optional embedding // \[\[ -> open brace @@ -106,511 +106,511 @@ export const arrowRegex = new RegExp(/(-{1,2}>|={1,2}>|<-{1,2}|<={1,2})/, "g") // (#[^\[\]\|\#]+)? -> # then one or more non-special characters (heading link) // (\|[^\[\]\#]+)? -> | then one or more non-special characters (alias) export const wikilinkRegex = new RegExp( - /!?\[\[([^\[\]\|\#]+)?(#+[^\[\]\|\#]+)?(\|[^\[\]\#]+)?\]\]/, - "g", -) -const highlightRegex = new RegExp(/==([^=]+)==/, "g") -const commentRegex = new RegExp(/%%[\s\S]*?%%/, "g") + /!?\[\[([^\[\]\|\#]+)?(#+[^\[\]\|\#]+)?(\|[^\[\]\#]+)?\]\]/, + "g", +); +const highlightRegex = new RegExp(/==([^=]+)==/, "g"); +const commentRegex = new RegExp(/%%[\s\S]*?%%/, "g"); // from https://github.com/escwxyz/remark-obsidian-callout/blob/main/src/index.ts -const calloutRegex = new RegExp(/^\[\!(\w+)\]([+-]?)/) -const calloutLineRegex = new RegExp(/^> *\[\!\w+\][+-]?.*$/, "gm") +const calloutRegex = new RegExp(/^\[\!(\w+)\]([+-]?)/); +const calloutLineRegex = new RegExp(/^> *\[\!\w+\][+-]?.*$/, "gm"); // (?:^| ) -> non-capturing group, tag should start be separated by a space or be the start of the line // #(...) -> capturing group, tag itself must start with # // (?:[-_\p{L}\d\p{Z}])+ -> non-capturing group, non-empty string of (Unicode-aware) alpha-numeric characters and symbols, hyphens and/or underscores // (?:\/[-_\p{L}\d\p{Z}]+)*) -> non-capturing group, matches an arbitrary number of tag strings separated by "/" const tagRegex = new RegExp( - /(?:^| )#((?:[-_\p{L}\p{Emoji}\p{M}\d])+(?:\/[-_\p{L}\p{Emoji}\p{M}\d]+)*)/, - "gu", -) -const blockReferenceRegex = new RegExp(/\^([-_A-Za-z0-9]+)$/, "g") -const ytLinkRegex = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/ -const videoExtensionRegex = new RegExp(/\.(mp4|webm|ogg|avi|mov|flv|wmv|mkv|mpg|mpeg|3gp|m4v)$/) + /(?:^| )#((?:[-_\p{L}\p{Emoji}\p{M}\d])+(?:\/[-_\p{L}\p{Emoji}\p{M}\d]+)*)/, + "gu", +); +const blockReferenceRegex = new RegExp(/\^([-_A-Za-z0-9]+)$/, "g"); +const ytLinkRegex = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/; +const videoExtensionRegex = new RegExp(/\.(mp4|webm|ogg|avi|mov|flv|wmv|mkv|mpg|mpeg|3gp|m4v)$/); const wikilinkImageEmbedRegex = new RegExp( - /^(?(?!^\d*x?\d*$).*?)?(\|?\s*?(?\d+)(x(?\d+))?)?$/, -) + /^(?(?!^\d*x?\d*$).*?)?(\|?\s*?(?\d+)(x(?\d+))?)?$/, +); export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin | undefined> = ( - userOpts, + userOpts, ) => { - const opts = { ...defaultOptions, ...userOpts } - - const mdastToHtml = (ast: PhrasingContent | Paragraph) => { - const hast = toHast(ast, { allowDangerousHtml: true })! - return toHtml(hast, { allowDangerousHtml: true }) - } - - return { - name: "ObsidianFlavoredMarkdown", - textTransform(_ctx, src) { - // do comments at text level - if (opts.comments) { - if (src instanceof Buffer) { - src = src.toString() - } - - src = src.replace(commentRegex, "") - } - - // pre-transform blockquotes - if (opts.callouts) { - if (src instanceof Buffer) { - src = src.toString() - } - - src = src.replace(calloutLineRegex, (value) => { - // force newline after title of callout - return value + "\n> " - }) - } - - // pre-transform wikilinks (fix anchors to things that may contain illegal syntax e.g. codeblocks, latex) - if (opts.wikilinks) { - if (src instanceof Buffer) { - src = src.toString() - } - - src = src.replace(wikilinkRegex, (value, ...capture) => { - const [rawFp, rawHeader, rawAlias]: (string | undefined)[] = capture - - const fp = rawFp ?? "" - const anchor = rawHeader?.trim().replace(/^#+/, "") - const blockRef = Boolean(anchor?.startsWith("^")) ? "^" : "" - const displayAnchor = anchor ? `#${blockRef}${slugAnchor(anchor)}` : "" - const displayAlias = rawAlias ?? rawHeader?.replace("#", "|") ?? "" - const embedDisplay = value.startsWith("!") ? "!" : "" - - if (rawFp?.match(externalLinkRegex)) { - return `${embedDisplay}[${displayAlias.replace(/^\|/, "")}](${rawFp})` - } - - return `${embedDisplay}[[${fp}${displayAnchor}${displayAlias}]]` - }) - } - - return src - }, - markdownPlugins(ctx) { - const plugins: PluggableList = [] - const cfg = ctx.cfg.configuration - - // regex replacements - plugins.push(() => { - return (tree: Root, file) => { - const replacements: [RegExp, string | ReplaceFunction][] = [] - const base = pathToRoot(file.data.slug!) - - if (opts.wikilinks) { - replacements.push([ - wikilinkRegex, - (value: string, ...capture: string[]) => { - let [rawFp, rawHeader, rawAlias] = capture - const fp = rawFp?.trim() ?? "" - const anchor = rawHeader?.trim() ?? "" - const alias = rawAlias?.slice(1).trim() - - // embed cases - if (value.startsWith("!")) { - const ext: string = path.extname(fp).toLowerCase() - const url = slugifyFilePath(fp as FilePath) - if ([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg", ".webp"].includes(ext)) { - const match = wikilinkImageEmbedRegex.exec(alias ?? "") - const alt = match?.groups?.alt ?? "" - const width = match?.groups?.width ?? "auto" - const height = match?.groups?.height ?? "auto" - return { - type: "image", - url, - data: { - hProperties: { - width, - height, - alt, - }, - }, - } - } else if ([".mp4", ".webm", ".ogv", ".mov", ".mkv"].includes(ext)) { - return { - type: "html", - value: ``, - } - } else if ( - [".mp3", ".webm", ".wav", ".m4a", ".ogg", ".3gp", ".flac"].includes(ext) - ) { - return { - type: "html", - value: ``, - } - } else if ([".pdf"].includes(ext)) { - return { - type: "html", - value: ``, - } - } else { - const block = anchor - return { - type: "html", - data: { hProperties: { transclude: true } }, - value: `
      Transclude of ${url}${block}
      `, - } - } - - // otherwise, fall through to regular link - } - - // internal link - const url = fp + anchor - return { - type: "link", - url, - children: [ - { - type: "text", - value: alias ?? fp, - }, - ], - } - }, - ]) - } - - if (opts.highlight) { - replacements.push([ - highlightRegex, - (_value: string, ...capture: string[]) => { - const [inner] = capture - return { - type: "html", - value: `${inner}`, - } - }, - ]) - } - - if (opts.parseArrows) { - replacements.push([ - arrowRegex, - (value: string, ..._capture: string[]) => { - const maybeArrow = arrowMapping[value] - if (maybeArrow === undefined) return SKIP - return { - type: "html", - value: `${maybeArrow}`, - } - }, - ]) - } - - if (opts.parseTags) { - replacements.push([ - tagRegex, - (_value: string, tag: string) => { - // Check if the tag only includes numbers - if (/^\d+$/.test(tag)) { - return false - } - - tag = slugTag(tag) - if (file.data.frontmatter) { - const noteTags = file.data.frontmatter.tags ?? [] - file.data.frontmatter.tags = [...new Set([...noteTags, tag])] - } - - return { - type: "link", - url: base + `/tags/${tag}`, - data: { - hProperties: { - className: ["tag-link"], - }, - }, - children: [ - { - type: "text", - value: `#${tag}`, - }, - ], - } - }, - ]) - } - - if (opts.enableInHtmlEmbed) { - visit(tree, "html", (node: Html) => { - for (const [regex, replace] of replacements) { - if (typeof replace === "string") { - node.value = node.value.replace(regex, replace) - } else { - node.value = node.value.replace(regex, (substring: string, ...args) => { - const replaceValue = replace(substring, ...args) - if (typeof replaceValue === "string") { - return replaceValue - } else if (Array.isArray(replaceValue)) { - return replaceValue.map(mdastToHtml).join("") - } else if (typeof replaceValue === "object" && replaceValue !== null) { - return mdastToHtml(replaceValue) - } else { - return substring - } - }) - } - } - }) - } - mdastFindReplace(tree, replacements) - } - }) - - if (opts.enableVideoEmbed) { - plugins.push(() => { - return (tree: Root, _file) => { - visit(tree, "image", (node, index, parent) => { - if (parent && index != undefined && videoExtensionRegex.test(node.url)) { - const newNode: Html = { - type: "html", - value: ``, - } - - parent.children.splice(index, 1, newNode) - return SKIP - } - }) - } - }) - } - - if (opts.callouts) { - plugins.push(() => { - return (tree: Root, _file) => { - visit(tree, "blockquote", (node) => { - if (node.children.length === 0) { - return - } - - // find first line - const firstChild = node.children[0] - if (firstChild.type !== "paragraph" || firstChild.children[0]?.type !== "text") { - return - } - - const text = firstChild.children[0].value - const restOfTitle = firstChild.children.slice(1) - const [firstLine, ...remainingLines] = text.split("\n") - const remainingText = remainingLines.join("\n") - - const match = firstLine.match(calloutRegex) - if (match && match.input) { - const [calloutDirective, typeString, collapseChar] = match - const calloutType = canonicalizeCallout(typeString.toLowerCase()) - const collapse = collapseChar === "+" || collapseChar === "-" - const defaultState = collapseChar === "-" ? "collapsed" : "expanded" - const titleContent = match.input.slice(calloutDirective.length).trim() - const useDefaultTitle = titleContent === "" && restOfTitle.length === 0 - const titleNode: Paragraph = { - type: "paragraph", - children: [ - { - type: "text", - value: useDefaultTitle ? capitalize(typeString) : titleContent + " ", - }, - ...restOfTitle, - ], - } - const title = mdastToHtml(titleNode) - - const toggleIcon = `
      ` - - const titleHtml: Html = { - type: "html", - value: `
      { + const hast = toHast(ast, { allowDangerousHtml: true })!; + return toHtml(hast, { allowDangerousHtml: true }); + }; + + return { + name: "ObsidianFlavoredMarkdown", + textTransform(_ctx, src) { + // do comments at text level + if (opts.comments) { + if (src instanceof Buffer) { + src = src.toString(); + } + + src = src.replace(commentRegex, ""); + } + + // pre-transform blockquotes + if (opts.callouts) { + if (src instanceof Buffer) { + src = src.toString(); + } + + src = src.replace(calloutLineRegex, (value) => { + // force newline after title of callout + return value + "\n> "; + }); + } + + // pre-transform wikilinks (fix anchors to things that may contain illegal syntax e.g. codeblocks, latex) + if (opts.wikilinks) { + if (src instanceof Buffer) { + src = src.toString(); + } + + src = src.replace(wikilinkRegex, (value, ...capture) => { + const [rawFp, rawHeader, rawAlias]: (string | undefined)[] = capture; + + const fp = rawFp ?? ""; + const anchor = rawHeader?.trim().replace(/^#+/, ""); + const blockRef = anchor?.startsWith("^") ? "^" : ""; + const displayAnchor = anchor ? `#${blockRef}${slugAnchor(anchor)}` : ""; + const displayAlias = rawAlias ?? rawHeader?.replace("#", "|") ?? ""; + const embedDisplay = value.startsWith("!") ? "!" : ""; + + if (rawFp?.match(externalLinkRegex)) { + return `${embedDisplay}[${displayAlias.replace(/^\|/, "")}](${rawFp})`; + } + + return `${embedDisplay}[[${fp}${displayAnchor}${displayAlias}]]`; + }); + } + + return src; + }, + markdownPlugins(ctx) { + const plugins: PluggableList = []; + const cfg = ctx.cfg.configuration; + + // regex replacements + plugins.push(() => { + return (tree: Root, file) => { + const replacements: [RegExp, string | ReplaceFunction][] = []; + const base = pathToRoot(file.data.slug!); + + if (opts.wikilinks) { + replacements.push([ + wikilinkRegex, + (value: string, ...capture: string[]) => { + const [rawFp, rawHeader, rawAlias] = capture; + const fp = rawFp?.trim() ?? ""; + const anchor = rawHeader?.trim() ?? ""; + const alias = rawAlias?.slice(1).trim(); + + // embed cases + if (value.startsWith("!")) { + const ext: string = path.extname(fp).toLowerCase(); + const url = slugifyFilePath(fp as FilePath); + if ([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg", ".webp"].includes(ext)) { + const match = wikilinkImageEmbedRegex.exec(alias ?? ""); + const alt = match?.groups?.alt ?? ""; + const width = match?.groups?.width ?? "auto"; + const height = match?.groups?.height ?? "auto"; + return { + type: "image", + url, + data: { + hProperties: { + width, + height, + alt, + }, + }, + }; + } else if ([".mp4", ".webm", ".ogv", ".mov", ".mkv"].includes(ext)) { + return { + type: "html", + value: ``, + }; + } else if ( + [".mp3", ".webm", ".wav", ".m4a", ".ogg", ".3gp", ".flac"].includes(ext) + ) { + return { + type: "html", + value: ``, + }; + } else if ([".pdf"].includes(ext)) { + return { + type: "html", + value: ``, + }; + } else { + const block = anchor; + return { + type: "html", + data: { hProperties: { transclude: true } }, + value: `
      Transclude of ${url}${block}
      `, + }; + } + + // otherwise, fall through to regular link + } + + // internal link + const url = fp + anchor; + return { + type: "link", + url, + children: [ + { + type: "text", + value: alias ?? fp, + }, + ], + }; + }, + ]); + } + + if (opts.highlight) { + replacements.push([ + highlightRegex, + (_value: string, ...capture: string[]) => { + const [inner] = capture; + return { + type: "html", + value: `${inner}`, + }; + }, + ]); + } + + if (opts.parseArrows) { + replacements.push([ + arrowRegex, + (value: string, ..._capture: string[]) => { + const maybeArrow = arrowMapping[value]; + if (maybeArrow === undefined) return SKIP; + return { + type: "html", + value: `${maybeArrow}`, + }; + }, + ]); + } + + if (opts.parseTags) { + replacements.push([ + tagRegex, + (_value: string, tag: string) => { + // Check if the tag only includes numbers + if (/^\d+$/.test(tag)) { + return false; + } + + tag = slugTag(tag); + if (file.data.frontmatter) { + const noteTags = file.data.frontmatter.tags ?? []; + file.data.frontmatter.tags = [...new Set([...noteTags, tag])]; + } + + return { + type: "link", + url: base + `/tags/${tag}`, + data: { + hProperties: { + className: ["tag-link"], + }, + }, + children: [ + { + type: "text", + value: `#${tag}`, + }, + ], + }; + }, + ]); + } + + if (opts.enableInHtmlEmbed) { + visit(tree, "html", (node: Html) => { + for (const [regex, replace] of replacements) { + if (typeof replace === "string") { + node.value = node.value.replace(regex, replace); + } else { + node.value = node.value.replace(regex, (substring: string, ...args) => { + const replaceValue = replace(substring, ...args); + if (typeof replaceValue === "string") { + return replaceValue; + } else if (Array.isArray(replaceValue)) { + return replaceValue.map(mdastToHtml).join(""); + } else if (typeof replaceValue === "object" && replaceValue !== null) { + return mdastToHtml(replaceValue); + } else { + return substring; + } + }); + } + } + }); + } + mdastFindReplace(tree, replacements); + }; + }); + + if (opts.enableVideoEmbed) { + plugins.push(() => { + return (tree: Root, _file) => { + visit(tree, "image", (node, index, parent) => { + if (parent && index != undefined && videoExtensionRegex.test(node.url)) { + const newNode: Html = { + type: "html", + value: ``, + }; + + parent.children.splice(index, 1, newNode); + return SKIP; + } + }); + }; + }); + } + + if (opts.callouts) { + plugins.push(() => { + return (tree: Root, _file) => { + visit(tree, "blockquote", (node) => { + if (node.children.length === 0) { + return; + } + + // find first line + const firstChild = node.children[0]; + if (firstChild.type !== "paragraph" || firstChild.children[0]?.type !== "text") { + return; + } + + const text = firstChild.children[0].value; + const restOfTitle = firstChild.children.slice(1); + const [firstLine, ...remainingLines] = text.split("\n"); + const remainingText = remainingLines.join("\n"); + + const match = firstLine.match(calloutRegex); + if (match && match.input) { + const [calloutDirective, typeString, collapseChar] = match; + const calloutType = canonicalizeCallout(typeString.toLowerCase()); + const collapse = collapseChar === "+" || collapseChar === "-"; + const defaultState = collapseChar === "-" ? "collapsed" : "expanded"; + const titleContent = match.input.slice(calloutDirective.length).trim(); + const useDefaultTitle = titleContent === "" && restOfTitle.length === 0; + const titleNode: Paragraph = { + type: "paragraph", + children: [ + { + type: "text", + value: useDefaultTitle ? capitalize(typeString) : titleContent + " ", + }, + ...restOfTitle, + ], + }; + const title = mdastToHtml(titleNode); + + const toggleIcon = "
      "; + + const titleHtml: Html = { + type: "html", + value: `
      ${title}
      ${collapse ? toggleIcon : ""}
      `, - } - - const blockquoteContent: (BlockContent | DefinitionContent)[] = [titleHtml] - if (remainingText.length > 0) { - blockquoteContent.push({ - type: "paragraph", - children: [ - { - type: "text", - value: remainingText, - }, - ], - }) - } - - // replace first line of blockquote with title and rest of the paragraph text - node.children.splice(0, 1, ...blockquoteContent) - - const classNames = ["callout", calloutType] - if (collapse) { - classNames.push("is-collapsible") - } - if (defaultState === "collapsed") { - classNames.push("is-collapsed") - } - - // add properties to base blockquote - node.data = { - hProperties: { - ...(node.data?.hProperties ?? {}), - className: classNames.join(" "), - "data-callout": calloutType, - "data-callout-fold": collapse, - }, - } - } - }) - } - }) - } - - if (opts.mermaid) { - plugins.push(() => { - return (tree: Root, _file) => { - visit(tree, "code", (node: Code) => { - if (node.lang === "mermaid") { - node.data = { - hProperties: { - className: ["mermaid"], - }, - } - } - }) - } - }) - } - - return plugins - }, - htmlPlugins() { - const plugins: PluggableList = [rehypeRaw] - - if (opts.parseBlockReferences) { - plugins.push(() => { - const inlineTagTypes = new Set(["p", "li"]) - const blockTagTypes = new Set(["blockquote"]) - return (tree: HtmlRoot, file) => { - file.data.blocks = {} - - visit(tree, "element", (node, index, parent) => { - if (blockTagTypes.has(node.tagName)) { - const nextChild = parent?.children.at(index! + 2) as Element - if (nextChild && nextChild.tagName === "p") { - const text = nextChild.children.at(0) as Literal - if (text && text.value && text.type === "text") { - const matches = text.value.match(blockReferenceRegex) - if (matches && matches.length >= 1) { - parent!.children.splice(index! + 2, 1) - const block = matches[0].slice(1) + }; + + const blockquoteContent: (BlockContent | DefinitionContent)[] = [titleHtml]; + if (remainingText.length > 0) { + blockquoteContent.push({ + type: "paragraph", + children: [ + { + type: "text", + value: remainingText, + }, + ], + }); + } + + // replace first line of blockquote with title and rest of the paragraph text + node.children.splice(0, 1, ...blockquoteContent); + + const classNames = ["callout", calloutType]; + if (collapse) { + classNames.push("is-collapsible"); + } + if (defaultState === "collapsed") { + classNames.push("is-collapsed"); + } + + // add properties to base blockquote + node.data = { + hProperties: { + ...(node.data?.hProperties ?? {}), + className: classNames.join(" "), + "data-callout": calloutType, + "data-callout-fold": collapse, + }, + }; + } + }); + }; + }); + } + + if (opts.mermaid) { + plugins.push(() => { + return (tree: Root, _file) => { + visit(tree, "code", (node: Code) => { + if (node.lang === "mermaid") { + node.data = { + hProperties: { + className: ["mermaid"], + }, + }; + } + }); + }; + }); + } + + return plugins; + }, + htmlPlugins() { + const plugins: PluggableList = [rehypeRaw]; + + if (opts.parseBlockReferences) { + plugins.push(() => { + const inlineTagTypes = new Set(["p", "li"]); + const blockTagTypes = new Set(["blockquote"]); + return (tree: HtmlRoot, file) => { + file.data.blocks = {}; + + visit(tree, "element", (node, index, parent) => { + if (blockTagTypes.has(node.tagName)) { + const nextChild = parent?.children.at(index! + 2) as Element; + if (nextChild && nextChild.tagName === "p") { + const text = nextChild.children.at(0) as Literal; + if (text && text.value && text.type === "text") { + const matches = text.value.match(blockReferenceRegex); + if (matches && matches.length >= 1) { + parent!.children.splice(index! + 2, 1); + const block = matches[0].slice(1); if (!Object.keys(file.data.blocks!).includes(block)) { - node.properties = { - ...node.properties, - id: block, - } - file.data.blocks![block] = node - } - } - } - } - } else if (inlineTagTypes.has(node.tagName)) { - const last = node.children.at(-1) as Literal - if (last && last.value && typeof last.value === "string") { - const matches = last.value.match(blockReferenceRegex) - if (matches && matches.length >= 1) { - last.value = last.value.slice(0, -matches[0].length) - const block = matches[0].slice(1) - - if (!Object.keys(file.data.blocks!).includes(block)) { - node.properties = { - ...node.properties, - id: block, + node.properties = { + ...node.properties, + id: block, + }; + file.data.blocks![block] = node; } - file.data.blocks![block] = node - } - } - } - } - }) - - file.data.htmlAst = tree - } - }) - } - - if (opts.enableYouTubeEmbed) { - plugins.push(() => { - return (tree: HtmlRoot) => { - visit(tree, "element", (node) => { - if (node.tagName === "img" && typeof node.properties.src === "string") { - const match = node.properties.src.match(ytLinkRegex) - const videoId = match && match[2].length == 11 ? match[2] : null - if (videoId) { - node.tagName = "iframe" - node.properties = { - class: "external-embed", - allow: "fullscreen", - frameborder: 0, - width: "600px", - height: "350px", - src: `https://www.youtube.com/embed/${videoId}`, - } - } - } - }) - } - }) - } - - if (opts.enableCheckbox) { - plugins.push(() => { - return (tree: HtmlRoot, _file) => { - visit(tree, "element", (node) => { - if (node.tagName === "input" && node.properties.type === "checkbox") { - const isChecked = node.properties?.checked ?? false - node.properties = { - type: "checkbox", - disabled: false, - checked: isChecked, - class: "checkbox-toggle", - } - } - }) - } - }) - } - - return plugins - }, - externalResources() { - const js: JSResource[] = [] - - if (opts.enableCheckbox) { - js.push({ - script: checkboxScript, - loadTime: "afterDOMReady", - contentType: "inline", - }) - } - - if (opts.callouts) { - js.push({ - script: calloutScript, - loadTime: "afterDOMReady", - contentType: "inline", - }) - } - - if (opts.mermaid) { - js.push({ - script: ` + } + } + } + } 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; + } + } + } + } + }); + + file.data.htmlAst = tree; + }; + }); + } + + if (opts.enableYouTubeEmbed) { + plugins.push(() => { + return (tree: HtmlRoot) => { + visit(tree, "element", (node) => { + if (node.tagName === "img" && typeof node.properties.src === "string") { + const match = node.properties.src.match(ytLinkRegex); + const videoId = match && match[2].length == 11 ? match[2] : null; + if (videoId) { + node.tagName = "iframe"; + node.properties = { + class: "external-embed", + allow: "fullscreen", + frameborder: 0, + width: "600px", + height: "350px", + src: `https://www.youtube.com/embed/${videoId}`, + }; + } + } + }); + }; + }); + } + + if (opts.enableCheckbox) { + plugins.push(() => { + return (tree: HtmlRoot, _file) => { + visit(tree, "element", (node) => { + if (node.tagName === "input" && node.properties.type === "checkbox") { + const isChecked = node.properties?.checked ?? false; + node.properties = { + type: "checkbox", + disabled: false, + checked: isChecked, + class: "checkbox-toggle", + }; + } + }); + }; + }); + } + + return plugins; + }, + externalResources() { + const js: JSResource[] = []; + + if (opts.enableCheckbox) { + js.push({ + script: checkboxScript, + loadTime: "afterDOMReady", + contentType: "inline", + }); + } + + if (opts.callouts) { + js.push({ + script: calloutScript, + loadTime: "afterDOMReady", + contentType: "inline", + }); + } + + if (opts.mermaid) { + js.push({ + script: ` let mermaidImport = undefined document.addEventListener('nav', async () => { if (document.querySelector("code.mermaid")) { @@ -629,16 +629,16 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin } }); `, - loadTime: "afterDOMReady", - moduleType: "module", - contentType: "inline", - }) - } - - return { js } - }, - } -} + loadTime: "afterDOMReady", + moduleType: "module", + contentType: "inline", + }); + } + + return { js }; + }, + }; +}; declare module "vfile" { interface DataMap { diff --git a/quartz/plugins/transformers/oxhugofm.ts b/quartz/plugins/transformers/oxhugofm.ts index 6e70bb1908fdd..99cf95cd27bab 100644 --- a/quartz/plugins/transformers/oxhugofm.ts +++ b/quartz/plugins/transformers/oxhugofm.ts @@ -1,4 +1,4 @@ -import { QuartzTransformerPlugin } from "../types" +import { QuartzTransformerPlugin } from "../types"; export interface Options { /** Replace {{ relref }} with quartz wikilinks []() */ @@ -15,31 +15,31 @@ export interface Options { } const defaultOptions: Options = { - wikilinks: true, - removePredefinedAnchor: true, - removeHugoShortcode: true, - replaceFigureWithMdImg: true, - replaceOrgLatex: true, -} + 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") +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") +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", -) + /(?:\\begin{equation}|\\\\\(|\\\\\[)([\s\S]*?)(?:\\\\\]|\\\\\)|\\end{equation})/, + "g", +); // \$\$[\s\S]*?\$\$ -> Matches block equations // \$.*?\$ -> Matches inline equations -const quartzLatexRegex = new RegExp(/\$\$[\s\S]*?\$\$|\$.*?\$/, "g") +const quartzLatexRegex = new RegExp(/\$\$[\s\S]*?\$\$|\$.*?\$/, "g"); /** * ox-hugo is an org exporter backend that exports org files to hugo-compatible @@ -48,61 +48,61 @@ const quartzLatexRegex = new RegExp(/\$\$[\s\S]*?\$\$|\$.*?\$/, "g") * is not exhaustive. * */ export const OxHugoFlavouredMarkdown: QuartzTransformerPlugin | undefined> = ( - userOpts, + 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})` - }) - } + 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.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.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.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}$$` - }) + 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 - }, - } -} + // ox-hugo escapes _ as \_ + src = src.replaceAll(quartzLatexRegex, (value) => { + return value.replaceAll("\\_", "_"); + }); + } + return src; + }, + }; +}; diff --git a/quartz/plugins/transformers/syntax.ts b/quartz/plugins/transformers/syntax.ts index f11734e5f5a90..bcfdf10e377d8 100644 --- a/quartz/plugins/transformers/syntax.ts +++ b/quartz/plugins/transformers/syntax.ts @@ -1,5 +1,6 @@ -import { QuartzTransformerPlugin } from "../types" -import rehypePrettyCode, { Options as CodeOptions, Theme as CodeTheme } from "rehype-pretty-code" +import rehypePrettyCode, { Options as CodeOptions, Theme as CodeTheme } from "rehype-pretty-code"; + +import { QuartzTransformerPlugin } from "../types"; interface Theme extends Record { light: CodeTheme @@ -12,22 +13,22 @@ interface Options { } const defaultOptions: Options = { - theme: { - light: "github-light", - dark: "github-dark", - }, - keepBackground: false, -} + theme: { + light: "github-light", + dark: "github-dark", + }, + keepBackground: false, +}; export const SyntaxHighlighting: QuartzTransformerPlugin = ( - userOpts?: Partial, + userOpts?: Partial, ) => { - const opts: Partial = { ...defaultOptions, ...userOpts } + const opts: Partial = { ...defaultOptions, ...userOpts }; - return { - name: "SyntaxHighlighting", - htmlPlugins() { - return [[rehypePrettyCode, opts]] - }, - } -} + return { + name: "SyntaxHighlighting", + htmlPlugins() { + return [[rehypePrettyCode, opts]]; + }, + }; +}; diff --git a/quartz/plugins/transformers/toc.ts b/quartz/plugins/transformers/toc.ts index bfc2f9877fea8..c5ac549815b31 100644 --- a/quartz/plugins/transformers/toc.ts +++ b/quartz/plugins/transformers/toc.ts @@ -1,8 +1,9 @@ -import { QuartzTransformerPlugin } from "../types" -import { Root } from "mdast" -import { visit } from "unist-util-visit" -import { toString } from "mdast-util-to-string" -import Slugger from "github-slugger" +import Slugger from "github-slugger"; +import { Root } from "mdast"; +import { toString } from "mdast-util-to-string"; +import { visit } from "unist-util-visit"; + +import { QuartzTransformerPlugin } from "../types"; export interface Options { maxDepth: 1 | 2 | 3 | 4 | 5 | 6 @@ -12,11 +13,11 @@ export interface Options { } const defaultOptions: Options = { - maxDepth: 3, - minEntries: 1, - showByDefault: true, - collapseByDefault: false, -} + maxDepth: 3, + minEntries: 1, + showByDefault: true, + collapseByDefault: false, +}; interface TocEntry { depth: number @@ -24,48 +25,48 @@ interface TocEntry { slug: string // this is just the anchor (#some-slug), not the canonical slug } -const slugAnchor = new Slugger() +const slugAnchor = new Slugger(); export const TableOfContents: QuartzTransformerPlugin | undefined> = ( - userOpts, + userOpts, ) => { - const opts = { ...defaultOptions, ...userOpts } - return { - name: "TableOfContents", - markdownPlugins() { - return [ - () => { - return async (tree: Root, file) => { - const display = file.data.frontmatter?.enableToc ?? opts.showByDefault - if (display) { - slugAnchor.reset() - const toc: TocEntry[] = [] - let highestDepth: number = opts.maxDepth - visit(tree, "heading", (node) => { - if (node.depth <= opts.maxDepth) { - const text = toString(node) - highestDepth = Math.min(highestDepth, node.depth) - toc.push({ - depth: node.depth, - text, - slug: slugAnchor.slug(text), - }) - } - }) + const opts = { ...defaultOptions, ...userOpts }; + return { + name: "TableOfContents", + markdownPlugins() { + return [ + () => { + return async (tree: Root, file) => { + const display = file.data.frontmatter?.enableToc ?? opts.showByDefault; + if (display) { + slugAnchor.reset(); + const toc: TocEntry[] = []; + let highestDepth: number = opts.maxDepth; + visit(tree, "heading", (node) => { + if (node.depth <= opts.maxDepth) { + const text = toString(node); + highestDepth = Math.min(highestDepth, node.depth); + toc.push({ + depth: node.depth, + text, + slug: slugAnchor.slug(text), + }); + } + }); - if (toc.length > 0 && toc.length > opts.minEntries) { - file.data.toc = toc.map((entry) => ({ - ...entry, - depth: entry.depth - highestDepth, - })) - file.data.collapseToc = opts.collapseByDefault - } - } - } - }, - ] - }, - } -} + if (toc.length > 0 && toc.length > opts.minEntries) { + file.data.toc = toc.map((entry) => ({ + ...entry, + depth: entry.depth - highestDepth, + })); + file.data.collapseToc = opts.collapseByDefault; + } + } + }; + }, + ]; + }, + }; +}; declare module "vfile" { interface DataMap { diff --git a/quartz/plugins/types.ts b/quartz/plugins/types.ts index a23f5d6f4630a..f224e7ad2a1de 100644 --- a/quartz/plugins/types.ts +++ b/quartz/plugins/types.ts @@ -1,10 +1,11 @@ -import { PluggableList } from "unified" -import { StaticResources } from "../util/resources" -import { ProcessedContent } from "./vfile" -import { QuartzComponent } from "../components/types" -import { FilePath } from "../util/path" -import { BuildCtx } from "../util/ctx" -import DepGraph from "../depgraph" +import { PluggableList } from "unified"; + +import { QuartzComponent } from "../components/types"; +import DepGraph from "../depgraph"; +import { BuildCtx } from "../util/ctx"; +import { FilePath } from "../util/path"; +import { StaticResources } from "../util/resources"; +import { ProcessedContent } from "./vfile"; export interface PluginTypes { transformers: QuartzTransformerPluginInstance[] diff --git a/quartz/plugins/vfile.ts b/quartz/plugins/vfile.ts index 5be21058471ab..8214ddaab8c9b 100644 --- a/quartz/plugins/vfile.ts +++ b/quartz/plugins/vfile.ts @@ -1,12 +1,12 @@ -import { Node, Parent } from "hast" -import { Data, VFile } from "vfile" +import { Node, Parent } from "hast"; +import { Data, VFile } from "vfile"; export type QuartzPluginData = Data export type ProcessedContent = [Node, VFile] export function defaultProcessedContent(vfileData: Partial): ProcessedContent { - const root: Parent = { type: "root", children: [] } - const vfile = new VFile("") - vfile.data = vfileData - return [root, vfile] + const root: Parent = { type: "root", children: [] }; + const vfile = new VFile(""); + vfile.data = vfileData; + return [root, vfile]; } diff --git a/quartz/processors/emit.ts b/quartz/processors/emit.ts index c68e0edebf12b..9b09519e46459 100644 --- a/quartz/processors/emit.ts +++ b/quartz/processors/emit.ts @@ -1,33 +1,33 @@ -import { PerfTimer } from "../util/perf" -import { getStaticResourcesFromPlugins } from "../plugins" -import { ProcessedContent } from "../plugins/vfile" -import { QuartzLogger } from "../util/log" -import { trace } from "../util/trace" -import { BuildCtx } from "../util/ctx" +import { getStaticResourcesFromPlugins } from "../plugins"; +import { ProcessedContent } from "../plugins/vfile"; +import { BuildCtx } from "../util/ctx"; +import { QuartzLogger } from "../util/log"; +import { PerfTimer } from "../util/perf"; +import { trace } from "../util/trace"; export async function emitContent(ctx: BuildCtx, content: ProcessedContent[]) { - const { argv, cfg } = ctx - const perf = new PerfTimer() - const log = new QuartzLogger(ctx.argv.verbose) + const { argv, cfg } = ctx; + const perf = new PerfTimer(); + const log = new QuartzLogger(ctx.argv.verbose); - log.start(`Emitting output files`) + log.start("Emitting output files"); - let emittedFiles = 0 - const staticResources = getStaticResourcesFromPlugins(ctx) - for (const emitter of cfg.plugins.emitters) { - try { - const emitted = await emitter.emit(ctx, content, staticResources) - emittedFiles += emitted.length + let emittedFiles = 0; + const staticResources = getStaticResourcesFromPlugins(ctx); + for (const emitter of cfg.plugins.emitters) { + try { + const emitted = await emitter.emit(ctx, content, staticResources); + emittedFiles += emitted.length; - if (ctx.argv.verbose) { - for (const file of emitted) { - console.log(`[emit:${emitter.name}] ${file}`) - } - } - } catch (err) { - trace(`Failed to emit from plugin \`${emitter.name}\``, err as Error) - } - } + if (ctx.argv.verbose) { + for (const file of emitted) { + console.log(`[emit:${emitter.name}] ${file}`); + } + } + } catch (err) { + trace(`Failed to emit from plugin \`${emitter.name}\``, err as Error); + } + } - log.end(`Emitted ${emittedFiles} files to \`${argv.output}\` in ${perf.timeSince()}`) + log.end(`Emitted ${emittedFiles} files to \`${argv.output}\` in ${perf.timeSince()}`); } diff --git a/quartz/processors/filter.ts b/quartz/processors/filter.ts index b269fb31344a0..1d3e86eb3fc64 100644 --- a/quartz/processors/filter.ts +++ b/quartz/processors/filter.ts @@ -1,24 +1,24 @@ -import { BuildCtx } from "../util/ctx" -import { PerfTimer } from "../util/perf" -import { ProcessedContent } from "../plugins/vfile" +import { ProcessedContent } from "../plugins/vfile"; +import { BuildCtx } from "../util/ctx"; +import { PerfTimer } from "../util/perf"; export function filterContent(ctx: BuildCtx, content: ProcessedContent[]): ProcessedContent[] { - const { cfg, argv } = ctx - const perf = new PerfTimer() - const initialLength = content.length - for (const plugin of cfg.plugins.filters) { - const updatedContent = content.filter((item) => plugin.shouldPublish(ctx, item)) + const { cfg, argv } = ctx; + const perf = new PerfTimer(); + const initialLength = content.length; + for (const plugin of cfg.plugins.filters) { + const updatedContent = content.filter((item) => plugin.shouldPublish(ctx, item)); - if (argv.verbose) { - const diff = content.filter((x) => !updatedContent.includes(x)) - for (const file of diff) { - console.log(`[filter:${plugin.name}] ${file[1].data.slug}`) - } - } + if (argv.verbose) { + const diff = content.filter((x) => !updatedContent.includes(x)); + for (const file of diff) { + console.log(`[filter:${plugin.name}] ${file[1].data.slug}`); + } + } - content = updatedContent - } + content = updatedContent; + } - console.log(`Filtered out ${initialLength - content.length} files in ${perf.timeSince()}`) - return content + console.log(`Filtered out ${initialLength - content.length} files in ${perf.timeSince()}`); + return content; } diff --git a/quartz/processors/parse.ts b/quartz/processors/parse.ts index 3950fee09146d..5f136eb01dfec 100644 --- a/quartz/processors/parse.ts +++ b/quartz/processors/parse.ts @@ -1,160 +1,161 @@ -import esbuild from "esbuild" -import remarkParse from "remark-parse" -import remarkRehype from "remark-rehype" -import { Processor, unified } from "unified" -import { Root as MDRoot } from "remark-parse/lib" -import { Root as HTMLRoot } from "hast" -import { ProcessedContent } from "../plugins/vfile" -import { PerfTimer } from "../util/perf" -import { read } from "to-vfile" -import { FilePath, QUARTZ, slugifyFilePath } from "../util/path" -import path from "path" -import workerpool, { Promise as WorkerPromise } from "workerpool" -import { QuartzLogger } from "../util/log" -import { trace } from "../util/trace" -import { BuildCtx } from "../util/ctx" +import esbuild from "esbuild"; +import { Root as HTMLRoot } from "hast"; +import path from "path"; +import remarkParse from "remark-parse"; +import { Root as MDRoot } from "remark-parse/lib"; +import remarkRehype from "remark-rehype"; +import { read } from "to-vfile"; +import { Processor, unified } from "unified"; +import workerpool, { Promise as WorkerPromise } from "workerpool"; + +import { ProcessedContent } from "../plugins/vfile"; +import { BuildCtx } from "../util/ctx"; +import { QuartzLogger } from "../util/log"; +import { FilePath, QUARTZ, slugifyFilePath } from "../util/path"; +import { PerfTimer } from "../util/perf"; +import { trace } from "../util/trace"; export type QuartzProcessor = Processor export function createProcessor(ctx: BuildCtx): QuartzProcessor { - const transformers = ctx.cfg.plugins.transformers - - return ( - unified() - // base Markdown -> MD AST - .use(remarkParse) - // MD AST -> MD AST transforms - .use( - transformers - .filter((p) => p.markdownPlugins) - .flatMap((plugin) => plugin.markdownPlugins!(ctx)), - ) - // MD AST -> HTML AST - .use(remarkRehype, { allowDangerousHtml: true }) - // HTML AST -> HTML AST transforms - .use(transformers.filter((p) => p.htmlPlugins).flatMap((plugin) => plugin.htmlPlugins!(ctx))) - ) + const transformers = ctx.cfg.plugins.transformers; + + return ( + unified() + // base Markdown -> MD AST + .use(remarkParse) + // MD AST -> MD AST transforms + .use( + transformers + .filter((p) => p.markdownPlugins) + .flatMap((plugin) => plugin.markdownPlugins!(ctx)), + ) + // MD AST -> HTML AST + .use(remarkRehype, { allowDangerousHtml: true }) + // HTML AST -> HTML AST transforms + .use(transformers.filter((p) => p.htmlPlugins).flatMap((plugin) => plugin.htmlPlugins!(ctx))) + ); } function* chunks(arr: T[], n: number) { - for (let i = 0; i < arr.length; i += n) { - yield arr.slice(i, i + n) - } + for (let i = 0; i < arr.length; i += n) { + yield arr.slice(i, i + n); + } } async function transpileWorkerScript() { - // transpile worker script - const cacheFile = "./.quartz-cache/transpiled-worker.mjs" - const fp = "./quartz/worker.ts" - return esbuild.build({ - entryPoints: [fp], - outfile: path.join(QUARTZ, cacheFile), - bundle: true, - keepNames: true, - platform: "node", - format: "esm", - packages: "external", - sourcemap: true, - sourcesContent: false, - plugins: [ - { - name: "css-and-scripts-as-text", - setup(build) { - build.onLoad({ filter: /\.scss$/ }, (_) => ({ - contents: "", - loader: "text", - })) - build.onLoad({ filter: /\.inline\.(ts|js)$/ }, (_) => ({ - contents: "", - loader: "text", - })) - }, - }, - ], - }) + // transpile worker script + const cacheFile = "./.quartz-cache/transpiled-worker.mjs"; + const fp = "./quartz/worker.ts"; + return esbuild.build({ + entryPoints: [fp], + outfile: path.join(QUARTZ, cacheFile), + bundle: true, + keepNames: true, + platform: "node", + format: "esm", + packages: "external", + sourcemap: true, + sourcesContent: false, + plugins: [ + { + name: "css-and-scripts-as-text", + setup(build) { + build.onLoad({ filter: /\.scss$/ }, (_) => ({ + contents: "", + loader: "text", + })); + build.onLoad({ filter: /\.inline\.(ts|js)$/ }, (_) => ({ + contents: "", + loader: "text", + })); + }, + }, + ], + }); } export function createFileParser(ctx: BuildCtx, fps: FilePath[]) { - const { argv, cfg } = ctx - return async (processor: QuartzProcessor) => { - const res: ProcessedContent[] = [] - for (const fp of fps) { - try { - const perf = new PerfTimer() - const file = await read(fp) - - // strip leading and trailing whitespace - file.value = file.value.toString().trim() - - // Text -> Text transforms - for (const plugin of cfg.plugins.transformers.filter((p) => p.textTransform)) { - file.value = plugin.textTransform!(ctx, file.value.toString()) - } - - // base data properties that plugins may use - file.data.filePath = file.path as FilePath - file.data.relativePath = path.posix.relative(argv.directory, file.path) as FilePath - file.data.slug = slugifyFilePath(file.data.relativePath) - - const ast = processor.parse(file) - const newAst = await processor.run(ast, file) - res.push([newAst, file]) - - if (argv.verbose) { - console.log(`[process] ${fp} -> ${file.data.slug} (${perf.timeSince()})`) - } - } catch (err) { - trace(`\nFailed to process \`${fp}\``, err as Error) - } - } - - return res - } + const { argv, cfg } = ctx; + return async (processor: QuartzProcessor) => { + const res: ProcessedContent[] = []; + for (const fp of fps) { + try { + const perf = new PerfTimer(); + const file = await read(fp); + + // strip leading and trailing whitespace + file.value = file.value.toString().trim(); + + // Text -> Text transforms + for (const plugin of cfg.plugins.transformers.filter((p) => p.textTransform)) { + file.value = plugin.textTransform!(ctx, file.value.toString()); + } + + // base data properties that plugins may use + file.data.filePath = file.path as FilePath; + file.data.relativePath = path.posix.relative(argv.directory, file.path) as FilePath; + file.data.slug = slugifyFilePath(file.data.relativePath); + + const ast = processor.parse(file); + const newAst = await processor.run(ast, file); + res.push([newAst, file]); + + if (argv.verbose) { + console.log(`[process] ${fp} -> ${file.data.slug} (${perf.timeSince()})`); + } + } catch (err) { + trace(`\nFailed to process \`${fp}\``, err as Error); + } + } + + return res; + }; } const clamp = (num: number, min: number, max: number) => - Math.min(Math.max(Math.round(num), min), max) + Math.min(Math.max(Math.round(num), min), max); export async function parseMarkdown(ctx: BuildCtx, fps: FilePath[]): Promise { - const { argv } = ctx - const perf = new PerfTimer() - const log = new QuartzLogger(argv.verbose) - - // rough heuristics: 128 gives enough time for v8 to JIT and optimize parsing code paths - const CHUNK_SIZE = 128 - const concurrency = ctx.argv.concurrency ?? clamp(fps.length / CHUNK_SIZE, 1, 4) - - let res: ProcessedContent[] = [] - log.start(`Parsing input files using ${concurrency} threads`) - if (concurrency === 1) { - try { - const processor = createProcessor(ctx) - const parse = createFileParser(ctx, fps) - res = await parse(processor) - } catch (error) { - log.end() - throw error - } - } else { - await transpileWorkerScript() - const pool = workerpool.pool("./quartz/bootstrap-worker.mjs", { - minWorkers: "max", - maxWorkers: concurrency, - workerType: "thread", - }) - - const childPromises: WorkerPromise[] = [] - for (const chunk of chunks(fps, CHUNK_SIZE)) { - childPromises.push(pool.exec("parseFiles", [argv, chunk, ctx.allSlugs])) - } - - const results: ProcessedContent[][] = await WorkerPromise.all(childPromises).catch((err) => { - const errString = err.toString().slice("Error:".length) - console.error(errString) - process.exit(1) - }) - res = results.flat() - await pool.terminate() - } - - log.end(`Parsed ${res.length} Markdown files in ${perf.timeSince()}`) - return res + const { argv } = ctx; + const perf = new PerfTimer(); + const log = new QuartzLogger(argv.verbose); + + // rough heuristics: 128 gives enough time for v8 to JIT and optimize parsing code paths + const CHUNK_SIZE = 128; + const concurrency = ctx.argv.concurrency ?? clamp(fps.length / CHUNK_SIZE, 1, 4); + + let res: ProcessedContent[] = []; + log.start(`Parsing input files using ${concurrency} threads`); + if (concurrency === 1) { + try { + const processor = createProcessor(ctx); + const parse = createFileParser(ctx, fps); + res = await parse(processor); + } catch (error) { + log.end(); + throw error; + } + } else { + await transpileWorkerScript(); + const pool = workerpool.pool("./quartz/bootstrap-worker.mjs", { + minWorkers: "max", + maxWorkers: concurrency, + workerType: "thread", + }); + + const childPromises: WorkerPromise[] = []; + for (const chunk of chunks(fps, CHUNK_SIZE)) { + childPromises.push(pool.exec("parseFiles", [argv, chunk, ctx.allSlugs])); + } + + const results: ProcessedContent[][] = await WorkerPromise.all(childPromises).catch((err) => { + const errString = err.toString().slice("Error:".length); + console.error(errString); + process.exit(1); + }); + res = results.flat(); + await pool.terminate(); + } + + log.end(`Parsed ${res.length} Markdown files in ${perf.timeSince()}`); + return res; } diff --git a/quartz/util/ctx.ts b/quartz/util/ctx.ts index e056114184dc0..2beae5b631b89 100644 --- a/quartz/util/ctx.ts +++ b/quartz/util/ctx.ts @@ -1,5 +1,5 @@ -import { QuartzConfig } from "../cfg" -import { FullSlug } from "./path" +import { QuartzConfig } from "../cfg"; +import { FullSlug } from "./path"; export interface Argv { directory: string diff --git a/quartz/util/escape.ts b/quartz/util/escape.ts index 197558c7dec75..91447f91e248f 100644 --- a/quartz/util/escape.ts +++ b/quartz/util/escape.ts @@ -1,8 +1,8 @@ export const escapeHTML = (unsafe: string) => { - return unsafe - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'") -} + return unsafe + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("\"", """) + .replaceAll("'", "'"); +}; diff --git a/quartz/util/glob.ts b/quartz/util/glob.ts index 7a711600745a7..3bb1e7cc58b13 100644 --- a/quartz/util/glob.ts +++ b/quartz/util/glob.ts @@ -1,22 +1,23 @@ -import path from "path" -import { FilePath } from "./path" -import { globby } from "globby" +import { globby } from "globby"; +import path from "path"; + +import { FilePath } from "./path"; export function toPosixPath(fp: string): string { - return fp.split(path.sep).join("/") + return fp.split(path.sep).join("/"); } export async function glob( - pattern: string, - cwd: string, - ignorePatterns: string[], + pattern: string, + cwd: string, + ignorePatterns: string[], ): Promise { - const fps = ( - await globby(pattern, { - cwd, - ignore: ignorePatterns, - gitignore: true, - }) - ).map(toPosixPath) - return fps as FilePath[] + const fps = ( + await globby(pattern, { + cwd, + ignore: ignorePatterns, + gitignore: true, + }) + ).map(toPosixPath); + return fps as FilePath[]; } diff --git a/quartz/util/lang.ts b/quartz/util/lang.ts index 6fb046996da25..a0c288181a499 100644 --- a/quartz/util/lang.ts +++ b/quartz/util/lang.ts @@ -1,13 +1,13 @@ export function capitalize(s: string): string { - return s.substring(0, 1).toUpperCase() + s.substring(1) + return s.substring(0, 1).toUpperCase() + s.substring(1); } export function classNames( - displayClass?: "mobile-only" | "desktop-only", - ...classes: string[] + displayClass?: "mobile-only" | "desktop-only", + ...classes: string[] ): string { - if (displayClass) { - classes.push(displayClass) - } - return classes.join(" ") + if (displayClass) { + classes.push(displayClass); + } + return classes.join(" "); } diff --git a/quartz/util/log.ts b/quartz/util/log.ts index 773945c974f6b..13be42f2408b9 100644 --- a/quartz/util/log.ts +++ b/quartz/util/log.ts @@ -1,28 +1,28 @@ -import { Spinner } from "cli-spinner" +import { Spinner } from "cli-spinner"; export class QuartzLogger { - verbose: boolean - spinner: Spinner | undefined - constructor(verbose: boolean) { - this.verbose = verbose - } + verbose: boolean; + spinner: Spinner | undefined; + constructor(verbose: boolean) { + this.verbose = verbose; + } - start(text: string) { - if (this.verbose) { - console.log(text) - } else { - this.spinner = new Spinner(`%s ${text}`) - this.spinner.setSpinnerString(18) - this.spinner.start() - } - } + start(text: string) { + if (this.verbose) { + console.log(text); + } else { + this.spinner = new Spinner(`%s ${text}`); + this.spinner.setSpinnerString(18); + this.spinner.start(); + } + } - end(text?: string) { - if (!this.verbose) { - this.spinner!.stop(true) - } - if (text) { - console.log(text) - } - } + end(text?: string) { + if (!this.verbose) { + this.spinner!.stop(true); + } + if (text) { + console.log(text); + } + } } diff --git a/quartz/util/path.test.ts b/quartz/util/path.test.ts index 7e9c4c84a32ac..f5c9dbc4f7cfc 100644 --- a/quartz/util/path.test.ts +++ b/quartz/util/path.test.ts @@ -1,282 +1,283 @@ -import test, { describe } from "node:test" -import * as path from "./path" -import assert from "node:assert" -import { FullSlug, TransformOptions } from "./path" +import assert from "node:assert"; +import test, { describe } from "node:test"; + +import * as path from "./path"; +import { FullSlug, TransformOptions } from "./path"; describe("typeguards", () => { - test("isSimpleSlug", () => { - assert(path.isSimpleSlug("")) - assert(path.isSimpleSlug("abc")) - assert(path.isSimpleSlug("abc/")) - assert(path.isSimpleSlug("notindex")) - assert(path.isSimpleSlug("notindex/def")) + test("isSimpleSlug", () => { + assert(path.isSimpleSlug("")); + assert(path.isSimpleSlug("abc")); + assert(path.isSimpleSlug("abc/")); + assert(path.isSimpleSlug("notindex")); + assert(path.isSimpleSlug("notindex/def")); - assert(!path.isSimpleSlug("//")) - assert(!path.isSimpleSlug("index")) - assert(!path.isSimpleSlug("https://example.com")) - assert(!path.isSimpleSlug("/abc")) - assert(!path.isSimpleSlug("abc/index")) - assert(!path.isSimpleSlug("abc#anchor")) - assert(!path.isSimpleSlug("abc?query=1")) - assert(!path.isSimpleSlug("index.md")) - assert(!path.isSimpleSlug("index.html")) - }) + assert(!path.isSimpleSlug("//")); + assert(!path.isSimpleSlug("index")); + assert(!path.isSimpleSlug("https://example.com")); + assert(!path.isSimpleSlug("/abc")); + assert(!path.isSimpleSlug("abc/index")); + assert(!path.isSimpleSlug("abc#anchor")); + assert(!path.isSimpleSlug("abc?query=1")); + assert(!path.isSimpleSlug("index.md")); + assert(!path.isSimpleSlug("index.html")); + }); - test("isRelativeURL", () => { - assert(path.isRelativeURL(".")) - assert(path.isRelativeURL("..")) - assert(path.isRelativeURL("./abc/def")) - assert(path.isRelativeURL("./abc/def#an-anchor")) - assert(path.isRelativeURL("./abc/def?query=1#an-anchor")) - assert(path.isRelativeURL("../abc/def")) - assert(path.isRelativeURL("./abc/def.pdf")) + test("isRelativeURL", () => { + assert(path.isRelativeURL(".")); + assert(path.isRelativeURL("..")); + assert(path.isRelativeURL("./abc/def")); + assert(path.isRelativeURL("./abc/def#an-anchor")); + assert(path.isRelativeURL("./abc/def?query=1#an-anchor")); + assert(path.isRelativeURL("../abc/def")); + assert(path.isRelativeURL("./abc/def.pdf")); - assert(!path.isRelativeURL("abc")) - assert(!path.isRelativeURL("/abc/def")) - assert(!path.isRelativeURL("")) - assert(!path.isRelativeURL("./abc/def.html")) - assert(!path.isRelativeURL("./abc/def.md")) - }) + assert(!path.isRelativeURL("abc")); + assert(!path.isRelativeURL("/abc/def")); + assert(!path.isRelativeURL("")); + assert(!path.isRelativeURL("./abc/def.html")); + assert(!path.isRelativeURL("./abc/def.md")); + }); - test("isFullSlug", () => { - assert(path.isFullSlug("index")) - assert(path.isFullSlug("abc/def")) - assert(path.isFullSlug("html.energy")) - assert(path.isFullSlug("test.pdf")) + test("isFullSlug", () => { + assert(path.isFullSlug("index")); + assert(path.isFullSlug("abc/def")); + assert(path.isFullSlug("html.energy")); + assert(path.isFullSlug("test.pdf")); - assert(!path.isFullSlug(".")) - assert(!path.isFullSlug("./abc/def")) - assert(!path.isFullSlug("../abc/def")) - assert(!path.isFullSlug("abc/def#anchor")) - assert(!path.isFullSlug("abc/def?query=1")) - assert(!path.isFullSlug("note with spaces")) - }) + assert(!path.isFullSlug(".")); + assert(!path.isFullSlug("./abc/def")); + assert(!path.isFullSlug("../abc/def")); + assert(!path.isFullSlug("abc/def#anchor")); + assert(!path.isFullSlug("abc/def?query=1")); + assert(!path.isFullSlug("note with spaces")); + }); - test("isFilePath", () => { - assert(path.isFilePath("content/index.md")) - assert(path.isFilePath("content/test.png")) - assert(!path.isFilePath("../test.pdf")) - assert(!path.isFilePath("content/test")) - assert(!path.isFilePath("./content/test")) - }) -}) + test("isFilePath", () => { + assert(path.isFilePath("content/index.md")); + assert(path.isFilePath("content/test.png")); + assert(!path.isFilePath("../test.pdf")); + assert(!path.isFilePath("content/test")); + assert(!path.isFilePath("./content/test")); + }); +}); describe("transforms", () => { - function asserts( - pairs: [string, string][], - transform: (inp: Inp) => Out, - checkPre: (x: any) => x is Inp, - checkPost: (x: any) => x is Out, - ) { - for (const [inp, expected] of pairs) { - assert(checkPre(inp), `${inp} wasn't the expected input type`) - const actual = transform(inp) - assert.strictEqual( - actual, - expected, - `after transforming ${inp}, '${actual}' was not '${expected}'`, - ) - assert(checkPost(actual), `${actual} wasn't the expected output type`) - } - } + function asserts( + pairs: [string, string][], + transform: (inp: Inp) => Out, + checkPre: (x: any) => x is Inp, + checkPost: (x: any) => x is Out, + ) { + for (const [inp, expected] of pairs) { + assert(checkPre(inp), `${inp} wasn't the expected input type`); + const actual = transform(inp); + assert.strictEqual( + actual, + expected, + `after transforming ${inp}, '${actual}' was not '${expected}'`, + ); + assert(checkPost(actual), `${actual} wasn't the expected output type`); + } + } - test("simplifySlug", () => { - asserts( - [ - ["index", "/"], - ["abc", "abc"], - ["abc/index", "abc/"], - ["abc/def", "abc/def"], - ], - path.simplifySlug, - path.isFullSlug, - path.isSimpleSlug, - ) - }) + test("simplifySlug", () => { + asserts( + [ + ["index", "/"], + ["abc", "abc"], + ["abc/index", "abc/"], + ["abc/def", "abc/def"], + ], + path.simplifySlug, + path.isFullSlug, + path.isSimpleSlug, + ); + }); - test("slugifyFilePath", () => { - asserts( - [ - ["content/index.md", "content/index"], - ["content/index.html", "content/index"], - ["content/_index.md", "content/index"], - ["/content/index.md", "content/index"], - ["content/cool.png", "content/cool.png"], - ["index.md", "index"], - ["test.mp4", "test.mp4"], - ["note with spaces.md", "note-with-spaces"], - ["notes.with.dots.md", "notes.with.dots"], - ["test/special chars?.md", "test/special-chars"], - ["test/special chars #3.md", "test/special-chars-3"], - ["cool/what about r&d?.md", "cool/what-about-r-and-d"], - ], - path.slugifyFilePath, - path.isFilePath, - path.isFullSlug, - ) - }) + test("slugifyFilePath", () => { + asserts( + [ + ["content/index.md", "content/index"], + ["content/index.html", "content/index"], + ["content/_index.md", "content/index"], + ["/content/index.md", "content/index"], + ["content/cool.png", "content/cool.png"], + ["index.md", "index"], + ["test.mp4", "test.mp4"], + ["note with spaces.md", "note-with-spaces"], + ["notes.with.dots.md", "notes.with.dots"], + ["test/special chars?.md", "test/special-chars"], + ["test/special chars #3.md", "test/special-chars-3"], + ["cool/what about r&d?.md", "cool/what-about-r-and-d"], + ], + path.slugifyFilePath, + path.isFilePath, + path.isFullSlug, + ); + }); - test("transformInternalLink", () => { - asserts( - [ - ["", "."], - [".", "."], - ["./", "./"], - ["./index", "./"], - ["./index#abc", "./#abc"], - ["./index.html", "./"], - ["./index.md", "./"], - ["./index.css", "./index.css"], - ["content", "./content"], - ["content/test.md", "./content/test"], - ["content/test.pdf", "./content/test.pdf"], - ["./content/test.md", "./content/test"], - ["../content/test.md", "../content/test"], - ["tags/", "./tags/"], - ["/tags/", "./tags/"], - ["content/with spaces", "./content/with-spaces"], - ["content/with spaces/index", "./content/with-spaces/"], - ["content/with spaces#and Anchor!", "./content/with-spaces#and-anchor"], - ], - path.transformInternalLink, - (_x: string): _x is string => true, - path.isRelativeURL, - ) - }) + test("transformInternalLink", () => { + asserts( + [ + ["", "."], + [".", "."], + ["./", "./"], + ["./index", "./"], + ["./index#abc", "./#abc"], + ["./index.html", "./"], + ["./index.md", "./"], + ["./index.css", "./index.css"], + ["content", "./content"], + ["content/test.md", "./content/test"], + ["content/test.pdf", "./content/test.pdf"], + ["./content/test.md", "./content/test"], + ["../content/test.md", "../content/test"], + ["tags/", "./tags/"], + ["/tags/", "./tags/"], + ["content/with spaces", "./content/with-spaces"], + ["content/with spaces/index", "./content/with-spaces/"], + ["content/with spaces#and Anchor!", "./content/with-spaces#and-anchor"], + ], + path.transformInternalLink, + (_x: string): _x is string => true, + path.isRelativeURL, + ); + }); - test("pathToRoot", () => { - asserts( - [ - ["index", "."], - ["abc", "."], - ["abc/def", ".."], - ["abc/def/ghi", "../.."], - ["abc/def/index", "../.."], - ], - path.pathToRoot, - path.isFullSlug, - path.isRelativeURL, - ) - }) -}) + test("pathToRoot", () => { + asserts( + [ + ["index", "."], + ["abc", "."], + ["abc/def", ".."], + ["abc/def/ghi", "../.."], + ["abc/def/index", "../.."], + ], + path.pathToRoot, + path.isFullSlug, + path.isRelativeURL, + ); + }); +}); describe("link strategies", () => { - const allSlugs = [ - "a/b/c", - "a/b/d", - "a/b/index", - "e/f", - "e/g/h", - "index", - "a/test.png", - ] as FullSlug[] + const allSlugs = [ + "a/b/c", + "a/b/d", + "a/b/index", + "e/f", + "e/g/h", + "index", + "a/test.png", + ] as FullSlug[]; - describe("absolute", () => { - const opts: TransformOptions = { - strategy: "absolute", - allSlugs, - } + describe("absolute", () => { + const opts: TransformOptions = { + strategy: "absolute", + allSlugs, + }; - test("from a/b/c", () => { - const cur = "a/b/c" as FullSlug - assert.strictEqual(path.transformLink(cur, "a/b/d", opts), "../../a/b/d") - assert.strictEqual(path.transformLink(cur, "a/b/index", opts), "../../a/b/") - assert.strictEqual(path.transformLink(cur, "e/f", opts), "../../e/f") - assert.strictEqual(path.transformLink(cur, "e/g/h", opts), "../../e/g/h") - assert.strictEqual(path.transformLink(cur, "index", opts), "../../") - assert.strictEqual(path.transformLink(cur, "index.png", opts), "../../index.png") - assert.strictEqual(path.transformLink(cur, "index#abc", opts), "../../#abc") - assert.strictEqual(path.transformLink(cur, "tag/test", opts), "../../tag/test") - assert.strictEqual(path.transformLink(cur, "a/b/c#test", opts), "../../a/b/c#test") - assert.strictEqual(path.transformLink(cur, "a/test.png", opts), "../../a/test.png") - }) + test("from a/b/c", () => { + const cur = "a/b/c" as FullSlug; + assert.strictEqual(path.transformLink(cur, "a/b/d", opts), "../../a/b/d"); + assert.strictEqual(path.transformLink(cur, "a/b/index", opts), "../../a/b/"); + assert.strictEqual(path.transformLink(cur, "e/f", opts), "../../e/f"); + assert.strictEqual(path.transformLink(cur, "e/g/h", opts), "../../e/g/h"); + assert.strictEqual(path.transformLink(cur, "index", opts), "../../"); + assert.strictEqual(path.transformLink(cur, "index.png", opts), "../../index.png"); + assert.strictEqual(path.transformLink(cur, "index#abc", opts), "../../#abc"); + assert.strictEqual(path.transformLink(cur, "tag/test", opts), "../../tag/test"); + assert.strictEqual(path.transformLink(cur, "a/b/c#test", opts), "../../a/b/c#test"); + assert.strictEqual(path.transformLink(cur, "a/test.png", opts), "../../a/test.png"); + }); - test("from a/b/index", () => { - const cur = "a/b/index" as FullSlug - assert.strictEqual(path.transformLink(cur, "a/b/d", opts), "../../a/b/d") - assert.strictEqual(path.transformLink(cur, "a/b", opts), "../../a/b") - assert.strictEqual(path.transformLink(cur, "index", opts), "../../") - }) + test("from a/b/index", () => { + const cur = "a/b/index" as FullSlug; + assert.strictEqual(path.transformLink(cur, "a/b/d", opts), "../../a/b/d"); + assert.strictEqual(path.transformLink(cur, "a/b", opts), "../../a/b"); + assert.strictEqual(path.transformLink(cur, "index", opts), "../../"); + }); - test("from index", () => { - const cur = "index" as FullSlug - assert.strictEqual(path.transformLink(cur, "index", opts), "./") - assert.strictEqual(path.transformLink(cur, "a/b/c", opts), "./a/b/c") - assert.strictEqual(path.transformLink(cur, "a/b/index", opts), "./a/b/") - }) - }) + test("from index", () => { + const cur = "index" as FullSlug; + assert.strictEqual(path.transformLink(cur, "index", opts), "./"); + assert.strictEqual(path.transformLink(cur, "a/b/c", opts), "./a/b/c"); + assert.strictEqual(path.transformLink(cur, "a/b/index", opts), "./a/b/"); + }); + }); - describe("shortest", () => { - const opts: TransformOptions = { - strategy: "shortest", - allSlugs, - } + describe("shortest", () => { + const opts: TransformOptions = { + strategy: "shortest", + allSlugs, + }; - test("from a/b/c", () => { - const cur = "a/b/c" as FullSlug - assert.strictEqual(path.transformLink(cur, "d", opts), "../../a/b/d") - assert.strictEqual(path.transformLink(cur, "h", opts), "../../e/g/h") - assert.strictEqual(path.transformLink(cur, "a/b/index", opts), "../../a/b/") - assert.strictEqual(path.transformLink(cur, "a/b/index.png", opts), "../../a/b/index.png") - assert.strictEqual(path.transformLink(cur, "a/b/index#abc", opts), "../../a/b/#abc") - assert.strictEqual(path.transformLink(cur, "index", opts), "../../") - assert.strictEqual(path.transformLink(cur, "index.png", opts), "../../index.png") - assert.strictEqual(path.transformLink(cur, "test.png", opts), "../../a/test.png") - assert.strictEqual(path.transformLink(cur, "index#abc", opts), "../../#abc") - }) + test("from a/b/c", () => { + const cur = "a/b/c" as FullSlug; + assert.strictEqual(path.transformLink(cur, "d", opts), "../../a/b/d"); + assert.strictEqual(path.transformLink(cur, "h", opts), "../../e/g/h"); + assert.strictEqual(path.transformLink(cur, "a/b/index", opts), "../../a/b/"); + assert.strictEqual(path.transformLink(cur, "a/b/index.png", opts), "../../a/b/index.png"); + assert.strictEqual(path.transformLink(cur, "a/b/index#abc", opts), "../../a/b/#abc"); + assert.strictEqual(path.transformLink(cur, "index", opts), "../../"); + assert.strictEqual(path.transformLink(cur, "index.png", opts), "../../index.png"); + assert.strictEqual(path.transformLink(cur, "test.png", opts), "../../a/test.png"); + assert.strictEqual(path.transformLink(cur, "index#abc", opts), "../../#abc"); + }); - test("from a/b/index", () => { - const cur = "a/b/index" as FullSlug - assert.strictEqual(path.transformLink(cur, "d", opts), "../../a/b/d") - assert.strictEqual(path.transformLink(cur, "h", opts), "../../e/g/h") - assert.strictEqual(path.transformLink(cur, "a/b/index", opts), "../../a/b/") - assert.strictEqual(path.transformLink(cur, "index", opts), "../../") - }) + test("from a/b/index", () => { + const cur = "a/b/index" as FullSlug; + assert.strictEqual(path.transformLink(cur, "d", opts), "../../a/b/d"); + assert.strictEqual(path.transformLink(cur, "h", opts), "../../e/g/h"); + assert.strictEqual(path.transformLink(cur, "a/b/index", opts), "../../a/b/"); + assert.strictEqual(path.transformLink(cur, "index", opts), "../../"); + }); - test("from index", () => { - const cur = "index" as FullSlug - assert.strictEqual(path.transformLink(cur, "d", opts), "./a/b/d") - assert.strictEqual(path.transformLink(cur, "h", opts), "./e/g/h") - assert.strictEqual(path.transformLink(cur, "a/b/index", opts), "./a/b/") - assert.strictEqual(path.transformLink(cur, "index", opts), "./") - }) - }) + test("from index", () => { + const cur = "index" as FullSlug; + assert.strictEqual(path.transformLink(cur, "d", opts), "./a/b/d"); + assert.strictEqual(path.transformLink(cur, "h", opts), "./e/g/h"); + assert.strictEqual(path.transformLink(cur, "a/b/index", opts), "./a/b/"); + assert.strictEqual(path.transformLink(cur, "index", opts), "./"); + }); + }); - describe("relative", () => { - const opts: TransformOptions = { - strategy: "relative", - allSlugs, - } + describe("relative", () => { + const opts: TransformOptions = { + strategy: "relative", + allSlugs, + }; - test("from a/b/c", () => { - const cur = "a/b/c" as FullSlug - assert.strictEqual(path.transformLink(cur, "d", opts), "./d") - assert.strictEqual(path.transformLink(cur, "index", opts), "./") - assert.strictEqual(path.transformLink(cur, "../../../index", opts), "../../../") - assert.strictEqual(path.transformLink(cur, "../../../index.png", opts), "../../../index.png") - assert.strictEqual(path.transformLink(cur, "../../../index#abc", opts), "../../../#abc") - assert.strictEqual(path.transformLink(cur, "../../../", opts), "../../../") - assert.strictEqual( - path.transformLink(cur, "../../../a/test.png", opts), - "../../../a/test.png", - ) - assert.strictEqual(path.transformLink(cur, "../../../e/g/h", opts), "../../../e/g/h") - assert.strictEqual(path.transformLink(cur, "../../../e/g/h", opts), "../../../e/g/h") - assert.strictEqual(path.transformLink(cur, "../../../e/g/h#abc", opts), "../../../e/g/h#abc") - }) + test("from a/b/c", () => { + const cur = "a/b/c" as FullSlug; + assert.strictEqual(path.transformLink(cur, "d", opts), "./d"); + assert.strictEqual(path.transformLink(cur, "index", opts), "./"); + assert.strictEqual(path.transformLink(cur, "../../../index", opts), "../../../"); + assert.strictEqual(path.transformLink(cur, "../../../index.png", opts), "../../../index.png"); + assert.strictEqual(path.transformLink(cur, "../../../index#abc", opts), "../../../#abc"); + assert.strictEqual(path.transformLink(cur, "../../../", opts), "../../../"); + assert.strictEqual( + path.transformLink(cur, "../../../a/test.png", opts), + "../../../a/test.png", + ); + assert.strictEqual(path.transformLink(cur, "../../../e/g/h", opts), "../../../e/g/h"); + assert.strictEqual(path.transformLink(cur, "../../../e/g/h", opts), "../../../e/g/h"); + assert.strictEqual(path.transformLink(cur, "../../../e/g/h#abc", opts), "../../../e/g/h#abc"); + }); - test("from a/b/index", () => { - const cur = "a/b/index" as FullSlug - assert.strictEqual(path.transformLink(cur, "../../index", opts), "../../") - assert.strictEqual(path.transformLink(cur, "../../", opts), "../../") - assert.strictEqual(path.transformLink(cur, "../../e/g/h", opts), "../../e/g/h") - assert.strictEqual(path.transformLink(cur, "c", opts), "./c") - }) + test("from a/b/index", () => { + const cur = "a/b/index" as FullSlug; + assert.strictEqual(path.transformLink(cur, "../../index", opts), "../../"); + assert.strictEqual(path.transformLink(cur, "../../", opts), "../../"); + assert.strictEqual(path.transformLink(cur, "../../e/g/h", opts), "../../e/g/h"); + assert.strictEqual(path.transformLink(cur, "c", opts), "./c"); + }); - test("from index", () => { - const cur = "index" as FullSlug - assert.strictEqual(path.transformLink(cur, "e/g/h", opts), "./e/g/h") - assert.strictEqual(path.transformLink(cur, "a/b/index", opts), "./a/b/") - }) - }) -}) + test("from index", () => { + const cur = "index" as FullSlug; + assert.strictEqual(path.transformLink(cur, "e/g/h", opts), "./e/g/h"); + assert.strictEqual(path.transformLink(cur, "a/b/index", opts), "./a/b/"); + }); + }); +}); diff --git a/quartz/util/path.ts b/quartz/util/path.ts index 094e682863672..697b49b2a2b3e 100644 --- a/quartz/util/path.ts +++ b/quartz/util/path.ts @@ -1,12 +1,12 @@ -import { slug as slugAnchor } from "github-slugger" -import type { Element as HastElement } from "hast" -import rfdc from "rfdc" +import { slug as slugAnchor } from "github-slugger"; +import type { Element as HastElement } from "hast"; +import rfdc from "rfdc"; -export const clone = rfdc() +export const clone = rfdc(); // this file must be isomorphic so it can't use node libs (e.g. path) -export const QUARTZ = "quartz" +export const QUARTZ = "quartz"; /// Utility type to simulate nominal types in TypeScript type SlugLike = string & { __brand: T } @@ -14,185 +14,185 @@ type SlugLike = string & { __brand: T } /** Cannot be relative and must have a file extension. */ export type FilePath = SlugLike<"filepath"> export function isFilePath(s: string): s is FilePath { - const validStart = !s.startsWith(".") - return validStart && _hasFileExtension(s) + const validStart = !s.startsWith("."); + return validStart && _hasFileExtension(s); } /** Cannot be relative and may not have leading or trailing slashes. It can have `index` as it's last segment. Use this wherever possible is it's the most 'general' interpretation of a slug. */ export type FullSlug = SlugLike<"full"> export function isFullSlug(s: string): s is FullSlug { - const validStart = !(s.startsWith(".") || s.startsWith("/")) - const validEnding = !s.endsWith("/") - return validStart && validEnding && !containsForbiddenCharacters(s) + const validStart = !(s.startsWith(".") || s.startsWith("/")); + const validEnding = !s.endsWith("/"); + return validStart && validEnding && !containsForbiddenCharacters(s); } /** Shouldn't be a relative path and shouldn't have `/index` as an ending or a file extension. It _can_ however have a trailing slash to indicate a folder path. */ export type SimpleSlug = SlugLike<"simple"> export function isSimpleSlug(s: string): s is SimpleSlug { - const validStart = !(s.startsWith(".") || (s.length > 1 && s.startsWith("/"))) - const validEnding = !endsWith(s, "index") - return validStart && !containsForbiddenCharacters(s) && validEnding && !_hasFileExtension(s) + const validStart = !(s.startsWith(".") || (s.length > 1 && s.startsWith("/"))); + const validEnding = !endsWith(s, "index"); + return validStart && !containsForbiddenCharacters(s) && validEnding && !_hasFileExtension(s); } /** Can be found on `href`s but can also be constructed for client-side navigation (e.g. search and graph) */ export type RelativeURL = SlugLike<"relative"> export function isRelativeURL(s: string): s is RelativeURL { - const validStart = /^\.{1,2}/.test(s) - const validEnding = !endsWith(s, "index") - return validStart && validEnding && ![".md", ".html"].includes(_getFileExtension(s) ?? "") + const validStart = /^\.{1,2}/.test(s); + const validEnding = !endsWith(s, "index"); + return validStart && validEnding && ![".md", ".html"].includes(_getFileExtension(s) ?? ""); } export function getFullSlug(window: Window): FullSlug { - const res = window.document.body.dataset.slug! as FullSlug - return res + const res = window.document.body.dataset.slug! as FullSlug; + return res; } export function sluggify(s: string): string { - return s - .split("/") - .map((segment) => - segment - .replace(/\s/g, "-") - .replace(/&/g, "-and-") - .replace(/%/g, "-percent") - .replace(/\?/g, "") - .replace(/#/g, ""), - ) - .join("/") // always use / as sep - .replace(/\/$/, "") + return s + .split("/") + .map((segment) => + segment + .replace(/\s/g, "-") + .replace(/&/g, "-and-") + .replace(/%/g, "-percent") + .replace(/\?/g, "") + .replace(/#/g, ""), + ) + .join("/") // always use / as sep + .replace(/\/$/, ""); } export function slugifyFilePath(fp: FilePath, excludeExt?: boolean): FullSlug { - fp = stripSlashes(fp) as FilePath - let ext = _getFileExtension(fp) - const withoutFileExt = fp.replace(new RegExp(ext + "$"), "") - if (excludeExt || [".md", ".html", undefined].includes(ext)) { - ext = "" - } + fp = stripSlashes(fp) as FilePath; + let ext = _getFileExtension(fp); + const withoutFileExt = fp.replace(new RegExp(ext + "$"), ""); + if (excludeExt || [".md", ".html", undefined].includes(ext)) { + ext = ""; + } - let slug = sluggify(withoutFileExt) + let slug = sluggify(withoutFileExt); - // treat _index as index - if (endsWith(slug, "_index")) { - slug = slug.replace(/_index$/, "index") - } + // treat _index as index + if (endsWith(slug, "_index")) { + slug = slug.replace(/_index$/, "index"); + } - return (slug + ext) as FullSlug + return (slug + ext) as FullSlug; } export function simplifySlug(fp: FullSlug): SimpleSlug { - const res = stripSlashes(trimSuffix(fp, "index"), true) - return (res.length === 0 ? "/" : res) as SimpleSlug + const res = stripSlashes(trimSuffix(fp, "index"), true); + return (res.length === 0 ? "/" : res) as SimpleSlug; } export function transformInternalLink(link: string): RelativeURL { - let [fplike, anchor] = splitAnchor(decodeURI(link)) - - const folderPath = isFolderPath(fplike) - let segments = fplike.split("/").filter((x) => x.length > 0) - let prefix = segments.filter(isRelativeSegment).join("/") - let fp = segments.filter((seg) => !isRelativeSegment(seg) && seg !== "").join("/") - - // manually add ext here as we want to not strip 'index' if it has an extension - const simpleSlug = simplifySlug(slugifyFilePath(fp as FilePath)) - const joined = joinSegments(stripSlashes(prefix), stripSlashes(simpleSlug)) - const trail = folderPath ? "/" : "" - const res = (_addRelativeToStart(joined) + trail + anchor) as RelativeURL - return res + const [fplike, anchor] = splitAnchor(decodeURI(link)); + + const folderPath = isFolderPath(fplike); + const segments = fplike.split("/").filter((x) => x.length > 0); + const prefix = segments.filter(isRelativeSegment).join("/"); + const fp = segments.filter((seg) => !isRelativeSegment(seg) && seg !== "").join("/"); + + // manually add ext here as we want to not strip 'index' if it has an extension + const simpleSlug = simplifySlug(slugifyFilePath(fp as FilePath)); + const joined = joinSegments(stripSlashes(prefix), stripSlashes(simpleSlug)); + const trail = folderPath ? "/" : ""; + const res = (_addRelativeToStart(joined) + trail + anchor) as RelativeURL; + return res; } // from micromorph/src/utils.ts // https://github.com/natemoo-re/micromorph/blob/main/src/utils.ts#L5 const _rebaseHtmlElement = (el: Element, attr: string, newBase: string | URL) => { - const rebased = new URL(el.getAttribute(attr)!, newBase) - el.setAttribute(attr, rebased.pathname + rebased.hash) -} + const rebased = new URL(el.getAttribute(attr)!, newBase); + el.setAttribute(attr, rebased.pathname + rebased.hash); +}; export function normalizeRelativeURLs(el: Element | Document, destination: string | URL) { - el.querySelectorAll('[href^="./"], [href^="../"]').forEach((item) => - _rebaseHtmlElement(item, "href", destination), - ) - el.querySelectorAll('[src^="./"], [src^="../"]').forEach((item) => - _rebaseHtmlElement(item, "src", destination), - ) + el.querySelectorAll("[href^=\"./\"], [href^=\"../\"]").forEach((item) => + _rebaseHtmlElement(item, "href", destination), + ); + el.querySelectorAll("[src^=\"./\"], [src^=\"../\"]").forEach((item) => + _rebaseHtmlElement(item, "src", destination), + ); } const _rebaseHastElement = ( - el: HastElement, - attr: string, - curBase: FullSlug, - newBase: FullSlug, + el: HastElement, + attr: string, + curBase: FullSlug, + newBase: FullSlug, ) => { - if (el.properties?.[attr]) { - if (!isRelativeURL(String(el.properties[attr]))) { - return - } - - const rel = joinSegments(resolveRelative(curBase, newBase), "..", el.properties[attr] as string) - el.properties[attr] = rel - } -} + if (el.properties?.[attr]) { + if (!isRelativeURL(String(el.properties[attr]))) { + return; + } + + const rel = joinSegments(resolveRelative(curBase, newBase), "..", el.properties[attr] as string); + el.properties[attr] = rel; + } +}; export function normalizeHastElement(rawEl: HastElement, curBase: FullSlug, newBase: FullSlug) { - const el = clone(rawEl) // clone so we dont modify the original page - _rebaseHastElement(el, "src", curBase, newBase) - _rebaseHastElement(el, "href", curBase, newBase) - if (el.children) { - el.children = el.children.map((child) => - normalizeHastElement(child as HastElement, curBase, newBase), - ) - } - - return el + const el = clone(rawEl); // clone so we dont modify the original page + _rebaseHastElement(el, "src", curBase, newBase); + _rebaseHastElement(el, "href", curBase, newBase); + if (el.children) { + el.children = el.children.map((child) => + normalizeHastElement(child as HastElement, curBase, newBase), + ); + } + + return el; } // resolve /a/b/c to ../.. export function pathToRoot(slug: FullSlug): RelativeURL { - let rootPath = slug - .split("/") - .filter((x) => x !== "") - .slice(0, -1) - .map((_) => "..") - .join("/") - - if (rootPath.length === 0) { - rootPath = "." - } - - return rootPath as RelativeURL + let rootPath = slug + .split("/") + .filter((x) => x !== "") + .slice(0, -1) + .map((_) => "..") + .join("/"); + + if (rootPath.length === 0) { + rootPath = "."; + } + + return rootPath as RelativeURL; } export function resolveRelative(current: FullSlug, target: FullSlug | SimpleSlug): RelativeURL { - const res = joinSegments(pathToRoot(current), simplifySlug(target as FullSlug)) as RelativeURL - return res + const res = joinSegments(pathToRoot(current), simplifySlug(target as FullSlug)) as RelativeURL; + return res; } export function splitAnchor(link: string): [string, string] { - let [fp, anchor] = link.split("#", 2) - anchor = anchor === undefined ? "" : "#" + slugAnchor(anchor) - return [fp, anchor] + let [fp, anchor] = link.split("#", 2); + anchor = anchor === undefined ? "" : "#" + slugAnchor(anchor); + return [fp, anchor]; } export function slugTag(tag: string) { - return tag - .split("/") - .map((tagSegment) => sluggify(tagSegment)) - .join("/") + return tag + .split("/") + .map((tagSegment) => sluggify(tagSegment)) + .join("/"); } export function joinSegments(...args: string[]): string { - return args - .filter((segment) => segment !== "") - .join("/") - .replace(/\/\/+/g, "/") + return args + .filter((segment) => segment !== "") + .join("/") + .replace(/\/\/+/g, "/"); } export function getAllSegmentPrefixes(tags: string): string[] { - const segments = tags.split("/") - const results: string[] = [] - for (let i = 0; i < segments.length; i++) { - results.push(segments.slice(0, i + 1).join("/")) - } - return results + const segments = tags.split("/"); + const results: string[] = []; + for (let i = 0; i < segments.length; i++) { + results.push(segments.slice(0, i + 1).join("/")); + } + return results; } export interface TransformOptions { @@ -201,92 +201,92 @@ export interface TransformOptions { } export function transformLink(src: FullSlug, target: string, opts: TransformOptions): RelativeURL { - let targetSlug = transformInternalLink(target) - - if (opts.strategy === "relative") { - return targetSlug as RelativeURL - } else { - const folderTail = isFolderPath(targetSlug) ? "/" : "" - const canonicalSlug = stripSlashes(targetSlug.slice(".".length)) - let [targetCanonical, targetAnchor] = splitAnchor(canonicalSlug) - - if (opts.strategy === "shortest") { - // if the file name is unique, then it's just the filename - const matchingFileNames = opts.allSlugs.filter((slug) => { - const parts = slug.split("/") - const fileName = parts.at(-1) - return targetCanonical === fileName - }) - - // only match, just use it - if (matchingFileNames.length === 1) { - const targetSlug = matchingFileNames[0] - return (resolveRelative(src, targetSlug) + targetAnchor) as RelativeURL - } - } - - // if it's not unique, then it's the absolute path from the vault root - return (joinSegments(pathToRoot(src), canonicalSlug) + folderTail) as RelativeURL - } + const targetSlug = transformInternalLink(target); + + if (opts.strategy === "relative") { + return targetSlug as RelativeURL; + } else { + const folderTail = isFolderPath(targetSlug) ? "/" : ""; + const canonicalSlug = stripSlashes(targetSlug.slice(".".length)); + const [targetCanonical, targetAnchor] = splitAnchor(canonicalSlug); + + if (opts.strategy === "shortest") { + // if the file name is unique, then it's just the filename + const matchingFileNames = opts.allSlugs.filter((slug) => { + const parts = slug.split("/"); + const fileName = parts.at(-1); + return targetCanonical === fileName; + }); + + // only match, just use it + if (matchingFileNames.length === 1) { + const targetSlug = matchingFileNames[0]; + return (resolveRelative(src, targetSlug) + targetAnchor) as RelativeURL; + } + } + + // if it's not unique, then it's the absolute path from the vault root + return (joinSegments(pathToRoot(src), canonicalSlug) + folderTail) as RelativeURL; + } } // path helpers function isFolderPath(fplike: string): boolean { - return ( - fplike.endsWith("/") || + return ( + fplike.endsWith("/") || endsWith(fplike, "index") || endsWith(fplike, "index.md") || endsWith(fplike, "index.html") - ) + ); } export function endsWith(s: string, suffix: string): boolean { - return s === suffix || s.endsWith("/" + suffix) + return s === suffix || s.endsWith("/" + suffix); } function trimSuffix(s: string, suffix: string): string { - if (endsWith(s, suffix)) { - s = s.slice(0, -suffix.length) - } - return s + if (endsWith(s, suffix)) { + s = s.slice(0, -suffix.length); + } + return s; } function containsForbiddenCharacters(s: string): boolean { - return s.includes(" ") || s.includes("#") || s.includes("?") || s.includes("&") + return s.includes(" ") || s.includes("#") || s.includes("?") || s.includes("&"); } function _hasFileExtension(s: string): boolean { - return _getFileExtension(s) !== undefined + return _getFileExtension(s) !== undefined; } function _getFileExtension(s: string): string | undefined { - return s.match(/\.[A-Za-z0-9]+$/)?.[0] + return s.match(/\.[A-Za-z0-9]+$/)?.[0]; } function isRelativeSegment(s: string): boolean { - return /^\.{0,2}$/.test(s) + return /^\.{0,2}$/.test(s); } export function stripSlashes(s: string, onlyStripPrefix?: boolean): string { - if (s.startsWith("/")) { - s = s.substring(1) - } + if (s.startsWith("/")) { + s = s.substring(1); + } - if (!onlyStripPrefix && s.endsWith("/")) { - s = s.slice(0, -1) - } + if (!onlyStripPrefix && s.endsWith("/")) { + s = s.slice(0, -1); + } - return s + return s; } function _addRelativeToStart(s: string): string { - if (s === "") { - s = "." - } + if (s === "") { + s = "."; + } - if (!s.startsWith(".")) { - s = joinSegments(".", s) - } + if (!s.startsWith(".")) { + s = joinSegments(".", s); + } - return s + return s; } diff --git a/quartz/util/perf.ts b/quartz/util/perf.ts index ba34ddb66dde9..859c3919a0c5b 100644 --- a/quartz/util/perf.ts +++ b/quartz/util/perf.ts @@ -1,19 +1,19 @@ -import chalk from "chalk" -import pretty from "pretty-time" +import chalk from "chalk"; +import pretty from "pretty-time"; export class PerfTimer { - evts: { [key: string]: [number, number] } + evts: { [key: string]: [number, number] }; - constructor() { - this.evts = {} - this.addEvent("start") - } + constructor() { + this.evts = {}; + this.addEvent("start"); + } - addEvent(evtName: string) { - this.evts[evtName] = process.hrtime() - } + addEvent(evtName: string) { + this.evts[evtName] = process.hrtime(); + } - timeSince(evtName?: string): string { - return chalk.yellow(pretty(process.hrtime(this.evts[evtName ?? "start"]))) - } + timeSince(evtName?: string): string { + return chalk.yellow(pretty(process.hrtime(this.evts[evtName ?? "start"]))); + } } diff --git a/quartz/util/sourcemap.ts b/quartz/util/sourcemap.ts index d3b9cf738c1ca..901f057e93bd2 100644 --- a/quartz/util/sourcemap.ts +++ b/quartz/util/sourcemap.ts @@ -1,18 +1,18 @@ -import fs from "fs" -import sourceMapSupport from "source-map-support" -import { fileURLToPath } from "url" +import fs from "fs"; +import sourceMapSupport from "source-map-support"; +import { fileURLToPath } from "url"; export const options: sourceMapSupport.Options = { - // source map hack to get around query param - // import cache busting - retrieveSourceMap(source) { - if (source.includes(".quartz-cache")) { - let realSource = fileURLToPath(source.split("?", 2)[0] + ".map") - return { - map: fs.readFileSync(realSource, "utf8"), - } - } else { - return null - } - }, -} + // source map hack to get around query param + // import cache busting + retrieveSourceMap(source) { + if (source.includes(".quartz-cache")) { + const realSource = fileURLToPath(source.split("?", 2)[0] + ".map"); + return { + map: fs.readFileSync(realSource, "utf8"), + }; + } else { + return null; + } + }, +}; diff --git a/quartz/util/theme.ts b/quartz/util/theme.ts index 49cc9cce8e02d..1c789ece1e90b 100644 --- a/quartz/util/theme.ts +++ b/quartz/util/theme.ts @@ -27,16 +27,16 @@ export interface Theme { export type ThemeKey = keyof Colors const DEFAULT_SANS_SERIF = - '-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif' -const DEFAULT_MONO = "ui-monospace, SFMono-Regular, SF Mono, Menlo, monospace" + "-apple-system, BlinkMacSystemFont, \"Segoe UI\", Helvetica, Arial, sans-serif"; +const DEFAULT_MONO = "ui-monospace, SFMono-Regular, SF Mono, Menlo, monospace"; export function googleFontHref(theme: Theme) { - const { code, header, body } = theme.typography - return `https://fonts.googleapis.com/css2?family=${code}&family=${header}:wght@400;700&family=${body}:ital,wght@0,400;0,600;1,400;1,600&display=swap` + const { code, header, body } = theme.typography; + return `https://fonts.googleapis.com/css2?family=${code}&family=${header}:wght@400;700&family=${body}:ital,wght@0,400;0,600;1,400;1,600&display=swap`; } export function joinStyles(theme: Theme, ...stylesheet: string[]) { - return ` + return ` ${stylesheet.join("\n\n")} :root { @@ -64,5 +64,5 @@ ${stylesheet.join("\n\n")} --tertiary: ${theme.colors.darkMode.tertiary}; --highlight: ${theme.colors.darkMode.highlight}; } -` +`; } diff --git a/quartz/util/trace.ts b/quartz/util/trace.ts index a33135d644c77..3f60dd5684841 100644 --- a/quartz/util/trace.ts +++ b/quartz/util/trace.ts @@ -1,43 +1,43 @@ -import chalk from "chalk" -import process from "process" -import { isMainThread } from "workerpool" +import chalk from "chalk"; +import process from "process"; +import { isMainThread } from "workerpool"; -const rootFile = /.*at file:/ +const rootFile = /.*at file:/; export function trace(msg: string, err: Error) { - let stack = err.stack ?? "" + const stack = err.stack ?? ""; - const lines: string[] = [] + const lines: string[] = []; - lines.push("") - lines.push( - "\n" + + lines.push(""); + lines.push( + "\n" + chalk.bgRed.black.bold(" ERROR ") + "\n\n" + chalk.red(` ${msg}`) + (err.message.length > 0 ? `: ${err.message}` : ""), - ) + ); - let reachedEndOfLegibleTrace = false - for (const line of stack.split("\n").slice(1)) { - if (reachedEndOfLegibleTrace) { - break - } + let reachedEndOfLegibleTrace = false; + for (const line of stack.split("\n").slice(1)) { + if (reachedEndOfLegibleTrace) { + break; + } - if (!line.includes("node_modules")) { - lines.push(` ${line}`) - if (rootFile.test(line)) { - reachedEndOfLegibleTrace = true - } - } - } + if (!line.includes("node_modules")) { + lines.push(` ${line}`); + if (rootFile.test(line)) { + reachedEndOfLegibleTrace = true; + } + } + } - const traceMsg = lines.join("\n") - if (!isMainThread) { - // gather lines and throw - throw new Error(traceMsg) - } else { - // print and exit - console.error(traceMsg) - process.exit(1) - } + const traceMsg = lines.join("\n"); + if (!isMainThread) { + // gather lines and throw + throw new Error(traceMsg); + } else { + // print and exit + console.error(traceMsg); + process.exit(1); + } } diff --git a/quartz/worker.ts b/quartz/worker.ts index b92bdac948897..db62c319dffcd 100644 --- a/quartz/worker.ts +++ b/quartz/worker.ts @@ -1,19 +1,21 @@ -import sourceMapSupport from "source-map-support" -sourceMapSupport.install(options) -import cfg from "../quartz.config" -import { Argv, BuildCtx } from "./util/ctx" -import { FilePath, FullSlug } from "./util/path" -import { createFileParser, createProcessor } from "./processors/parse" -import { options } from "./util/sourcemap" +import sourceMapSupport from "source-map-support"; + +import cfg from "../quartz.config"; +import { createFileParser, createProcessor } from "./processors/parse"; +import { Argv, BuildCtx } from "./util/ctx"; +import { FilePath, FullSlug } from "./util/path"; +import { options } from "./util/sourcemap"; + +sourceMapSupport.install(options); // only called from worker thread export async function parseFiles(argv: Argv, fps: FilePath[], allSlugs: FullSlug[]) { - const ctx: BuildCtx = { - cfg, - argv, - allSlugs, - } - const processor = createProcessor(ctx) - const parse = createFileParser(ctx, fps) - return parse(processor) + const ctx: BuildCtx = { + cfg, + argv, + allSlugs, + }; + const processor = createProcessor(ctx); + const parse = createFileParser(ctx, fps); + return parse(processor); }