Initial import

This commit is contained in:
Admin
2026-07-11 17:08:37 +00:00
commit 03e5f3452b
303 changed files with 43365 additions and 0 deletions
+284
View File
@@ -0,0 +1,284 @@
#!/usr/bin/env -S node --no-deprecation
const [major] = process.versions.node.split(".").map(Number)
if (major < 22) {
console.error(
`\nQuartz requires Node.js >= 22, but you are running Node.js ${process.version}.\n` +
`Please upgrade: https://nodejs.org/\n`,
)
process.exit(1)
}
import yargs from "yargs"
import { hideBin } from "yargs/helpers"
import {
handleBuild,
handleCreate,
handleUpgrade,
handleRestore,
handleSync,
} from "./cli/handlers.js"
import {
handlePluginInstallUnified,
handlePluginAdd,
handlePluginRemove,
handlePluginList,
handlePluginStatus,
handlePluginEnable,
handlePluginDisable,
handlePluginConfig,
handlePluginPrune,
} from "./cli/plugin-git-handlers.js"
import { CommonArgv, BuildArgv, CreateArgv, SyncArgv } from "./cli/args.js"
import { version } from "./cli/constants.js"
async function launchTui() {
const { join } = await import("path")
const { existsSync } = await import("fs")
const { spawn } = await import("child_process")
const tuiPath = join(process.cwd(), ".quartz", "plugins", "tui", "dist", "App.mjs")
if (!existsSync(tuiPath)) {
console.error(
"TUI plugin not installed. Install with:\n" +
" npx quartz plugin add github:quartz-community/tui\n",
)
process.exit(1)
}
// OpenTUI requires Bun runtime (uses bun:ffi for Zig renderer)
return new Promise((resolve, reject) => {
const child = spawn("bun", ["run", tuiPath], {
stdio: "inherit",
cwd: process.cwd(),
})
child.on("error", (err) => {
if (err.code === "ENOENT") {
console.error(
"Error: Bun runtime not found. The TUI requires Bun to run.\n" +
"Install Bun: https://bun.sh/docs/installation",
)
}
reject(err)
})
child.on("close", (code) => {
if (code === 0) {
resolve()
} else {
reject(new Error(`TUI exited with code ${code}`))
}
})
})
}
yargs(hideBin(process.argv))
.scriptName("quartz")
.version(version)
.usage("$0 <cmd> [args]")
.command("create", "Initialize Quartz", CreateArgv, async (argv) => {
await handleCreate(argv)
})
.command(
["upgrade", "update"],
"Upgrade Quartz to the latest version",
CommonArgv,
async (argv) => {
await handleUpgrade(argv)
},
)
.command(
"restore",
"Try to restore your content folder from the cache",
CommonArgv,
async (argv) => {
await handleRestore(argv)
},
)
.command("sync", "Sync your Quartz to and from GitHub.", SyncArgv, async (argv) => {
await handleSync(argv)
})
.command("build", "Build Quartz into a bundle of static HTML files", BuildArgv, async (argv) => {
await handleBuild(argv)
})
.command("tui", "Launch interactive plugin manager", CommonArgv, async () => {
await launchTui()
})
.command(
"plugin [subcommand]",
"Manage Quartz plugins",
(yargs) => {
return (
yargs
.command(
"install [names..]",
"Install plugins from lockfile or config",
{
...CommonArgv,
"from-config": {
boolean: true,
default: false,
describe: "install plugins referenced in quartz.config.yaml instead of lockfile",
},
latest: {
boolean: true,
default: false,
describe: "fetch latest version from remote instead of pinned lockfile commit",
},
clean: {
boolean: true,
default: false,
describe: "skip plugins whose directory already exists",
},
"dry-run": {
boolean: true,
default: false,
describe: "show what would happen without making changes",
},
},
async (argv) => {
await handlePluginInstallUnified({
names: argv.names?.length ? argv.names : undefined,
fromConfig: argv.fromConfig,
latest: argv.latest,
clean: argv.clean,
dryRun: argv.dryRun,
concurrency: argv.concurrency,
})
},
)
.command(
"add <repos..>",
"Add plugins from Git repositories",
{
...CommonArgv,
name: {
string: true,
alias: ["as"],
describe: "Override the plugin name (for resolving conflicts with duplicate names)",
},
subdir: {
string: true,
describe: "Subdirectory within the repository containing the plugin",
},
},
async (argv) => {
await handlePluginAdd(argv.repos, {
name: argv.name,
subdir: argv.subdir,
concurrency: argv.concurrency,
})
},
)
.command("remove <names..>", "Remove installed plugins", CommonArgv, async (argv) => {
await handlePluginRemove(argv.names)
})
.command("list", "List all installed plugins", CommonArgv, async () => {
await handlePluginList()
})
.command(
"enable <names..>",
"Enable plugins in quartz.config.yaml",
CommonArgv,
async (argv) => {
await handlePluginEnable(argv.names)
},
)
.command(
"disable <names..>",
"Disable plugins in quartz.config.yaml",
CommonArgv,
async (argv) => {
await handlePluginDisable(argv.names)
},
)
.command(
"config <name>",
"View or set plugin configuration",
{
...CommonArgv,
set: {
string: true,
describe: "Set a config value (key=value)",
},
},
async (argv) => {
await handlePluginConfig(argv.name, { set: argv.set })
},
)
.command(
"prune",
"Remove installed plugins no longer referenced in config",
{
...CommonArgv,
"dry-run": {
boolean: true,
default: false,
describe: "show what would be pruned without making changes",
},
},
async (argv) => {
await handlePluginPrune({ dryRun: argv.dryRun })
},
)
// Hidden deprecated aliases
.command("restore", false, CommonArgv, async (argv) => {
console.log(
"\x1b[33m⚠ 'plugin restore' is deprecated. Use 'plugin install --clean' instead.\x1b[0m",
)
await handlePluginInstallUnified({ clean: true, concurrency: argv.concurrency })
})
.command("update [names..]", false, CommonArgv, async (argv) => {
console.log(
"\x1b[33m⚠ 'plugin update' is deprecated. Use 'plugin install --latest' instead.\x1b[0m",
)
await handlePluginInstallUnified({
names: argv.names?.length ? argv.names : undefined,
latest: true,
concurrency: argv.concurrency,
})
})
.command("check", false, CommonArgv, async (argv) => {
console.log(
"\x1b[33m⚠ 'plugin check' is deprecated. Use 'plugin install --latest --dry-run' instead.\x1b[0m",
)
await handlePluginInstallUnified({
latest: true,
dryRun: true,
concurrency: argv.concurrency,
})
})
.command(
"resolve",
false,
{
...CommonArgv,
"dry-run": {
boolean: true,
default: false,
describe: "show what would be resolved without making changes",
},
},
async (argv) => {
console.log(
"\x1b[33m⚠ 'plugin resolve' is deprecated. Use 'plugin install --from-config' instead.\x1b[0m",
)
await handlePluginInstallUnified({
fromConfig: true,
dryRun: argv.dryRun,
concurrency: argv.concurrency,
})
},
)
.demandCommand(0, "")
)
},
async (argv) => {
if (!argv._.includes("plugin") || argv._.length > 1) return
await handlePluginStatus()
},
)
.showHelpOnFail(true)
.help()
.strict()
.demandCommand().argv
@@ -0,0 +1,8 @@
#!/usr/bin/env node
import workerpool from "workerpool"
const cacheFile = "./.quartz-cache/transpiled-worker.mjs"
const { parseMarkdown, processHtml } = await import(cacheFile)
workerpool.worker({
parseMarkdown,
processHtml,
})
@@ -0,0 +1,369 @@
import sourceMapSupport from "source-map-support"
sourceMapSupport.install(options)
import path from "path"
import { PerfTimer } from "./util/perf"
import { rm } from "fs/promises"
import { GlobbyFilterFunction, isGitIgnored } from "globby"
import { styleText } from "util"
import { parseMarkdown } from "./processors/parse"
import { filterContent } from "./processors/filter"
import { emitContent } from "./processors/emit"
import cfg from "../quartz"
import { FilePath, joinSegments, slugifyFilePath } from "./util/path"
import { detectSlugCollisions, formatCollisionWarning } from "./util/slugCollisions"
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 { getStaticResourcesFromPlugins } from "./plugins"
import { randomIdNonSecure } from "./util/random"
import { ChangeEvent } from "./plugins/types"
import { minimatch } from "minimatch"
function reportSlugCollisions(content: ProcessedContent[]): void {
const collisions = detectSlugCollisions(content)
if (collisions.length === 0) return
console.warn(styleText("yellow", formatCollisionWarning(collisions)))
}
type ContentMap = Map<
FilePath,
| {
type: "markdown"
content: ProcessedContent
}
| {
type: "other"
}
>
type BuildData = {
ctx: BuildCtx
ignored: GlobbyFilterFunction
mut: Mutex
contentMap: ContentMap
changesSinceLastBuild: Record<FilePath, ChangeEvent["type"]>
lastBuildMs: number
}
async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) {
const ctx: BuildCtx = {
buildId: randomIdNonSecure(),
argv,
cfg,
allSlugs: [],
allFiles: [],
incremental: false,
virtualPages: [],
}
const perf = new PerfTimer()
const output = argv.output
const pluginCount = Object.values(cfg.plugins).flat().length
const pluginNames = (key: "transformers" | "filters" | "emitters" | "pageTypes") =>
(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(", ")}`)
console.log(` PageTypes: ${pluginNames("pageTypes").join(", ")}`)
}
const release = await mut.acquire()
perf.addEvent("clean")
await rm(output, { recursive: true, force: true })
console.log(`Cleaned output directory \`${output}\` in ${perf.timeSince("clean")}`)
perf.addEvent("glob")
const allFiles = await glob("**/*.*", argv.directory, cfg.configuration.ignorePatterns)
const markdownPaths = allFiles.filter((fp) => fp.endsWith(".md")).sort()
console.log(
`Found ${markdownPaths.length} input files from \`${argv.directory}\` in ${perf.timeSince("glob")}`,
)
const filePaths = markdownPaths.map((fp) => joinSegments(argv.directory, fp) as FilePath)
ctx.allFiles = allFiles
ctx.allSlugs = allFiles.map((fp) => slugifyFilePath(fp as FilePath))
const parsedFiles = await parseMarkdown(ctx, filePaths)
reportSlugCollisions(parsedFiles)
const filteredContent = filterContent(ctx, parsedFiles)
await emitContent(ctx, filteredContent)
console.log(
styleText("green", `Done processing ${markdownPaths.length} files in ${perf.timeSince()}`),
)
release()
if (argv.watch) {
ctx.incremental = true
return startWatching(ctx, mut, parsedFiles, clientRefresh)
}
}
// setup watcher for rebuilds
async function startWatching(
ctx: BuildCtx,
mut: Mutex,
initialContent: ProcessedContent[],
clientRefresh: () => void,
) {
const { argv, allFiles } = ctx
const contentMap: ContentMap = new Map()
for (const filePath of allFiles) {
contentMap.set(filePath, {
type: "other",
})
}
for (const content of initialContent) {
const [_tree, vfile] = content
const relPath = vfile.data.relativePath
if (!relPath) {
console.warn(`Skipping file with no relativePath: ${vfile.path}`)
continue
}
contentMap.set(relPath, {
type: "markdown",
content,
})
}
const gitIgnoredMatcher = await isGitIgnored()
const buildData: BuildData = {
ctx,
mut,
contentMap,
ignored: (fp) => {
const pathStr = toPosixPath(fp.toString())
if (pathStr.startsWith(".git/")) return true
if (gitIgnoredMatcher(pathStr)) return true
for (const pattern of cfg.configuration.ignorePatterns) {
if (minimatch(pathStr, pattern)) {
return true
}
}
return false
},
changesSinceLastBuild: {},
lastBuildMs: 0,
}
const watcher = chokidar.watch(".", {
awaitWriteFinish: { stabilityThreshold: 250 },
persistent: true,
cwd: argv.directory,
ignoreInitial: true,
})
const changes: ChangeEvent[] = []
let rebuildTimeout: ReturnType<typeof setTimeout> | null = null
const scheduleRebuild = () => {
if (rebuildTimeout) clearTimeout(rebuildTimeout)
rebuildTimeout = setTimeout(() => {
rebuildTimeout = null
rebuild(changes, clientRefresh, buildData).catch((err) => {
console.error(styleText("red", "Rebuild failed:"), err.message ?? err)
})
}, 100)
}
watcher
.on("add", (fp) => {
fp = toPosixPath(fp)
if (buildData.ignored(fp)) return
changes.push({ path: fp as FilePath, type: "add" })
scheduleRebuild()
})
.on("change", (fp) => {
fp = toPosixPath(fp)
if (buildData.ignored(fp)) return
changes.push({ path: fp as FilePath, type: "change" })
scheduleRebuild()
})
.on("unlink", (fp) => {
fp = toPosixPath(fp)
if (buildData.ignored(fp)) return
changes.push({ path: fp as FilePath, type: "delete" })
scheduleRebuild()
})
return async () => {
await watcher.close()
}
}
async function rebuild(changes: ChangeEvent[], clientRefresh: () => void, buildData: BuildData) {
const { ctx, contentMap, mut, changesSinceLastBuild } = buildData
const { argv, cfg } = ctx
const buildId = randomIdNonSecure()
ctx.buildId = buildId
buildData.lastBuildMs = new Date().getTime()
const numChangesInBuild = changes.length
const release = await mut.acquire()
try {
// if there's another build after us, release and let them do it
if (ctx.buildId !== buildId) {
return
}
const perf = new PerfTimer()
perf.addEvent("rebuild")
console.log(styleText("yellow", "Detected change, rebuilding..."))
// update changesSinceLastBuild
for (const change of changes) {
changesSinceLastBuild[change.path] = change.type
}
const staticResources = getStaticResourcesFromPlugins(ctx)
const pathsToParse: FilePath[] = []
for (const [fp, type] of Object.entries(changesSinceLastBuild)) {
if (type === "delete" || path.extname(fp) !== ".md") continue
const fullPath = joinSegments(argv.directory, toPosixPath(fp)) as FilePath
pathsToParse.push(fullPath)
}
const parsed = await parseMarkdown(ctx, pathsToParse)
for (const content of parsed) {
const relPath = content[1].data.relativePath
if (!relPath) {
console.warn(`Skipping file with no relativePath: ${content[1].path}`)
continue
}
contentMap.set(relPath, {
type: "markdown",
content,
})
}
// update state using changesSinceLastBuild
// we do this weird play of add => compute change events => remove
// so that partialEmitters can do appropriate cleanup based on the content of deleted files
for (const [file, change] of Object.entries(changesSinceLastBuild)) {
if (change === "delete") {
// universal delete case
contentMap.delete(file as FilePath)
}
// manually track non-markdown files as processed files only
// contains markdown files
if (change === "add" && path.extname(file) !== ".md") {
contentMap.set(file as FilePath, {
type: "other",
})
}
}
const changeEvents: ChangeEvent[] = Object.entries(changesSinceLastBuild).map(([fp, type]) => {
const path = fp as FilePath
const processedContent = contentMap.get(path)
if (processedContent?.type === "markdown") {
const [_tree, file] = processedContent.content
return {
type,
path,
file,
}
}
return {
type,
path,
}
})
// update allFiles and then allSlugs with the consistent view of content map
ctx.allFiles = Array.from(contentMap.keys())
ctx.allSlugs = ctx.allFiles.map((fp) => slugifyFilePath(fp as FilePath))
const markdownContent = Array.from(contentMap.values())
.filter((file) => file.type === "markdown")
.map((file) => file.content)
reportSlugCollisions(markdownContent)
let processedFiles = filterContent(ctx, markdownContent)
let emittedFiles = 0
// Phase 1: Run PageTypeDispatcher first so it populates ctx.virtualPages
const dispatcher = cfg.plugins.emitters.find((e) => e.name === "PageTypeDispatcher")
if (dispatcher) {
ctx.virtualPages = []
const emitFn = dispatcher.partialEmit ?? dispatcher.emit
const emitted = await emitFn(ctx, processedFiles, staticResources, changeEvents)
if (emitted !== null) {
if (Symbol.asyncIterator in emitted) {
for await (const file of emitted) {
emittedFiles++
if (ctx.argv.verbose) {
console.log(`[emit:${dispatcher.name}] ${file}`)
}
}
} else {
emittedFiles += emitted.length
if (ctx.argv.verbose) {
for (const file of emitted) {
console.log(`[emit:${dispatcher.name}] ${file}`)
}
}
}
}
}
// Phase 2: Run all other emitters with content extended by virtual pages
const contentWithVirtual =
ctx.virtualPages.length > 0 ? [...processedFiles, ...ctx.virtualPages] : processedFiles
for (const emitter of cfg.plugins.emitters) {
if (emitter.name === "PageTypeDispatcher") continue
// Try to use partialEmit if available, otherwise assume the output is static
const emitFn = emitter.partialEmit ?? emitter.emit
const emitted = await emitFn(ctx, contentWithVirtual, staticResources, changeEvents)
if (emitted === null) {
continue
}
if (Symbol.asyncIterator in emitted) {
// Async generator case
for await (const file of emitted) {
emittedFiles++
if (ctx.argv.verbose) {
console.log(`[emit:${emitter.name}] ${file}`)
}
}
} else {
// Array case
emittedFiles += emitted.length
if (ctx.argv.verbose) {
for (const file of emitted) {
console.log(`[emit:${emitter.name}] ${file}`)
}
}
}
}
console.log(
`Emitted ${emittedFiles} files to \`${argv.output}\` in ${perf.timeSince("rebuild")}`,
)
console.log(styleText("green", `Done rebuilding in ${perf.timeSince()}`))
changes.splice(0, numChangesInBuild)
clientRefresh()
} finally {
release()
}
}
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)
}
}
@@ -0,0 +1,106 @@
import { QuartzComponent } from "./components/types"
import { ValidLocale } from "./i18n"
import { PluginSpecifier } from "./plugins/loader/types"
import { PluginTypes } from "./plugins/types"
import { Theme } from "./util/theme"
export type Analytics =
| null
| {
provider: "plausible"
host?: string
}
| {
provider: "google"
tagId: string
}
| {
provider: "umami"
websiteId: string
host?: string
}
| {
provider: "goatcounter"
websiteId: string
host?: string
scriptSrc?: string
}
| {
provider: "posthog"
apiKey: string
host?: string
}
| {
provider: "tinylytics"
siteId: string
}
| {
provider: "cabin"
host?: string
}
| {
provider: "clarity"
projectId?: string
}
| {
provider: "matomo"
host: string
siteId: string
}
| {
provider: "vercel"
}
| {
provider: "rybbit"
siteId: string
host?: string
}
export interface GlobalConfiguration {
pageTitle: string
pageTitleSuffix?: string
/** Whether to enable single-page-app style rendering. this prevents flashes of unstyled content and improves smoothness of Quartz */
enableSPA: boolean
/** Whether to display Wikipedia-style popovers when hovering over links */
enablePopovers: boolean
/** Analytics mode */
analytics: Analytics
/** Glob patterns to not search */
ignorePatterns: string[]
/** Base URL to use for CNAME files, sitemaps, and RSS feeds that require an absolute URL.
* Quartz will avoid using this as much as possible and use relative URLs most of the time
*/
baseUrl?: string
theme: Theme
/**
* Allow to translate the date in the language of your choice.
* Also used for UI translation (default: en-US)
* Need to be formatted following BCP 47: https://en.wikipedia.org/wiki/IETF_language_tag
* The first part is the language (en) and the second part is the script/region (US)
* Language Codes: https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes
* Region Codes: https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2
*/
locale: ValidLocale
}
export interface QuartzConfig {
configuration: GlobalConfiguration
plugins: PluginTypes
externalPlugins?: PluginSpecifier[]
}
export interface FullPageLayout {
head: QuartzComponent
header: QuartzComponent[]
beforeBody: QuartzComponent[]
pageBody: QuartzComponent
afterBody: QuartzComponent[]
left: QuartzComponent[]
right: QuartzComponent[]
footer: QuartzComponent
/** Page frame name (e.g. "default", "full-width", "minimal"). Defaults to "default". */
frame?: string
}
export type PageLayout = Pick<FullPageLayout, "beforeBody" | "left" | "right">
export type SharedLayout = Pick<FullPageLayout, "head" | "header" | "footer" | "afterBody">
@@ -0,0 +1,120 @@
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",
},
concurrency: {
number: true,
alias: ["c"],
describe: "max parallel operations (default: number of CPU cores)",
},
}
export const CreateArgv = {
...CommonArgv,
template: {
string: true,
alias: ["t"],
choices: ["default", "obsidian", "ttrpg", "blog"],
describe: "template to use for initial configuration",
},
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",
},
baseUrl: {
string: true,
alias: ["b"],
describe: "base URL for your Quartz site (e.g. mysite.github.io/quartz)",
},
links: {
string: true,
alias: ["l"],
choices: ["absolute", "shortest", "relative"],
describe: "strategy to resolve links",
},
}
export const SyncArgv = {
...CommonArgv,
commit: {
boolean: true,
default: true,
describe: "create a git commit for your unsaved changes",
},
message: {
string: true,
alias: ["m"],
describe: "option to override the default Quartz commit message",
},
push: {
boolean: true,
default: true,
describe: "push updates to your Quartz fork",
},
pull: {
boolean: true,
default: true,
describe: "pull updates from your Quartz fork",
},
}
export const BuildArgv = {
...CommonArgv,
output: {
string: true,
alias: ["o"],
default: "public",
describe: "output folder for files",
},
serve: {
boolean: true,
default: false,
describe: "run a local server to live-preview your Quartz",
},
watch: {
boolean: true,
default: false,
describe: "watch for changes and rebuild automatically",
},
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",
},
}
@@ -0,0 +1,16 @@
import path from "path"
import { readFileSync } from "fs"
/**
* All constants relating to helpers or handlers
*/
export const ORIGIN_NAME = "origin"
export const UPSTREAM_NAME = "upstream"
export const QUARTZ_SOURCE_BRANCH = "v5"
export const QUARTZ_SOURCE_REPO = "https://github.com/jackyzha0/quartz.git"
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")
@@ -0,0 +1,801 @@
import { promises } from "fs"
import path from "path"
import esbuild from "esbuild"
import { styleText } from "util"
import { sassPlugin } from "esbuild-sass-plugin"
import fs from "fs"
import { intro, outro, select, text } from "@clack/prompts"
import { rm } from "fs/promises"
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 { globby } from "globby"
import {
exitIfCancel,
escapePath,
gitPull,
popContentFolder,
stashContentFolder,
symlinkOrCopy,
} from "./helpers.js"
import {
handlePluginRestore,
handlePluginCheck,
handlePluginResolve,
} from "./plugin-git-handlers.js"
import {
configExists,
createConfigFromDefault,
createConfigFromTemplate,
readPluginsJson,
writePluginsJson,
extractPluginName,
updateGlobalConfig,
LOCKFILE_PATH,
} from "./plugin-data.js"
import {
UPSTREAM_NAME,
QUARTZ_SOURCE_BRANCH,
QUARTZ_SOURCE_REPO,
ORIGIN_NAME,
version,
fp,
cacheFile,
cwd,
} from "./constants.js"
/**
* Resolve content directory path
* @param contentPath path to resolve
*/
function resolveContentPath(contentPath) {
if (path.isAbsolute(contentPath)) return path.relative(cwd, contentPath)
return path.join(cwd, contentPath)
}
/**
* Handles `npx quartz create`
* @param {*} argv arguments for `create`
*/
export async function handleCreate(argv) {
console.log()
intro(styleText(["bgGreen", "black"], ` Quartz v${version} `))
const contentFolder = resolveContentPath(argv.directory)
let setupStrategy = argv.strategy?.toLowerCase()
let linkResolutionStrategy = argv.links?.toLowerCase()
const sourceDirectory = argv.source
let template = argv.template?.toLowerCase()
let baseUrl = argv.baseUrl
// If all cmd arguments were provided, check if they're valid
if (setupStrategy && linkResolutionStrategy) {
// If setup isn't, "new", source argument is required
if (setupStrategy !== "new") {
// Error handling
if (!sourceDirectory) {
outro(
styleText(
"red",
`Setup strategies (arg '${styleText(
"yellow",
`-${CreateArgv.strategy.alias[0]}`,
)}') other than '${styleText(
"yellow",
"new",
)}' require content folder argument ('${styleText(
"yellow",
`-${CreateArgv.source.alias[0]}`,
)}') to be set`,
),
)
process.exit(1)
} else {
if (!fs.existsSync(sourceDirectory)) {
outro(
styleText(
"red",
`Input directory to copy/symlink 'content' from not found ('${styleText(
"yellow",
sourceDirectory,
)}', invalid argument "${styleText("yellow", `-${CreateArgv.source.alias[0]}`)})`,
),
)
process.exit(1)
} else if (!fs.lstatSync(sourceDirectory).isDirectory()) {
outro(
styleText(
"red",
`Source directory to copy/symlink 'content' from is not a directory (found file at '${styleText(
"yellow",
sourceDirectory,
)}', invalid argument ${styleText("yellow", `-${CreateArgv.source.alias[0]}`)}")`,
),
)
process.exit(1)
}
}
}
}
// Template selection
if (!template) {
template = exitIfCancel(
await select({
message: "Choose a template for your Quartz configuration",
options: [
{ value: "default", label: "Default", hint: "clean Quartz setup with sensible defaults" },
{
value: "obsidian",
label: "Obsidian",
hint: "optimized for Obsidian vaults with full OFM support",
},
{
value: "ttrpg",
label: "TTRPG",
hint: "Obsidian + map plugin + ITS Theme for D&D/TTRPG wikis",
},
{
value: "blog",
label: "Blog",
hint: "recent notes and comments enabled for blogging",
},
],
}),
)
}
// 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 rm(contentFolder, { recursive: true, force: true })
}
}
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 symlinkOrCopy(originalFolder, contentFolder)
}
} 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.
`,
)
}
// Obsidian and TTRPG templates auto-set link resolution to "shortest"
const skipLinkPrompt = template === "obsidian" || template === "ttrpg"
if (skipLinkPrompt) {
linkResolutionStrategy = "shortest"
}
// 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.yaml\`.`,
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",
},
],
}),
)
}
// Base URL prompt
if (!baseUrl) {
baseUrl = exitIfCancel(
await text({
message: "Enter the base URL for your Quartz site (e.g. mysite.github.io/quartz)",
placeholder: "mysite.github.io",
validate(value) {
if (!value || value.trim().length === 0) {
return "Base URL cannot be empty"
}
},
}),
)
}
// Strip protocol prefix if user included it
baseUrl = baseUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "")
if (template && template !== "default") {
createConfigFromTemplate(template)
console.log(styleText("green", `Created quartz.config.yaml from '${template}' template`))
} else {
createConfigFromTemplate("default")
console.log(styleText("green", "Created quartz.config.yaml from defaults"))
}
// Update markdownLinkResolution in the crawl-links plugin options via YAML config
const json = readPluginsJson()
if (json?.plugins) {
const crawlLinksIndex = json.plugins.findIndex(
(p) => extractPluginName(p.source) === "crawl-links",
)
if (crawlLinksIndex !== -1) {
json.plugins[crawlLinksIndex].options = {
...json.plugins[crawlLinksIndex].options,
markdownLinkResolution: linkResolutionStrategy,
}
writePluginsJson(json)
}
}
// Update baseUrl in configuration
updateGlobalConfig({ baseUrl })
// install plugins referenced in the template config
await handlePluginResolve()
// setup remote
execSync(`git remote show upstream || git remote add upstream ${QUARTZ_SOURCE_REPO}`, {
stdio: "ignore",
})
outro(`You're all set! Not sure what to do next? Try:
• Customizing Quartz a bit more by editing \`quartz.config.yaml\`
• Running \`npx quartz build --serve\` to preview your Quartz locally
• Hosting your Quartz online (see: https://quartz.jzhao.xyz/hosting)
`)
}
/**
* Handles `npx quartz build`
* @param {*} argv arguments for `build`
*/
export async function handleBuild(argv) {
if (argv.concurrency !== undefined && argv.concurrency < 1) {
console.error("Concurrency must be at least 1")
process.exit(1)
}
if (argv.serve) {
argv.watch = true
}
console.log(`\n${styleText(["bgGreen", "black"], ` 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,
logOverride: {
"direct-eval": "silent",
"equals-negative-zero": "silent",
"duplicate-object-key": "silent",
},
plugins: [
sassPlugin({
type: "css-text",
cssImports: true,
}),
sassPlugin({
filter: /\.inline\.scss$/,
type: "css",
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) {
console.log(styleText("yellow", "Detected a source code change, doing a hard rebuild..."))
await cleanupBuild()
}
const result = await ctx.rebuild().catch((err) => {
console.error(
`${styleText("red", "Failed to build Quartz.")} Check for syntax errors in your configuration or plugins.`,
)
console.log(`Reason: ${styleText("gray", err.message ?? String(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()
}
let clientRefresh = () => {}
if (argv.serve) {
const connections = []
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(
styleText(
"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" }],
},
{
source: "**/*.webp",
headers: [{ key: "Content-Type", value: "image/webp" }],
},
// fixes bug where avif images are displayed as text instead of images (future proof)
{
source: "**/*.avif",
headers: [{ key: "Content-Type", value: "image/avif" }],
},
],
})
const status = res.statusCode
const statusString =
status >= 200 && status < 300
? styleText("green", `[${status}]`)
: styleText("red", `[${status}]`)
console.log(statusString + styleText("gray", ` ${argv.baseDir}${req.url}`))
release()
}
const redirect = (newFp) => {
newFp = argv.baseDir + newFp
res.writeHead(302, {
Location: newFp,
})
console.log(
styleText("yellow", "[302]") +
styleText("gray", ` ${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.on("error", (err) => {
if (err.code === "EADDRINUSE") {
console.error(
`Port ${argv.port} is already in use. Try a different port with --port <number>`,
)
process.exit(1)
}
throw err
})
server.listen(argv.port)
const wss = new WebSocketServer({ port: argv.wsPort })
wss.on("error", (err) => {
if (err.code === "EADDRINUSE") {
console.error(
`WebSocket port ${argv.wsPort} is already in use. Try a different port with --wsPort <number>`,
)
process.exit(1)
}
throw err
})
wss.on("connection", (ws) => connections.push(ws))
console.log(
styleText(
"cyan",
`Started a Quartz server listening at http://localhost:${argv.port}${argv.baseDir}`,
),
)
} else {
await build(clientRefresh)
ctx.dispose()
}
if (argv.watch) {
const paths = await globby([
"**/*.ts",
"quartz/cli/*.js",
"quartz/static/**/*",
"**/*.tsx",
"**/*.scss",
"package.json",
"quartz.config.yaml",
"quartz.config.default.yaml",
])
chokidar
.watch(paths, { ignoreInitial: true })
.on("add", () => build(clientRefresh))
.on("change", () => build(clientRefresh))
.on("unlink", () => build(clientRefresh))
console.log(styleText("gray", "hint: exit with ctrl+c"))
}
}
/**
* Handles `npx quartz upgrade`
* Upgrades the Quartz framework itself by pulling latest changes from upstream.
* @param {*} argv arguments for `upgrade`
*/
export async function handleUpgrade(argv) {
const contentFolder = resolveContentPath(argv.directory)
console.log(`\n${styleText(["bgGreen", "black"], ` Quartz v${version} `)} \n`)
console.log("Backing up your content")
execSync(`git remote show upstream || git remote add upstream ${QUARTZ_SOURCE_REPO}`)
await stashContentFolder(contentFolder)
const lockfileBackup = LOCKFILE_PATH + ".bak"
const hasLockfile = fs.existsSync(LOCKFILE_PATH)
if (hasLockfile) {
fs.copyFileSync(LOCKFILE_PATH, lockfileBackup)
}
console.log(
"Pulling updates... you may need to resolve some `git` conflicts if you've made changes to components or plugins.",
)
let pullOk = false
try {
gitPull(UPSTREAM_NAME, QUARTZ_SOURCE_BRANCH)
pullOk = true
} catch {
if (hasLockfile) {
try {
fs.copyFileSync(lockfileBackup, LOCKFILE_PATH)
execSync(`git add ${LOCKFILE_PATH}`)
const remaining = execSync("git diff --name-only --diff-filter=U", {
encoding: "utf-8",
}).trim()
if (remaining.length === 0) {
execSync("git commit --no-edit")
pullOk = true
console.log(styleText("cyan", "Resolved quartz.lock.json merge conflict automatically."))
}
} catch {
// Could not auto-resolve, fall through to manual resolution
}
}
if (!pullOk) {
console.log(
styleText("red", "An error occurred while pulling updates.") +
"\nCheck your network connection and git credentials. If you see merge conflicts, resolve them manually and run `npx quartz sync --no-pull`.",
)
await popContentFolder(contentFolder)
if (fs.existsSync(lockfileBackup)) fs.unlinkSync(lockfileBackup)
return
}
}
if (hasLockfile && fs.existsSync(lockfileBackup)) {
fs.copyFileSync(lockfileBackup, LOCKFILE_PATH)
fs.unlinkSync(lockfileBackup)
}
await popContentFolder(contentFolder)
// Read the new version after pulling
const newPkg = JSON.parse(fs.readFileSync("./package.json").toString())
const newVersion = newPkg.version
if (newVersion !== version) {
console.log(styleText("cyan", `Upgraded Quartz: v${version} → v${newVersion}`))
} else {
console.log(styleText("gray", `Quartz is already up to date (v${version})`))
}
console.log("Ensuring dependencies are up to date")
/*
On Windows, if the command `npm` is really `npm.cmd', this call fails
as it will be unable to find `npm`. This is often the case on systems
where `npm` is installed via a package manager.
This means `npx quartz upgrade` will not actually update dependencies
on Windows, without a manual `npm i` from the caller.
However, by spawning a shell, we are able to call `npm.cmd`.
See: https://nodejs.org/api/child_process.html#spawning-bat-and-cmd-files-on-windows
*/
const opts = { stdio: "inherit" }
if (process.platform === "win32") {
opts.shell = true
}
const res = spawnSync("npm", ["i"], opts)
if (res.status === 0) {
console.log(styleText("green", "Dependencies updated!"))
} else {
console.log(
styleText("red", "An error occurred while installing dependencies.") +
"\nTry running `npm install` manually to see detailed errors.",
)
}
console.log("Restoring plugins from lockfile...")
await handlePluginRestore()
console.log("Checking plugin compatibility...")
await handlePluginCheck()
console.log(styleText("green", "Done!"))
}
/**
* Handles `npx quartz restore`
* @param {*} argv arguments for `restore`
*/
export async function handleRestore(argv) {
const contentFolder = resolveContentPath(argv.directory)
await popContentFolder(contentFolder)
}
/**
* Handles `npx quartz sync`
* @param {*} argv arguments for `sync`
*/
export async function handleSync(argv) {
const contentFolder = resolveContentPath(argv.directory)
console.log(`\n${styleText(["bgGreen", "black"], ` 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(styleText("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(
styleText("red", "An error occurred while pulling updates from your repository.") +
"\nCheck your network connection and git credentials.",
)
await popContentFolder(contentFolder)
return
}
}
await popContentFolder(contentFolder)
if (argv.push) {
console.log("Pushing your changes")
const currentBranch = execSync("git rev-parse --abbrev-ref HEAD").toString().trim()
const res = spawnSync("git", ["push", "-uf", ORIGIN_NAME, currentBranch], {
stdio: "inherit",
})
if (res.status !== 0) {
console.log(
styleText("red", `An error occurred while pushing to remote ${ORIGIN_NAME}.`) +
"\nCheck that you have push access to the remote repository.",
)
return
}
}
console.log(styleText("green", "Done!"))
}
@@ -0,0 +1,109 @@
import { isCancel, outro } from "@clack/prompts"
import { styleText } from "util"
import { contentCacheFolder } from "./constants.js"
import { spawnSync } from "child_process"
import fs from "fs"
import path from "path"
export function escapePath(fp) {
return fp
.replace(/\\ /g, " ") // unescape spaces
.replace(/^"(.*)"$/, "$1")
.replace(/^'(.*)'$/, "$1")
.trim()
}
export function exitIfCancel(val) {
if (isCancel(val)) {
outro(styleText("red", "Exiting"))
process.exit(0)
} else {
return val
}
}
export async function stashContentFolder(contentFolder) {
await fs.promises.rm(contentCacheFolder, { force: true, recursive: true })
await fs.promises.cp(contentFolder, contentCacheFolder, {
force: true,
recursive: true,
verbatimSymlinks: true,
preserveTimestamps: true,
})
await fs.promises.rm(contentFolder, { force: true, recursive: true })
}
export function gitPull(origin, branch) {
const flags = ["--no-rebase", "--autostash", "--no-edit", "--allow-unrelated-histories"]
const out = spawnSync("git", ["pull", ...flags, origin, branch], { stdio: "inherit" })
if (out.stderr) {
throw new Error(styleText("red", `Error while pulling updates: ${out.stderr}`))
} else if (out.status !== 0) {
throw new Error(styleText("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 })
}
/**
* Create a directory symlink with Windows fallback.
*
* On Windows, creating symlinks requires Developer Mode or admin privileges.
* When that fails (EPERM), we try a junction first (no elevation needed),
* then fall back to a recursive copy as a last resort.
*
* @param {string} target Symlink target (may be relative)
* @param {string} linkPath Path where the link is created
*/
export function symlinkOrCopySync(target, linkPath) {
try {
fs.symlinkSync(target, linkPath, "dir")
} catch (err) {
if (err.code === "EEXIST") return
if (err.code === "EPERM" && process.platform === "win32") {
try {
fs.symlinkSync(target, linkPath, "junction")
return
} catch {
const resolvedTarget = path.resolve(path.dirname(linkPath), target)
fs.cpSync(resolvedTarget, linkPath, { recursive: true })
return
}
}
throw err
}
}
/**
* Async version of {@link symlinkOrCopySync}.
*
* @param {string} target Symlink target (may be relative)
* @param {string} linkPath Path where the link is created
*/
export async function symlinkOrCopy(target, linkPath) {
try {
await fs.promises.symlink(target, linkPath, "dir")
} catch (err) {
if (err.code === "EEXIST") return
if (err.code === "EPERM" && process.platform === "win32") {
try {
await fs.promises.symlink(target, linkPath, "junction")
return
} catch {
const resolvedTarget = path.resolve(path.dirname(linkPath), target)
await fs.promises.cp(resolvedTarget, linkPath, { recursive: true })
return
}
}
throw err
}
}
@@ -0,0 +1,237 @@
import test, { describe, beforeEach, afterEach, mock } from "node:test"
import assert from "node:assert"
import fs from "fs"
import path from "path"
import os from "os"
import { symlinkOrCopySync, symlinkOrCopy } from "./helpers.js"
function makeTmpDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), "quartz-symlink-test-"))
}
function makeTarget(tmpDir) {
const target = path.join(tmpDir, "target")
fs.mkdirSync(target)
fs.writeFileSync(path.join(target, "marker.txt"), "hello")
return target
}
describe("symlinkOrCopySync", () => {
let tmpDir
beforeEach(() => {
tmpDir = makeTmpDir()
})
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true })
})
test("creates a symlink on success", () => {
const target = makeTarget(tmpDir)
const linkPath = path.join(tmpDir, "link")
symlinkOrCopySync(target, linkPath)
const stat = fs.lstatSync(linkPath)
assert.ok(stat.isSymbolicLink())
assert.ok(fs.existsSync(path.join(linkPath, "marker.txt")))
})
test("silently succeeds when link already exists (EEXIST)", () => {
const target = makeTarget(tmpDir)
const linkPath = path.join(tmpDir, "link")
symlinkOrCopySync(target, linkPath)
assert.doesNotThrow(() => symlinkOrCopySync(target, linkPath))
})
test("re-throws non-EPERM errors", () => {
const target = makeTarget(tmpDir)
const linkPath = path.join(tmpDir, "nonexistent-parent", "link")
assert.throws(
() => symlinkOrCopySync(target, linkPath),
(err) => {
return err.code === "ENOENT"
},
)
})
test("falls back to junction on Windows EPERM", () => {
const target = makeTarget(tmpDir)
const linkPath = path.join(tmpDir, "link")
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform")
Object.defineProperty(process, "platform", { value: "win32", configurable: true })
let callCount = 0
const originalSymlinkSync = fs.symlinkSync
mock.method(fs, "symlinkSync", (t, lp, type) => {
callCount++
if (callCount === 1 && type === "dir") {
const err = new Error("EPERM: operation not permitted, symlink")
err.code = "EPERM"
err.errno = -4048
err.syscall = "symlink"
throw err
}
return originalSymlinkSync(t, lp, type)
})
try {
symlinkOrCopySync(target, linkPath)
assert.ok(fs.existsSync(path.join(linkPath, "marker.txt")))
assert.strictEqual(callCount, 2)
} finally {
fs.symlinkSync.mock.restore()
if (originalPlatform) {
Object.defineProperty(process, "platform", originalPlatform)
}
}
})
test("falls back to copy when both symlink and junction fail on Windows", () => {
const target = makeTarget(tmpDir)
const linkPath = path.join(tmpDir, "link")
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform")
Object.defineProperty(process, "platform", { value: "win32", configurable: true })
const originalSymlinkSync = fs.symlinkSync
mock.method(fs, "symlinkSync", (_t, _lp, _type) => {
const err = new Error("EPERM: operation not permitted, symlink")
err.code = "EPERM"
err.errno = -4048
err.syscall = "symlink"
throw err
})
try {
symlinkOrCopySync(target, linkPath)
const stat = fs.lstatSync(linkPath)
assert.ok(stat.isDirectory(), "fallback should produce a real directory, not a symlink")
assert.strictEqual(fs.readFileSync(path.join(linkPath, "marker.txt"), "utf-8"), "hello")
} finally {
fs.symlinkSync.mock.restore()
if (originalPlatform) {
Object.defineProperty(process, "platform", originalPlatform)
}
}
})
test("does not fall back on EPERM when not on Windows", () => {
const target = makeTarget(tmpDir)
const linkPath = path.join(tmpDir, "link")
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform")
Object.defineProperty(process, "platform", { value: "linux", configurable: true })
const originalSymlinkSync = fs.symlinkSync
mock.method(fs, "symlinkSync", (_t, _lp, _type) => {
const err = new Error("EPERM: operation not permitted, symlink")
err.code = "EPERM"
throw err
})
try {
assert.throws(
() => symlinkOrCopySync(target, linkPath),
(err) => err.code === "EPERM",
)
} finally {
fs.symlinkSync.mock.restore()
if (originalPlatform) {
Object.defineProperty(process, "platform", originalPlatform)
}
}
})
})
describe("symlinkOrCopy", () => {
let tmpDir
beforeEach(() => {
tmpDir = makeTmpDir()
})
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true })
})
test("creates a symlink on success", async () => {
const target = makeTarget(tmpDir)
const linkPath = path.join(tmpDir, "link")
await symlinkOrCopy(target, linkPath)
const stat = fs.lstatSync(linkPath)
assert.ok(stat.isSymbolicLink())
assert.ok(fs.existsSync(path.join(linkPath, "marker.txt")))
})
test("silently succeeds when link already exists (EEXIST)", async () => {
const target = makeTarget(tmpDir)
const linkPath = path.join(tmpDir, "link")
await symlinkOrCopy(target, linkPath)
await assert.doesNotReject(() => symlinkOrCopy(target, linkPath))
})
test("falls back to copy when both symlink and junction fail on Windows", async () => {
const target = makeTarget(tmpDir)
const linkPath = path.join(tmpDir, "link")
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform")
Object.defineProperty(process, "platform", { value: "win32", configurable: true })
const originalSymlink = fs.promises.symlink
mock.method(fs.promises, "symlink", async (_t, _lp, _type) => {
const err = new Error("EPERM: operation not permitted, symlink")
err.code = "EPERM"
err.errno = -4048
err.syscall = "symlink"
throw err
})
try {
await symlinkOrCopy(target, linkPath)
const stat = fs.lstatSync(linkPath)
assert.ok(stat.isDirectory(), "fallback should produce a real directory, not a symlink")
assert.strictEqual(fs.readFileSync(path.join(linkPath, "marker.txt"), "utf-8"), "hello")
} finally {
fs.promises.symlink.mock.restore()
if (originalPlatform) {
Object.defineProperty(process, "platform", originalPlatform)
}
}
})
test("does not fall back on EPERM when not on Windows", async () => {
const target = makeTarget(tmpDir)
const linkPath = path.join(tmpDir, "link")
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform")
Object.defineProperty(process, "platform", { value: "linux", configurable: true })
mock.method(fs.promises, "symlink", async (_t, _lp, _type) => {
const err = new Error("EPERM: operation not permitted, symlink")
err.code = "EPERM"
throw err
})
try {
await assert.rejects(
() => symlinkOrCopy(target, linkPath),
(err) => err.code === "EPERM",
)
} finally {
fs.promises.symlink.mock.restore()
if (originalPlatform) {
Object.defineProperty(process, "platform", originalPlatform)
}
}
})
})
@@ -0,0 +1,367 @@
import fs from "fs"
import path from "path"
import { execSync } from "child_process"
import YAML from "yaml"
const LOCKFILE_PATH = path.join(process.cwd(), "quartz.lock.json")
const PLUGINS_DIR = path.join(process.cwd(), ".quartz", "plugins")
const CONFIG_YAML_PATH = path.join(process.cwd(), "quartz.config.yaml")
const DEFAULT_CONFIG_YAML_PATH = path.join(process.cwd(), "quartz.config.default.yaml")
const TEMPLATES_DIR = path.join(process.cwd(), "quartz", "cli", "templates")
const LEGACY_PLUGINS_JSON_PATH = path.join(process.cwd(), "quartz.plugins.json")
const LEGACY_DEFAULT_PLUGINS_JSON_PATH = path.join(process.cwd(), "quartz.plugins.default.json")
function resolveConfigPath() {
if (fs.existsSync(CONFIG_YAML_PATH)) return CONFIG_YAML_PATH
if (fs.existsSync(LEGACY_PLUGINS_JSON_PATH)) return LEGACY_PLUGINS_JSON_PATH
if (fs.existsSync(DEFAULT_CONFIG_YAML_PATH)) return DEFAULT_CONFIG_YAML_PATH
if (fs.existsSync(LEGACY_DEFAULT_PLUGINS_JSON_PATH)) return LEGACY_DEFAULT_PLUGINS_JSON_PATH
return CONFIG_YAML_PATH
}
function resolveDefaultConfigPath() {
if (fs.existsSync(DEFAULT_CONFIG_YAML_PATH)) return DEFAULT_CONFIG_YAML_PATH
if (fs.existsSync(LEGACY_DEFAULT_PLUGINS_JSON_PATH)) return LEGACY_DEFAULT_PLUGINS_JSON_PATH
return DEFAULT_CONFIG_YAML_PATH
}
function readFileAsData(filePath) {
if (!fs.existsSync(filePath)) return null
try {
const raw = fs.readFileSync(filePath, "utf-8")
if (filePath.endsWith(".yaml") || filePath.endsWith(".yml")) {
return YAML.parse(raw)
}
return JSON.parse(raw)
} catch {
return null
}
}
function writeDataToFile(filePath, data) {
if (filePath.endsWith(".yaml") || filePath.endsWith(".yml")) {
const header = "# yaml-language-server: $schema=./quartz/plugins/quartz-plugins.schema.json\n"
fs.writeFileSync(filePath, header + YAML.stringify(data, { lineWidth: 120 }))
} else {
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n")
}
}
export function readPluginsJson() {
const configPath = resolveConfigPath()
return readFileAsData(configPath)
}
export function writePluginsJson(data) {
const { $schema, ...rest } = data
writeDataToFile(CONFIG_YAML_PATH, rest)
}
function readDefaultPluginsJson() {
const defaultPath = resolveDefaultConfigPath()
return readFileAsData(defaultPath)
}
export function readLockfile() {
if (!fs.existsSync(LOCKFILE_PATH)) return null
try {
return JSON.parse(fs.readFileSync(LOCKFILE_PATH, "utf-8"))
} catch {
return null
}
}
export function writeLockfile(lockfile) {
if (lockfile.plugins) {
const sorted = {}
for (const key of Object.keys(lockfile.plugins).sort()) {
sorted[key] = lockfile.plugins[key]
}
lockfile = { ...lockfile, plugins: sorted }
}
fs.writeFileSync(LOCKFILE_PATH, JSON.stringify(lockfile, null, 2) + "\n")
}
/**
* Normalizes a source value to a URL string.
* Source can be a plain string (e.g. "github:owner/repo") or an object
* with { name?, repo, subdir? } for installing from a subdirectory of a repo.
*/
export function getSourceUrl(source) {
if (typeof source === "string") return source
if (typeof source === "object" && source !== null && typeof source.repo === "string") {
return source.repo
}
throw new Error(`Invalid plugin source: ${JSON.stringify(source)}`)
}
/**
* Returns the subdir from an object source, or undefined for string sources.
*/
function getSourceSubdir(source) {
if (typeof source === "object" && source !== null && typeof source.subdir === "string") {
return source.subdir
}
return undefined
}
/**
* Returns a display-friendly string for a source value.
*/
export function formatSource(source) {
if (typeof source === "string") return source
if (typeof source === "object" && source !== null) {
const parts = [source.repo]
if (source.subdir) parts.push(`(subdir: ${source.subdir})`)
return parts.join(" ")
}
return String(source)
}
export function isLocalSource(source) {
const url = getSourceUrl(source)
if (url.startsWith("./") || url.startsWith("../") || url.startsWith("/")) {
return true
}
// Windows absolute paths (e.g. C:\ or D:/)
if (/^[A-Za-z]:[\\/]/.test(url)) {
return true
}
return false
}
export function extractPluginName(source) {
if (typeof source === "object" && source !== null && typeof source.name === "string") {
return source.name
}
const url = getSourceUrl(source)
if (isLocalSource(url)) {
return path.basename(url.replace(/[\/]+$/, ""))
}
if (url.startsWith("github:")) {
const withoutPrefix = url.replace("github:", "")
const [repoPath] = withoutPrefix.split("#")
const parts = repoPath.split("/")
return parts[parts.length - 1]
}
if (url.startsWith("git+") || url.startsWith("https://")) {
const cleaned = url.replace("git+", "")
const match = cleaned.match(/\/([^/]+?)(?:\.git)?(?:#|$)/)
return match?.[1] ?? url
}
return url
}
export function readManifestFromPackageJson(pluginDir) {
const pkgPath = path.join(pluginDir, "package.json")
if (!fs.existsSync(pkgPath)) return null
try {
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"))
return pkg.quartz ?? null
} catch {
return null
}
}
export function parseGitSource(source) {
const url = getSourceUrl(source)
const subdir = getSourceSubdir(source)
if (isLocalSource(url)) {
const resolved = path.resolve(url)
const name = typeof source === "object" && source.name ? source.name : path.basename(resolved)
return { name, url: resolved, ref: undefined, local: true, subdir }
}
if (url.startsWith("github:")) {
const [repoPath, ref] = url.replace("github:", "").split("#")
const [owner, repo] = repoPath.split("/")
const name = typeof source === "object" && source.name ? source.name : repo
return { name, url: `https://github.com/${owner}/${repo}.git`, ref, subdir }
}
if (url.startsWith("git+")) {
const raw = url.replace("git+", "")
const [parsed, ref] = raw.split("#")
const name =
typeof source === "object" && source.name ? source.name : path.basename(parsed, ".git")
return { name, url: parsed, ref, subdir }
}
if (url.startsWith("https://")) {
const [parsed, ref] = url.split("#")
const name =
typeof source === "object" && source.name ? source.name : path.basename(parsed, ".git")
return { name, url: parsed, ref, subdir }
}
throw new Error(`Cannot parse plugin source: ${formatSource(source)}`)
}
export function getGitCommit(pluginDir) {
try {
return execSync("git rev-parse HEAD", { cwd: pluginDir, encoding: "utf-8" }).trim()
} catch {
return "unknown"
}
}
export function updateGlobalConfig(updates) {
const json = readPluginsJson()
if (!json) return false
json.configuration = { ...json.configuration, ...updates }
writePluginsJson(json)
return true
}
export function configExists() {
return fs.existsSync(CONFIG_YAML_PATH) || fs.existsSync(LEGACY_PLUGINS_JSON_PATH)
}
export function createConfigFromDefault() {
const defaultData = readDefaultPluginsJson()
if (!defaultData) {
// No default available — create minimal config
const minimal = {
configuration: {
pageTitle: "Quartz",
enableSPA: true,
enablePopovers: true,
analytics: { provider: "plausible" },
locale: "en-US",
baseUrl: "quartz.jzhao.xyz",
ignorePatterns: ["private", "templates", ".obsidian"],
theme: {
cdnCaching: true,
typography: {
header: "Schibsted Grotesk",
body: "Source Sans Pro",
code: "IBM Plex Mono",
},
colors: {
lightMode: {
light: "#faf8f8",
lightgray: "#e5e5e5",
gray: "#b8b8b8",
darkgray: "#4e4e4e",
dark: "#2b2b2b",
secondary: "#284b63",
tertiary: "#84a59d",
highlight: "rgba(143, 159, 169, 0.15)",
textHighlight: "#fff23688",
},
darkMode: {
light: "#161618",
lightgray: "#393639",
gray: "#646464",
darkgray: "#d4d4d4",
dark: "#ebebec",
secondary: "#7b97aa",
tertiary: "#84a59d",
highlight: "rgba(143, 159, 169, 0.15)",
textHighlight: "#fff23688",
},
},
},
},
plugins: [],
layout: { groups: {}, byPageType: {} },
}
writePluginsJson(minimal)
return minimal
}
const { $schema, ...rest } = defaultData
writePluginsJson(rest)
return rest
}
const VALID_TEMPLATES = ["default", "obsidian", "ttrpg", "blog"]
export function createConfigFromTemplate(templateName) {
if (!VALID_TEMPLATES.includes(templateName)) {
throw new Error(
`Unknown template: ${templateName}. Valid templates: ${VALID_TEMPLATES.join(", ")}`,
)
}
const templatePath = path.join(TEMPLATES_DIR, `${templateName}.yaml`)
const templateData = readFileAsData(templatePath)
if (!templateData) {
// Template file missing — fall back to default config creation
return createConfigFromDefault()
}
const { $schema, ...rest } = templateData
writePluginsJson(rest)
return rest
}
/**
* Resolves a user-facing plugin name (which may be an overridden name from config)
* to the corresponding lockfile key (the original name at install time).
*
* This bridges the naming identity split between config YAML (which supports
* source.name overrides) and the lockfile/disk (which are keyed by the original name).
*
* @param {string} name - The name the user provided (may be overridden or original)
* @param {object|null} lockfile - The parsed lockfile
* @param {object|null} pluginsJson - The parsed config YAML
* @returns {string} The lockfile key that corresponds to this plugin
*/
export function resolveLockfileName(name, lockfile, pluginsJson) {
// Direct match — no resolution needed
if (lockfile?.plugins?.[name]) return name
// Check if any config entry with this overridden name maps to a different lockfile key
if (pluginsJson?.plugins) {
const configEntry = pluginsJson.plugins.find(
(e) => extractPluginName(e.source) === name || formatSource(e.source) === name,
)
if (configEntry) {
const url = getSourceUrl(configEntry.source)
for (const [key, lock] of Object.entries(lockfile?.plugins ?? {})) {
if (
lock.source === url ||
lock.source === formatSource(configEntry.source) ||
lock.resolved === url
) {
return key
}
}
}
}
return name
}
/**
* Builds a map from lockfile keys to their overridden display names from config.
* Returns entries only where the overridden name differs from the lockfile key.
*
* @param {object|null} lockfile - The parsed lockfile
* @param {object|null} pluginsJson - The parsed config YAML
* @returns {Map<string, string>} Map of lockfileKey → overriddenName
*/
export function getNameOverrides(lockfile, pluginsJson) {
const overrides = new Map()
if (!lockfile?.plugins || !pluginsJson?.plugins) return overrides
for (const entry of pluginsJson.plugins) {
const configName = extractPluginName(entry.source)
const url = getSourceUrl(entry.source)
for (const [lockKey, lock] of Object.entries(lockfile.plugins)) {
if (lockKey === configName) break // no override, names match
if (
lock.source === url ||
lock.source === formatSource(entry.source) ||
lock.resolved === url
) {
overrides.set(lockKey, configName)
break
}
}
}
return overrides
}
export const PLUGINS_JSON_PATH = CONFIG_YAML_PATH
export const DEFAULT_PLUGINS_JSON_PATH = DEFAULT_CONFIG_YAML_PATH
export { LOCKFILE_PATH, PLUGINS_DIR }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,291 @@
# yaml-language-server: $schema=../../plugins/quartz-plugins.schema.json
# Template: blog
# A blog-focused setup with recent notes and comments enabled.
configuration:
pageTitle: Quartz 5
pageTitleSuffix: ""
enableSPA: true
enablePopovers: true
analytics:
provider: plausible
locale: en-US
baseUrl: quartz.jzhao.xyz
ignorePatterns:
- private
- templates
- .obsidian
theme:
fontOrigin: googleFonts
cdnCaching: true
typography:
header: Schibsted Grotesk
body: Source Sans Pro
code: IBM Plex Mono
colors:
lightMode:
light: "#faf8f8"
lightgray: "#e5e5e5"
gray: "#b8b8b8"
darkgray: "#4e4e4e"
dark: "#2b2b2b"
secondary: "#284b63"
tertiary: "#84a59d"
highlight: rgba(143, 159, 169, 0.15)
textHighlight: "#fff23688"
darkMode:
light: "#161618"
lightgray: "#393639"
gray: "#646464"
darkgray: "#d4d4d4"
dark: "#ebebec"
secondary: "#7b97aa"
tertiary: "#84a59d"
highlight: rgba(143, 159, 169, 0.15)
textHighlight: "#b3aa0288"
plugins:
- source: github:quartz-community/created-modified-date
enabled: true
options:
defaultDateType: modified
priority:
- frontmatter
- git
- filesystem
order: 10
- source: github:quartz-community/syntax-highlighting
enabled: true
options:
theme:
light: github-light
dark: github-dark
keepBackground: false
order: 20
- source: github:quartz-community/obsidian-flavored-markdown
enabled: true
options:
enableInHtmlEmbed: false
enableCheckbox: true
order: 30
- source: github:quartz-community/github-flavored-markdown
enabled: true
order: 40
- source: github:quartz-community/table-of-contents
enabled: true
order: 50
layout:
position: right
priority: 30
- source: github:quartz-community/crawl-links
enabled: true
options:
markdownLinkResolution: shortest
order: 60
- source: github:quartz-community/description
enabled: true
order: 70
- source: github:quartz-community/latex
enabled: true
options:
renderEngine: katex
order: 80
- source: github:quartz-community/citations
enabled: false
order: 85
- source: github:quartz-community/hard-line-breaks
enabled: false
order: 90
- source: github:quartz-community/ox-hugo
enabled: false
order: 91
- source: github:quartz-community/roam
enabled: false
order: 92
- source: github:quartz-community/fonts
enabled: true
- source: github:quartz-community/remove-draft
enabled: true
- source: github:quartz-community/explicit-publish
enabled: false
- source: github:quartz-community/unlisted-pages
enabled: true
options: {}
order: 45
- source: github:quartz-community/encrypted-pages
enabled: false
- source: github:quartz-community/stacked-pages
enabled: false
layout:
position: afterBody
priority: 50
display: all
- source: github:quartz-community/alias-redirects
enabled: true
- source: github:quartz-community/content-index
enabled: true
options:
enableSiteMap: true
enableRSS: true
- source: github:quartz-community/favicon
enabled: true
- source: github:quartz-community/og-image
enabled: true
- source: github:quartz-community/cname
enabled: true
- source: github:quartz-community/canvas-page
enabled: true
- source: github:quartz-community/content-page
enabled: true
- source: github:quartz-community/folder-page
enabled: true
- source: github:quartz-community/tag-page
enabled: true
- source: github:quartz-community/explorer
enabled: true
layout:
position: left
priority: 50
- source: github:quartz-community/graph
enabled: true
layout:
position: right
priority: 10
- source: github:quartz-community/search
enabled: true
layout:
position: left
priority: 20
group: toolbar
groupOptions:
grow: true
- source: github:quartz-community/backlinks
enabled: true
layout:
position: right
priority: 50
- source: github:quartz-community/article-title
enabled: true
layout:
position: beforeBody
priority: 10
- source: github:quartz-community/content-meta
enabled: true
layout:
position: beforeBody
priority: 20
- source: github:quartz-community/tag-list
enabled: false
layout:
position: beforeBody
priority: 30
- source: github:quartz-community/page-title
enabled: true
layout:
position: left
priority: 10
- source: github:quartz-community/darkmode
enabled: true
layout:
position: left
priority: 30
group: toolbar
- source: github:quartz-community/reader-mode
enabled: true
layout:
position: left
priority: 35
group: toolbar
- source: github:quartz-community/breadcrumbs
enabled: true
layout:
position: beforeBody
priority: 5
condition: not-index
- source: github:quartz-community/comments
enabled: true
options:
provider: giscus
options:
repo: "TODO:username/repo-name"
repoId: "TODO:your-repo-id"
category: Announcements
categoryId: "TODO:your-category-id"
mapping: url
strict: true
reactionsEnabled: true
inputPosition: bottom
lightTheme: light
darkTheme: dark
lang: en
layout:
position: afterBody
priority: 10
- source: github:quartz-community/footer
enabled: true
options:
links:
GitHub: https://github.com/jackyzha0/quartz
Discord Community: https://discord.gg/cRFFHYye7t
- source: github:quartz-community/recent-notes
enabled: true
options:
title: Recent Notes
limit: 5
linkToMore: false
showTags: true
layout:
position: left
priority: 25
- source: github:quartz-community/spacer
enabled: true
options: {}
order: 25
layout:
position: left
priority: 25
display: mobile-only
- source: github:quartz-community/bases-page
enabled: true
options: {}
order: 50
- source: github:quartz-community/note-properties
enabled: true
options:
includeAll: false
includedProperties:
- description
- tags
- aliases
excludedProperties: []
hidePropertiesView: false
delimiters: "---"
language: yaml
order: 5
layout:
position: beforeBody
priority: 15
display: all
layout:
groups:
toolbar:
priority: 35
direction: row
gap: 0.5rem
byPageType:
"404":
positions:
beforeBody: []
left: []
right: []
content: {}
folder:
exclude:
- reader-mode
positions:
right: []
tag:
exclude:
- reader-mode
positions:
right: []
canvas: {}
bases: {}
@@ -0,0 +1,277 @@
# yaml-language-server: $schema=../../plugins/quartz-plugins.schema.json
# Template: default
# A clean Quartz setup with sensible defaults.
configuration:
pageTitle: Quartz 5
pageTitleSuffix: ""
enableSPA: true
enablePopovers: true
analytics:
provider: plausible
locale: en-US
baseUrl: quartz.jzhao.xyz
ignorePatterns:
- private
- templates
- .obsidian
theme:
fontOrigin: googleFonts
cdnCaching: true
typography:
header: Schibsted Grotesk
body: Source Sans Pro
code: IBM Plex Mono
colors:
lightMode:
light: "#faf8f8"
lightgray: "#e5e5e5"
gray: "#b8b8b8"
darkgray: "#4e4e4e"
dark: "#2b2b2b"
secondary: "#284b63"
tertiary: "#84a59d"
highlight: rgba(143, 159, 169, 0.15)
textHighlight: "#fff23688"
darkMode:
light: "#161618"
lightgray: "#393639"
gray: "#646464"
darkgray: "#d4d4d4"
dark: "#ebebec"
secondary: "#7b97aa"
tertiary: "#84a59d"
highlight: rgba(143, 159, 169, 0.15)
textHighlight: "#b3aa0288"
plugins:
- source: github:quartz-community/created-modified-date
enabled: true
options:
defaultDateType: modified
priority:
- frontmatter
- git
- filesystem
order: 10
- source: github:quartz-community/syntax-highlighting
enabled: true
options:
theme:
light: github-light
dark: github-dark
keepBackground: false
order: 20
- source: github:quartz-community/obsidian-flavored-markdown
enabled: true
options:
enableInHtmlEmbed: false
enableCheckbox: true
order: 30
- source: github:quartz-community/github-flavored-markdown
enabled: true
order: 40
- source: github:quartz-community/table-of-contents
enabled: true
order: 50
layout:
position: right
priority: 30
- source: github:quartz-community/crawl-links
enabled: true
options:
markdownLinkResolution: shortest
order: 60
- source: github:quartz-community/description
enabled: true
order: 70
- source: github:quartz-community/latex
enabled: true
options:
renderEngine: katex
order: 80
- source: github:quartz-community/citations
enabled: false
order: 85
- source: github:quartz-community/hard-line-breaks
enabled: false
order: 90
- source: github:quartz-community/ox-hugo
enabled: false
order: 91
- source: github:quartz-community/roam
enabled: false
order: 92
- source: github:quartz-community/fonts
enabled: true
- source: github:quartz-community/remove-draft
enabled: true
- source: github:quartz-community/explicit-publish
enabled: false
- source: github:quartz-community/unlisted-pages
enabled: true
options: {}
order: 45
- source: github:quartz-community/encrypted-pages
enabled: true
options:
iterations: 600000
passwordField: password
unlistWhenEncrypted: false
outputPath: static/encryptedContentIndex.json
- source: github:quartz-community/stacked-pages
enabled: false
layout:
position: afterBody
priority: 50
display: all
- source: github:quartz-community/alias-redirects
enabled: true
- source: github:quartz-community/content-index
enabled: true
options:
enableSiteMap: true
enableRSS: true
- source: github:quartz-community/favicon
enabled: true
- source: github:quartz-community/og-image
enabled: true
- source: github:quartz-community/cname
enabled: true
- source: github:quartz-community/canvas-page
enabled: true
- source: github:quartz-community/content-page
enabled: true
- source: github:quartz-community/folder-page
enabled: true
- source: github:quartz-community/tag-page
enabled: true
- source: github:quartz-community/explorer
enabled: true
layout:
position: left
priority: 50
- source: github:quartz-community/graph
enabled: true
layout:
position: right
priority: 10
- source: github:quartz-community/search
enabled: true
layout:
position: left
priority: 20
group: toolbar
groupOptions:
grow: true
- source: github:quartz-community/backlinks
enabled: true
layout:
position: right
priority: 50
- source: github:quartz-community/article-title
enabled: true
layout:
position: beforeBody
priority: 10
- source: github:quartz-community/content-meta
enabled: true
layout:
position: beforeBody
priority: 20
- source: github:quartz-community/tag-list
enabled: false
layout:
position: beforeBody
priority: 30
- source: github:quartz-community/page-title
enabled: true
layout:
position: left
priority: 10
- source: github:quartz-community/darkmode
enabled: true
layout:
position: left
priority: 30
group: toolbar
- source: github:quartz-community/reader-mode
enabled: true
layout:
position: left
priority: 35
group: toolbar
- source: github:quartz-community/breadcrumbs
enabled: true
layout:
position: beforeBody
priority: 5
condition: not-index
- source: github:quartz-community/comments
enabled: false
options:
provider: giscus
options: {}
layout:
position: afterBody
priority: 10
- source: github:quartz-community/footer
enabled: true
options:
links:
GitHub: https://github.com/jackyzha0/quartz
Discord Community: https://discord.gg/cRFFHYye7t
- source: github:quartz-community/recent-notes
enabled: false
- source: github:quartz-community/spacer
enabled: true
options: {}
order: 25
layout:
position: left
priority: 25
display: mobile-only
- source: github:quartz-community/bases-page
enabled: true
options: {}
order: 50
- source: github:quartz-community/note-properties
enabled: true
options:
includeAll: false
includedProperties:
- description
- tags
- aliases
excludedProperties: []
hidePropertiesView: false
delimiters: "---"
language: yaml
order: 5
layout:
position: beforeBody
priority: 15
display: all
layout:
groups:
toolbar:
priority: 35
direction: row
gap: 0.5rem
byPageType:
"404":
positions:
beforeBody: []
left: []
right: []
content: {}
folder:
exclude:
- reader-mode
positions:
right: []
tag:
exclude:
- reader-mode
positions:
right: []
canvas: {}
bases: {}
@@ -0,0 +1,302 @@
# yaml-language-server: $schema=../../plugins/quartz-plugins.schema.json
# Template: obsidian
# Optimized for Obsidian vaults with full OFM support and shortest link resolution.
configuration:
pageTitle: Quartz 5
pageTitleSuffix: ""
enableSPA: true
enablePopovers: true
analytics:
provider: plausible
locale: en-US
baseUrl: quartz.jzhao.xyz
ignorePatterns:
- private
- templates
- .obsidian
theme:
fontOrigin: googleFonts
cdnCaching: true
typography:
header: Schibsted Grotesk
body: Source Sans Pro
code: IBM Plex Mono
colors:
lightMode:
light: "#faf8f8"
lightgray: "#e5e5e5"
gray: "#b8b8b8"
darkgray: "#4e4e4e"
dark: "#2b2b2b"
secondary: "#284b63"
tertiary: "#84a59d"
highlight: rgba(143, 159, 169, 0.15)
textHighlight: "#fff23688"
darkMode:
light: "#161618"
lightgray: "#393639"
gray: "#646464"
darkgray: "#d4d4d4"
dark: "#ebebec"
secondary: "#7b97aa"
tertiary: "#84a59d"
highlight: rgba(143, 159, 169, 0.15)
textHighlight: "#b3aa0288"
plugins:
- source: github:quartz-community/created-modified-date
enabled: true
options:
defaultDateType: modified
priority:
- frontmatter
- git
- filesystem
order: 10
- source: github:quartz-community/syntax-highlighting
enabled: true
options:
theme:
light: github-light
dark: github-dark
keepBackground: false
order: 20
- source: github:quartz-community/obsidian-flavored-markdown
enabled: true
options:
comments: true
highlight: true
wikilinks: true
callouts: true
mermaid: true
parseTags: true
parseArrows: true
parseBlockReferences: true
enableInHtmlEmbed: false
enableYouTubeEmbed: true
enableVideoEmbed: true
enableCheckbox: true
order: 30
- source: github:quartz-community/github-flavored-markdown
enabled: true
order: 40
- source: github:quartz-community/table-of-contents
enabled: true
order: 50
layout:
position: right
priority: 30
- source: github:quartz-community/crawl-links
enabled: true
options:
markdownLinkResolution: shortest
# disableBrokenWikilinks: false # Set true to add a "broken" CSS class to internal links whose target is not in ctx.allSlugs.
order: 60
- source: github:quartz-community/description
enabled: true
order: 70
- source: github:quartz-community/latex
enabled: true
options:
renderEngine: katex
order: 80
- source: github:quartz-community/citations
enabled: false
order: 85
- source: github:quartz-community/hard-line-breaks
enabled: true
order: 90
- source: github:quartz-community/ox-hugo
enabled: false
order: 91
- source: github:quartz-community/roam
enabled: false
order: 92
- source: github:quartz-community/fonts
enabled: true
- source: github:quartz-community/remove-draft
enabled: true
- source: github:quartz-community/explicit-publish
enabled: false
- source: github:quartz-community/unlisted-pages
enabled: true
options: {}
order: 45
- source: github:quartz-community/encrypted-pages
enabled: true
options:
iterations: 600000
passwordField: password
unlistWhenEncrypted: false
outputPath: static/encryptedContentIndex.json
- source: github:quartz-community/stacked-pages
enabled: false
layout:
position: afterBody
priority: 50
display: all
- source: github:quartz-community/alias-redirects
enabled: true
- source: github:quartz-community/content-index
enabled: true
options:
enableSiteMap: true
enableRSS: true
- source: github:quartz-community/favicon
enabled: true
- source: github:quartz-community/og-image
enabled: true
- source: github:quartz-community/cname
enabled: true
- source: github:quartz-community/canvas-page
enabled: true
- source: github:quartz-community/content-page
enabled: true
- source: github:quartz-community/folder-page
enabled: true
- source: github:quartz-community/tag-page
enabled: true
- source: github:quartz-community/explorer
enabled: true
layout:
position: left
priority: 50
- source: github:quartz-community/graph
enabled: true
layout:
position: right
priority: 10
- source: github:quartz-community/search
enabled: true
layout:
position: left
priority: 20
group: toolbar
groupOptions:
grow: true
- source: github:quartz-community/backlinks
enabled: true
layout:
position: right
priority: 50
- source: github:quartz-community/article-title
enabled: true
layout:
position: beforeBody
priority: 10
- source: github:quartz-community/content-meta
enabled: true
layout:
position: beforeBody
priority: 20
- source: github:quartz-community/tag-list
enabled: false
layout:
position: beforeBody
priority: 30
- source: github:quartz-community/page-title
enabled: true
layout:
position: left
priority: 10
- source: github:quartz-community/darkmode
enabled: true
layout:
position: left
priority: 30
group: toolbar
- source: github:quartz-community/reader-mode
enabled: true
layout:
position: left
priority: 35
group: toolbar
- source: github:quartz-community/breadcrumbs
enabled: true
layout:
position: beforeBody
priority: 5
condition: not-index
- source: github:quartz-community/comments
enabled: false
options:
provider: giscus
options: {}
layout:
position: afterBody
priority: 10
- source: github:quartz-community/footer
enabled: true
options:
links:
GitHub: https://github.com/jackyzha0/quartz
Discord Community: https://discord.gg/cRFFHYye7t
- source: github:quartz-community/recent-notes
enabled: false
- source: github:quartz-community/spacer
enabled: true
options: {}
order: 25
layout:
position: left
priority: 25
display: mobile-only
- source: github:quartz-community/bases-page
enabled: true
options: {}
order: 50
- source: github:quartz-community/note-properties
enabled: true
options:
includeAll: false
includedProperties:
- description
- tags
- aliases
excludedProperties: []
hidePropertiesView: false
delimiters: "---"
language: yaml
order: 5
layout:
position: beforeBody
priority: 15
display: all
- source:
name: quartz-themes
repo: github:saberzero1/quartz-themes
subdir: plugin
enabled: true
options:
theme: default
- source: github:quartz-community/obsidian-plugin-excalidraw
enabled: true
options:
enableInteraction: true
darkMode: auto
exportPadding: 20
order: 50
layout:
groups:
toolbar:
priority: 35
direction: row
gap: 0.5rem
byPageType:
"404":
positions:
beforeBody: []
left: []
right: []
content: {}
folder:
exclude:
- reader-mode
positions:
right: []
tag:
exclude:
- reader-mode
positions:
right: []
canvas: {}
bases: {}
@@ -0,0 +1,308 @@
# yaml-language-server: $schema=../../plugins/quartz-plugins.schema.json
# Template: ttrpg
# Obsidian-based setup with map plugin and ITS Theme for TTRPG/D&D wikis.
configuration:
pageTitle: Quartz 5
pageTitleSuffix: ""
enableSPA: true
enablePopovers: true
analytics:
provider: plausible
locale: en-US
baseUrl: quartz.jzhao.xyz
ignorePatterns:
- private
- templates
- .obsidian
theme:
fontOrigin: googleFonts
cdnCaching: true
typography:
header: Schibsted Grotesk
body: Source Sans Pro
code: IBM Plex Mono
colors:
lightMode:
light: "#faf8f8"
lightgray: "#e5e5e5"
gray: "#b8b8b8"
darkgray: "#4e4e4e"
dark: "#2b2b2b"
secondary: "#284b63"
tertiary: "#84a59d"
highlight: rgba(143, 159, 169, 0.15)
textHighlight: "#fff23688"
darkMode:
light: "#161618"
lightgray: "#393639"
gray: "#646464"
darkgray: "#d4d4d4"
dark: "#ebebec"
secondary: "#7b97aa"
tertiary: "#84a59d"
highlight: rgba(143, 159, 169, 0.15)
textHighlight: "#b3aa0288"
plugins:
- source: github:quartz-community/created-modified-date
enabled: true
options:
defaultDateType: modified
priority:
- frontmatter
- git
- filesystem
order: 10
- source: github:quartz-community/syntax-highlighting
enabled: true
options:
theme:
light: github-light
dark: github-dark
keepBackground: false
order: 20
- source: github:quartz-community/obsidian-flavored-markdown
enabled: true
options:
comments: true
highlight: true
wikilinks: true
callouts: true
mermaid: true
parseTags: true
parseArrows: true
parseBlockReferences: true
enableInHtmlEmbed: false
enableYouTubeEmbed: true
enableVideoEmbed: true
enableCheckbox: true
order: 30
- source: github:quartz-community/github-flavored-markdown
enabled: true
order: 40
- source: github:quartz-community/table-of-contents
enabled: true
order: 50
layout:
position: right
priority: 30
- source: github:quartz-community/crawl-links
enabled: true
options:
markdownLinkResolution: shortest
# disableBrokenWikilinks: false # Set true to add a "broken" CSS class to internal links whose target is not in ctx.allSlugs.
order: 60
- source: github:quartz-community/description
enabled: true
order: 70
- source: github:quartz-community/latex
enabled: true
options:
renderEngine: katex
order: 80
- source: github:quartz-community/citations
enabled: false
order: 85
- source: github:quartz-community/hard-line-breaks
enabled: true
order: 90
- source: github:quartz-community/ox-hugo
enabled: false
order: 91
- source: github:quartz-community/roam
enabled: false
order: 92
- source: github:quartz-community/fonts
enabled: true
- source: github:quartz-community/remove-draft
enabled: true
- source: github:quartz-community/explicit-publish
enabled: false
- source: github:quartz-community/unlisted-pages
enabled: true
options: {}
order: 45
- source: github:quartz-community/encrypted-pages
enabled: true
options:
iterations: 600000
passwordField: password
unlistWhenEncrypted: false
outputPath: static/encryptedContentIndex.json
- source: github:quartz-community/stacked-pages
enabled: false
layout:
position: afterBody
priority: 50
display: all
- source: github:quartz-community/alias-redirects
enabled: true
- source: github:quartz-community/content-index
enabled: true
options:
enableSiteMap: true
enableRSS: true
- source: github:quartz-community/favicon
enabled: true
- source: github:quartz-community/og-image
enabled: true
- source: github:quartz-community/cname
enabled: true
- source: github:quartz-community/canvas-page
enabled: true
- source: github:quartz-community/content-page
enabled: true
- source: github:quartz-community/folder-page
enabled: true
- source: github:quartz-community/tag-page
enabled: true
- source: github:quartz-community/explorer
enabled: true
layout:
position: left
priority: 50
- source: github:quartz-community/graph
enabled: true
layout:
position: right
priority: 10
- source: github:quartz-community/search
enabled: true
layout:
position: left
priority: 20
group: toolbar
groupOptions:
grow: true
- source: github:quartz-community/backlinks
enabled: true
layout:
position: right
priority: 50
- source: github:quartz-community/article-title
enabled: true
layout:
position: beforeBody
priority: 10
- source: github:quartz-community/content-meta
enabled: true
layout:
position: beforeBody
priority: 20
- source: github:quartz-community/tag-list
enabled: false
layout:
position: beforeBody
priority: 30
- source: github:quartz-community/page-title
enabled: true
layout:
position: left
priority: 10
- source: github:quartz-community/darkmode
enabled: true
layout:
position: left
priority: 30
group: toolbar
- source: github:quartz-community/reader-mode
enabled: true
layout:
position: left
priority: 35
group: toolbar
- source: github:quartz-community/breadcrumbs
enabled: true
layout:
position: beforeBody
priority: 5
condition: not-index
- source: github:quartz-community/comments
enabled: false
options:
provider: giscus
options: {}
layout:
position: afterBody
priority: 10
- source: github:quartz-community/footer
enabled: true
options:
links:
GitHub: https://github.com/jackyzha0/quartz
Discord Community: https://discord.gg/cRFFHYye7t
- source: github:quartz-community/recent-notes
enabled: false
- source: github:quartz-community/spacer
enabled: true
options: {}
order: 25
layout:
position: left
priority: 25
display: mobile-only
- source: github:quartz-community/bases-page
enabled: true
options: {}
order: 50
- source: github:quartz-community/note-properties
enabled: true
options:
includeAll: false
includedProperties:
- description
- tags
- aliases
excludedProperties: []
hidePropertiesView: false
delimiters: "---"
language: yaml
order: 5
layout:
position: beforeBody
priority: 15
display: all
# TTRPG-specific plugins
- source: github:Requiae/quartz-leaflet-bases-plugin
enabled: true
options:
enableCopyTool: false
- source:
name: quartz-themes
repo: github:saberzero1/quartz-themes
subdir: plugin
enabled: true
options:
theme: its-theme
variation: ttrpg-dnd
- source: github:quartz-community/obsidian-plugin-excalidraw
enabled: true
options:
enableInteraction: true
darkMode: auto
exportPadding: 20
order: 50
layout:
groups:
toolbar:
priority: 35
direction: row
gap: 0.5rem
byPageType:
"404":
positions:
beforeBody: []
left: []
right: []
content: {}
folder:
exclude:
- reader-mode
positions:
right: []
tag:
exclude:
- reader-mode
positions:
right: []
canvas: {}
bases: {}
@@ -0,0 +1,7 @@
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
const Body: QuartzComponent = ({ children }: QuartzComponentProps) => {
return <div id="quartz-body">{children}</div>
}
export default (() => Body) satisfies QuartzComponentConstructor
@@ -0,0 +1,22 @@
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
type ConditionalRenderConfig = {
component: QuartzComponent
condition: (props: QuartzComponentProps) => boolean
}
export default ((config: ConditionalRenderConfig) => {
const ConditionalRender: QuartzComponent = (props: QuartzComponentProps) => {
if (config.condition(props)) {
return <config.component {...props} />
}
return null
}
ConditionalRender.afterDOMLoaded = config.component.afterDOMLoaded
ConditionalRender.beforeDOMLoaded = config.component.beforeDOMLoaded
ConditionalRender.css = config.component.css
return ConditionalRender
}) satisfies QuartzComponentConstructor<ConditionalRenderConfig>
@@ -0,0 +1,30 @@
import { ValidLocale } from "../i18n"
import { QuartzPluginData } from "../plugins/vfile"
interface Props {
date: Date
locale?: ValidLocale
}
export type ValidDateType = keyof Required<QuartzPluginData>["dates"]
export function getDate(data: QuartzPluginData): Date | undefined {
if (!data.defaultDateType) {
throw new Error(
`Field 'defaultDateType' was not set. Ensure the CreatedModifiedDate plugin is configured with a 'defaultDateType' option. See https://quartz.jzhao.xyz/plugins/CreatedModifiedDate for more details.`,
)
}
return data.dates?.[data.defaultDateType]
}
export function formatDate(d: Date, locale: ValidLocale = "en-US"): string {
return d.toLocaleDateString(locale, {
year: "numeric",
month: "short",
day: "2-digit",
})
}
export function Date({ date, locale }: Props) {
return <time datetime={date.toISOString()}>{formatDate(date, locale)}</time>
}
@@ -0,0 +1,18 @@
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
export default ((component: QuartzComponent) => {
const Component = component
const DesktopOnly: QuartzComponent = (props: QuartzComponentProps) => {
return (
<div class="desktop-only">
<Component {...props} />
</div>
)
}
DesktopOnly.displayName = component.displayName
DesktopOnly.afterDOMLoaded = component?.afterDOMLoaded
DesktopOnly.beforeDOMLoaded = component?.beforeDOMLoaded
DesktopOnly.css = component?.css
return DesktopOnly
}) satisfies QuartzComponentConstructor<QuartzComponent>
@@ -0,0 +1,59 @@
import { concatenateResources } from "../util/resources"
import { classNames } from "../util/lang"
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
type FlexConfig = {
components: {
Component: QuartzComponent
grow?: boolean
shrink?: boolean
basis?: string
order?: number
align?: "start" | "end" | "center" | "stretch"
justify?: "start" | "end" | "center" | "between" | "around"
}[]
direction?: "row" | "row-reverse" | "column" | "column-reverse"
wrap?: "nowrap" | "wrap" | "wrap-reverse"
gap?: string
}
export default ((config: FlexConfig) => {
const Flex: QuartzComponent = (props: QuartzComponentProps) => {
const direction = config.direction ?? "row"
const wrap = config.wrap ?? "nowrap"
const gap = config.gap ?? "1rem"
return (
<div
class={classNames(props.displayClass, "flex-component")}
style={`flex-direction: ${direction}; flex-wrap: ${wrap}; gap: ${gap};`}
>
{config.components.map((c) => {
const grow = c.grow ? 1 : 0
const shrink = (c.shrink ?? true) ? 1 : 0
const basis = c.basis ?? "auto"
const order = c.order ?? 0
const align = c.align ?? "center"
const justify = c.justify ?? "center"
return (
<div
style={`flex-grow: ${grow}; flex-shrink: ${shrink}; flex-basis: ${basis}; order: ${order}; align-self: ${align}; justify-self: ${justify};`}
>
<c.Component {...props} />
</div>
)
})}
</div>
)
}
Flex.afterDOMLoaded = concatenateResources(
...config.components.map((c) => c.Component.afterDOMLoaded),
)
Flex.beforeDOMLoaded = concatenateResources(
...config.components.map((c) => c.Component.beforeDOMLoaded),
)
Flex.css = concatenateResources(...config.components.map((c) => c.Component.css))
return Flex
}) satisfies QuartzComponentConstructor<FlexConfig>
@@ -0,0 +1,114 @@
import { i18n } from "../i18n"
import { FullSlug, getFileExtension, joinSegments, pathToRoot } from "../util/path"
import { CSSResourceToStyleElement, JSResourceToScriptElement } from "../util/resources"
import { googleFontHref, googleFontSubsetHref } from "../util/theme"
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
import { unescapeHTML } from "../util/escape"
import { CustomOgImagesEmitterName } from "../../.quartz/plugins"
export default (() => {
const Head: QuartzComponent = ({
cfg,
fileData,
externalResources,
ctx,
}: QuartzComponentProps) => {
const titleSuffix = cfg.pageTitleSuffix ?? ""
const title =
(fileData.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title) + titleSuffix
const description =
fileData.frontmatter?.socialDescription ??
fileData.frontmatter?.description ??
unescapeHTML(fileData.description?.trim() ?? i18n(cfg.locale).propertyDefaults.description)
const { css, js, additionalHead } = externalResources
const url = new URL(`https://${cfg.baseUrl ?? "example.com"}`)
const path = url.pathname as FullSlug
const baseDir = fileData.slug === "404" ? path : pathToRoot(fileData.slug!)
const iconPath = joinSegments(baseDir, "static/icon.png")
// Url of current page
const socialUrl =
fileData.slug === "404" ? url.toString() : joinSegments(url.toString(), fileData.slug!)
const usesCustomOgImage = ctx.cfg.plugins.emitters.some(
(e) => e.name === CustomOgImagesEmitterName,
)
const ogImageDefaultPath = `https://${cfg.baseUrl}/static/og-image.png`
const coreStylesheet = css[0]?.content
const coreScript = js.find(
(r) => r.loadTime === "beforeDOMReady" && r.contentType === "external",
)
return (
<head>
<title>{title}</title>
<meta charSet="utf-8" />
{coreStylesheet && <link rel="preload" href={coreStylesheet} as="style" />}
{coreScript && coreScript.contentType === "external" && (
<link rel="preload" href={coreScript.src} as="script" />
)}
{cfg.theme.cdnCaching && cfg.theme.fontOrigin === "googleFonts" && (
<>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" />
<link rel="stylesheet" href={googleFontHref(cfg.theme)} />
{cfg.theme.typography.title && (
<link rel="stylesheet" href={googleFontSubsetHref(cfg.theme, cfg.pageTitle)} />
)}
</>
)}
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossOrigin="anonymous" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="og:site_name" content={cfg.pageTitle}></meta>
<meta property="og:title" content={title} />
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta property="og:description" content={description} />
<meta property="og:image:alt" content={description} />
{!usesCustomOgImage && (
<>
<meta property="og:image" content={ogImageDefaultPath} />
<meta property="og:image:url" content={ogImageDefaultPath} />
<meta name="twitter:image" content={ogImageDefaultPath} />
<meta
property="og:image:type"
content={`image/${getFileExtension(ogImageDefaultPath) ?? "png"}`}
/>
</>
)}
{cfg.baseUrl && (
<>
<meta property="twitter:domain" content={cfg.baseUrl}></meta>
<meta property="og:url" content={socialUrl}></meta>
<meta property="twitter:url" content={socialUrl}></meta>
</>
)}
<link rel="icon" href={iconPath} />
<meta name="description" content={description} />
<meta name="generator" content="Quartz" />
{css.map((resource) => CSSResourceToStyleElement(resource, true))}
{js
.filter((resource) => resource.loadTime === "beforeDOMReady")
.map((res) => JSResourceToScriptElement(res, true))}
{additionalHead.map((resource) => {
if (typeof resource === "function") {
return resource(fileData)
} else {
return resource
}
})}
</head>
)
}
return Head
}) satisfies QuartzComponentConstructor
@@ -0,0 +1,22 @@
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
const Header: QuartzComponent = ({ children }: QuartzComponentProps) => {
return children.length > 0 ? <header>{children}</header> : null
}
Header.css = `
header {
display: flex;
flex-direction: row;
align-items: center;
margin: 2rem 0;
gap: 1.5rem;
}
header h1 {
margin: 0;
flex: auto;
}
`
export default (() => Header) satisfies QuartzComponentConstructor
@@ -0,0 +1,18 @@
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
export default ((component: QuartzComponent) => {
const Component = component
const MobileOnly: QuartzComponent = (props: QuartzComponentProps) => {
return (
<div class="mobile-only">
<Component {...props} />
</div>
)
}
MobileOnly.displayName = component.displayName
MobileOnly.afterDOMLoaded = component?.afterDOMLoaded
MobileOnly.beforeDOMLoaded = component?.beforeDOMLoaded
MobileOnly.css = component?.css
return MobileOnly
}) satisfies QuartzComponentConstructor<QuartzComponent>
@@ -0,0 +1,114 @@
import { FullSlug, isFolderPath, resolveRelative } from "../util/path"
import { QuartzPluginData } from "../plugins/vfile"
import { Date, getDate } from "./Date"
import { QuartzComponent, QuartzComponentProps } from "./types"
export type SortFn = (f1: QuartzPluginData, f2: QuartzPluginData) => number
export function byDateAndAlphabetical(): SortFn {
return (f1, f2) => {
// Sort by date/alphabetical
if (f1.dates && f2.dates) {
// sort descending
return getDate(f2)!.getTime() - getDate(f1)!.getTime()
} else if (f1.dates && !f2.dates) {
// prioritize files with dates
return -1
} else if (!f1.dates && f2.dates) {
return 1
}
// otherwise, sort lexographically by title
const f1Title = f1.frontmatter?.title.toLowerCase() ?? ""
const f2Title = f2.frontmatter?.title.toLowerCase() ?? ""
return f1Title.localeCompare(f2Title)
}
}
export function byDateAndAlphabeticalFolderFirst(): SortFn {
return (f1, f2) => {
// Sort folders first
const f1IsFolder = isFolderPath(f1.slug ?? "")
const f2IsFolder = isFolderPath(f2.slug ?? "")
if (f1IsFolder && !f2IsFolder) return -1
if (!f1IsFolder && f2IsFolder) return 1
// If both are folders or both are files, sort by date/alphabetical
if (f1.dates && f2.dates) {
// sort descending
return getDate(f2)!.getTime() - getDate(f1)!.getTime()
} else if (f1.dates && !f2.dates) {
// prioritize files with dates
return -1
} else if (!f1.dates && f2.dates) {
return 1
}
// otherwise, sort lexographically by title
const f1Title = f1.frontmatter?.title.toLowerCase() ?? ""
const f2Title = f2.frontmatter?.title.toLowerCase() ?? ""
return f1Title.localeCompare(f2Title)
}
}
type Props = {
limit?: number
sort?: SortFn
} & QuartzComponentProps
export const PageList: QuartzComponent = ({ cfg, fileData, allFiles, limit, sort }: Props) => {
const sorter = sort ?? byDateAndAlphabeticalFolderFirst()
let list = allFiles.sort(sorter)
if (limit) {
list = list.slice(0, limit)
}
return (
<ul class="section-ul">
{list.map((page) => {
const title = page.frontmatter?.title
const tags = page.frontmatter?.tags ?? []
return (
<li class="section-li">
<div class="section">
<p class="meta">{page.dates && <Date date={getDate(page)!} locale={cfg.locale} />}</p>
<div class="desc">
<h3>
<a
href={resolveRelative(fileData.slug!, page.slug!)}
class="internal internal-link"
>
{title}
</a>
</h3>
</div>
<ul class="tags">
{tags.map((tag) => (
<li>
<a
class="internal tag-link"
href={resolveRelative(fileData.slug!, `tags/${tag}` as FullSlug)}
>
{tag}
</a>
</li>
))}
</ul>
</div>
</li>
)
})}
</ul>
)
}
PageList.css = `
.section h3 {
margin: 0;
}
.section > .tags {
margin: 0;
}
`
@@ -0,0 +1,8 @@
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
import { classNames } from "../util/lang"
function Spacer({ displayClass }: QuartzComponentProps) {
return <div class={classNames(displayClass, "spacer")}></div>
}
export default (() => Spacer) satisfies QuartzComponentConstructor
@@ -0,0 +1,23 @@
import { componentRegistry } from "./registry"
import { QuartzComponent, QuartzComponentConstructor } from "./types"
export function External<Options extends object | undefined>(
name: string,
options?: Options,
): QuartzComponent {
const registered = componentRegistry.get(name)
if (!registered) {
throw new Error(
`External component "${name}" not found. ` +
`Make sure the plugin is installed and components are loaded before layouts are evaluated.`,
)
}
const { component } = registered
if (typeof component === "function") {
return (component as QuartzComponentConstructor<Options>)(options as Options)
}
return component as QuartzComponent
}
@@ -0,0 +1,61 @@
import { PageFrame, PageFrameProps } from "./types"
import HeaderConstructor from "../Header"
const Header = HeaderConstructor()
/**
* The default page frame — three-column layout with left sidebar, center
* content (header + body + afterBody), and right sidebar, followed by a footer.
*
* This is the original Quartz layout, extracted from renderPage.tsx.
*/
export const DefaultFrame: PageFrame = {
name: "default",
render({
componentData,
header,
beforeBody,
pageBody: Content,
afterBody,
left,
right,
footer: Footer,
}: PageFrameProps) {
return (
<>
<div class="left sidebar">
{left.map((BodyComponent) => (
<BodyComponent {...componentData} />
))}
</div>
<div class="center">
<div class="page-header">
<Header {...componentData}>
{header.map((HeaderComponent) => (
<HeaderComponent {...componentData} />
))}
</Header>
<div class="popover-hint">
{beforeBody.map((BodyComponent) => (
<BodyComponent {...componentData} />
))}
</div>
</div>
<Content {...componentData} />
<hr />
<div class="page-footer">
{afterBody.map((BodyComponent) => (
<BodyComponent {...componentData} />
))}
</div>
</div>
<div class="right sidebar">
{right.map((BodyComponent) => (
<BodyComponent {...componentData} />
))}
</div>
<Footer {...componentData} />
</>
)
},
}
@@ -0,0 +1,51 @@
import { PageFrame, PageFrameProps } from "./types"
import HeaderConstructor from "../Header"
const Header = HeaderConstructor()
/**
* Full-width page frame — no sidebars. The center content area spans the
* full width of the page. Header, beforeBody, body, afterBody, and footer
* are all rendered in a single column.
*
* Useful for page types like Canvas, presentations, or dashboards that
* need maximum horizontal space.
*/
export const FullWidthFrame: PageFrame = {
name: "full-width",
render({
componentData,
header,
beforeBody,
pageBody: Content,
afterBody,
footer: Footer,
}: PageFrameProps) {
return (
<>
<div class="center full-width">
<div class="page-header">
<Header {...componentData}>
{header.map((HeaderComponent) => (
<HeaderComponent {...componentData} />
))}
</Header>
<div class="popover-hint">
{beforeBody.map((BodyComponent) => (
<BodyComponent {...componentData} />
))}
</div>
</div>
<Content {...componentData} />
<hr />
<div class="page-footer">
{afterBody.map((BodyComponent) => (
<BodyComponent {...componentData} />
))}
</div>
</div>
<Footer {...componentData} />
</>
)
},
}
@@ -0,0 +1,23 @@
import { PageFrame, PageFrameProps } from "./types"
/**
* Minimal page frame — no sidebars, no header/footer chrome. Only the
* page body is rendered with a thin wrapper, plus the footer for legal/link
* obligations.
*
* Useful for immersive page types like full-screen canvases, kiosks,
* or custom landing pages that want complete control of the viewport.
*/
export const MinimalFrame: PageFrame = {
name: "minimal",
render({ componentData, pageBody: Content, footer: Footer }: PageFrameProps) {
return (
<>
<div class="center minimal">
<Content {...componentData} />
</div>
<Footer {...componentData} />
</>
)
},
}
@@ -0,0 +1,52 @@
import { PageFrame } from "./types"
import { DefaultFrame } from "./DefaultFrame"
import { FullWidthFrame } from "./FullWidthFrame"
import { MinimalFrame } from "./MinimalFrame"
import { frameRegistry } from "./registry"
export type { PageFrame, PageFrameProps } from "./types"
export { DefaultFrame } from "./DefaultFrame"
export { FullWidthFrame } from "./FullWidthFrame"
export { MinimalFrame } from "./MinimalFrame"
export { frameRegistry } from "./registry"
export type { RegisteredFrame } from "./registry"
/**
* Registry of built-in page frames. Page types can reference these by name
* via their `frame` property, and YAML config can override via
* `layout.byPageType.<name>.template`.
*
* The "default" frame reproduces the original three-column Quartz layout.
*/
const builtinFrames: Record<string, PageFrame> = {
default: DefaultFrame,
"full-width": FullWidthFrame,
minimal: MinimalFrame,
}
/**
* Resolve a frame by name. Checks plugin-registered frames first,
* then built-in frames, then falls back to DefaultFrame.
*/
export function resolveFrame(name: string | undefined): PageFrame {
if (!name || name === "default") {
return DefaultFrame
}
// Check plugin-registered frames first
const registered = frameRegistry.get(name)
if (registered) {
return registered.frame
}
// Fall back to built-in frames
const frame = builtinFrames[name]
if (!frame) {
const allFrameNames = [...Object.keys(builtinFrames), ...[...frameRegistry.getAll().keys()]]
console.warn(
`Unknown page frame "${name}", falling back to "default". Available frames: ${allFrameNames.join(", ")}`,
)
return DefaultFrame
}
return frame
}
@@ -0,0 +1,34 @@
import { PageFrame } from "./types"
export interface RegisteredFrame {
frame: PageFrame
source: string
}
class FrameRegistry {
private frames = new Map<string, RegisteredFrame>()
register(name: string, frame: PageFrame, source: string): void {
const existing = this.frames.get(name)
if (existing && existing.source !== source) {
console.warn(
`Page frame "${name}" from ${source} is overwriting frame from ${existing.source}`,
)
}
this.frames.set(name, { frame, source })
}
get(name: string): RegisteredFrame | undefined {
return this.frames.get(name)
}
getAll(): Map<string, RegisteredFrame> {
return new Map(this.frames)
}
has(name: string): boolean {
return this.frames.has(name)
}
}
export const frameRegistry = new FrameRegistry()
@@ -0,0 +1,43 @@
import { JSX } from "preact"
import { QuartzComponent, QuartzComponentProps } from "../types"
/**
* Props passed to a PageFrame's render function.
* Contains the resolved layout components and the shared component data.
*/
export interface PageFrameProps {
/** Component data shared across all components on the page */
componentData: QuartzComponentProps
/** The Head component (rendered in <head>) — NOT used by frames, included for completeness */
head: QuartzComponent
/** Header slot components (rendered inside <header>) */
header: QuartzComponent[]
/** Components rendered before the page body */
beforeBody: QuartzComponent[]
/** The page body component (Content) */
pageBody: QuartzComponent
/** Components rendered after the page body */
afterBody: QuartzComponent[]
/** Left sidebar components */
left: QuartzComponent[]
/** Right sidebar components */
right: QuartzComponent[]
/** Footer component */
footer: QuartzComponent
}
/**
* A PageFrame defines the inner HTML structure of a page inside the
* `<div id="quartz-root">` shell. Different frames can produce completely
* different layouts (e.g. with/without sidebars, horizontal scroll, etc.)
* while the outer shell (html, head, body, quartz-root) remains stable
* for SPA navigation.
*/
export interface PageFrame {
/** Unique name for this frame (e.g. "default", "full-width", "minimal") */
name: string
/** Render the inner page structure. Returns a JSX tree to be placed inside Body > #quartz-body. */
render: (props: PageFrameProps) => JSX.Element
/** Optional CSS string to include when this frame is active */
css?: string
}
@@ -0,0 +1,14 @@
import NotFound from "./pages/404"
import Head from "./Head"
import Spacer from "./Spacer"
import DesktopOnly from "./DesktopOnly"
import MobileOnly from "./MobileOnly"
import Flex from "./Flex"
import ConditionalRender from "./ConditionalRender"
export { componentRegistry, defineComponent } from "./registry"
export { External } from "./external"
export type { ComponentManifest, RegisteredComponent } from "./registry"
export type { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
export { Head, Spacer, DesktopOnly, MobileOnly, NotFound, Flex, ConditionalRender }
@@ -0,0 +1,54 @@
import { i18n } from "../../i18n"
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types"
const NotFound: QuartzComponent = ({ cfg, ctx }: QuartzComponentProps) => {
const url = new URL(`https://${cfg.baseUrl ?? "example.com"}`)
const baseDir = ctx.argv.serve ? "/" : url.pathname
return (
<article class="popover-hint">
<h1>404</h1>
<p>{i18n(cfg.locale).pages.error.notFound}</p>
<a href={baseDir}>{i18n(cfg.locale).pages.error.home}</a>
<script
dangerouslySetInnerHTML={{
__html: `
if (typeof fetchData !== "undefined") {
fetchData.then(function(index) {
var basePath = document.body.dataset.basepath || "";
if (basePath.length > 1 && basePath.endsWith("/")) {
basePath = basePath.slice(0, -1);
}
var pathname = window.location.pathname;
var hasBasePrefix = basePath.length > 1 && pathname.startsWith(basePath);
if (hasBasePrefix) {
pathname = pathname.slice(basePath.length);
}
if (pathname.startsWith("/")) {
pathname = pathname.slice(1);
}
if (pathname.endsWith("/")) {
pathname = pathname.slice(0, -1);
}
if (pathname.endsWith(".html")) {
pathname = pathname.slice(0, -5);
}
if (pathname.endsWith("/index")) {
pathname = pathname.slice(0, -6);
}
var lowered = pathname.toLowerCase();
if (lowered !== pathname && index[lowered] != null) {
var prefix = hasBasePrefix ? basePath : "";
var target = prefix + (prefix.endsWith("/") ? "" : "/") + lowered;
window.location.replace(target);
}
});
}
`,
}}
/>
</article>
)
}
export default (() => NotFound) satisfies QuartzComponentConstructor
@@ -0,0 +1,132 @@
import { QuartzComponent, QuartzComponentConstructor } from "./types"
export interface ComponentManifest {
name: string
displayName: string
description: string
version: string
quartzVersion?: string
author?: string
homepage?: string
}
export interface RegisteredComponent {
component: QuartzComponent | QuartzComponentConstructor
source: string
manifest?: ComponentManifest
}
class ComponentRegistry {
private components = new Map<string, RegisteredComponent>()
private instanceCache = new Map<string, QuartzComponent>()
private optionOverrides = new Map<string, Record<string, unknown>>()
register(
name: string,
component: QuartzComponent | QuartzComponentConstructor,
source: string,
manifest?: ComponentManifest,
): void {
const existing = this.components.get(name)
if (existing && existing.source !== source) {
console.warn(`Component "${name}" is being overwritten by ${source}`)
}
this.components.set(name, { component, source, manifest })
}
get(name: string): RegisteredComponent | undefined {
return this.components.get(name)
}
getAll(): Map<string, RegisteredComponent> {
return new Map(this.components)
}
/** Store option overrides for a plugin, keyed by plugin directory name. */
setOptionOverrides(pluginName: string, opts?: Record<string, unknown>): void {
if (!opts || Object.keys(opts).length === 0) return
this.optionOverrides.set(pluginName, { ...this.optionOverrides.get(pluginName), ...opts })
this.instanceCache.clear()
}
getOptionOverrides(pluginName: string): Record<string, unknown> | undefined {
return this.optionOverrides.get(pluginName)
}
/**
* Instantiate a component constructor with options, returning a cached instance
* if the same constructor was already called with equivalent options.
* This prevents duplicate afterDOMLoaded scripts when the same component
* appears in multiple page-type layouts.
*/
instantiate(
constructor: QuartzComponentConstructor<any>,
options?: Record<string, unknown>,
): QuartzComponent {
const optsKey = options !== undefined ? JSON.stringify(options) : ""
// Use constructor identity + serialized options as cache key
// We store constructor name as a hint but rely on a unique id for identity
const ctorId =
(constructor as unknown as { __cacheId?: string }).__cacheId ??
((constructor as unknown as { __cacheId: string }).__cacheId =
`ctor_${this.instanceCache.size}`)
const cacheKey = `${ctorId}:${optsKey}`
const cached = this.instanceCache.get(cacheKey)
if (cached) return cached
const instance = constructor(options)
this.instanceCache.set(cacheKey, instance)
return instance
}
getAllComponents(): QuartzComponent[] {
// Deduplicate by component reference (same constructor may be registered under multiple keys)
const seen = new Set<QuartzComponent | QuartzComponentConstructor>()
const results: QuartzComponent[] = []
for (const r of this.components.values()) {
if (seen.has(r.component)) continue
seen.add(r.component)
try {
let instance: QuartzComponent
if (typeof r.component === "function") {
// Check if this constructor was already instantiated (with any options).
// Re-instantiating with `undefined` when options were provided would create
// a duplicate instance with separate afterDOMLoaded scripts.
const existing = this.findCachedInstance(r.component as QuartzComponentConstructor)
instance =
existing ?? this.instantiate(r.component as QuartzComponentConstructor, undefined)
} else {
instance = r.component as QuartzComponent
}
if (instance) {
results.push(instance)
}
} catch {
// Skip components that fail to instantiate
}
}
return results
}
private findCachedInstance(
constructor: QuartzComponentConstructor<any>,
): QuartzComponent | undefined {
const ctorId = (constructor as unknown as { __cacheId?: string }).__cacheId
if (!ctorId) return undefined
for (const [key, instance] of this.instanceCache) {
if (key.startsWith(`${ctorId}:`)) return instance
}
return undefined
}
}
export const componentRegistry = new ComponentRegistry()
export function defineComponent<Options extends object | undefined = undefined>(
factory: QuartzComponentConstructor<Options>,
manifest: ComponentManifest,
): QuartzComponentConstructor<Options> {
;(factory as any).__quartzComponent = { manifest }
return factory
}
@@ -0,0 +1,327 @@
import test, { describe } from "node:test"
import assert from "node:assert"
import { renderTranscludes, pageResources } from "./renderPage"
import { Root, Element } from "hast"
import { FullSlug } from "../util/path"
import { GlobalConfiguration } from "../cfg"
import { QuartzComponentProps } from "./types"
import { StaticResources } from "../util/resources"
function makeTranscludeBlockquote(targetSlug: string, block?: string): Element {
return {
type: "element",
tagName: "blockquote",
properties: {
className: ["transclude"],
...(block ? { dataBlock: block } : {}),
},
children: [
{
type: "element",
tagName: "a",
properties: {
href: `./${targetSlug}`,
"data-slug": targetSlug,
className: ["transclude-inner"],
},
children: [{ type: "text", value: `Transclude of ${targetSlug}` }],
},
],
}
}
function makePageData(slug: string, htmlAst: Root, extra?: Record<string, unknown>) {
return {
slug: slug as FullSlug,
htmlAst,
frontmatter: { title: slug, tags: [] },
...extra,
} as unknown as QuartzComponentProps["allFiles"][number]
}
const cfg = { locale: "en-US" } as GlobalConfiguration
function makeComponentData(
allFiles: QuartzComponentProps["allFiles"],
): Pick<QuartzComponentProps, "allFiles" | "cfg"> {
return { allFiles, cfg } as unknown as QuartzComponentProps
}
describe("renderTranscludes", () => {
test("resolves a single page transclusion", () => {
const root: Root = {
type: "root",
children: [makeTranscludeBlockquote("target")],
}
const targetHtml: Root = {
type: "root",
children: [
{
type: "element",
tagName: "p",
properties: {},
children: [{ type: "text", value: "Target content" }],
},
],
}
const allFiles = [makePageData("target", targetHtml)]
const visited = new Set<FullSlug>(["current" as FullSlug])
renderTranscludes(
root,
cfg,
"current" as FullSlug,
makeComponentData(allFiles) as QuartzComponentProps,
visited,
)
const bq = root.children[0] as Element
const texts = JSON.stringify(bq.children)
assert.ok(texts.includes("Target content"), "transcluded content should be inlined")
})
test("allows the same page to be embedded twice as siblings", () => {
const root: Root = {
type: "root",
children: [makeTranscludeBlockquote("target"), makeTranscludeBlockquote("target")],
}
const targetHtml: Root = {
type: "root",
children: [
{
type: "element",
tagName: "p",
properties: {},
children: [{ type: "text", value: "Duplicated content" }],
},
],
}
const allFiles = [makePageData("target", targetHtml)]
const visited = new Set<FullSlug>(["current" as FullSlug])
renderTranscludes(
root,
cfg,
"current" as FullSlug,
makeComponentData(allFiles) as QuartzComponentProps,
visited,
)
const first = root.children[0] as Element
const second = root.children[1] as Element
const firstText = JSON.stringify(first.children)
const secondText = JSON.stringify(second.children)
assert.ok(firstText.includes("Duplicated content"), "first embed should resolve")
assert.ok(
secondText.includes("Duplicated content"),
"second embed should resolve, not be rejected as circular",
)
assert.ok(!secondText.includes("Circular transclusion"), "should not show circular warning")
})
test("allows different sections of the same page to be embedded", () => {
const root: Root = {
type: "root",
children: [
makeTranscludeBlockquote("target", "#intro"),
makeTranscludeBlockquote("target", "#details"),
],
}
const targetHtml: Root = {
type: "root",
children: [
{
type: "element",
tagName: "h2",
properties: { id: "intro" },
children: [{ type: "text", value: "Intro" }],
},
{
type: "element",
tagName: "p",
properties: {},
children: [{ type: "text", value: "Intro text" }],
},
{
type: "element",
tagName: "h2",
properties: { id: "details" },
children: [{ type: "text", value: "Details" }],
},
{
type: "element",
tagName: "p",
properties: {},
children: [{ type: "text", value: "Details text" }],
},
],
}
const allFiles = [makePageData("target", targetHtml)]
const visited = new Set<FullSlug>(["current" as FullSlug])
renderTranscludes(
root,
cfg,
"current" as FullSlug,
makeComponentData(allFiles) as QuartzComponentProps,
visited,
)
const first = root.children[0] as Element
const second = root.children[1] as Element
const firstText = JSON.stringify(first.children)
const secondText = JSON.stringify(second.children)
assert.ok(firstText.includes("Intro text"), "first header section should resolve")
assert.ok(
!firstText.includes("Details text"),
"first section should not include second section",
)
assert.ok(secondText.includes("Details text"), "second header section should resolve")
assert.ok(!secondText.includes("Circular transclusion"), "should not show circular warning")
})
test("detects actual circular transclusion (A -> B -> A)", () => {
// Page A embeds B, and B's htmlAst contains a transclusion of A
const bTranscludesA = makeTranscludeBlockquote("pageA")
const pageB_htmlAst: Root = {
type: "root",
children: [
{
type: "element",
tagName: "p",
properties: {},
children: [{ type: "text", value: "Page B content" }],
},
bTranscludesA,
],
}
const pageA_htmlAst: Root = {
type: "root",
children: [
{
type: "element",
tagName: "p",
properties: {},
children: [{ type: "text", value: "Page A content" }],
},
],
}
const root: Root = {
type: "root",
children: [makeTranscludeBlockquote("pageB")],
}
const allFiles = [makePageData("pageA", pageA_htmlAst), makePageData("pageB", pageB_htmlAst)]
const visited = new Set<FullSlug>(["pageA" as FullSlug])
renderTranscludes(
root,
cfg,
"pageA" as FullSlug,
makeComponentData(allFiles) as QuartzComponentProps,
visited,
)
const bq = root.children[0] as Element
const fullText = JSON.stringify(bq.children)
assert.ok(fullText.includes("Page B content"), "page B content should be inlined")
assert.ok(fullText.includes("Circular transclusion"), "circular A->B->A should be detected")
assert.ok(!fullText.includes("Page A content"), "page A should not be re-inlined inside B")
})
test("self-referencing transclusion is blocked", () => {
const root: Root = {
type: "root",
children: [makeTranscludeBlockquote("self")],
}
const selfHtml: Root = {
type: "root",
children: [
{
type: "element",
tagName: "p",
properties: {},
children: [{ type: "text", value: "Self content" }],
},
],
}
const allFiles = [makePageData("self", selfHtml)]
const visited = new Set<FullSlug>(["self" as FullSlug])
renderTranscludes(
root,
cfg,
"self" as FullSlug,
makeComponentData(allFiles) as QuartzComponentProps,
visited,
)
const bq = root.children[0] as Element
const text = JSON.stringify(bq.children)
assert.ok(text.includes("Circular transclusion"), "self-reference should be blocked")
})
})
describe("pageResources", () => {
const emptyResources: StaticResources = {
css: [],
js: [],
additionalHead: [],
}
test("uses baseDir prefix for resource paths in production mode", () => {
const result = pageResources("/quartz" as FullSlug, emptyResources)
assert.ok(
result.css[0].content.startsWith("/quartz/"),
`expected css path to start with /quartz/, got: ${result.css[0].content}`,
)
const externalJs = result.js.find((j) => j.contentType === "external" && "src" in j)
assert.ok(externalJs && "src" in externalJs)
assert.ok(
externalJs.src.startsWith("/quartz/"),
`expected js src to start with /quartz/, got: ${externalJs.src}`,
)
})
test("omits subpath prefix when baseDir is empty (serve mode)", () => {
const result = pageResources("." as FullSlug, emptyResources)
for (const css of result.css) {
assert.ok(
!css.content.includes("/quartz/"),
`css path should not contain /quartz/, got: ${css.content}`,
)
}
for (const js of result.js) {
if (js.contentType === "external" && "src" in js) {
assert.ok(
!js.src.includes("/quartz/"),
`js src should not contain /quartz/, got: ${js.src}`,
)
}
}
})
test("contentIndex path reflects baseDir", () => {
const withPrefix = pageResources("/quartz" as FullSlug, emptyResources)
const inlineJs = withPrefix.js.find((j) => j.contentType === "inline" && "script" in j)
assert.ok(inlineJs && "script" in inlineJs)
assert.ok(
inlineJs.script.includes("/quartz/static/contentIndex.json"),
`expected contentIndex fetch to include /quartz/ prefix, got: ${inlineJs.script}`,
)
const withoutPrefix = pageResources("." as FullSlug, emptyResources)
const inlineJsServe = withoutPrefix.js.find((j) => j.contentType === "inline" && "script" in j)
assert.ok(inlineJsServe && "script" in inlineJsServe)
assert.ok(
!inlineJsServe.script.includes("/quartz/static/contentIndex.json"),
`expected contentIndex fetch without /quartz/ prefix in serve mode, got: ${inlineJsServe.script}`,
)
})
})
@@ -0,0 +1,376 @@
import { render } from "preact-render-to-string"
import { QuartzComponent, QuartzComponentProps } from "./types"
import BodyConstructor from "./Body"
import {
CSSResource,
JSResource,
JSResourceToScriptElement,
StaticResources,
} from "../util/resources"
import { FullSlug, RelativeURL, joinSegments, normalizeHastElement } from "../util/path"
import { clone } from "../util/clone"
import { Root, Element, ElementContent } from "hast"
import { GlobalConfiguration } from "../cfg"
import { i18n } from "../i18n"
import { styleText } from "util"
import { resolveFrame } from "./frames"
import type { TreeTransform } from "../plugins/types"
import type { BuildCtx } from "../util/ctx"
interface RenderComponents {
head: QuartzComponent
header: QuartzComponent[]
beforeBody: QuartzComponent[]
pageBody: QuartzComponent
afterBody: QuartzComponent[]
left: QuartzComponent[]
right: QuartzComponent[]
footer: QuartzComponent
frame?: string
}
const headerRegex = new RegExp(/h[1-6]/)
export function pageResources(
baseDir: FullSlug | RelativeURL,
staticResources: StaticResources,
ctx?: BuildCtx,
): StaticResources {
const hashedNames = ctx?.hashedResourceNames
const cssFile = hashedNames?.["index.css"] ?? "index.css"
const prescriptFile = hashedNames?.["prescript.js"] ?? "prescript.js"
const postscriptFile = hashedNames?.["postscript.js"] ?? "postscript.js"
const componentCssResources: CSSResource[] = []
if (ctx?.componentCssMap) {
const seen = new Set<string>()
for (const filename of ctx.componentCssMap.values()) {
if (seen.has(filename)) continue
seen.add(filename)
componentCssResources.push({ content: joinSegments(baseDir, filename) })
}
}
const extracted = ctx?.extractedInlineResources
const resolvedCss: CSSResource[] = staticResources.css.map((resource) => {
if (!(resource.inline ?? false) || !extracted) return resource
const filename = extracted.get(resource.content)
if (!filename) return resource
return { content: joinSegments(baseDir, filename) }
})
const resolvedJs: JSResource[] = staticResources.js.map((resource) => {
if (resource.contentType !== "inline" || !extracted) return resource
const filename = extracted.get(resource.script)
if (!filename) return resource
return {
src: joinSegments(baseDir, filename),
loadTime: resource.loadTime,
contentType: "external" as const,
moduleType: resource.moduleType,
spaPreserve: resource.spaPreserve,
}
})
const contentIndexPath = joinSegments(baseDir, "static/contentIndex.json")
const contentIndexScript = `const fetchData = fetch("${contentIndexPath}").then(data => data.json())`
const resources: StaticResources = {
css: [
{
content: joinSegments(baseDir, cssFile),
},
...componentCssResources,
...resolvedCss,
],
js: [
{
src: joinSegments(baseDir, prescriptFile),
loadTime: "beforeDOMReady",
contentType: "external",
},
{
loadTime: "beforeDOMReady",
contentType: "inline",
spaPreserve: true,
script: contentIndexScript,
},
...resolvedJs,
],
additionalHead: staticResources.additionalHead,
}
resources.js.push({
src: joinSegments(baseDir, postscriptFile),
loadTime: "afterDOMReady",
moduleType: "module",
contentType: "external",
})
return resources
}
/** @internal Exported for testing only. */
export function renderTranscludes(
root: Root,
cfg: GlobalConfiguration,
slug: FullSlug,
componentData: QuartzComponentProps,
visited: Set<FullSlug>,
) {
// Walk the tree manually instead of using visit() so we can track the
// ancestor chain for cycle detection. visit() runs the callback before
// descending into replaced children, so a Set-based guard there falsely
// rejects sibling transclusions of the same target.
function walk(node: Element | Root) {
const children = (node as Root).children ?? []
for (let i = 0; i < children.length; i++) {
const child = children[i]
if (child?.type !== "element") continue
const el = child as Element
if (el.tagName !== "blockquote") {
walk(el)
continue
}
const classNames = (el.properties?.className ?? []) as string[]
if (!classNames.includes("transclude")) {
walk(el)
continue
}
const inner = el.children[0] as Element
const transcludeTarget = (inner.properties["data-slug"] ?? slug) as FullSlug
if (visited.has(transcludeTarget)) {
console.warn(
styleText(
"yellow",
`Warning: Skipping circular transclusion: ${slug} -> ${transcludeTarget}`,
),
)
el.children = [
{
type: "element",
tagName: "p",
properties: { style: "color: var(--secondary);" },
children: [
{
type: "text",
value: `Circular transclusion detected: ${transcludeTarget}`,
},
],
},
]
continue
}
visited.add(transcludeTarget)
let page = componentData.allFiles.find((f) => f.slug === transcludeTarget)
if (!page) {
const dotIdx = transcludeTarget.lastIndexOf(".")
const slashIdx = transcludeTarget.lastIndexOf("/")
if (dotIdx > slashIdx + 1) {
const stripped = transcludeTarget.slice(0, dotIdx) as FullSlug
page = componentData.allFiles.findLast((f) => f.slug === stripped)
}
}
if (!page) {
visited.delete(transcludeTarget)
continue
}
let blockRef = el.properties.dataBlock as string | undefined
if (blockRef?.startsWith("#^")) {
// block transclude
blockRef = blockRef.slice("#^".length)
let blockNode = page.blocks?.[blockRef]
if (blockNode) {
if (blockNode.tagName === "li") {
blockNode = {
type: "element",
tagName: "ul",
properties: {},
children: [blockNode],
}
}
el.children = [
normalizeHastElement(blockNode, slug, transcludeTarget),
{
type: "element",
tagName: "a",
properties: {
href: inner.properties?.href,
class: ["internal", "internal-link", "transclude-src"],
},
children: [
{ type: "text", value: i18n(cfg.locale).components.transcludes.linkToOriginal },
],
},
]
}
} else if (blockRef?.startsWith("#") && page.htmlAst) {
// header transclude
blockRef = blockRef.slice(1)
let startIdx = undefined
let startDepth = undefined
let endIdx = undefined
for (const [i, htmlEl] of page.htmlAst.children.entries()) {
if (!(htmlEl.type === "element" && htmlEl.tagName.match(headerRegex))) continue
const depth = Number(htmlEl.tagName.substring(1))
if (startIdx === undefined || startDepth === undefined) {
if (htmlEl.properties?.id === blockRef) {
startIdx = i
startDepth = depth
}
} else if (depth <= startDepth) {
endIdx = i
break
}
}
if (startIdx === undefined) {
visited.delete(transcludeTarget)
continue
}
el.children = [
...(page.htmlAst.children.slice(startIdx, endIdx) as ElementContent[]).map((c) =>
normalizeHastElement(c as Element, slug, transcludeTarget),
),
{
type: "element",
tagName: "a",
properties: {
href: inner.properties?.href,
class: ["internal", "internal-link", "transclude-src"],
},
children: [
{ type: "text", value: i18n(cfg.locale).components.transcludes.linkToOriginal },
],
},
]
} else if (page.htmlAst) {
// page transclude
el.children = [
{
type: "element",
tagName: "h1",
properties: {},
children: [
{
type: "text",
value:
page.frontmatter?.title ??
i18n(cfg.locale).components.transcludes.transcludeOf({
targetSlug: page.slug!,
}),
},
],
},
...(page.htmlAst.children as ElementContent[]).map((c) =>
normalizeHastElement(c as Element, slug, transcludeTarget),
),
{
type: "element",
tagName: "a",
properties: {
href: inner.properties?.href,
class: ["internal", "internal-link", "transclude-src"],
},
children: [
{ type: "text", value: i18n(cfg.locale).components.transcludes.linkToOriginal },
],
},
]
}
// Recurse into the replaced children to resolve nested transclusions,
// then remove from visited so sibling embeds of the same target work.
walk(el)
visited.delete(transcludeTarget)
}
}
walk(root)
}
export function renderPage(
cfg: GlobalConfiguration,
slug: FullSlug,
componentData: QuartzComponentProps,
components: RenderComponents,
pageResources: StaticResources,
treeTransforms?: TreeTransform[],
): string {
// make a deep copy of the tree so we don't remove the transclusion references
// for the file cached in contentMap in build.ts
const root = clone(componentData.tree) as Root
const visited = new Set<FullSlug>([slug])
renderTranscludes(root, cfg, slug, componentData, visited)
// Run plugin-provided tree transforms (e.g. resolving inline bases codeblocks)
if (treeTransforms) {
for (const transform of treeTransforms) {
transform(root, slug, componentData)
}
}
// set componentData.tree to the edited html that has transclusions rendered
componentData.tree = root
const {
head: Head,
header,
beforeBody,
pageBody: Content,
afterBody,
left,
right,
footer: Footer,
frame: frameName,
} = components
const Body = BodyConstructor()
const frame = resolveFrame(frameName)
const lang = componentData.fileData.frontmatter?.lang ?? cfg.locale?.split("-")[0] ?? "en"
const direction = i18n(cfg.locale).direction ?? "ltr"
// During local dev (--serve), the dev server serves from root without the
// baseUrl subpath, so basePath must be empty to avoid broken links.
const basePath =
componentData.ctx.argv.serve || !cfg.baseUrl
? ""
: new URL(`https://${cfg.baseUrl}`).pathname.replace(/\/$/, "")
const doc = (
<html lang={lang} dir={direction}>
<Head {...componentData} />
<body data-slug={slug} data-basepath={basePath}>
{frame.css && <style dangerouslySetInnerHTML={{ __html: frame.css }} />}
<div id="quartz-root" class="page" data-frame={frame.name}>
<Body {...componentData}>
{[
frame.render({
componentData,
head: Head,
header,
beforeBody,
pageBody: Content,
afterBody,
left,
right,
footer: Footer,
}),
]}
</Body>
</div>
</body>
{pageResources.js
.filter((resource) => resource.loadTime === "afterDOMReady")
.map((res) => JSResourceToScriptElement(res, true))}
</html>
)
return "<!DOCTYPE html>\n" + render(doc)
}
@@ -0,0 +1,141 @@
import { computePosition, flip, inline, shift } from "@floating-ui/dom"
import { normalizeRelativeURLs } from "../../util/path"
import { fetchCanonical } from "./util"
const p = new DOMParser()
let activeAnchor: HTMLAnchorElement | null = null
async function mouseEnterHandler(
this: HTMLAnchorElement,
{ clientX, clientY }: { clientX: number; clientY: number },
) {
const link = (activeAnchor = this)
if (link.dataset.noPopover === "true") {
return
}
async function setPosition(popoverElement: HTMLElement) {
const { x, y } = await computePosition(link, popoverElement, {
strategy: "fixed",
middleware: [inline({ x: clientX, y: clientY }), shift(), flip()],
})
Object.assign(popoverElement.style, {
transform: `translate(${x.toFixed()}px, ${y.toFixed()}px)`,
})
}
function showPopover(popoverElement: HTMLElement) {
clearActivePopover()
popoverElement.classList.add("active-popover")
setPosition(popoverElement as HTMLElement)
if (hash !== "") {
const inner = popoverElement.querySelector(".popover-inner") as HTMLElement | null
if (inner) {
const targetAnchor = `#popover-internal-${hash.slice(1)}`
const heading = inner.querySelector(targetAnchor) as HTMLElement | null
if (heading) {
// leave ~12px of buffer when scrolling to a heading
inner.scroll({ top: heading.offsetTop - 12, behavior: "instant" })
}
}
}
}
const targetUrl = new URL(link.href)
const hash = decodeURIComponent(targetUrl.hash)
targetUrl.hash = ""
targetUrl.search = ""
const popoverId = `popover-${link.pathname}`
const prevPopoverElement = document.getElementById(popoverId)
// dont refetch if there's already a popover
if (!!document.getElementById(popoverId)) {
showPopover(prevPopoverElement as HTMLElement)
return
}
const response = await fetchCanonical(targetUrl).catch((err) => {
console.error(err)
})
if (!response) return
const rawContentType = response.headers.get("Content-Type")
if (!rawContentType) return
const [contentType] = rawContentType.split(";")
const [contentTypeCategory, typeInfo] = contentType.split("/")
const popoverElement = document.createElement("div")
popoverElement.id = popoverId
popoverElement.classList.add("popover")
const popoverInner = document.createElement("div")
popoverInner.classList.add("popover-inner")
popoverInner.dataset.contentType = contentType ?? undefined
popoverElement.appendChild(popoverInner)
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)
// prepend all IDs inside popovers to prevent duplicates
html.querySelectorAll("[id]").forEach((el) => {
const targetID = `popover-internal-${el.id}`
el.id = targetID
})
const elts = [...html.getElementsByClassName("popover-hint")]
if (elts.length === 0) return
elts.forEach((elt) => popoverInner.appendChild(elt))
}
if (!!document.getElementById(popoverId)) {
return
}
document.body.appendChild(popoverElement)
if (activeAnchor !== this) {
return
}
showPopover(popoverElement)
}
function clearActivePopover() {
activeAnchor = null
const allPopoverElements = document.querySelectorAll(".popover")
allPopoverElements.forEach((popoverElement) => popoverElement.classList.remove("active-popover"))
}
function setupPopovers() {
const links = [...document.querySelectorAll("a.internal")] as HTMLAnchorElement[]
for (const link of links) {
link.addEventListener("mouseenter", mouseEnterHandler)
link.addEventListener("mouseleave", clearActivePopover)
window.addCleanup(() => {
link.removeEventListener("mouseenter", mouseEnterHandler)
link.removeEventListener("mouseleave", clearActivePopover)
})
}
}
document.addEventListener("nav", setupPopovers)
document.addEventListener("render", setupPopovers)
@@ -0,0 +1,185 @@
import test, { describe } from "node:test"
import assert from "node:assert"
type ScrollArg = { top: number; behavior?: ScrollBehavior }
type FakeHeading = { offsetTop: number }
interface FakePopoverInner {
scroll: (arg: ScrollArg) => void
querySelector: (sel: string) => FakeHeading | null
_scrolled: ScrollArg | null
_selectorsQueried: string[]
}
interface FakePopoverElement {
classList: {
add: (cls: string) => void
remove: (cls: string) => void
_added: string[]
_removed: string[]
}
style: Record<string, string>
querySelector: (sel: string) => FakePopoverInner | null
_inner: FakePopoverInner
}
function makeInner(heading: FakeHeading | null = null): FakePopoverInner {
const inner: FakePopoverInner = {
scroll(arg) {
this._scrolled = arg
},
querySelector(sel) {
this._selectorsQueried.push(sel)
return heading
},
_scrolled: null,
_selectorsQueried: [],
}
return inner
}
function makePopoverElement(inner: FakePopoverInner): FakePopoverElement {
const added: string[] = []
const removed: string[] = []
return {
classList: {
add(cls) {
added.push(cls)
},
remove(cls) {
removed.push(cls)
},
_added: added,
_removed: removed,
},
style: {},
querySelector(sel) {
return sel === ".popover-inner" ? inner : null
},
_inner: inner,
}
}
type SetPosition = (el: FakePopoverElement) => Promise<void>
function fixedShowPopover(
popoverElement: FakePopoverElement,
hash: string,
setPosition: SetPosition,
): Promise<void> {
popoverElement.classList.add("active-popover")
const positionResult = setPosition(popoverElement)
if (hash !== "") {
const inner = popoverElement.querySelector(".popover-inner")
if (inner) {
const targetAnchor = `#popover-internal-${hash.slice(1)}`
const heading = inner.querySelector(targetAnchor)
if (heading) {
inner.scroll({ top: heading.offsetTop - 12, behavior: "instant" })
}
}
}
return positionResult
}
describe("showPopover on cache-hit with hash", () => {
test("does not reference any lexical popoverInner from an outer scope", async () => {
const heading: FakeHeading = { offsetTop: 200 }
const inner = makeInner(heading)
const popoverElement = makePopoverElement(inner)
await fixedShowPopover(popoverElement, "#plugins", async () => {})
assert.ok(
popoverElement.classList._added.includes("active-popover"),
"active-popover class must be applied",
)
assert.deepStrictEqual(
inner._scrolled,
{ top: 200 - 12, behavior: "instant" },
"scroll must target heading.offsetTop - 12",
)
assert.deepStrictEqual(inner._selectorsQueried, ["#popover-internal-plugins"])
})
test("skips scroll when hash is empty", async () => {
const inner = makeInner({ offsetTop: 123 })
const popoverElement = makePopoverElement(inner)
await fixedShowPopover(popoverElement, "", async () => {})
assert.strictEqual(inner._scrolled, null)
assert.deepStrictEqual(inner._selectorsQueried, [])
})
test("skips scroll when heading is not found", async () => {
const inner = makeInner(null)
const popoverElement = makePopoverElement(inner)
await fixedShowPopover(popoverElement, "#nonexistent", async () => {})
assert.strictEqual(inner._scrolled, null)
assert.deepStrictEqual(inner._selectorsQueried, ["#popover-internal-nonexistent"])
})
test("decodes percent-encoded fragments when building the selector", async () => {
const heading: FakeHeading = { offsetTop: 50 }
const inner = makeInner(heading)
const popoverElement = makePopoverElement(inner)
await fixedShowPopover(popoverElement, "#a-b", async () => {})
assert.deepStrictEqual(inner._selectorsQueried, ["#popover-internal-a-b"])
})
})
describe("buggy showPopover (lexical-capture pattern) regression guard", () => {
test("accessing a capture-before-declaration variable throws ReferenceError (TDZ simulation)", () => {
function simulateBuggyMouseEnter(hash: string) {
function buggyShowPopover(popoverElement: FakePopoverElement) {
popoverElement.classList.add("active-popover")
if (hash !== "") {
const targetAnchor = `#popover-internal-${hash.slice(1)}`
const heading = popoverInner.querySelector(targetAnchor)
if (heading) {
popoverInner.scroll({ top: heading.offsetTop - 12, behavior: "instant" })
}
}
}
const cachedInner = makeInner({ offsetTop: 999 })
const cachedElement = makePopoverElement(cachedInner)
buggyShowPopover(cachedElement)
const popoverInner = makeInner(null)
return popoverInner
}
assert.throws(() => simulateBuggyMouseEnter("#plugins"), {
name: "ReferenceError",
})
})
test("same pattern does NOT throw when hash is empty (explains why first link without fragment works)", () => {
function simulateBuggyMouseEnter(hash: string) {
function buggyShowPopover(popoverElement: FakePopoverElement) {
popoverElement.classList.add("active-popover")
if (hash !== "") {
const _unused = popoverInner.querySelector("x")
void _unused
}
}
const cachedInner = makeInner(null)
const cachedElement = makePopoverElement(cachedInner)
buggyShowPopover(cachedElement)
const popoverInner = makeInner(null)
return popoverInner
}
assert.doesNotThrow(() => simulateBuggyMouseEnter(""))
})
})
@@ -0,0 +1,163 @@
import test, { describe } from "node:test"
import assert from "node:assert"
// Inline the encoder function from search.inline.ts for testing
const encoder = (str: string): string[] => {
const tokens: string[] = []
let bufferStart = -1
let bufferEnd = -1
const lower = str.toLowerCase()
let i = 0
for (const char of lower) {
const code = char.codePointAt(0)!
const isCJK =
(code >= 0x3040 && code <= 0x309f) ||
(code >= 0x30a0 && code <= 0x30ff) ||
(code >= 0x4e00 && code <= 0x9fff) ||
(code >= 0xac00 && code <= 0xd7af) ||
(code >= 0x20000 && code <= 0x2a6df)
const isWhitespace = code === 32 || code === 9 || code === 10 || code === 13
if (isCJK) {
if (bufferStart !== -1) {
tokens.push(lower.slice(bufferStart, bufferEnd))
bufferStart = -1
}
tokens.push(char)
} else if (isWhitespace) {
if (bufferStart !== -1) {
tokens.push(lower.slice(bufferStart, bufferEnd))
bufferStart = -1
}
} else {
if (bufferStart === -1) bufferStart = i
bufferEnd = i + char.length
}
i += char.length
}
if (bufferStart !== -1) {
tokens.push(lower.slice(bufferStart))
}
return tokens
}
describe("search encoder", () => {
describe("English text", () => {
test("should tokenize simple English words", () => {
const result = encoder("hello world")
assert.deepStrictEqual(result, ["hello", "world"])
})
test("should handle multiple spaces", () => {
const result = encoder("hello world")
assert.deepStrictEqual(result, ["hello", "world"])
})
test("should handle tabs and newlines", () => {
const result = encoder("hello\tworld\ntest")
assert.deepStrictEqual(result, ["hello", "world", "test"])
})
test("should lowercase all text", () => {
const result = encoder("Hello WORLD Test")
assert.deepStrictEqual(result, ["hello", "world", "test"])
})
})
describe("CJK text", () => {
test("should tokenize Japanese Hiragana character by character", () => {
const result = encoder("こんにちは")
assert.deepStrictEqual(result, ["こ", "ん", "に", "ち", "は"])
})
test("should tokenize Japanese Katakana character by character", () => {
const result = encoder("コントロール")
assert.deepStrictEqual(result, ["コ", "ン", "ト", "ロ", "ー", "ル"])
})
test("should tokenize Japanese Kanji character by character", () => {
const result = encoder("日本語")
assert.deepStrictEqual(result, ["日", "本", "語"])
})
test("should tokenize Korean Hangul character by character", () => {
const result = encoder("안녕하세요")
assert.deepStrictEqual(result, ["안", "녕", "하", "세", "요"])
})
test("should tokenize Chinese characters character by character", () => {
const result = encoder("你好世界")
assert.deepStrictEqual(result, ["你", "好", "世", "界"])
})
test("should handle mixed Hiragana/Katakana/Kanji", () => {
const result = encoder("て以来")
assert.deepStrictEqual(result, ["て", "以", "来"])
})
})
describe("Mixed CJK and English", () => {
test("should handle Japanese with English words", () => {
const result = encoder("hello 世界")
assert.deepStrictEqual(result, ["hello", "世", "界"])
})
test("should handle English with Japanese words", () => {
const result = encoder("世界 hello world")
assert.deepStrictEqual(result, ["世", "界", "hello", "world"])
})
test("should handle complex mixed content", () => {
const result = encoder("これはtest文章です")
assert.deepStrictEqual(result, ["こ", "れ", "は", "test", "文", "章", "で", "す"])
})
test("should handle mixed Korean and English", () => {
const result = encoder("hello 안녕 world")
assert.deepStrictEqual(result, ["hello", "안", "녕", "world"])
})
test("should handle mixed Chinese and English", () => {
const result = encoder("你好 world")
assert.deepStrictEqual(result, ["你", "好", "world"])
})
})
describe("Edge cases", () => {
test("should handle empty string", () => {
const result = encoder("")
assert.deepStrictEqual(result, [])
})
test("should handle only whitespace", () => {
const result = encoder(" \t\n ")
assert.deepStrictEqual(result, [])
})
test("should handle single character", () => {
const result = encoder("a")
assert.deepStrictEqual(result, ["a"])
})
test("should handle single CJK character", () => {
const result = encoder("あ")
assert.deepStrictEqual(result, ["あ"])
})
test("should handle CJK with trailing whitespace", () => {
const result = encoder("日本語 ")
assert.deepStrictEqual(result, ["日", "本", "語"])
})
test("should handle English with trailing whitespace", () => {
const result = encoder("hello ")
assert.deepStrictEqual(result, ["hello"])
})
})
})
@@ -0,0 +1,221 @@
import micromorph from "micromorph"
import { FullSlug, RelativeURL, getFullSlug, normalizeRelativeURLs } from "../../util/path"
import { fetchCanonical } from "./util"
// adapted from `micromorph`
// https://github.com/natemoo-re/micromorph
const NODE_TYPE_ELEMENT = 1
let announcer = document.createElement("route-announcer")
const isElement = (target: EventTarget | null): target is Element =>
(target as Node)?.nodeType === NODE_TYPE_ELEMENT
const isLocalUrl = (href: string) => {
try {
const url = new URL(href)
if (window.location.origin === url.origin) {
return true
}
} catch (e) {}
return false
}
const isSamePage = (url: URL): boolean => {
const sameOrigin = url.origin === window.location.origin
const samePath = url.pathname === window.location.pathname
return sameOrigin && samePath
}
const getOpts = ({ target }: Event): { url: URL; scroll?: boolean } | undefined => {
if (!isElement(target)) return
if (target.attributes.getNamedItem("target")?.value === "_blank") return
const a = target.closest("a")
if (!a) return
if ("routerIgnore" in a.dataset) return
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 cleanupFns: Set<(...args: any[]) => void> = new Set()
window.addCleanup = (fn) => cleanupFns.add(fn)
function startLoading() {
document.querySelector(".navigation-progress")?.remove()
const loadingBar = document.createElement("div")
loadingBar.className = "navigation-progress"
loadingBar.style.width = "0"
document.body.prepend(loadingBar)
setTimeout(() => {
loadingBar.style.width = "80%"
}, 100)
}
function stopLoading() {
const loadingBar = document.querySelector(".navigation-progress")
if (loadingBar) {
loadingBar.remove()
}
}
let isNavigating = false
let p: DOMParser
async function _navigate(url: URL, isBack: boolean = false) {
isNavigating = true
startLoading()
p = p || new DOMParser()
const contents = await fetchCanonical(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
// notify about to nav
const event: CustomEventMap["prenav"] = new CustomEvent("prenav", { detail: {} })
document.dispatchEvent(event)
// 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)
document.querySelector(".navigation-progress")?.remove()
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, re-executing scripts
const elementsToRemove = document.head.querySelectorAll(":not([data-persist])")
elementsToRemove.forEach((el) => el.remove())
const elementsToAdd = html.head.querySelectorAll(":not([data-persist])")
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
}
async function navigate(url: URL, isBack: boolean = false) {
if (isNavigating) return
isNavigating = true
try {
await _navigate(url, isBack)
} catch (e) {
console.error(e)
window.location.assign(url)
} finally {
stopLoading()
isNavigating = false
}
}
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
}
navigate(url, false)
})
window.addEventListener("popstate", (event) => {
const { url } = getOpts(event) ?? {}
if (window.location.hash && window.location.pathname === url?.pathname) return
navigate(new URL(window.location.toString()), true)
return
})
}
return new (class Router {
go(pathname: RelativeURL) {
const url = new URL(pathname, window.location.toString())
return navigate(url, false)
}
back() {
return window.history.back()
}
forward() {
return window.history.forward()
}
})()
}
createRouter()
notifyNav(getFullSlug(window))
if (!customElements.get("route-announcer")) {
const attrs = {
"aria-live": "assertive",
"aria-atomic": "true",
style:
"position: absolute; left: 0; top: 0; clip: rect(0 0 0 0); clip-path: inset(50%); overflow: hidden; white-space: nowrap; width: 1px; height: 1px",
}
customElements.define(
"route-announcer",
class RouteAnnouncer extends HTMLElement {
constructor() {
super()
}
connectedCallback() {
for (const [key, value] of Object.entries(attrs)) {
this.setAttribute(key, value)
}
}
},
)
}
@@ -0,0 +1,46 @@
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()
e.stopPropagation()
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))
}
export function removeAllChildren(node: HTMLElement) {
while (node.firstChild) {
node.removeChild(node.firstChild)
}
}
// AliasRedirect emits HTML redirects which also have the link[rel="canonical"]
// containing the URL it's redirecting to.
// Extracting it here with regex is _probably_ faster than parsing the entire HTML
// with a DOMParser effectively twice (here and later in the SPA code), even if
// way less robust - we only care about our own generated redirects after all.
const canonicalRegex = /<link rel="canonical" href="([^"]*)">/
export async function fetchCanonical(url: URL): Promise<Response> {
const res = await fetch(`${url}`)
if (!res.headers.get("content-type")?.startsWith("text/html")) {
return res
}
// reading the body can only be done once, so we need to clone the response
// to allow the caller to read it if it's was not a redirect
const text = await res.clone().text()
const [_, redirect] = text.match(canonicalRegex) ?? []
return redirect ? fetch(`${new URL(redirect, url)}`) : res
}
@@ -0,0 +1,89 @@
@use "../../styles/variables.scss" as *;
@keyframes dropin {
0% {
opacity: 0;
visibility: hidden;
}
1% {
opacity: 0;
}
100% {
opacity: 1;
visibility: visible;
}
}
.popover {
z-index: 999;
position: fixed;
overflow: visible;
padding: 1rem;
left: 0;
top: 0;
will-change: transform;
& > .popover-inner {
position: relative;
width: 30rem;
max-height: 20rem;
padding: 0 1rem 1rem 1rem;
font-weight: initial;
font-style: initial;
line-height: normal;
font-size: initial;
font-family: var(--bodyFont);
border: 1px solid var(--lightgray);
background-color: var(--light);
border-radius: 5px;
box-shadow: 6px 6px 36px 0 rgba(0, 0, 0, 0.25);
overflow: auto;
overscroll-behavior: contain;
white-space: normal;
user-select: none;
cursor: default;
}
& > .popover-inner[data-content-type] {
&[data-content-type*="pdf"],
&[data-content-type*="image"] {
padding: 0;
max-height: 100%;
}
&[data-content-type*="image"] {
img {
margin: 0;
border-radius: 0;
display: block;
}
}
&[data-content-type*="pdf"] {
iframe {
width: 100%;
}
}
}
h1 {
font-size: 1.5rem;
}
visibility: hidden;
opacity: 0;
transition:
opacity 0.3s ease,
visibility 0.3s ease;
@media all and ($mobile) {
display: none !important;
}
}
.active-popover,
.popover:hover {
animation: dropin 0.3s ease;
animation-fill-mode: forwards;
animation-delay: 0.2s;
}
@@ -0,0 +1,30 @@
import { JSX } from "preact"
import { StaticResources, StringResource } from "../util/resources"
import { QuartzPluginData } from "../plugins/vfile"
import { GlobalConfiguration } from "../cfg"
import { Node } from "hast"
import { BuildCtx } from "../util/ctx"
export type QuartzComponentProps = {
ctx: BuildCtx
externalResources: StaticResources
fileData: QuartzPluginData
cfg: GlobalConfiguration
children: (QuartzComponent | JSX.Element)[]
tree: Node
allFiles: QuartzPluginData[]
displayClass?: "mobile-only" | "desktop-only"
} & JSX.IntrinsicAttributes & {
[key: string]: any
}
export type QuartzComponent = ((props: QuartzComponentProps) => any) & {
displayName?: string
css?: StringResource
beforeDOMLoaded?: StringResource
afterDOMLoaded?: StringResource
}
export type QuartzComponentConstructor<Options extends object | undefined = undefined> = (
opts: Options,
) => QuartzComponent
@@ -0,0 +1,90 @@
import { Translation, CalloutTranslation } from "./locales/definition"
import enUs from "./locales/en-US"
import enGb from "./locales/en-GB"
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 ca from "./locales/ca-ES"
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 zhTw from "./locales/zh-TW"
import vi from "./locales/vi-VN"
import pt from "./locales/pt-BR"
import hu from "./locales/hu-HU"
import fa from "./locales/fa-IR"
import pl from "./locales/pl-PL"
import cs from "./locales/cs-CZ"
import tr from "./locales/tr-TR"
import th from "./locales/th-TH"
import lt from "./locales/lt-LT"
import fi from "./locales/fi-FI"
import no from "./locales/nb-NO"
import id from "./locales/id-ID"
import kk from "./locales/kk-KZ"
import he from "./locales/he-IL"
export const TRANSLATIONS = {
"en-US": enUs,
"en-GB": enGb,
"fr-FR": fr,
"it-IT": it,
"ja-JP": ja,
"de-DE": de,
"nl-NL": nl,
"nl-BE": nl,
"ro-RO": ro,
"ro-MD": ro,
"ca-ES": ca,
"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,
"zh-TW": zhTw,
"vi-VN": vi,
"pt-BR": pt,
"hu-HU": hu,
"fa-IR": fa,
"pl-PL": pl,
"cs-CZ": cs,
"tr-TR": tr,
"th-TH": th,
"lt-LT": lt,
"fi-FI": fi,
"nb-NO": no,
"id-ID": id,
"kk-KZ": kk,
"he-IL": he,
} as const
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
@@ -0,0 +1,93 @@
import { Translation } from "./definition"
export default {
propertyDefaults: {
title: "غير معنون",
description: "لم يتم تقديم أي وصف",
},
direction: "rtl" as const,
components: {
callout: {
note: "ملاحظة",
abstract: "ملخص",
info: "معلومات",
todo: "للقيام",
tip: "نصيحة",
success: "نجاح",
question: "سؤال",
warning: "تحذير",
failure: "فشل",
danger: "خطر",
bug: "خلل",
example: "مثال",
quote: "اقتباس",
},
backlinks: {
title: "وصلات العودة",
noBacklinksFound: "لا يوجد وصلات عودة",
},
themeToggle: {
lightMode: "الوضع النهاري",
darkMode: "الوضع الليلي",
},
explorer: {
title: "المستعرض",
},
readerMode: {
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: "إما أن هذه الصفحة خاصة أو غير موجودة.",
home: "العوده للصفحة الرئيسية",
},
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
@@ -0,0 +1,87 @@
import { Translation } from "./definition"
export default {
propertyDefaults: {
title: "Sense títol",
description: "Sense descripció",
},
components: {
callout: {
note: "Nota",
abstract: "Resum",
info: "Informació",
todo: "Per fer",
tip: "Consell",
success: "Èxit",
question: "Pregunta",
warning: "Advertència",
failure: "Fall",
danger: "Perill",
bug: "Error",
example: "Exemple",
quote: "Cita",
},
backlinks: {
title: "Retroenllaç",
noBacklinksFound: "No s'han trobat retroenllaços",
},
themeToggle: {
lightMode: "Mode clar",
darkMode: "Mode fosc",
},
readerMode: {
title: "Mode lector",
},
explorer: {
title: "Explorador",
},
footer: {
createdWith: "Creat amb",
},
graph: {
title: "Vista Gràfica",
},
recentNotes: {
title: "Notes Recents",
seeRemainingMore: ({ remaining }) => `Vegi ${remaining} més →`,
},
transcludes: {
transcludeOf: ({ targetSlug }) => `Transcluit de ${targetSlug}`,
linkToOriginal: "Enllaç a l'original",
},
search: {
title: "Cercar",
searchBarPlaceholder: "Cerca alguna cosa",
},
tableOfContents: {
title: "Taula de Continguts",
},
contentMeta: {
readingTime: ({ minutes }) => `Es llegeix en ${minutes} min`,
},
},
pages: {
rss: {
recentNotes: "Notes recents",
lastFewNotes: ({ count }) => `Últimes ${count} notes`,
},
error: {
title: "No s'ha trobat.",
notFound: "Aquesta pàgina és privada o no existeix.",
home: "Torna a la pàgina principal",
},
folderContent: {
folder: "Carpeta",
itemsUnderFolder: ({ count }) =>
count === 1 ? "1 article en aquesta carpeta." : `${count} articles en esta carpeta.`,
},
tagContent: {
tag: "Etiqueta",
tagIndex: "índex d'Etiquetes",
itemsUnderTag: ({ count }) =>
count === 1 ? "1 article amb aquesta etiqueta." : `${count} article amb aquesta etiqueta.`,
showingFirst: ({ count }) => `Mostrant les primeres ${count} etiquetes.`,
totalTags: ({ count }) => `S'han trobat ${count} etiquetes en total.`,
},
},
} as const satisfies Translation
@@ -0,0 +1,87 @@
import { Translation } from "./definition"
export default {
propertyDefaults: {
title: "Bez názvu",
description: "Nebyl uveden žádný popis",
},
components: {
callout: {
note: "Poznámka",
abstract: "Abstract",
info: "Info",
todo: "Todo",
tip: "Tip",
success: "Úspěch",
question: "Otázka",
warning: "Upozornění",
failure: "Chyba",
danger: "Nebezpečí",
bug: "Bug",
example: "Příklad",
quote: "Citace",
},
backlinks: {
title: "Příchozí odkazy",
noBacklinksFound: "Nenalezeny žádné příchozí odkazy",
},
themeToggle: {
lightMode: "Světlý režim",
darkMode: "Tmavý režim",
},
readerMode: {
title: "Režim čtečky",
},
explorer: {
title: "Procházet",
},
footer: {
createdWith: "Vytvořeno pomocí",
},
graph: {
title: "Graf",
},
recentNotes: {
title: "Nejnovější poznámky",
seeRemainingMore: ({ remaining }) => `Zobraz ${remaining} dalších →`,
},
transcludes: {
transcludeOf: ({ targetSlug }) => `Zobrazení ${targetSlug}`,
linkToOriginal: "Odkaz na původní dokument",
},
search: {
title: "Hledat",
searchBarPlaceholder: "Hledejte něco",
},
tableOfContents: {
title: "Obsah",
},
contentMeta: {
readingTime: ({ minutes }) => `${minutes} min čtení`,
},
},
pages: {
rss: {
recentNotes: "Nejnovější poznámky",
lastFewNotes: ({ count }) => `Posledních ${count} poznámek`,
},
error: {
title: "Nenalezeno",
notFound: "Tato stránka je buď soukromá, nebo neexistuje.",
home: "Návrat na domovskou stránku",
},
folderContent: {
folder: "Složka",
itemsUnderFolder: ({ count }) =>
count === 1 ? "1 položka v této složce." : `${count} položek v této složce.`,
},
tagContent: {
tag: "Tag",
tagIndex: "Rejstřík tagů",
itemsUnderTag: ({ count }) =>
count === 1 ? "1 položka s tímto tagem." : `${count} položek s tímto tagem.`,
showingFirst: ({ count }) => `Zobrazují se první ${count} tagy.`,
totalTags: ({ count }) => `Nalezeno celkem ${count} tagů.`,
},
},
} as const satisfies Translation
@@ -0,0 +1,87 @@
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: "Fehlgeschlagen",
danger: "Gefahr",
bug: "Fehler",
example: "Beispiel",
quote: "Zitat",
},
backlinks: {
title: "Backlinks",
noBacklinksFound: "Keine Backlinks gefunden",
},
themeToggle: {
lightMode: "Heller Modus",
darkMode: "Dunkler Modus",
},
readerMode: {
title: "Lesemodus",
},
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. Lesezeit`,
},
},
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.",
home: "Zur Startseite",
},
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
@@ -0,0 +1,88 @@
import { FullSlug } from "../../util/path"
export interface CalloutTranslation {
note: string
abstract: string
info: string
todo: string
tip: string
success: string
question: string
warning: string
failure: string
danger: string
bug: string
example: string
quote: string
}
export interface Translation {
propertyDefaults: {
title: string
description: string
}
direction?: "ltr" | "rtl"
components: {
callout: CalloutTranslation
backlinks: {
title: string
noBacklinksFound: string
}
themeToggle: {
lightMode: string
darkMode: string
}
readerMode: {
title: string
}
explorer: {
title: string
}
footer: {
createdWith: string
}
graph: {
title: string
}
recentNotes: {
title: string
seeRemainingMore: (variables: { remaining: number }) => string
}
transcludes: {
transcludeOf: (variables: { targetSlug: FullSlug }) => string
linkToOriginal: string
}
search: {
title: string
searchBarPlaceholder: string
}
tableOfContents: {
title: string
}
contentMeta: {
readingTime: (variables: { minutes: number }) => string
}
}
pages: {
rss: {
recentNotes: string
lastFewNotes: (variables: { count: number }) => string
}
error: {
title: string
notFound: string
home: string
}
folderContent: {
folder: string
itemsUnderFolder: (variables: { count: number }) => string
}
tagContent: {
tag: string
tagIndex: string
itemsUnderTag: (variables: { count: number }) => string
showingFirst: (variables: { count: number }) => string
totalTags: (variables: { count: number }) => string
}
}
}
@@ -0,0 +1,87 @@
import { Translation } from "./definition"
export default {
propertyDefaults: {
title: "Untitled",
description: "No description provided",
},
components: {
callout: {
note: "Note",
abstract: "Abstract",
info: "Info",
todo: "To-Do",
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",
},
readerMode: {
title: "Reader 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.",
home: "Return to Homepage",
},
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
@@ -0,0 +1,87 @@
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",
},
readerMode: {
title: "Reader 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.",
home: "Return to Homepage",
},
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
@@ -0,0 +1,87 @@
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: "Retroenlaces",
noBacklinksFound: "No se han encontrado retroenlaces",
},
themeToggle: {
lightMode: "Modo claro",
darkMode: "Modo oscuro",
},
readerMode: {
title: "Modo lector",
},
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 }) => `Se lee en ${minutes} min`,
},
},
pages: {
rss: {
recentNotes: "Notas recientes",
lastFewNotes: ({ count }) => `Últimas ${count} notas`,
},
error: {
title: "No se ha encontrado.",
notFound: "Esta página es privada o no existe.",
home: "Regresa a la página principal",
},
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 han encontrado ${count} etiquetas en total.`,
},
},
} as const satisfies Translation
@@ -0,0 +1,88 @@
import { Translation } from "./definition"
export default {
propertyDefaults: {
title: "بدون عنوان",
description: "توضیح خاصی اضافه نشده است",
},
direction: "rtl" as const,
components: {
callout: {
note: "یادداشت",
abstract: "چکیده",
info: "اطلاعات",
todo: "اقدام",
tip: "نکته",
success: "تیک",
question: "سؤال",
warning: "هشدار",
failure: "شکست",
danger: "خطر",
bug: "باگ",
example: "مثال",
quote: "نقل قول",
},
backlinks: {
title: "بک‌لینک‌ها",
noBacklinksFound: "بدون بک‌لینک",
},
themeToggle: {
lightMode: "حالت روشن",
darkMode: "حالت تاریک",
},
readerMode: {
title: "حالت خواندن",
},
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} دقیقه`,
},
},
pages: {
rss: {
recentNotes: "یادداشت‌های اخیر",
lastFewNotes: ({ count }) => `${count} یادداشت اخیر`,
},
error: {
title: "یافت نشد",
notFound: "این صفحه یا خصوصی است یا وجود ندارد",
home: "بازگشت به صفحه اصلی",
},
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
@@ -0,0 +1,87 @@
import { Translation } from "./definition"
export default {
propertyDefaults: {
title: "Nimetön",
description: "Ei kuvausta saatavilla",
},
components: {
callout: {
note: "Merkintä",
abstract: "Tiivistelmä",
info: "Info",
todo: "Tehtävälista",
tip: "Vinkki",
success: "Onnistuminen",
question: "Kysymys",
warning: "Varoitus",
failure: "Epäonnistuminen",
danger: "Vaara",
bug: "Virhe",
example: "Esimerkki",
quote: "Lainaus",
},
backlinks: {
title: "Takalinkit",
noBacklinksFound: "Takalinkkejä ei löytynyt",
},
themeToggle: {
lightMode: "Vaalea tila",
darkMode: "Tumma tila",
},
readerMode: {
title: "Lukijatila",
},
explorer: {
title: "Selain",
},
footer: {
createdWith: "Luotu käyttäen",
},
graph: {
title: "Verkkonäkymä",
},
recentNotes: {
title: "Viimeisimmät muistiinpanot",
seeRemainingMore: ({ remaining }) => `Näytä ${remaining} lisää →`,
},
transcludes: {
transcludeOf: ({ targetSlug }) => `Upote kohteesta ${targetSlug}`,
linkToOriginal: "Linkki alkuperäiseen",
},
search: {
title: "Haku",
searchBarPlaceholder: "Hae jotain",
},
tableOfContents: {
title: "Sisällysluettelo",
},
contentMeta: {
readingTime: ({ minutes }) => `${minutes} min lukuaika`,
},
},
pages: {
rss: {
recentNotes: "Viimeisimmät muistiinpanot",
lastFewNotes: ({ count }) => `Viimeiset ${count} muistiinpanoa`,
},
error: {
title: "Ei löytynyt",
notFound: "Tämä sivu on joko yksityinen tai sitä ei ole olemassa.",
home: "Palaa etusivulle",
},
folderContent: {
folder: "Kansio",
itemsUnderFolder: ({ count }) =>
count === 1 ? "1 kohde tässä kansiossa." : `${count} kohdetta tässä kansiossa.`,
},
tagContent: {
tag: "Tunniste",
tagIndex: "Tunnisteluettelo",
itemsUnderTag: ({ count }) =>
count === 1 ? "1 kohde tällä tunnisteella." : `${count} kohdetta tällä tunnisteella.`,
showingFirst: ({ count }) => `Näytetään ensimmäiset ${count} tunnistetta.`,
totalTags: ({ count }) => `Löytyi yhteensä ${count} tunnistetta.`,
},
},
} as const satisfies Translation
@@ -0,0 +1,87 @@
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",
},
readerMode: {
title: "Mode lecture",
},
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 de lecture`,
},
},
pages: {
rss: {
recentNotes: "Notes récentes",
lastFewNotes: ({ count }) => `Les dernières ${count} notes`,
},
error: {
title: "Introuvable",
notFound: "Cette page est soit privée, soit elle n'existe pas.",
home: "Retour à la page d'accueil",
},
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
@@ -0,0 +1,88 @@
import { Translation } from "./definition"
export default {
propertyDefaults: {
title: "ללא כותרת",
description: "לא סופק תיאור",
},
direction: "rtl" as const,
components: {
callout: {
note: "הערה",
abstract: "תקציר",
info: "מידע",
todo: "לעשות",
tip: "טיפ",
success: "הצלחה",
question: "שאלה",
warning: "אזהרה",
failure: "כשלון",
danger: "סכנה",
bug: "באג",
example: "דוגמה",
quote: "ציטוט",
},
backlinks: {
title: "קישורים חוזרים",
noBacklinksFound: "לא נמצאו קישורים חוזרים",
},
themeToggle: {
lightMode: "מצב בהיר",
darkMode: "מצב כהה",
},
readerMode: {
title: "מצב קריאה",
},
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} דקות קריאה`,
},
},
pages: {
rss: {
recentNotes: "הערות אחרונות",
lastFewNotes: ({ count }) => `${count} הערות אחרונות`,
},
error: {
title: "לא נמצא",
notFound: "העמוד הזה פרטי או לא קיים.",
home: "חזרה לעמוד הבית",
},
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
@@ -0,0 +1,85 @@
import { Translation } from "./definition"
export default {
propertyDefaults: {
title: "Névtelen",
description: "Nincs leírás",
},
components: {
callout: {
note: "Jegyzet",
abstract: "Abstract",
info: "Információ",
todo: "Tennivaló",
tip: "Tipp",
success: "Siker",
question: "Kérdés",
warning: "Figyelmeztetés",
failure: "Hiba",
danger: "Veszély",
bug: "Bug",
example: "Példa",
quote: "Idézet",
},
backlinks: {
title: "Visszautalások",
noBacklinksFound: "Nincs visszautalás",
},
themeToggle: {
lightMode: "Világos mód",
darkMode: "Sötét mód",
},
readerMode: {
title: "Olvasó mód",
},
explorer: {
title: "Fájlböngésző",
},
footer: {
createdWith: "Készítve ezzel:",
},
graph: {
title: "Grafikonnézet",
},
recentNotes: {
title: "Legutóbbi jegyzetek",
seeRemainingMore: ({ remaining }) => `${remaining} további megtekintése →`,
},
transcludes: {
transcludeOf: ({ targetSlug }) => `${targetSlug} áthivatkozása`,
linkToOriginal: "Hivatkozás az eredetire",
},
search: {
title: "Keresés",
searchBarPlaceholder: "Keress valamire",
},
tableOfContents: {
title: "Tartalomjegyzék",
},
contentMeta: {
readingTime: ({ minutes }) => `${minutes} perces olvasás`,
},
},
pages: {
rss: {
recentNotes: "Legutóbbi jegyzetek",
lastFewNotes: ({ count }) => `Legutóbbi ${count} jegyzet`,
},
error: {
title: "Nem található",
notFound: "Ez a lap vagy privát vagy nem létezik.",
home: "Vissza a kezdőlapra",
},
folderContent: {
folder: "Mappa",
itemsUnderFolder: ({ count }) => `Ebben a mappában ${count} elem található.`,
},
tagContent: {
tag: "Címke",
tagIndex: "Címke index",
itemsUnderTag: ({ count }) => `${count} elem található ezzel a címkével.`,
showingFirst: ({ count }) => `Első ${count} címke megjelenítve.`,
totalTags: ({ count }) => `Összesen ${count} címke található.`,
},
},
} as const satisfies Translation
@@ -0,0 +1,87 @@
import { Translation } from "./definition"
export default {
propertyDefaults: {
title: "Tanpa Judul",
description: "Tidak ada deskripsi",
},
components: {
callout: {
note: "Catatan",
abstract: "Abstrak",
info: "Info",
todo: "Daftar Tugas",
tip: "Tips",
success: "Berhasil",
question: "Pertanyaan",
warning: "Peringatan",
failure: "Gagal",
danger: "Bahaya",
bug: "Bug",
example: "Contoh",
quote: "Kutipan",
},
backlinks: {
title: "Tautan Balik",
noBacklinksFound: "Tidak ada tautan balik ditemukan",
},
themeToggle: {
lightMode: "Mode Terang",
darkMode: "Mode Gelap",
},
readerMode: {
title: "Mode Pembaca",
},
explorer: {
title: "Penjelajah",
},
footer: {
createdWith: "Dibuat dengan",
},
graph: {
title: "Tampilan Grafik",
},
recentNotes: {
title: "Catatan Terbaru",
seeRemainingMore: ({ remaining }) => `Lihat ${remaining} lagi →`,
},
transcludes: {
transcludeOf: ({ targetSlug }) => `Transklusi dari ${targetSlug}`,
linkToOriginal: "Tautan ke asli",
},
search: {
title: "Cari",
searchBarPlaceholder: "Cari sesuatu",
},
tableOfContents: {
title: "Daftar Isi",
},
contentMeta: {
readingTime: ({ minutes }) => `${minutes} menit baca`,
},
},
pages: {
rss: {
recentNotes: "Catatan terbaru",
lastFewNotes: ({ count }) => `${count} catatan terakhir`,
},
error: {
title: "Tidak Ditemukan",
notFound: "Halaman ini bersifat privat atau tidak ada.",
home: "Kembali ke Beranda",
},
folderContent: {
folder: "Folder",
itemsUnderFolder: ({ count }) =>
count === 1 ? "1 item di bawah folder ini." : `${count} item di bawah folder ini.`,
},
tagContent: {
tag: "Tag",
tagIndex: "Indeks Tag",
itemsUnderTag: ({ count }) =>
count === 1 ? "1 item dengan tag ini." : `${count} item dengan tag ini.`,
showingFirst: ({ count }) => `Menampilkan ${count} tag pertama.`,
totalTags: ({ count }) => `Ditemukan total ${count} tag.`,
},
},
} as const satisfies Translation
@@ -0,0 +1,89 @@
import { Translation } from "./definition"
export default {
propertyDefaults: {
title: "Senza titolo",
description: "Nessuna descrizione",
},
components: {
callout: {
note: "Nota",
abstract: "Abstract",
info: "Info",
todo: "Da fare",
tip: "Consiglio",
success: "Completato",
question: "Domanda",
warning: "Attenzione",
failure: "Errore",
danger: "Pericolo",
bug: "Problema",
example: "Esempio",
quote: "Citazione",
},
backlinks: {
title: "Link entranti",
noBacklinksFound: "Nessun link entrante",
},
themeToggle: {
lightMode: "Tema chiaro",
darkMode: "Tema scuro",
},
readerMode: {
title: "Modalità lettura",
},
explorer: {
title: "Esplora",
},
footer: {
createdWith: "Creato con",
},
graph: {
title: "Vista grafico",
},
recentNotes: {
title: "Note recenti",
seeRemainingMore: ({ remaining }) =>
remaining === 1 ? "Vedi 1 altra →" : `Vedi altre ${remaining}`,
},
transcludes: {
transcludeOf: ({ targetSlug }) => `Inclusione di ${targetSlug}`,
linkToOriginal: "Link all'originale",
},
search: {
title: "Cerca",
searchBarPlaceholder: "Cerca qualcosa",
},
tableOfContents: {
title: "Indice",
},
contentMeta: {
readingTime: ({ minutes }) => (minutes === 1 ? "1 minuto" : `${minutes} minuti`),
},
},
pages: {
rss: {
recentNotes: "Note recenti",
lastFewNotes: ({ count }) => (count === 1 ? "Ultima nota" : `Ultime ${count} note`),
},
error: {
title: "Non trovato",
notFound: "Questa pagina è privata o non esiste.",
home: "Ritorna alla home page",
},
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 }) => (count === 1 ? "Prima etichetta." : `Prime ${count} etichette.`),
totalTags: ({ count }) =>
count === 1 ? "Trovata 1 etichetta in totale." : `Trovate ${count} etichette totali.`,
},
},
} as const satisfies Translation
@@ -0,0 +1,85 @@
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: "ダークモード",
},
readerMode: {
title: "リーダーモード",
},
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: "ページが存在しないか、非公開設定になっています。",
home: "ホームページに戻る",
},
folderContent: {
folder: "フォルダ",
itemsUnderFolder: ({ count }) => `${count}件のページ`,
},
tagContent: {
tag: "タグ",
tagIndex: "タグ一覧",
itemsUnderTag: ({ count }) => `${count}件のページ`,
showingFirst: ({ count }) => `のうち最初の${count}件を表示しています`,
totalTags: ({ count }) => `${count}個のタグを表示中`,
},
},
} as const satisfies Translation
@@ -0,0 +1,87 @@
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: "Қараңғы режим",
},
readerMode: {
title: "Оқу режимі",
},
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} мин оқу`,
},
},
pages: {
rss: {
recentNotes: "Соңғы жазбалар",
lastFewNotes: ({ count }) => `Соңғы ${count} жазба`,
},
error: {
title: "Табылмады",
notFound: "Бұл бет жеке немесе жоқ болуы мүмкін.",
home: "Басты бетке оралу",
},
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
@@ -0,0 +1,85 @@
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: "다크 모드",
},
readerMode: {
title: "리더 모드",
},
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: "페이지가 존재하지 않거나 비공개 설정이 되어 있습니다.",
home: "홈페이지로 돌아가기",
},
folderContent: {
folder: "폴더",
itemsUnderFolder: ({ count }) => `${count}건의 항목`,
},
tagContent: {
tag: "태그",
tagIndex: "태그 목록",
itemsUnderTag: ({ count }) => `${count}건의 항목`,
showingFirst: ({ count }) => `처음 ${count}개의 태그`,
totalTags: ({ count }) => `${count}개의 태그를 찾았습니다.`,
},
},
} as const satisfies Translation
@@ -0,0 +1,107 @@
import { Translation } from "./definition"
export default {
propertyDefaults: {
title: "Be Pavadinimo",
description: "Aprašymas Nepateiktas",
},
components: {
callout: {
note: "Pastaba",
abstract: "Santrauka",
info: "Informacija",
todo: "Darbų sąrašas",
tip: "Patarimas",
success: "Sėkmingas",
question: "Klausimas",
warning: "Įspėjimas",
failure: "Nesėkmingas",
danger: "Pavojus",
bug: "Klaida",
example: "Pavyzdys",
quote: "Citata",
},
backlinks: {
title: "Atgalinės Nuorodos",
noBacklinksFound: "Atgalinių Nuorodų Nerasta",
},
themeToggle: {
lightMode: "Šviesus Režimas",
darkMode: "Tamsus Režimas",
},
readerMode: {
title: "Modalità lettore",
},
explorer: {
title: "Naršyklė",
},
footer: {
createdWith: "Sukurta Su",
},
graph: {
title: "Grafiko Vaizdas",
},
recentNotes: {
title: "Naujausi Užrašai",
seeRemainingMore: ({ remaining }) => `Peržiūrėti dar ${remaining}`,
},
transcludes: {
transcludeOf: ({ targetSlug }) => `Įterpimas iš ${targetSlug}`,
linkToOriginal: "Nuoroda į originalą",
},
search: {
title: "Paieška",
searchBarPlaceholder: "Ieškoti",
},
tableOfContents: {
title: "Turinys",
},
contentMeta: {
readingTime: ({ minutes }) => `${minutes} min skaitymo`,
},
},
pages: {
rss: {
recentNotes: "Naujausi užrašai",
lastFewNotes: ({ count }) =>
count === 1
? "Paskutinis 1 užrašas"
: count < 10
? `Paskutiniai ${count} užrašai`
: `Paskutiniai ${count} užrašų`,
},
error: {
title: "Nerasta",
notFound:
"Arba šis puslapis yra pasiekiamas tik tam tikriems vartotojams, arba tokio puslapio nėra.",
home: "Grįžti į pagrindinį puslapį",
},
folderContent: {
folder: "Aplankas",
itemsUnderFolder: ({ count }) =>
count === 1
? "1 elementas šiame aplanke."
: count < 10
? `${count} elementai šiame aplanke.`
: `${count} elementų šiame aplanke.`,
},
tagContent: {
tag: "Žyma",
tagIndex: "Žymų indeksas",
itemsUnderTag: ({ count }) =>
count === 1
? "1 elementas su šia žyma."
: count < 10
? `${count} elementai su šia žyma.`
: `${count} elementų su šia žyma.`,
showingFirst: ({ count }) =>
count < 10 ? `Rodomos pirmosios ${count} žymos.` : `Rodomos pirmosios ${count} žymų.`,
totalTags: ({ count }) =>
count === 1
? "Rasta iš viso 1 žyma."
: count < 10
? `Rasta iš viso ${count} žymos.`
: `Rasta iš viso ${count} žymų.`,
},
},
} as const satisfies Translation
@@ -0,0 +1,87 @@
import { Translation } from "./definition"
export default {
propertyDefaults: {
title: "Uten navn",
description: "Ingen beskrivelse angitt",
},
components: {
callout: {
note: "Notis",
abstract: "Abstrakt",
info: "Info",
todo: "Husk på",
tip: "Tips",
success: "Suksess",
question: "Spørsmål",
warning: "Advarsel",
failure: "Feil",
danger: "Farlig",
bug: "Bug",
example: "Eksempel",
quote: "Sitat",
},
backlinks: {
title: "Tilbakekoblinger",
noBacklinksFound: "Ingen tilbakekoblinger funnet",
},
themeToggle: {
lightMode: "Lys modus",
darkMode: "Mørk modus",
},
readerMode: {
title: "Læsemodus",
},
explorer: {
title: "Utforsker",
},
footer: {
createdWith: "Laget med",
},
graph: {
title: "Graf-visning",
},
recentNotes: {
title: "Nylige notater",
seeRemainingMore: ({ remaining }) => `Se ${remaining} til →`,
},
transcludes: {
transcludeOf: ({ targetSlug }) => `Transkludering of ${targetSlug}`,
linkToOriginal: "Lenke til original",
},
search: {
title: "Søk",
searchBarPlaceholder: "Søk etter noe",
},
tableOfContents: {
title: "Oversikt",
},
contentMeta: {
readingTime: ({ minutes }) => `${minutes} min lesning`,
},
},
pages: {
rss: {
recentNotes: "Nylige notat",
lastFewNotes: ({ count }) => `Siste ${count} notat`,
},
error: {
title: "Ikke funnet",
notFound: "Enten er denne siden privat eller så finnes den ikke.",
home: "Returner til hovedsiden",
},
folderContent: {
folder: "Mappe",
itemsUnderFolder: ({ count }) =>
count === 1 ? "1 gjenstand i denne mappen." : `${count} gjenstander i denne mappen.`,
},
tagContent: {
tag: "Tagg",
tagIndex: "Tagg Indeks",
itemsUnderTag: ({ count }) =>
count === 1 ? "1 gjenstand med denne taggen." : `${count} gjenstander med denne taggen.`,
showingFirst: ({ count }) => `Viser første ${count} tagger.`,
totalTags: ({ count }) => `Fant totalt ${count} tagger.`,
},
},
} as const satisfies Translation
@@ -0,0 +1,89 @@
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",
},
readerMode: {
title: "Leesmodus",
},
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.",
home: "Keer terug naar de start pagina",
},
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
@@ -0,0 +1,87 @@
import { Translation } from "./definition"
export default {
propertyDefaults: {
title: "Bez nazwy",
description: "Brak opisu",
},
components: {
callout: {
note: "Notatka",
abstract: "Streszczenie",
info: "informacja",
todo: "Do zrobienia",
tip: "Wskazówka",
success: "Zrobione",
question: "Pytanie",
warning: "Ostrzeżenie",
failure: "Usterka",
danger: "Niebiezpieczeństwo",
bug: "Błąd w kodzie",
example: "Przykład",
quote: "Cytat",
},
backlinks: {
title: "Odnośniki zwrotne",
noBacklinksFound: "Brak połączeń zwrotnych",
},
themeToggle: {
lightMode: "Trzyb jasny",
darkMode: "Tryb ciemny",
},
readerMode: {
title: "Tryb czytania",
},
explorer: {
title: "Przeglądaj",
},
footer: {
createdWith: "Stworzone z użyciem",
},
graph: {
title: "Graf",
},
recentNotes: {
title: "Najnowsze notatki",
seeRemainingMore: ({ remaining }) => `Zobacz ${remaining} nastepnych →`,
},
transcludes: {
transcludeOf: ({ targetSlug }) => `Osadzone ${targetSlug}`,
linkToOriginal: "Łącze do oryginału",
},
search: {
title: "Szukaj",
searchBarPlaceholder: "Wpisz frazę wyszukiwania",
},
tableOfContents: {
title: "Spis treści",
},
contentMeta: {
readingTime: ({ minutes }) => `${minutes} min. czytania `,
},
},
pages: {
rss: {
recentNotes: "Najnowsze notatki",
lastFewNotes: ({ count }) => `Ostatnie ${count} notatek`,
},
error: {
title: "Nie znaleziono",
notFound: "Ta strona jest prywatna lub nie istnieje.",
home: "Powrót do strony głównej",
},
folderContent: {
folder: "Folder",
itemsUnderFolder: ({ count }) =>
count === 1 ? "W tym folderze jest 1 element." : `Elementów w folderze: ${count}.`,
},
tagContent: {
tag: "Znacznik",
tagIndex: "Spis znaczników",
itemsUnderTag: ({ count }) =>
count === 1 ? "Oznaczony 1 element." : `Elementów z tym znacznikiem: ${count}.`,
showingFirst: ({ count }) => `Pokazuje ${count} pierwszych znaczników.`,
totalTags: ({ count }) => `Znalezionych wszystkich znaczników: ${count}.`,
},
},
} as const satisfies Translation
@@ -0,0 +1,87 @@
import { Translation } from "./definition"
export default {
propertyDefaults: {
title: "Sem título",
description: "Sem descrição",
},
components: {
callout: {
note: "Nota",
abstract: "Abstrato",
info: "Info",
todo: "Pendência",
tip: "Dica",
success: "Sucesso",
question: "Pergunta",
warning: "Aviso",
failure: "Falha",
danger: "Perigo",
bug: "Bug",
example: "Exemplo",
quote: "Citação",
},
backlinks: {
title: "Backlinks",
noBacklinksFound: "Sem backlinks encontrados",
},
themeToggle: {
lightMode: "Tema claro",
darkMode: "Tema escuro",
},
readerMode: {
title: "Modo leitor",
},
explorer: {
title: "Explorador",
},
footer: {
createdWith: "Criado com",
},
graph: {
title: "Visão de gráfico",
},
recentNotes: {
title: "Notas recentes",
seeRemainingMore: ({ remaining }) => `Veja mais ${remaining}`,
},
transcludes: {
transcludeOf: ({ targetSlug }) => `Transcrever de ${targetSlug}`,
linkToOriginal: "Link ao original",
},
search: {
title: "Pesquisar",
searchBarPlaceholder: "Pesquisar por algo",
},
tableOfContents: {
title: "Sumário",
},
contentMeta: {
readingTime: ({ minutes }) => `Leitura de ${minutes} min`,
},
},
pages: {
rss: {
recentNotes: "Notas recentes",
lastFewNotes: ({ count }) => `Últimas ${count} notas`,
},
error: {
title: "Não encontrado",
notFound: "Esta página é privada ou não existe.",
home: "Retornar a página inicial",
},
folderContent: {
folder: "Arquivo",
itemsUnderFolder: ({ count }) =>
count === 1 ? "1 item neste arquivo." : `${count} items neste arquivo.`,
},
tagContent: {
tag: "Tag",
tagIndex: "Sumário de Tags",
itemsUnderTag: ({ count }) =>
count === 1 ? "1 item com esta tag." : `${count} items com esta tag.`,
showingFirst: ({ count }) => `Mostrando as ${count} primeiras tags.`,
totalTags: ({ count }) => `Encontradas ${count} tags.`,
},
},
} as const satisfies Translation
@@ -0,0 +1,88 @@
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",
},
readerMode: {
title: "Modul de citire",
},
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ă.",
home: "Reveniți la pagina de pornire",
},
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
@@ -0,0 +1,99 @@
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: "Тёмный режим",
},
readerMode: {
title: "Режим чтения",
},
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: "Эта страница приватная или не существует",
home: "Вернуться на главную страницу",
},
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
if (remainder100 >= 10 && remainder100 <= 20) return form5
if (remainder10 > 1 && remainder10 < 5) return form2
if (remainder10 == 1) return form1
return form5
}
@@ -0,0 +1,85 @@
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: "โหมดมืด",
},
readerMode: {
title: "โหมดอ่าน",
},
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} นาที`,
},
},
pages: {
rss: {
recentNotes: "บันทึกล่าสุด",
lastFewNotes: ({ count }) => `${count} บันทึกล่าสุด`,
},
error: {
title: "ไม่มีหน้านี้",
notFound: "หน้านี้อาจตั้งค่าเป็นส่วนตัวหรือยังไม่ถูกสร้าง",
home: "กลับหน้าหลัก",
},
folderContent: {
folder: "โฟลเดอร์",
itemsUnderFolder: ({ count }) => `มี ${count} รายการในโฟลเดอร์นี้`,
},
tagContent: {
tag: "แท็ก",
tagIndex: "แท็กทั้งหมด",
itemsUnderTag: ({ count }) => `มี ${count} รายการในแท็กนี้`,
showingFirst: ({ count }) => `แสดง ${count} แท็กแรก`,
totalTags: ({ count }) => `มีทั้งหมด ${count} แท็ก`,
},
},
} as const satisfies Translation
@@ -0,0 +1,87 @@
import { Translation } from "./definition"
export default {
propertyDefaults: {
title: "İsimsiz",
description: "Herhangi bir açıklama eklenmedi",
},
components: {
callout: {
note: "Not",
abstract: "Özet",
info: "Bilgi",
todo: "Yapılacaklar",
tip: "İpucu",
success: "Başarılı",
question: "Soru",
warning: "Uyarı",
failure: "Başarısız",
danger: "Tehlike",
bug: "Hata",
example: "Örnek",
quote: "Alıntı",
},
backlinks: {
title: "Backlinkler",
noBacklinksFound: "Backlink bulunamadı",
},
themeToggle: {
lightMode: "Açık mod",
darkMode: "Koyu mod",
},
readerMode: {
title: "Okuma modu",
},
explorer: {
title: "Gezgin",
},
footer: {
createdWith: "Şununla oluşturuldu",
},
graph: {
title: "Grafik Görünümü",
},
recentNotes: {
title: "Son Notlar",
seeRemainingMore: ({ remaining }) => `${remaining} tane daha gör →`,
},
transcludes: {
transcludeOf: ({ targetSlug }) => `${targetSlug} sayfasından alıntı`,
linkToOriginal: "Orijinal bağlantı",
},
search: {
title: "Arama",
searchBarPlaceholder: "Bir şey arayın",
},
tableOfContents: {
title: "İçindekiler",
},
contentMeta: {
readingTime: ({ minutes }) => `${minutes} dakika okuma süresi`,
},
},
pages: {
rss: {
recentNotes: "Son notlar",
lastFewNotes: ({ count }) => `Son ${count} not`,
},
error: {
title: "Bulunamadı",
notFound: "Bu sayfa ya özel ya da mevcut değil.",
home: "Anasayfaya geri dön",
},
folderContent: {
folder: "Klasör",
itemsUnderFolder: ({ count }) =>
count === 1 ? "Bu klasör altında 1 öğe." : `Bu klasör altındaki ${count} öğe.`,
},
tagContent: {
tag: "Etiket",
tagIndex: "Etiket Sırası",
itemsUnderTag: ({ count }) =>
count === 1 ? "Bu etikete sahip 1 öğe." : `Bu etiket altındaki ${count} öğe.`,
showingFirst: ({ count }) => `İlk ${count} etiket gösteriliyor.`,
totalTags: ({ count }) => `Toplam ${count} adet etiket bulundu.`,
},
},
} as const satisfies Translation
@@ -0,0 +1,87 @@
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: "Темний режим",
},
readerMode: {
title: "Режим читання",
},
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} хв читання`,
},
},
pages: {
rss: {
recentNotes: "Останні нотатки",
lastFewNotes: ({ count }) => `Останні нотатки: ${count}`,
},
error: {
title: "Не знайдено",
notFound: "Ця сторінка або приватна, або не існує.",
home: "Повернутися на головну сторінку",
},
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
@@ -0,0 +1,85 @@
import { Translation } from "./definition"
export default {
propertyDefaults: {
title: "Không có tiêu đề",
description: "Không có mô tả",
},
components: {
callout: {
note: "Ghi chú",
abstract: "Tổng quan",
info: "Thông tin",
todo: "Cần phải làm",
tip: "Gợi ý",
success: "Thành công",
question: "Câu hỏi",
warning: "Cảnh báo",
failure: "Thất bại",
danger: "Nguy hiểm",
bug: "Lỗi",
example: "Ví dụ",
quote: "Trích dẫn",
},
backlinks: {
title: "Liên kết ngược",
noBacklinksFound: "Không có liên kết ngược nào",
},
themeToggle: {
lightMode: "Chế độ sáng",
darkMode: "Chế độ tối",
},
readerMode: {
title: "Chế độ đọc",
},
explorer: {
title: "Nội dung",
},
footer: {
createdWith: "Được tạo bằng",
},
graph: {
title: "Sơ đồ",
},
recentNotes: {
title: "Ghi chú gần đây",
seeRemainingMore: ({ remaining }) => `Xem thêm ${remaining} ghi chú →`,
},
transcludes: {
transcludeOf: ({ targetSlug }) => `Trích dẫn toàn bộ từ ${targetSlug}`,
linkToOriginal: "Xem trang gốc",
},
search: {
title: "Tìm",
searchBarPlaceholder: "Tìm kiếm thông tin",
},
tableOfContents: {
title: "Mục lục",
},
contentMeta: {
readingTime: ({ minutes }) => `${minutes} phút đọc`,
},
},
pages: {
rss: {
recentNotes: "Ghi chú gần đây",
lastFewNotes: ({ count }) => `${count} Trang gần đây`,
},
error: {
title: "Không tìm thấy",
notFound: "Trang này riêng tư hoặc không tồn tại.",
home: "Về trang chủ",
},
folderContent: {
folder: "Thư mục",
itemsUnderFolder: ({ count }) => `${count} trang trong thư mục này.`,
},
tagContent: {
tag: "Thẻ",
tagIndex: "Danh sách thẻ",
itemsUnderTag: ({ count }) => `${count} trang gắn thẻ này.`,
showingFirst: ({ count }) => `Đang hiển thị ${count} trang đầu tiên.`,
totalTags: ({ count }) => `Có tổng cộng ${count} thẻ.`,
},
},
} as const satisfies Translation
@@ -0,0 +1,85 @@
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: "暗色模式",
},
readerMode: {
title: "阅读模式",
},
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: "私有笔记或笔记不存在。",
home: "返回首页",
},
folderContent: {
folder: "文件夹",
itemsUnderFolder: ({ count }) => `此文件夹下有${count}条笔记。`,
},
tagContent: {
tag: "标签",
tagIndex: "标签索引",
itemsUnderTag: ({ count }) => `此标签下有${count}条笔记。`,
showingFirst: ({ count }) => `显示前${count}个标签。`,
totalTags: ({ count }) => `总共有${count}个标签。`,
},
},
} as const satisfies Translation
@@ -0,0 +1,85 @@
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: "暗色模式",
},
readerMode: {
title: "閱讀模式",
},
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: "私人筆記或筆記不存在。",
home: "返回首頁",
},
folderContent: {
folder: "資料夾",
itemsUnderFolder: ({ count }) => `此資料夾下有 ${count} 條筆記。`,
},
tagContent: {
tag: "標籤",
tagIndex: "標籤索引",
itemsUnderTag: ({ count }) => `此標籤下有 ${count} 條筆記。`,
showingFirst: ({ count }) => `顯示前 ${count} 個標籤。`,
totalTags: ({ count }) => `總共有 ${count} 個標籤。`,
},
},
} as const satisfies Translation
@@ -0,0 +1,51 @@
import {
QuartzTransformerPluginInstance,
QuartzFilterPluginInstance,
QuartzEmitterPluginInstance,
PageTypePluginEntry,
} from "./types"
import { LoadedPlugin } from "./loader/types"
export interface PluginConfiguration {
transformers: (QuartzTransformerPluginInstance | LoadedPlugin)[]
filters: (QuartzFilterPluginInstance | LoadedPlugin)[]
emitters: (QuartzEmitterPluginInstance | LoadedPlugin)[]
pageTypes?: (PageTypePluginEntry | LoadedPlugin)[]
}
export function isLoadedPlugin(plugin: unknown): plugin is LoadedPlugin {
return (
typeof plugin === "object" &&
plugin !== null &&
"plugin" in plugin &&
"manifest" in plugin &&
"type" in plugin &&
typeof (plugin as LoadedPlugin).plugin === "function"
)
}
export function getPluginInstance<T extends object | undefined>(
plugin:
| QuartzTransformerPluginInstance
| QuartzFilterPluginInstance
| QuartzEmitterPluginInstance
| PageTypePluginEntry
| LoadedPlugin,
options?: T,
):
| QuartzTransformerPluginInstance
| QuartzFilterPluginInstance
| QuartzEmitterPluginInstance
| PageTypePluginEntry {
if (isLoadedPlugin(plugin)) {
const factory = plugin.plugin as (
opts?: T,
) =>
| QuartzTransformerPluginInstance
| QuartzFilterPluginInstance
| QuartzEmitterPluginInstance
| PageTypePluginEntry
return factory(options)
}
return plugin
}
@@ -0,0 +1,69 @@
import { FilePath, joinSegments, slugifyFilePath } from "../../util/path"
import { QuartzEmitterPlugin, QuartzPageTypePluginInstance } from "../types"
import path from "path"
import fs from "fs"
import { glob } from "../../util/glob"
import { Argv, BuildCtx } from "../../util/ctx"
import { QuartzConfig } from "../../cfg"
function getPageTypeExtensions(ctx: BuildCtx): Set<string> {
const extensions = new Set<string>()
const pageTypes = (ctx.cfg.plugins.pageTypes ?? []) as unknown as QuartzPageTypePluginInstance[]
for (const pt of pageTypes) {
if (pt.fileExtensions) {
for (const ext of pt.fileExtensions) {
extensions.add(ext)
}
}
}
return extensions
}
const filesToCopy = async (argv: Argv, cfg: QuartzConfig, excludeExtensions: Set<string>) => {
const excludePatterns = ["**/*.md", ...cfg.configuration.ignorePatterns]
for (const ext of excludeExtensions) {
excludePatterns.push(`**/*${ext}`)
}
return await glob("**", argv.directory, excludePatterns)
}
const copyFile = async (argv: Argv, fp: FilePath) => {
const src = joinSegments(argv.directory, fp) as FilePath
const name = slugifyFilePath(fp)
const dest = joinSegments(argv.output, name) as FilePath
const dir = path.dirname(dest) as FilePath
await fs.promises.mkdir(dir, { recursive: true })
await fs.promises.copyFile(src, dest)
return dest
}
export const Assets: QuartzEmitterPlugin = () => {
return {
name: "Assets",
async *emit(ctx) {
const excludeExtensions = getPageTypeExtensions(ctx)
const fps = await filesToCopy(ctx.argv, ctx.cfg, excludeExtensions)
for (const fp of fps) {
yield copyFile(ctx.argv, fp)
}
},
async *partialEmit(ctx, _content, _resources, changeEvents) {
const excludeExtensions = getPageTypeExtensions(ctx)
for (const changeEvent of changeEvents) {
const ext = path.extname(changeEvent.path)
if (ext === ".md" || excludeExtensions.has(ext)) continue
if (changeEvent.type === "add" || changeEvent.type === "change") {
yield copyFile(ctx.argv, changeEvent.path)
} else if (changeEvent.type === "delete") {
const name = slugifyFilePath(changeEvent.path)
const dest = joinSegments(ctx.argv.output, name) as FilePath
await fs.promises.unlink(dest)
}
}
},
}
}
@@ -0,0 +1,525 @@
import { createHash } from "crypto"
import { FullSlug, joinSegments } from "../../util/path"
import { QuartzEmitterPlugin } from "../types"
// @ts-ignore
import spaRouterScript from "../../components/scripts/spa.inline"
// @ts-ignore
import popoverScript from "../../components/scripts/popover.inline"
import baseStyles from "../../styles/base.scss"
import customStyles from "../../styles/custom.scss"
import popoverStyle from "../../components/styles/popover.scss"
import { BuildCtx } from "../../util/ctx"
import { QuartzComponent } from "../../components/types"
import { normalizeResource } from "../../util/resources"
import { componentRegistry } from "../../components/registry"
import {
googleFontHref,
googleFontSubsetHref,
joinStyles,
processGoogleFonts,
} from "../../util/theme"
import { Features, transform } from "lightningcss"
import { transform as transpile } from "esbuild"
import { write } from "./helpers"
function hashContent(content: string | Buffer): string {
return createHash("sha256").update(content).digest("hex").slice(0, 8)
}
type ComponentResources = {
css: string[]
beforeDOMLoaded: string[]
afterDOMLoaded: string[]
componentCssStrings: Set<string>
}
function getComponentResources(ctx: BuildCtx): ComponentResources {
const allComponents: Set<QuartzComponent> = new Set()
for (const emitter of ctx.cfg.plugins.emitters) {
const components = emitter.getQuartzComponents?.(ctx) ?? []
for (const component of components) {
allComponents.add(component)
}
}
for (const component of componentRegistry.getAllComponents()) {
allComponents.add(component)
}
const componentResources = {
css: new Set<string>(),
beforeDOMLoaded: new Set<string>(),
afterDOMLoaded: new Set<string>(),
}
for (const component of allComponents) {
const { css, beforeDOMLoaded, afterDOMLoaded } = component
for (const c of normalizeResource(css)) componentResources.css.add(c)
for (const b of normalizeResource(beforeDOMLoaded)) componentResources.beforeDOMLoaded.add(b)
for (const a of normalizeResource(afterDOMLoaded)) componentResources.afterDOMLoaded.add(a)
}
return {
css: [...componentResources.css],
beforeDOMLoaded: [...componentResources.beforeDOMLoaded],
afterDOMLoaded: [...componentResources.afterDOMLoaded],
componentCssStrings: new Set(componentResources.css),
}
}
async function joinScripts(scripts: string[]): Promise<string> {
// 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,
})
return res.code
}
function addGlobalPageResources(ctx: BuildCtx, componentResources: ComponentResources) {
const cfg = ctx.cfg.configuration
// popovers
if (cfg.enablePopovers) {
componentResources.afterDOMLoaded.push(popoverScript)
componentResources.css.push(popoverStyle)
}
if (cfg.analytics?.provider === "google") {
const tagId = cfg.analytics.tagId
componentResources.afterDOMLoaded.push(`
const gtagScript = document.createElement('script');
gtagScript.src = 'https://www.googletagmanager.com/gtag/js?id=${tagId}';
gtagScript.defer = true;
gtagScript.onload = () => {
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag('js', new Date());
gtag('config', '${tagId}', { send_page_view: false });
gtag('event', 'page_view', { page_title: document.title, page_location: location.href });
document.addEventListener('nav', () => {
gtag('event', 'page_view', { page_title: document.title, page_location: location.href });
});
};
document.head.appendChild(gtagScript);
`)
} 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);
plausibleScript.defer = true;
plausibleScript.onload = () => {
window.plausible = window.plausible || function () { (window.plausible.q = window.plausible.q || []).push(arguments); };
plausible('pageview');
document.addEventListener('nav', () => {
plausible('pageview');
});
};
document.head.appendChild(plausibleScript);
`)
} 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.setAttribute("data-auto-track", "true");
umamiScript.defer = true;
document.head.appendChild(umamiScript);
`)
} else if (cfg.analytics?.provider === "goatcounter") {
componentResources.afterDOMLoaded.push(`
const goatcounterScriptPre = document.createElement('script');
goatcounterScriptPre.textContent = \`
window.goatcounter = { no_onload: true };
\`;
document.head.appendChild(goatcounterScriptPre);
const endpoint = "https://${cfg.analytics.websiteId}.${cfg.analytics.host ?? "goatcounter.com"}/count";
const goatcounterScript = document.createElement('script');
goatcounterScript.src = "${cfg.analytics.scriptSrc ?? "https://gc.zgo.at/count.js"}";
goatcounterScript.defer = true;
goatcounterScript.setAttribute('data-goatcounter', endpoint);
goatcounterScript.onload = () => {
window.goatcounter.endpoint = endpoint;
goatcounter.count({ path: location.pathname });
document.addEventListener('nav', () => {
goatcounter.count({ path: location.pathname });
});
};
document.head.appendChild(goatcounterScript);
`)
} else if (cfg.analytics?.provider === "posthog") {
componentResources.afterDOMLoaded.push(`
const posthogScript = document.createElement("script");
posthogScript.innerHTML= \`!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags getFeatureFlag getFeatureFlagPayload reloadFeatureFlags group updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures getActiveMatchingSurveys getSurveys onSessionId".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
posthog.init('${cfg.analytics.apiKey}', {
api_host: '${cfg.analytics.host ?? "https://app.posthog.com"}',
capture_pageview: false,
});
document.addEventListener('nav', () => {
posthog.capture('$pageview', { path: location.pathname });
})\`
document.head.appendChild(posthogScript);
`)
} else if (cfg.analytics?.provider === "tinylytics") {
const siteId = cfg.analytics.siteId
componentResources.afterDOMLoaded.push(`
const tinylyticsScript = document.createElement('script');
tinylyticsScript.src = 'https://tinylytics.app/embed/${siteId}.js?spa';
tinylyticsScript.defer = true;
tinylyticsScript.onload = () => {
window.tinylytics.triggerUpdate();
document.addEventListener('nav', () => {
window.tinylytics.triggerUpdate();
});
};
document.head.appendChild(tinylyticsScript);
`)
} else if (cfg.analytics?.provider === "cabin") {
componentResources.afterDOMLoaded.push(`
const cabinScript = document.createElement("script")
cabinScript.src = "${cfg.analytics.host ?? "https://scripts.withcabin.com"}/hello.js"
cabinScript.defer = true
document.head.appendChild(cabinScript)
`)
} else if (cfg.analytics?.provider === "clarity") {
componentResources.afterDOMLoaded.push(`
const clarityScript = document.createElement("script")
clarityScript.innerHTML= \`(function(c,l,a,r,i,t,y){c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
t=l.createElement(r);t.defer=1;t.src="https://www.clarity.ms/tag/"+i;
y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
})(window, document, "clarity", "script", "${cfg.analytics.projectId}");\`
document.head.appendChild(clarityScript)
`)
} else if (cfg.analytics?.provider === "matomo") {
componentResources.afterDOMLoaded.push(`
const matomoScript = document.createElement("script");
matomoScript.innerHTML = \`
let _paq = window._paq = window._paq || [];
// Track SPA navigation
// https://developer.matomo.org/guides/spa-tracking
document.addEventListener("nav", () => {
_paq.push(['setCustomUrl', location.pathname]);
_paq.push(['setDocumentTitle', document.title]);
_paq.push(['trackPageView']);
});
_paq.push(['trackPageView']);
_paq.push(['enableLinkTracking']);
(function() {
const u="//${cfg.analytics.host}/";
_paq.push(['setTrackerUrl', u+'matomo.php']);
_paq.push(['setSiteId', ${cfg.analytics.siteId}]);
const d=document, g=d.createElement('script'), s=d.getElementsByTagName
('script')[0];
g.type='text/javascript'; g.async=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s);
})();
\`
document.head.appendChild(matomoScript);
`)
} else if (cfg.analytics?.provider === "vercel") {
/**
* script from {@link https://vercel.com/docs/analytics/quickstart?framework=html#add-the-script-tag-to-your-site|Vercel Docs}
*/
componentResources.beforeDOMLoaded.push(`
window.va = window.va || function () { (window.vaq = window.vaq || []).push(arguments); };
`)
componentResources.afterDOMLoaded.push(`
const vercelInsightsScript = document.createElement("script")
vercelInsightsScript.src = "/_vercel/insights/script.js"
vercelInsightsScript.defer = true
document.head.appendChild(vercelInsightsScript)
`)
} else if (cfg.analytics?.provider === "rybbit") {
componentResources.afterDOMLoaded.push(`
const rybbitScript = document.createElement("script");
rybbitScript.src = "${cfg.analytics.host ?? "https://app.rybbit.io"}/api/script.js";
rybbitScript.setAttribute("data-site-id", "${cfg.analytics.siteId}");
rybbitScript.async = true;
rybbitScript.defer = true;
document.head.appendChild(rybbitScript);
`)
}
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)
`)
}
}
// This emitter should not update the `resources` parameter. If it does, partial
// rebuilds may not work as expected.
export const ComponentResources: QuartzEmitterPlugin = () => {
return {
name: "ComponentResources",
async *emit(ctx, _content, resources) {
const cfg = ctx.cfg.configuration
// component specific scripts and styles
const componentResources = getComponentResources(ctx)
let googleFontsStyleSheet = ""
if (cfg.theme.fontOrigin === "local") {
// let the user do it themselves in css
} else if (cfg.theme.fontOrigin === "googleFonts" && !cfg.theme.cdnCaching) {
// when cdnCaching is true, we link to google fonts in Head.tsx
const theme = ctx.cfg.configuration.theme
const response = await fetch(googleFontHref(theme))
googleFontsStyleSheet = await response.text()
if (theme.typography.title) {
const title = ctx.cfg.configuration.pageTitle
const response = await fetch(googleFontSubsetHref(theme, title))
googleFontsStyleSheet += `\n${await response.text()}`
}
if (!cfg.baseUrl) {
throw new Error(
"baseUrl must be defined when using Google Fonts without cfg.theme.cdnCaching",
)
}
const { processedStylesheet, fontFiles } = await processGoogleFonts(
googleFontsStyleSheet,
cfg.baseUrl,
)
googleFontsStyleSheet = processedStylesheet
// Download and save font files
for (const fontFile of fontFiles) {
const res = await fetch(fontFile.url)
if (!res.ok) {
throw new Error(`Failed to fetch font ${fontFile.filename}`)
}
const buf = await res.arrayBuffer()
yield write({
ctx,
slug: joinSegments("static", "fonts", fontFile.filename) as FullSlug,
ext: `.${fontFile.extension}`,
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, componentResources)
const useHashing = !ctx.argv.serve
// Separate global CSS (added by addGlobalPageResources, e.g. popover CSS)
// from component CSS. Global CSS was pushed onto componentResources.css
// AFTER getComponentResources() returned, so it's not in componentCssStrings.
const globalCss = componentResources.css.filter(
(c) => !componentResources.componentCssStrings.has(c),
)
// Core CSS: theme + fonts + global CSS + base styles (no per-component CSS)
const quartzBase = joinStyles(
ctx.cfg.configuration.theme,
googleFontsStyleSheet,
...globalCss,
baseStyles,
)
const stylesheet = `@layer quartz-base {\n${quartzBase}\n}\n${customStyles}`
const prescript = await joinScripts(componentResources.beforeDOMLoaded)
let postscript: string
if (!useHashing) {
// Serve mode: monolithic IIFE bundle for fast rebuilds
postscript = await joinScripts(componentResources.afterDOMLoaded)
} else {
// Production: emit each afterDOMLoaded script as an individual cached file,
// then generate an orchestrator that imports them with correct ordering.
// The last script is always the SPA router (pushed last by addGlobalPageResources),
// which must execute after all other scripts register their nav listeners.
const scripts = componentResources.afterDOMLoaded
const scriptFilenames: string[] = []
for (let i = 0; i < scripts.length; i++) {
const hash = hashContent(scripts[i])
const slug = `static/scripts/script-${i}-${hash}`
const filename = `${slug}.js`
scriptFilenames.push(filename)
yield write({
ctx,
slug: slug as FullSlug,
ext: ".js",
content: scripts[i],
})
}
// Generate orchestrator: import all component scripts in parallel,
// then import SPA router last (it dispatches the initial nav event)
const componentImports = scriptFilenames
.slice(0, -1)
.map((f) => `import("./${f}")`)
.join(",\n ")
const spaImport = `await import("./${scriptFilenames[scriptFilenames.length - 1]}");`
postscript = [`await Promise.all([\n ${componentImports}\n]);`, spaImport]
.filter(Boolean)
.join("\n")
}
const lightningTargets = {
safari: (15 << 16) | (6 << 8), // 15.6
ios_saf: (15 << 16) | (6 << 8), // 15.6
edge: 115 << 16,
firefox: 102 << 16,
chrome: 109 << 16,
}
const cssContent = transform({
filename: "index.css",
code: Buffer.from(stylesheet),
minify: true,
targets: lightningTargets,
include: Features.MediaQueries,
}).code.toString()
const cssStringToFilename = new Map<string, string>()
for (const cssString of componentResources.componentCssStrings) {
if (cssStringToFilename.has(cssString)) continue
const wrapped = `@layer quartz-base {\n${cssString}\n}`
const minified = transform({
filename: "component.css",
code: Buffer.from(wrapped),
minify: true,
targets: lightningTargets,
include: Features.MediaQueries,
}).code.toString()
const hash = hashContent(minified)
const slug = `component-${hash}`
const filename = `${slug}.css`
cssStringToFilename.set(cssString, filename)
yield write({
ctx,
slug: slug as FullSlug,
ext: ".css",
content: minified,
})
}
ctx.componentCssMap = cssStringToFilename
// Extract inline CSS/JS from plugin externalResources() into external files.
// This prevents large inline payloads (e.g. theme CSS) from being duplicated
// into every HTML page's <head>.
const extractedInlineResources = new Map<string, string>()
for (const cssResource of resources.css) {
if (!(cssResource.inline ?? false)) continue
let output: string
try {
output = transform({
filename: "plugin-resource.css",
code: Buffer.from(cssResource.content),
minify: true,
targets: lightningTargets,
include: Features.MediaQueries,
}).code.toString()
} catch {
output = cssResource.content
}
const hash = hashContent(output)
const slug = `static/resource-style-${hash}`
const filename = `${slug}.css`
extractedInlineResources.set(cssResource.content, filename)
yield write({
ctx,
slug: slug as FullSlug,
ext: ".css",
content: output,
})
}
for (const jsResource of resources.js) {
if (jsResource.contentType !== "inline") continue
const minified = await joinScripts([jsResource.script])
const hash = hashContent(minified)
const loadTimePrefix = jsResource.loadTime === "beforeDOMReady" ? "before" : "after"
const slug = `static/resource-${loadTimePrefix}-${hash}`
const filename = `${slug}.js`
extractedInlineResources.set(jsResource.script, filename)
yield write({
ctx,
slug: slug as FullSlug,
ext: ".js",
content: minified,
})
}
ctx.extractedInlineResources = extractedInlineResources
const cssHash = useHashing ? hashContent(cssContent) : null
const prescriptHash = useHashing ? hashContent(prescript) : null
const postscriptHash = useHashing ? hashContent(postscript) : null
const cssSlug = cssHash ? `index-${cssHash}` : "index"
const prescriptSlug = prescriptHash ? `prescript-${prescriptHash}` : "prescript"
const postscriptSlug = postscriptHash ? `postscript-${postscriptHash}` : "postscript"
ctx.hashedResourceNames = {
"index.css": `${cssSlug}.css`,
"prescript.js": `${prescriptSlug}.js`,
"postscript.js": `${postscriptSlug}.js`,
}
yield write({
ctx,
slug: cssSlug as FullSlug,
ext: ".css",
content: cssContent,
})
yield write({
ctx,
slug: prescriptSlug as FullSlug,
ext: ".js",
content: prescript,
})
yield write({
ctx,
slug: postscriptSlug as FullSlug,
ext: ".js",
content: postscript,
})
},
async *partialEmit() {},
}
}
@@ -0,0 +1,20 @@
import path from "path"
import fs from "fs"
import { BuildCtx } from "../../util/ctx"
import { FilePath, FullSlug, joinSegments } from "../../util/path"
import { Readable } from "stream"
type WriteOptions = {
ctx: BuildCtx
slug: FullSlug
ext: `.${string}` | ""
content: string | Buffer | Readable
}
export const write = async ({ ctx, slug, ext, content }: WriteOptions): Promise<FilePath> => {
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
}
@@ -0,0 +1,3 @@
export { Assets } from "./assets"
export { Static } from "./static"
export { ComponentResources } from "./componentResources"
@@ -0,0 +1,23 @@
import { FilePath, QUARTZ, joinSegments } from "../../util/path"
import { QuartzEmitterPlugin } from "../types"
import fs from "fs"
import { glob } from "../../util/glob"
import { dirname } from "path"
export const Static: QuartzEmitterPlugin = () => ({
name: "Static",
async *emit({ argv, cfg }) {
const staticPath = joinSegments(QUARTZ, "static")
const fps = await glob("**", staticPath, cfg.configuration.ignorePatterns)
const outputStaticPath = joinSegments(argv.output, "static")
await fs.promises.mkdir(outputStaticPath, { recursive: true })
for (const fp of fps) {
const src = joinSegments(staticPath, fp) as FilePath
const dest = joinSegments(outputStaticPath, fp) as FilePath
await fs.promises.mkdir(dirname(dest), { recursive: true })
await fs.promises.copyFile(src, dest)
yield dest
}
},
async *partialEmit() {},
})
@@ -0,0 +1 @@
export {}
@@ -0,0 +1,50 @@
import { StaticResources } from "../util/resources"
import { BuildCtx } from "../util/ctx"
export function getStaticResourcesFromPlugins(ctx: BuildCtx) {
const staticResources: StaticResources = {
css: [],
js: [],
additionalHead: [],
}
for (const transformer of [...ctx.cfg.plugins.transformers, ...ctx.cfg.plugins.emitters]) {
const res = transformer.externalResources ? transformer.externalResources(ctx) : {}
if (res?.js) {
staticResources.js.push(...res.js)
}
if (res?.css) {
staticResources.css.push(...res.css)
}
if (res?.additionalHead) {
staticResources.additionalHead.push(...res.additionalHead)
}
}
// if serving locally, listen for rebuilds and reload the page
if (ctx.argv.serve) {
const wsUrl = ctx.argv.remoteDevHost
? `wss://${ctx.argv.remoteDevHost}:${ctx.argv.wsPort}`
: `ws://localhost:${ctx.argv.wsPort}`
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))
`,
})
}
return staticResources
}
export * from "./transformers"
export * from "./filters"
export * from "./emitters"
export * from "./types"
export * from "./config"
export * as PageTypes from "./pageTypes"
export * as PluginLoader from "./loader"
@@ -0,0 +1,71 @@
import { componentRegistry } from "../../components/registry"
import { ComponentManifest, PluginManifest } from "./types"
import { QuartzComponentConstructor } from "../../components/types"
import { getPluginSubpathEntry, toFileUrl } from "./gitLoader"
export async function loadComponentsFromPackage(
pluginName: string,
manifest: PluginManifest | null,
): Promise<void> {
if (!manifest?.components) return
try {
const componentsPath = getPluginSubpathEntry(pluginName, "./components")
let componentsModule: Record<string, unknown>
if (componentsPath) {
componentsModule = await import(toFileUrl(componentsPath))
} else {
componentsModule = await import(`${pluginName}/components`)
}
const componentEntries = Object.entries(manifest.components)
for (const [exportName, componentManifest] of componentEntries) {
const component = componentsModule[exportName]
if (!component) {
console.warn(
`Component "${exportName}" declared in manifest but not found in ${pluginName}/components`,
)
continue
}
// Register under the fully-qualified key (pluginName/exportName)
componentRegistry.register(
`${pluginName}/${exportName}`,
component as QuartzComponentConstructor,
pluginName,
componentManifest as ComponentManifest,
)
// Also register under just the export name (e.g. "Footer", "NotePropertiesComponent")
// so buildLayoutForEntries can find it via PascalCase conversion of plugin name
if (!componentRegistry.get(exportName)) {
componentRegistry.register(
exportName,
component as QuartzComponentConstructor,
pluginName,
componentManifest as ComponentManifest,
)
}
}
// If plugin has exactly one component, also register under just the plugin name
// (e.g. "footer", "note-properties") for direct kebab-case lookup
if (componentEntries.length === 1) {
const [exportName] = componentEntries[0]
const component = componentsModule[exportName]
if (component && !componentRegistry.get(pluginName)) {
componentRegistry.register(
pluginName,
component as QuartzComponentConstructor,
pluginName,
componentEntries[0][1] as ComponentManifest,
)
}
}
} catch {
if (manifest.components && Object.keys(manifest.components).length > 0) {
console.warn(`Plugin "${pluginName}" declares components but failed to load them`)
}
}
}
@@ -0,0 +1,33 @@
import { QuartzComponentProps } from "../../components/types"
export type ConditionPredicate = (props: QuartzComponentProps) => boolean
const builtinConditions: Record<string, ConditionPredicate> = {
"not-index": (props) => props.fileData.slug !== "index",
"has-tags": (props) => {
const tags = props.fileData.frontmatter?.tags
return Array.isArray(tags) && tags.length > 0
},
"has-backlinks": (props) => {
const backlinks = (props.fileData as Record<string, unknown>).backlinks
return Array.isArray(backlinks) && backlinks.length > 0
},
"has-toc": (props) => {
const toc = (props.fileData as Record<string, unknown>).toc
return Array.isArray(toc) && toc.length > 0
},
}
const customConditions = new Map<string, ConditionPredicate>()
export function registerCondition(name: string, predicate: ConditionPredicate): void {
customConditions.set(name, predicate)
}
export function getCondition(name: string): ConditionPredicate | undefined {
return customConditions.get(name) ?? builtinConditions[name]
}
export function getAllConditionNames(): string[] {
return [...Object.keys(builtinConditions), ...customConditions.keys()]
}
@@ -0,0 +1,943 @@
import fs from "fs"
import path from "path"
import YAML from "yaml"
import { styleText } from "util"
import { QuartzConfig, GlobalConfiguration, FullPageLayout } from "../../cfg"
import { QuartzComponent, QuartzComponentConstructor } from "../../components/types"
import { PluginTypes } from "../types"
import {
PluginManifest,
PluginJsonEntry,
PluginSource,
QuartzPluginsJson,
LayoutConfig,
PluginLayoutDeclaration,
FlexGroupConfig,
} from "./types"
import {
parsePluginSource,
installPlugin,
installNativeDeps,
getPluginEntryPoint,
toFileUrl,
isLocalSource,
} from "./gitLoader"
import { loadComponentsFromPackage } from "./componentLoader"
import { loadFramesFromPackage } from "./frameLoader"
import { componentRegistry } from "../../components/registry"
import { getCondition } from "./conditions"
const CONFIG_YAML_PATH = path.join(process.cwd(), "quartz.config.yaml")
const DEFAULT_CONFIG_YAML_PATH = path.join(process.cwd(), "quartz.config.default.yaml")
const LEGACY_PLUGINS_JSON_PATH = path.join(process.cwd(), "quartz.plugins.json")
const LEGACY_DEFAULT_PLUGINS_JSON_PATH = path.join(process.cwd(), "quartz.plugins.default.json")
function resolveConfigPath(): string {
if (fs.existsSync(CONFIG_YAML_PATH)) return CONFIG_YAML_PATH
if (fs.existsSync(LEGACY_PLUGINS_JSON_PATH)) return LEGACY_PLUGINS_JSON_PATH
if (fs.existsSync(DEFAULT_CONFIG_YAML_PATH)) return DEFAULT_CONFIG_YAML_PATH
if (fs.existsSync(LEGACY_DEFAULT_PLUGINS_JSON_PATH)) return LEGACY_DEFAULT_PLUGINS_JSON_PATH
return CONFIG_YAML_PATH
}
function readPluginsJson(): QuartzPluginsJson | null {
const configPath = resolveConfigPath()
if (!fs.existsSync(configPath)) {
return null
}
const raw = fs.readFileSync(configPath, "utf-8")
if (configPath.endsWith(".yaml") || configPath.endsWith(".yml")) {
return YAML.parse(raw) as QuartzPluginsJson
}
return JSON.parse(raw) as QuartzPluginsJson
}
function extractPluginName(source: PluginSource): string {
if (typeof source === "object" && source !== null) {
if (source.name) return source.name
return extractPluginName(source.repo)
}
if (isLocalSource(source)) {
return path.basename(source.replace(/[\/]+$/, ""))
}
if (source.startsWith("github:")) {
const withoutPrefix = source.replace("github:", "")
const [repoPath] = withoutPrefix.split("#")
const parts = repoPath.split("/")
return parts[parts.length - 1]
}
if (source.startsWith("git+") || source.startsWith("https://")) {
const url = source.replace("git+", "")
const match = url.match(/\/([^/]+?)(?:\.git)?(?:#|$)/)
return match?.[1] ?? source
}
return source
}
function formatSourceDisplay(source: PluginSource): string {
if (typeof source === "string") return source
const parts = [source.repo]
if (source.subdir) parts.push(`(subdir: ${source.subdir})`)
if (source.ref) parts.push(`(ref: ${source.ref})`)
return parts.join(" ")
}
function sourceKey(source: PluginSource): string {
if (typeof source === "string") return source
return JSON.stringify(source)
}
interface DependencyValidationResult {
errors: string[]
warnings: string[]
}
function validateDependencies(
entries: PluginJsonEntry[],
manifests: Map<string, PluginManifest>,
): DependencyValidationResult {
const errors: string[] = []
const warnings: string[] = []
const sourceToEntry = new Map<string, PluginJsonEntry>()
const nameToSource = new Map<string, string>()
for (const entry of entries) {
sourceToEntry.set(sourceKey(entry.source), entry)
nameToSource.set(extractPluginName(entry.source), sourceKey(entry.source))
}
for (const entry of entries) {
if (!entry.enabled) continue
const manifest = manifests.get(sourceKey(entry.source))
if (!manifest?.dependencies?.length) continue
const pluginName = manifest.displayName || extractPluginName(entry.source)
const pluginOrder = entry.order ?? manifest.defaultOrder ?? 50
for (const dep of manifest.dependencies) {
const depEntry = sourceToEntry.get(dep)
const depName = extractPluginName(dep)
if (!depEntry) {
errors.push(
`Plugin "${pluginName}" requires "${depName}". Run: npx quartz plugin add ${dep}`,
)
continue
}
if (!depEntry.enabled) {
warnings.push(
`Plugin "${pluginName}" depends on "${depName}" which is disabled. "${pluginName}" may not function correctly.`,
)
}
const depManifest = manifests.get(dep)
const depOrder = depEntry.order ?? depManifest?.defaultOrder ?? 50
if (pluginOrder < depOrder) {
errors.push(
`Plugin "${pluginName}" (order: ${pluginOrder}) depends on "${depName}" (order: ${depOrder}), ` +
`but "${pluginName}" is configured to run first. Either increase "${pluginName}"'s order above ${depOrder} ` +
`or decrease "${depName}"'s order below ${pluginOrder}.`,
)
}
}
}
const graph = new Map<string, string[]>()
for (const entry of entries) {
const manifest = manifests.get(sourceKey(entry.source))
if (manifest?.dependencies?.length) {
graph.set(sourceKey(entry.source), manifest.dependencies)
}
}
const visited = new Set<string>()
const inStack = new Set<string>()
function detectCycle(node: string, pathSoFar: string[]): string[] | null {
if (inStack.has(node)) {
const cycleStart = pathSoFar.indexOf(node)
return pathSoFar.slice(cycleStart).concat(node)
}
if (visited.has(node)) return null
visited.add(node)
inStack.add(node)
for (const dep of graph.get(node) ?? []) {
const cycle = detectCycle(dep, [...pathSoFar, node])
if (cycle) return cycle
}
inStack.delete(node)
return null
}
for (const node of graph.keys()) {
const cycle = detectCycle(node, [])
if (cycle) {
const names = cycle.map(extractPluginName)
errors.push(`Circular dependency detected: ${names.join(" → ")}`)
break
}
}
return { errors, warnings }
}
async function resolvePluginManifest(source: PluginSource): Promise<PluginManifest | null> {
try {
const gitSpec = parsePluginSource(source)
const entryPoint = getPluginEntryPoint(gitSpec.name)
const module = await import(toFileUrl(entryPoint))
return module.manifest ?? null
} catch {
return null
}
}
async function readManifestFromPackageJson(source: PluginSource): Promise<PluginManifest | null> {
try {
const gitSpec = parsePluginSource(source)
const pluginDir = path.join(process.cwd(), ".quartz", "plugins", gitSpec.name)
const pkgPath = path.join(pluginDir, "package.json")
if (!fs.existsSync(pkgPath)) return null
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"))
if (!pkg.quartz) return null
const q = pkg.quartz
return {
name: q.name ?? gitSpec.name,
displayName: q.displayName ?? q.name ?? gitSpec.name,
description: q.description ?? pkg.description ?? "No description",
version: q.version ?? pkg.version ?? "1.0.0",
author: q.author ?? pkg.author,
homepage: q.homepage ?? pkg.homepage,
category: q.category,
quartzVersion: q.quartzVersion,
dependencies: q.dependencies,
defaultOrder: q.defaultOrder,
defaultEnabled: q.defaultEnabled,
defaultOptions: q.defaultOptions,
configSchema: q.configSchema,
components: q.components,
frames: q.frames,
}
} catch {
return null
}
}
async function getManifest(source: PluginSource): Promise<PluginManifest | null> {
// Try package.json quartz field first (preferred), then fall back to manifest.ts export
return (await readManifestFromPackageJson(source)) ?? (await resolvePluginManifest(source))
}
export async function loadQuartzConfig(
configOverrides?: Partial<GlobalConfiguration>,
): Promise<QuartzConfig> {
const json = readPluginsJson()
if (!json) {
// Fallback: import old-style config directly
const oldConfig = await import("../../../quartz")
return oldConfig.default
}
const configuration = {
...(json.configuration as unknown as GlobalConfiguration),
...configOverrides,
}
const enabledEntries = json.plugins.filter((e) => e.enabled)
const manifests = new Map<string, PluginManifest>()
// Ensure all plugins are installed and collect native deps
const allNativeDeps = new Map<string, Map<string, string>>()
for (const entry of enabledEntries) {
try {
const gitSpec = parsePluginSource(entry.source)
const result = await installPlugin(gitSpec, { verbose: false })
if (result.nativeDeps.size > 0) {
allNativeDeps.set(gitSpec.name, result.nativeDeps)
}
} catch (err) {
console.error(
styleText("red", ``) +
` Failed to install plugin: ${styleText("yellow", formatSourceDisplay(entry.source))}\n` +
` ${err instanceof Error ? err.message : String(err)}`,
)
}
}
if (allNativeDeps.size > 0) {
installNativeDeps(allNativeDeps, { verbose: false })
}
// Collect manifests (requires native deps to be installed first)
for (const entry of enabledEntries) {
try {
const manifest = await getManifest(entry.source)
if (manifest) {
manifests.set(sourceKey(entry.source), manifest)
}
} catch (err) {
console.error(
styleText("red", ``) +
` Failed to load manifest: ${styleText("yellow", formatSourceDisplay(entry.source))}\n` +
` ${err instanceof Error ? err.message : String(err)}`,
)
}
}
// Validate dependencies
const validation = validateDependencies(enabledEntries, manifests)
for (const warning of validation.warnings) {
console.warn(styleText("yellow", ``) + ` ${warning}`)
}
if (validation.errors.length > 0) {
for (const error of validation.errors) {
console.error(styleText("red", ``) + ` ${error}`)
}
throw new Error(
`Plugin dependency validation failed with ${validation.errors.length} error(s). See above for details.`,
)
}
// Categorize and sort plugins
const transformers: { entry: PluginJsonEntry; manifest: PluginManifest | undefined }[] = []
const filters: { entry: PluginJsonEntry; manifest: PluginManifest | undefined }[] = []
const emitters: { entry: PluginJsonEntry; manifest: PluginManifest | undefined }[] = []
const pageTypes: { entry: PluginJsonEntry; manifest: PluginManifest | undefined }[] = []
for (const entry of enabledEntries) {
const manifest = manifests.get(sourceKey(entry.source))
const category = manifest?.category
// Resolve processing categories: for array categories (e.g. ["transformer", "pageType", "component"]),
// push the plugin into ALL matching processing category buckets.
// "component" is handled separately via loadComponentsFromPackage during instantiation.
const processingCategories = ["transformer", "filter", "emitter", "pageType"] as const
const categoryMap: Record<string, typeof transformers> = {
transformer: transformers,
filter: filters,
emitter: emitters,
pageType: pageTypes,
}
const categories = Array.isArray(category) ? category : category ? [category] : []
const matchedProcessing = categories.filter((c) =>
(processingCategories as readonly string[]).includes(c),
)
if (matchedProcessing.length > 0) {
for (const cat of matchedProcessing) {
categoryMap[cat].push({ entry, manifest })
}
} else {
const gitSpec = parsePluginSource(entry.source)
const isComponentOnly = categories.length > 0 && categories.every((c) => c === "component")
if (isComponentOnly) {
// Always import the main entry point for component-only plugins.
// Some plugins (e.g. Bases view registrations) rely on side effects
// in their index module to register functionality.
const entryPoint = getPluginEntryPoint(gitSpec.name)
try {
const module = await import(toFileUrl(entryPoint))
// If the module exports an init() function, call it with merged options
// so component-only plugins can receive user configuration from YAML.
if (typeof module.init === "function") {
const initOverrides = componentRegistry.getOptionOverrides(gitSpec.name)
const options = { ...manifest?.defaultOptions, ...entry.options, ...initOverrides }
await module.init(Object.keys(options).length > 0 ? options : undefined)
}
} catch (e) {
// Side-effect import failed — continue with manifest-based loading
}
if (manifest?.components && Object.keys(manifest.components).length > 0) {
await loadComponentsFromPackage(gitSpec.name, manifest)
}
if (manifest?.frames && Object.keys(manifest.frames).length > 0) {
await loadFramesFromPackage(gitSpec.name, manifest)
}
} else {
const entryPoint = getPluginEntryPoint(gitSpec.name)
try {
const module = await import(toFileUrl(entryPoint))
const detected = detectCategoryFromModule(module)
if (detected) {
categoryMap[detected].push({ entry, manifest })
} else if (manifest?.components && Object.keys(manifest.components).length > 0) {
await loadComponentsFromPackage(gitSpec.name, manifest)
if (manifest?.frames && Object.keys(manifest.frames).length > 0) {
await loadFramesFromPackage(gitSpec.name, manifest)
}
} else {
console.warn(
styleText("yellow", ``) +
` Could not determine category for plugin "${extractPluginName(entry.source)}". Skipping.`,
)
}
} catch {
const hasComponents = manifest?.components && Object.keys(manifest.components).length > 0
const hasFrames = manifest?.frames && Object.keys(manifest.frames).length > 0
if (hasComponents) {
await loadComponentsFromPackage(gitSpec.name, manifest)
}
if (hasFrames) {
await loadFramesFromPackage(gitSpec.name, manifest)
}
if (!hasComponents && !hasFrames) {
console.warn(
styleText("yellow", ``) +
` Could not load plugin "${extractPluginName(entry.source)}" to detect category. Skipping.`,
)
}
}
}
}
}
// Sort by order within each category
const sortByOrder = (
a: { entry: PluginJsonEntry; manifest: PluginManifest | undefined },
b: { entry: PluginJsonEntry; manifest: PluginManifest | undefined },
) => {
const orderA = a.entry.order ?? a.manifest?.defaultOrder ?? 50
const orderB = b.entry.order ?? b.manifest?.defaultOrder ?? 50
return orderA - orderB
}
transformers.sort(sortByOrder)
filters.sort(sortByOrder)
emitters.sort(sortByOrder)
pageTypes.sort(sortByOrder)
// Instantiate plugins
const instantiate = async (
items: { entry: PluginJsonEntry; manifest: PluginManifest | undefined }[],
expectedCategory: ProcessingCategory,
) => {
const instances = []
for (const { entry, manifest } of items) {
try {
const gitSpec = parsePluginSource(entry.source)
const entryPoint = getPluginEntryPoint(gitSpec.name)
const module = await import(toFileUrl(entryPoint))
if (manifest?.components && Object.keys(manifest.components).length > 0) {
await loadComponentsFromPackage(gitSpec.name, manifest)
}
if (manifest?.frames && Object.keys(manifest.frames).length > 0) {
await loadFramesFromPackage(gitSpec.name, manifest)
}
const factory = findFactory(module, expectedCategory)
if (!factory) {
console.warn(
styleText("yellow", ``) +
` Plugin "${extractPluginName(entry.source)}" has no factory function for category "${expectedCategory}". ` +
`Ensure your plugin exports a default function, a "plugin" named export, or a single exported function.`,
)
continue
}
const pluginOverrides = componentRegistry.getOptionOverrides(gitSpec.name)
const options = { ...manifest?.defaultOptions, ...entry.options, ...pluginOverrides }
const instance = factory(Object.keys(options).length > 0 ? options : undefined)
if (!instance || typeof instance !== "object") {
console.warn(
styleText("yellow", ``) +
` Plugin "${extractPluginName(entry.source)}" factory did not return a valid plugin instance. Skipping.`,
)
continue
}
if (!validateCategory(instance, expectedCategory)) {
console.warn(
styleText("yellow", ``) +
` Plugin "${extractPluginName(entry.source)}" declares category "${expectedCategory}" ` +
`but its factory returned an instance missing the required methods. Skipping.`,
)
continue
}
instances.push(instance)
} catch (err) {
console.error(
styleText("red", ``) +
` Failed to instantiate plugin "${extractPluginName(entry.source)}": ${err instanceof Error ? err.message : String(err)}`,
)
}
}
return instances
}
// Import built-in plugins
const builtinPlugins = await import("../index")
const builtinTransformers: unknown[] = []
const builtinEmitters = [
builtinPlugins.ComponentResources(),
builtinPlugins.Assets(),
builtinPlugins.Static(),
]
const builtinPageTypes = [builtinPlugins.PageTypes.NotFoundPageType()]
const plugins: PluginTypes = {
transformers: [...builtinTransformers, ...(await instantiate(transformers, "transformer"))],
filters: await instantiate(filters, "filter"),
emitters: [...builtinEmitters, ...(await instantiate(emitters, "emitter"))],
pageTypes: [...(await instantiate(pageTypes, "pageType")), ...builtinPageTypes],
}
// Load layout and add PageTypeDispatcher to emitters.
// This must happen after plugin instantiation so the component registry is populated.
const layout = await loadQuartzLayout()
plugins.emitters.push(
builtinPlugins.PageTypes.PageTypeDispatcher({
defaults: layout.defaults,
byPageType: layout.byPageType,
}),
)
return {
configuration,
plugins,
}
}
type ProcessingCategory = "transformer" | "filter" | "emitter" | "pageType"
/**
* Validate that a plugin instance has the required methods for its declared category.
* Called AFTER real instantiation — never used to probe/discover category.
*/
function validateCategory(
instance: Record<string, unknown>,
expected: ProcessingCategory,
): boolean {
switch (expected) {
case "pageType":
return "match" in instance && "body" in instance && "layout" in instance
case "emitter":
return "emit" in instance
case "filter":
return "shouldPublish" in instance
case "transformer":
return (
"textTransform" in instance || "markdownPlugins" in instance || "htmlPlugins" in instance
)
}
}
/**
* Find the factory function from a plugin module by export convention.
* Prefers `default` export, then `plugin` named export, then the sole exported function.
* For multi-export modules with an expectedCategory, probes candidate functions to find
* the one matching the category shape.
*/
function findFactory(
module: Record<string, unknown>,
expectedCategory?: ProcessingCategory,
): Function | null {
if (typeof module.default === "function") {
return module.default as Function
}
if (typeof module.plugin === "function") {
return module.plugin as Function
}
const exportedFunctions = Object.entries(module).filter(
([key, value]) => typeof value === "function" && !key.startsWith("__"),
)
if (exportedFunctions.length === 1) {
return exportedFunctions[0][1] as Function
}
// Multiple exports: probe candidates to find the one matching the expected category.
// This is the only code path that calls factory() for discovery, and only when
// there is no default/plugin export and multiple functions are exported.
if (exportedFunctions.length > 1 && expectedCategory) {
for (const [, fn] of exportedFunctions) {
try {
const instance = (fn as Function)()
if (
instance &&
typeof instance === "object" &&
validateCategory(instance, expectedCategory)
) {
return fn as Function
}
} catch {
// This export doesn't work without args — skip it
}
}
}
return null
}
function detectCategoryFromModule(module: unknown): ProcessingCategory | null {
if (!module || typeof module !== "object") return null
const mod = module as Record<string, unknown>
// Prefer static category marker on the factory if available
const factory = findFactory(mod as Record<string, unknown>)
if (factory && "quartzCategory" in factory) {
const cat = (factory as Record<string, unknown>).quartzCategory
if (cat === "transformer" || cat === "filter" || cat === "emitter" || cat === "pageType") {
return cat
}
}
// Fallback: try instantiating with no args and inspect the result.
// This may fail for plugins that do I/O or require options during construction.
if (typeof factory === "function") {
try {
const instance = factory()
if (instance && typeof instance === "object") {
if ("match" in instance && "body" in instance && "layout" in instance) return "pageType"
if ("emit" in instance) return "emitter"
if ("shouldPublish" in instance) return "filter"
if (
"textTransform" in instance ||
"markdownPlugins" in instance ||
"htmlPlugins" in instance
)
return "transformer"
}
} catch {
// Factory requires arguments or does I/O — cannot detect category by probing.
// Plugin should declare category in package.json quartz.category field.
}
}
return null
}
export async function loadQuartzLayout(layoutOverrides?: {
defaults?: Partial<FullPageLayout>
byPageType?: Record<string, Partial<FullPageLayout>>
}): Promise<{
defaults: Partial<FullPageLayout>
byPageType: Record<string, Partial<FullPageLayout>>
}> {
const json = readPluginsJson()
if (!json) {
// Fallback: import old-style layout directly
const oldLayout = await import("../../../quartz")
return oldLayout.layout
}
const enabledWithLayout = json.plugins.filter((e) => e.enabled && e.layout)
const layoutConfig = json.layout ?? {}
// Build default layout for all page types
const defaultLayout = buildLayoutForEntries(enabledWithLayout, layoutConfig)
// Build per-page-type overrides
const byPageType: Record<string, Partial<FullPageLayout>> = {}
if (layoutConfig.byPageType) {
for (const [pageType, override] of Object.entries(layoutConfig.byPageType)) {
let filteredEntries = enabledWithLayout
// Apply exclusions
if (override.exclude?.length) {
filteredEntries = filteredEntries.filter((e) => {
const name = extractPluginName(e.source)
return !override.exclude!.includes(name)
})
}
const ptLayout = buildLayoutForEntries(filteredEntries, layoutConfig)
// Apply position overrides (empty array = clear position)
if (override.positions) {
for (const [pos, components] of Object.entries(override.positions)) {
if (Array.isArray(components) && components.length === 0) {
const key = pos as keyof Pick<
FullPageLayout,
"left" | "right" | "beforeBody" | "afterBody"
>
if (key in ptLayout) {
;(ptLayout as Record<string, unknown>)[key] = []
}
}
}
}
// Apply frame template override
if (override.template) {
ptLayout.frame = override.template
}
byPageType[pageType] = ptLayout
}
}
// Add Head (built-in) and Footer (plugin)
const HeadModule = await import("../../components/Head")
const head = HeadModule.default()
// Find footer from component registry (loaded during plugin instantiation)
const footerEntry = json.plugins.find(
(e) => e.enabled && extractPluginName(e.source) === "footer",
)
let footer: QuartzComponent | undefined
if (footerEntry) {
// Try registry lookup: plugin name ("footer") or export name ("Footer")
const footerReg = componentRegistry.get("footer") ?? componentRegistry.get("Footer")
if (footerReg) {
if (typeof footerReg.component === "function" && !("displayName" in footerReg.component)) {
// It's a constructor — use registry cache for consistent instances
const footerOverrides = componentRegistry.getOptionOverrides("footer")
const opts = { ...footerEntry.options, ...footerOverrides }
footer = componentRegistry.instantiate(
footerReg.component as QuartzComponentConstructor,
Object.keys(opts).length > 0 ? opts : undefined,
)
} else {
footer = footerReg.component as QuartzComponent
}
}
}
// Apply structural defaults
defaultLayout.head = head
defaultLayout.header = defaultLayout.header ?? []
if (footer) {
defaultLayout.footer = footer
}
// Ensure all byPageType entries inherit structural slots
for (const pageType of Object.keys(byPageType)) {
const pt = byPageType[pageType]
if (!pt.head) pt.head = head
if (!pt.header) pt.header = []
if (footer && !pt.footer) pt.footer = footer
}
const mergedDefaults = { ...defaultLayout, ...layoutOverrides?.defaults }
const mergedByPageType = { ...byPageType }
if (layoutOverrides?.byPageType) {
for (const [pageType, overrideLayout] of Object.entries(layoutOverrides.byPageType)) {
mergedByPageType[pageType] = { ...mergedByPageType[pageType], ...overrideLayout }
}
}
return { defaults: mergedDefaults, byPageType: mergedByPageType }
}
function buildLayoutForEntries(
entries: PluginJsonEntry[],
layoutConfig: LayoutConfig,
): Partial<FullPageLayout> {
const positions: Record<
string,
{
component: QuartzComponent
priority: number
group?: string
groupOptions?: PluginLayoutDeclaration["groupOptions"]
}[]
> = {
left: [],
right: [],
beforeBody: [],
afterBody: [],
}
for (const entry of entries) {
if (!entry.layout) continue
const layout = entry.layout
const name = extractPluginName(entry.source)
// Look up component from registry
const registered =
componentRegistry.get(name) ??
componentRegistry.get(`${formatSourceDisplay(entry.source)}/${name}`)
if (!registered) {
// Try common naming patterns
const pascalName = name
.split("-")
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
.join("")
const altRegistered = componentRegistry.get(pascalName)
if (!altRegistered) continue
}
const reg =
registered ??
componentRegistry.get(
name
.split("-")
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
.join(""),
)
if (!reg) continue
let component: QuartzComponent
if (typeof reg.component === "function" && !("displayName" in reg.component)) {
// It's a constructor — use registry cache to avoid duplicate instances
// (and duplicate afterDOMLoaded scripts) across page-type layouts
const tsOverrides = componentRegistry.getOptionOverrides(name)
const opts = { ...entry.options, ...tsOverrides }
const optsArg = Object.keys(opts).length > 0 ? opts : undefined
component = componentRegistry.instantiate(
reg.component as QuartzComponentConstructor,
optsArg,
)
} else {
component = reg.component as QuartzComponent
}
// Apply display modifier
if (layout.display && layout.display !== "all") {
component = applyDisplayWrapper(component, layout.display)
}
// Apply condition
if (layout.condition) {
component = applyConditionWrapper(component, layout.condition)
}
const posArray = positions[layout.position]
if (posArray) {
posArray.push({
component,
priority: layout.priority,
group: layout.group,
groupOptions: layout.groupOptions,
})
}
}
// Sort by priority and resolve groups
const result: Partial<FullPageLayout> = {}
for (const [position, items] of Object.entries(positions)) {
items.sort((a, b) => a.priority - b.priority)
const resolved = resolveGroups(items, layoutConfig.groups ?? {})
const key = position as keyof Pick<
FullPageLayout,
"left" | "right" | "beforeBody" | "afterBody"
>
;(result as Record<string, QuartzComponent[]>)[key] = resolved
}
return result
}
function resolveGroups(
items: {
component: QuartzComponent
priority: number
group?: string
groupOptions?: PluginLayoutDeclaration["groupOptions"]
}[],
groups: Record<string, FlexGroupConfig>,
): QuartzComponent[] {
// Collect grouped components and track the effective priority for each group.
// Effective priority = explicit group config priority ?? first member's priority.
const groupedComponents = new Map<
string,
{ component: QuartzComponent; groupOptions?: PluginLayoutDeclaration["groupOptions"] }[]
>()
const groupPriority = new Map<string, number>()
for (const item of items) {
if (item.group) {
if (!groupedComponents.has(item.group)) {
groupedComponents.set(item.group, [])
// Use explicit group priority from config if set, otherwise fall back to first member's priority
const groupConfig = groups[item.group]
groupPriority.set(item.group, groupConfig?.priority ?? item.priority)
}
const groupMembers = groupedComponents.get(item.group)
if (groupMembers) {
groupMembers.push({
component: item.component,
groupOptions: item.groupOptions,
})
}
}
}
// Build a unified list of renderable entries (ungrouped components + flex groups),
// each with a priority, so we can sort them together.
type RenderEntry = { priority: number; component: QuartzComponent }
const entries: RenderEntry[] = []
const processedGroups = new Set<string>()
for (const item of items) {
if (item.group) {
// Only emit the flex group once (on first encounter)
if (processedGroups.has(item.group)) continue
processedGroups.add(item.group)
const members = groupedComponents.get(item.group)
if (!members) continue
const groupConfig = groups[item.group] ?? {}
const flexComponents = members.map((m) => ({
Component: m.component,
grow: m.groupOptions?.grow,
shrink: m.groupOptions?.shrink,
basis: m.groupOptions?.basis,
order: m.groupOptions?.order,
align: m.groupOptions?.align,
justify: m.groupOptions?.justify,
}))
// Dynamically import Flex to avoid circular dependencies
const FlexModule = require("../../components/Flex")
const Flex = FlexModule.default as Function
const flexComponent = Flex({
components: flexComponents,
direction: groupConfig.direction ?? "row",
wrap: groupConfig.wrap,
gap: groupConfig.gap ?? "1rem",
}) as QuartzComponent
entries.push({ priority: groupPriority.get(item.group) ?? 50, component: flexComponent })
} else {
entries.push({ priority: item.priority, component: item.component })
}
}
// Stable sort by priority (items already arrive sorted, so equal priorities preserve order)
entries.sort((a, b) => a.priority - b.priority)
return entries.map((e) => e.component)
}
function applyDisplayWrapper(
component: QuartzComponent,
display: "mobile-only" | "desktop-only",
): QuartzComponent {
if (display === "mobile-only") {
const MobileOnly = require("../../components/MobileOnly").default as Function
return MobileOnly(component) as QuartzComponent
} else {
const DesktopOnly = require("../../components/DesktopOnly").default as Function
return DesktopOnly(component) as QuartzComponent
}
}
function applyConditionWrapper(component: QuartzComponent, conditionName: string): QuartzComponent {
const predicate = getCondition(conditionName)
if (!predicate) {
console.warn(
styleText("yellow", ``) +
` Unknown condition "${conditionName}". Component will always render.`,
)
return component
}
const ConditionalRender = require("../../components/ConditionalRender").default as Function
return ConditionalRender({
component,
condition: predicate,
}) as QuartzComponent
}
@@ -0,0 +1,47 @@
import { frameRegistry } from "../../components/frames/registry"
import { PluginManifest } from "./types"
import { PageFrame } from "../../components/frames/types"
import { getPluginSubpathEntry, toFileUrl } from "./gitLoader"
export async function loadFramesFromPackage(
pluginName: string,
manifest: PluginManifest | null,
): Promise<void> {
if (!manifest?.frames) return
try {
const framesPath = getPluginSubpathEntry(pluginName, "./frames")
let framesModule: Record<string, unknown>
if (framesPath) {
framesModule = await import(toFileUrl(framesPath))
} else {
framesModule = await import(`${pluginName}/frames`)
}
for (const [exportName, _frameMeta] of Object.entries(manifest.frames)) {
const frame = framesModule[exportName]
if (!frame) {
console.warn(
`Frame "${exportName}" declared in manifest but not found in ${pluginName}/frames`,
)
continue
}
const pageFrame = frame as PageFrame
if (!pageFrame.name || typeof pageFrame.render !== "function") {
console.warn(
`Frame "${exportName}" from ${pluginName} is not a valid PageFrame (missing name or render)`,
)
continue
}
// Register under the frame's declared name
frameRegistry.register(pageFrame.name, pageFrame, pluginName)
}
} catch {
if (manifest.frames && Object.keys(manifest.frames).length > 0) {
console.warn(`Plugin "${pluginName}" declares frames but failed to load them`)
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,499 @@
import { styleText } from "util"
import {
PluginManifest,
PluginCategory,
LoadedPlugin,
PluginResolution,
PluginResolutionError,
PluginResolutionOptions,
PluginSpecifier,
} from "./types"
import {
QuartzTransformerPlugin,
QuartzFilterPlugin,
QuartzEmitterPlugin,
QuartzPageTypePlugin,
} from "../types"
import {
parsePluginSource,
installPlugin,
getPluginEntryPoint,
toFileUrl,
isLocalSource,
validatePluginExternals,
} from "./gitLoader"
const MINIMUM_QUARTZ_VERSION = "4.5.0"
function satisfiesVersion(required: string | undefined, current: string): boolean {
if (!required) return true
const parseVersion = (v: string) => {
const parts = v.replace(/^v/, "").split(".")
return {
major: parseInt(parts[0]) || 0,
minor: parseInt(parts[1]) || 0,
patch: parseInt(parts[2]) || 0,
}
}
const req = parseVersion(required)
const cur = parseVersion(current)
if (cur.major > req.major) return true
if (cur.major < req.major) return false
if (cur.minor > req.minor) return true
if (cur.minor < req.minor) return false
return cur.patch >= req.patch
}
async function tryImportPlugin(packageName: string): Promise<{
module: unknown
manifest: PluginManifest | null
}> {
try {
const module = await import(packageName)
const manifest: PluginManifest | null = module.manifest ?? null
return { module, manifest }
} catch (error) {
throw new Error(
`Failed to import package: ${error instanceof Error ? error.message : String(error)}`,
)
}
}
function detectPluginType(
module: unknown,
): "transformer" | "filter" | "emitter" | "pageType" | null {
if (!module || typeof module !== "object") return null
const mod = module as Record<string, unknown>
if (typeof mod.default === "function") {
return null
}
const hasPageTypeProps = ["match", "body", "layout"].every((key) => key in mod)
const hasTransformerProps = ["textTransform", "markdownPlugins", "htmlPlugins"].some(
(key) => key in mod && (typeof mod[key] === "function" || mod[key] === undefined),
)
const hasFilterProps = ["shouldPublish"].some(
(key) => key in mod && typeof mod[key] === "function",
)
const hasEmitterProps = ["emit"].some((key) => key in mod && typeof mod[key] === "function")
if (hasPageTypeProps) return "pageType"
if (hasEmitterProps) return "emitter"
if (hasFilterProps) return "filter"
if (hasTransformerProps) return "transformer"
return null
}
function extractPluginFactory(
module: unknown,
type: "transformer" | "filter" | "emitter" | "pageType",
):
| QuartzTransformerPlugin
| QuartzFilterPlugin
| QuartzEmitterPlugin
| QuartzPageTypePlugin
| null {
if (!module || typeof module !== "object") return null
const mod = module as Record<string, unknown>
const factory = mod.default ?? mod[type] ?? mod.plugin ?? null
if (typeof factory === "function") {
return factory as
| QuartzTransformerPlugin
| QuartzFilterPlugin
| QuartzEmitterPlugin
| QuartzPageTypePlugin
}
return null
}
function isGitSource(source: string): boolean {
// Check if it's a Git-based or local file path source
return (
isLocalSource(source) ||
source.startsWith("github:") ||
source.startsWith("git+") ||
source.startsWith("https://github.com/") ||
source.startsWith("https://gitlab.com/") ||
source.startsWith("https://bitbucket.org/")
)
}
async function resolveSinglePlugin(
specifier: PluginSpecifier,
options: PluginResolutionOptions,
): Promise<{ plugin: LoadedPlugin | null; error: PluginResolutionError | null }> {
let packageName: string
let manifest: Partial<PluginManifest> = {}
let pluginSource = "npm"
if (typeof specifier === "string") {
packageName = specifier
// Check if it's a Git-based source
if (isGitSource(specifier)) {
pluginSource = "git"
}
} else if ("name" in specifier) {
packageName = specifier.name
if (isGitSource(specifier.name)) {
pluginSource = "git"
}
} else if ("plugin" in specifier) {
const rawType = specifier.manifest?.category ?? "transformer"
const type = Array.isArray(rawType) ? rawType[0] : rawType
return {
plugin: {
plugin: specifier.plugin as QuartzTransformerPlugin,
manifest: {
name: specifier.manifest?.name ?? "inline-plugin",
displayName: specifier.manifest?.displayName ?? "Inline Plugin",
description: specifier.manifest?.description ?? "Inline plugin instance",
version: specifier.manifest?.version ?? "1.0.0",
category: rawType,
...specifier.manifest,
} as PluginManifest,
type,
source: "inline",
},
error: null,
}
} else {
return {
plugin: null,
error: {
plugin: "unknown",
message: "Invalid plugin specifier format",
type: "invalid-manifest",
},
}
}
if (pluginSource === "git") {
try {
const gitSpec = parsePluginSource(packageName)
await installPlugin(gitSpec, { verbose: options.verbose })
const entryPoint = getPluginEntryPoint(gitSpec.name)
// Import the plugin
const module = await import(toFileUrl(entryPoint))
const importedManifest: PluginManifest | null = module.manifest ?? null
validatePluginExternals(gitSpec.name, entryPoint, { verbose: options.verbose })
manifest = importedManifest ?? {}
const categoryOrCategories = manifest.category ?? detectPluginType(module)
if (!categoryOrCategories) {
return {
plugin: null,
error: {
plugin: packageName,
message: "Could not detect plugin type from Git source",
type: "invalid-manifest",
},
}
}
// Normalize to single processing category for factory extraction
const processingCategories = ["transformer", "filter", "emitter", "pageType"] as const
type ProcessingCategory = (typeof processingCategories)[number]
const detectedType: PluginCategory = Array.isArray(categoryOrCategories)
? categoryOrCategories[0]
: categoryOrCategories
const processingType: ProcessingCategory | undefined = Array.isArray(categoryOrCategories)
? (categoryOrCategories.find((c) =>
(processingCategories as readonly string[]).includes(c),
) as ProcessingCategory | undefined)
: (processingCategories as readonly string[]).includes(categoryOrCategories)
? (categoryOrCategories as ProcessingCategory)
: undefined
// Component-only plugins don't have a processing factory
if (!processingType) {
const fullManifest: PluginManifest = {
name: manifest.name ?? gitSpec.name,
displayName: manifest.displayName ?? gitSpec.name,
description: manifest.description ?? "No description provided",
version: manifest.version ?? "1.0.0",
author: manifest.author,
homepage: manifest.homepage,
keywords: manifest.keywords,
category: manifest.category ?? detectedType,
quartzVersion: manifest.quartzVersion,
configSchema: manifest.configSchema,
}
if (options.verbose) {
console.log(
styleText("green", `\u2713`) +
` Loaded ${detectedType} plugin: ${styleText("cyan", fullManifest.displayName)}@${fullManifest.version} ${styleText("gray", `(from ${gitSpec.repo})`)}`,
)
}
return { plugin: null, error: null }
}
const factory = extractPluginFactory(module, processingType)
if (!factory) {
return {
plugin: null,
error: {
plugin: packageName,
message: "Could not find plugin factory in Git source",
type: "invalid-manifest",
},
}
}
const fullManifest: PluginManifest = {
name: manifest.name ?? gitSpec.name,
displayName: manifest.displayName ?? gitSpec.name,
description: manifest.description ?? "No description provided",
version: manifest.version ?? "1.0.0",
author: manifest.author,
homepage: manifest.homepage,
keywords: manifest.keywords,
category: manifest.category ?? detectedType,
quartzVersion: manifest.quartzVersion,
configSchema: manifest.configSchema,
}
const loadedPlugin: LoadedPlugin = {
plugin: factory,
manifest: fullManifest,
type: detectedType,
source: gitSpec.local ? `local:${gitSpec.repo}` : `${gitSpec.repo}#${gitSpec.ref}`,
}
if (options.verbose) {
console.log(
styleText("green", ``) +
` Loaded ${detectedType} plugin: ${styleText("cyan", fullManifest.displayName)}@${fullManifest.version} ${styleText("gray", `(from ${gitSpec.repo})`)}`,
)
}
return { plugin: loadedPlugin, error: null }
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
return {
plugin: null,
error: {
plugin: packageName,
message: `Failed to load Git plugin: ${errorMessage}`,
type: "import-error",
},
}
}
}
try {
const { module: importedModule, manifest: importedManifest } =
await tryImportPlugin(packageName)
manifest = importedManifest ?? {}
// Load components if the plugin declares any
if (manifest.components && Object.keys(manifest.components).length > 0) {
const { loadComponentsFromPackage } = await import("./componentLoader")
await loadComponentsFromPackage(packageName, manifest as PluginManifest)
}
const categoryOrCategories = manifest.category ?? detectPluginType(importedModule)
if (!categoryOrCategories) {
return {
plugin: null,
error: {
plugin: packageName,
message: `Could not detect plugin type. Ensure the plugin exports a valid factory function or has a 'category' field in its manifest.`,
type: "invalid-manifest",
},
}
}
// Normalize to single processing category for factory extraction
const processingCategories = ["transformer", "filter", "emitter", "pageType"] as const
type ProcessingCategory = (typeof processingCategories)[number]
const detectedType: PluginCategory = Array.isArray(categoryOrCategories)
? categoryOrCategories[0]
: categoryOrCategories
const processingType: ProcessingCategory | undefined = Array.isArray(categoryOrCategories)
? (categoryOrCategories.find((c) =>
(processingCategories as readonly string[]).includes(c),
) as ProcessingCategory | undefined)
: (processingCategories as readonly string[]).includes(categoryOrCategories)
? (categoryOrCategories as ProcessingCategory)
: undefined
if (
manifest.quartzVersion &&
!satisfiesVersion(manifest.quartzVersion, options.quartzVersion)
) {
return {
plugin: null,
error: {
plugin: packageName,
message: `Plugin requires Quartz ${manifest.quartzVersion} but current version is ${options.quartzVersion}`,
type: "version-mismatch",
},
}
}
// Component-only plugins don't have a processing factory
if (!processingType) {
const fullManifest: PluginManifest = {
name: manifest.name ?? packageName,
displayName: manifest.displayName ?? packageName,
description: manifest.description ?? "No description provided",
version: manifest.version ?? "1.0.0",
author: manifest.author,
homepage: manifest.homepage,
keywords: manifest.keywords,
category: manifest.category ?? detectedType,
quartzVersion: manifest.quartzVersion,
configSchema: manifest.configSchema,
}
if (options.verbose) {
console.log(
styleText("green", `\u2713`) +
` Loaded ${detectedType} plugin: ${styleText("cyan", fullManifest.displayName)}@${fullManifest.version}`,
)
}
return { plugin: null, error: null }
}
const factory = extractPluginFactory(importedModule, processingType)
if (!factory) {
return {
plugin: null,
error: {
plugin: packageName,
message: `Could not find plugin factory in module. Expected 'export default' or '${processingType}' export.`,
type: "invalid-manifest",
},
}
}
const fullManifest: PluginManifest = {
name: manifest.name ?? packageName,
displayName: manifest.displayName ?? packageName,
description: manifest.description ?? "No description provided",
version: manifest.version ?? "1.0.0",
author: manifest.author,
homepage: manifest.homepage,
keywords: manifest.keywords,
category: manifest.category ?? detectedType,
quartzVersion: manifest.quartzVersion,
configSchema: manifest.configSchema,
}
const loadedPlugin: LoadedPlugin = {
plugin: factory,
manifest: fullManifest,
type: detectedType,
source: packageName,
}
if (options.verbose) {
console.log(
styleText("green", ``) +
` Loaded ${detectedType} plugin: ${styleText("cyan", fullManifest.displayName)}@${fullManifest.version}`,
)
}
return { plugin: loadedPlugin, error: null }
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
if (errorMessage.includes("Cannot find module") || errorMessage.includes("MODULE_NOT_FOUND")) {
return {
plugin: null,
error: {
plugin: packageName,
message: `Plugin package not found. Run 'npm install ${packageName}' to install it.`,
type: "not-found",
},
}
}
return {
plugin: null,
error: {
plugin: packageName,
message: errorMessage,
type: "import-error",
},
}
}
}
export async function resolvePlugins(
specifiers: PluginSpecifier[],
options: PluginResolutionOptions,
): Promise<PluginResolution> {
const plugins: LoadedPlugin[] = []
const errors: PluginResolutionError[] = []
if (options.verbose) {
console.log(styleText("cyan", `Resolving ${specifiers.length} external plugin(s)...`))
}
for (const specifier of specifiers) {
const { plugin, error } = await resolveSinglePlugin(specifier, options)
if (plugin) {
plugins.push(plugin)
} else if (error) {
errors.push(error)
console.error(
styleText("red", ``) +
` Failed to load plugin: ${styleText("yellow", error.plugin)}\n` +
` ${error.message}`,
)
}
}
if (options.verbose && plugins.length > 0) {
const byType = plugins.reduce(
(acc, p) => {
acc[p.type] = (acc[p.type] || 0) + 1
return acc
},
{} as Record<string, number>,
)
console.log(
styleText("cyan", `External plugins loaded:`) +
` ${byType.transformer ?? 0} transformers, ${byType.filter ?? 0} filters, ${byType.emitter ?? 0} emitters, ${byType.pageType ?? 0} pageTypes`,
)
}
return { plugins, errors }
}
export function instantiatePlugin<T>(
loadedPlugin: LoadedPlugin,
options?: T,
): ReturnType<typeof loadedPlugin.plugin> {
const factory = loadedPlugin.plugin as (opts?: T) => ReturnType<typeof loadedPlugin.plugin>
return factory(options)
}
export { satisfiesVersion, MINIMUM_QUARTZ_VERSION }
@@ -0,0 +1,30 @@
#!/usr/bin/env node
import { installPlugins, parsePluginSource } from "./gitLoader.js"
import config from "../../../quartz.js"
async function main() {
const quartzConfig: any = config
const externalPlugins = quartzConfig.externalPlugins || []
if (externalPlugins.length === 0) {
console.log("No external plugins to install.")
return
}
console.log(`Installing ${externalPlugins.length} plugin(s) from Git...`)
const specs = externalPlugins.map((source: string) => parsePluginSource(source))
const installed = await installPlugins(specs, { verbose: true })
if (installed.size === externalPlugins.length) {
console.log("✓ All plugins installed successfully")
} else {
console.error(`✗ Only ${installed.size}/${externalPlugins.length} plugins installed`)
process.exit(1)
}
}
main().catch((err) => {
console.error("Failed to install plugins:", err)
process.exit(1)
})
@@ -0,0 +1,4 @@
export * from "./types"
export * from "./index"
export * from "./conditions"
export { loadQuartzConfig, loadQuartzLayout } from "./config-loader"
@@ -0,0 +1,193 @@
import {
QuartzTransformerPlugin,
QuartzFilterPlugin,
QuartzEmitterPlugin,
QuartzPageTypePlugin,
} from "../types"
import { BuildCtx } from "../../util/ctx"
export type PluginCategory = "transformer" | "filter" | "emitter" | "pageType" | "component"
export type LayoutPosition = "left" | "right" | "beforeBody" | "afterBody"
export type LayoutDisplay = "all" | "mobile-only" | "desktop-only"
/**
* Component manifest metadata
*/
export interface ComponentManifest {
name: string
displayName: string
description: string
version: string
quartzVersion?: string
author?: string
homepage?: string
}
/**
* Layout defaults for a component declared in a plugin manifest.
* These are used as fallback values when no user layout config is specified.
*/
export interface ComponentLayoutDefaults {
displayName: string
description?: string
defaultPosition?: LayoutPosition
defaultPriority?: number
}
/**
* Plugin manifest metadata for discovery and documentation.
*
* This corresponds to the `quartz` field in a plugin's `package.json`.
*/
export interface PluginManifest {
name: string
displayName: string
description: string
version: string
author?: string
homepage?: string
keywords?: string[]
category?: PluginCategory | PluginCategory[]
quartzVersion?: string
/** Plugin sources this plugin depends on (e.g., "github:quartz-community/crawl-links") */
dependencies?: string[]
/** Default numeric execution order (0-100 convention, lower = runs first). Defaults to 50. */
defaultOrder?: number
/** Whether the plugin is enabled by default on install. Defaults to true. */
defaultEnabled?: boolean
/** Default options applied when no user options are specified */
defaultOptions?: Record<string, unknown>
/** JSON Schema for the plugin's options object, used for validation and TUI generation */
configSchema?: object
/** Components provided by this plugin, keyed by component export name */
components?: Record<string, ComponentManifest & ComponentLayoutDefaults>
/** Page frames provided by this plugin, keyed by export name. Each entry maps to a PageFrame object. */
frames?: Record<string, { exportName: string }>
/** Whether the plugin requires `npm install` after cloning (e.g. for native dependencies like sharp). */
requiresInstall?: boolean
}
/**
* Loaded plugin with metadata
*/
export interface LoadedPlugin {
plugin: QuartzTransformerPlugin | QuartzFilterPlugin | QuartzEmitterPlugin | QuartzPageTypePlugin
manifest: PluginManifest
type: PluginCategory
source: string
}
/**
* Plugin resolution result
*/
export interface PluginResolution {
/** Successfully loaded plugins */
plugins: LoadedPlugin[]
/** Errors that occurred during resolution */
errors: PluginResolutionError[]
}
/**
* Plugin resolution error
*/
export interface PluginResolutionError {
/** Plugin name that failed to load */
plugin: string
/** Error message */
message: string
/** Error type */
type: "not-found" | "invalid-manifest" | "version-mismatch" | "import-error"
}
/**
* Options for plugin resolution
*/
export interface PluginResolutionOptions {
/** Current Quartz version for compatibility checking */
quartzVersion: string
/** Build context for logging */
ctx: BuildCtx
/** Whether to enable verbose logging */
verbose?: boolean
}
/**
* Plugin specifier - can be:
* - String package name (e.g., "@quartz-community/my-plugin")
* - Object with name and options (e.g., { name: "@quartz-community/my-plugin", options: {...} })
* - Inline plugin object (already loaded plugin instance)
*/
export type PluginSpecifier =
| string
| { name: string; options?: unknown }
| { plugin: LoadedPlugin["plugin"]; manifest?: Partial<PluginManifest> }
/** Layout declaration for a component-providing plugin in quartz.config.yaml */
export interface PluginLayoutDeclaration {
position: LayoutPosition
priority: number
display?: LayoutDisplay
condition?: string
group?: string
groupOptions?: {
grow?: boolean
shrink?: boolean
basis?: string
order?: number
align?: "start" | "end" | "center" | "stretch"
justify?: "start" | "end" | "center" | "between" | "around"
}
}
/** Object form of a plugin source (for monorepo / advanced config) */
export interface PluginSourceObject {
repo: string
subdir?: string
ref?: string
name?: string
}
/** A plugin source can be a string shorthand or an object with additional fields */
export type PluginSource = string | PluginSourceObject
/** A single plugin entry in quartz.config.yaml */
export interface PluginJsonEntry {
source: PluginSource
enabled: boolean
options?: Record<string, unknown>
order?: number
layout?: PluginLayoutDeclaration
}
/** Flex group configuration in the top-level layout section */
export interface FlexGroupConfig {
/** Explicit priority for the group. Overrides first-member priority. Lower = renders first. */
priority?: number
direction?: "row" | "row-reverse" | "column" | "column-reverse"
wrap?: "nowrap" | "wrap" | "wrap-reverse"
gap?: string
}
/** Per-page-type layout overrides */
export interface PageTypeLayoutOverride {
exclude?: string[]
positions?: Partial<Record<LayoutPosition, PluginLayoutDeclaration[]>>
/** Override the page frame template (e.g. "default", "full-width", "minimal") */
template?: string
}
/** Top-level layout section of quartz.config.yaml */
export interface LayoutConfig {
groups?: Record<string, FlexGroupConfig>
byPageType?: Record<string, PageTypeLayoutOverride>
}
/** Root type for quartz.config.yaml */
export interface QuartzPluginsJson {
$schema?: string
configuration: Record<string, unknown>
plugins: PluginJsonEntry[]
layout?: LayoutConfig
}
@@ -0,0 +1,33 @@
import { QuartzPageTypePlugin } from "../types"
import { match } from "./matchers"
import { NotFound } from "../../components"
import { defaultProcessedContent } from "../vfile"
import { i18n } from "../../i18n"
import { FullSlug } from "../../util/path"
export const NotFoundPageType: QuartzPageTypePlugin = () => ({
name: "404",
priority: -1,
match: match.none(),
generate({ cfg }) {
const notFound = i18n(cfg.locale).pages.error.title
const slug = "404" as FullSlug
const [, vfile] = defaultProcessedContent({
slug,
text: notFound,
description: notFound,
frontmatter: { title: notFound, tags: [] },
})
return [
{
slug,
title: notFound,
data: vfile.data,
},
]
},
layout: "404",
frame: "minimal",
body: NotFound,
})
@@ -0,0 +1,339 @@
import { QuartzEmitterPlugin, QuartzPageTypePluginInstance, TreeTransform } from "../types"
import { QuartzComponent, QuartzComponentProps } from "../../components/types"
import { pageResources, renderPage } from "../../components/renderPage"
import { FullPageLayout } from "../../cfg"
import { FilePath, FullSlug, pathToRoot } from "../../util/path"
import { ProcessedContent, defaultProcessedContent } from "../vfile"
import { write } from "../emitters/helpers"
import { BuildCtx, trieFromAllFiles } from "../../util/ctx"
import { StaticResources } from "../../util/resources"
import { render } from "preact-render-to-string"
import { fromHtml } from "hast-util-from-html"
import { Root as HtmlRoot } from "hast"
function getPageTypes(ctx: BuildCtx): QuartzPageTypePluginInstance[] {
return (ctx.cfg.plugins.pageTypes ?? []) as unknown as QuartzPageTypePluginInstance[]
}
function resolveLayout(
pageType: QuartzPageTypePluginInstance,
sharedDefaults: Partial<FullPageLayout>,
byPageType: Record<string, Partial<FullPageLayout>>,
): FullPageLayout {
const overrides = byPageType[pageType.layout] ?? {}
// Frame priority: config override > page type declaration > default
const frame = overrides.frame ?? pageType.frame ?? "default"
return {
head: overrides.head ?? sharedDefaults.head!,
header: overrides.header ?? sharedDefaults.header ?? [],
beforeBody: overrides.beforeBody ?? sharedDefaults.beforeBody ?? [],
pageBody: pageType.body(undefined),
afterBody: overrides.afterBody ?? sharedDefaults.afterBody ?? [],
left: overrides.left ?? sharedDefaults.left ?? [],
right: overrides.right ?? sharedDefaults.right ?? [],
footer: overrides.footer ?? sharedDefaults.footer!,
frame,
}
}
function collectComponents(
pageTypes: QuartzPageTypePluginInstance[],
sharedDefaults: Partial<FullPageLayout>,
byPageType: Record<string, Partial<FullPageLayout>>,
): QuartzComponent[] {
const seen = new Set<QuartzComponent>()
for (const pt of pageTypes) {
const layout = resolveLayout(pt, sharedDefaults, byPageType)
const all = [
layout.head,
...layout.header,
...layout.beforeBody,
layout.pageBody,
...layout.afterBody,
...layout.left,
...layout.right,
layout.footer,
]
for (const c of all) {
if (c) seen.add(c)
}
}
return [...seen]
}
interface DispatcherOptions {
defaults: Partial<FullPageLayout>
byPageType: Record<string, Partial<FullPageLayout>>
}
async function emitPage(
ctx: BuildCtx,
slug: FullSlug,
tree: ProcessedContent[0],
fileData: ProcessedContent[1]["data"],
allFiles: ProcessedContent[1]["data"][],
layout: FullPageLayout,
resources: StaticResources,
treeTransforms?: TreeTransform[],
) {
const cfg = ctx.cfg.configuration
// For the 404 page, use an absolute base path so assets resolve correctly
// when the hosting provider serves 404.html from any URL depth.
// During local dev (--serve), the dev server strips baseDir itself and
// serves files from root, so the 404 page must use "/" to avoid requesting
// assets under a path prefix that the dev server doesn't serve.
const baseDir =
slug === "404"
? ((ctx.argv.serve
? "/"
: new URL(`https://${cfg.baseUrl ?? "example.com"}`).pathname) as FullSlug)
: pathToRoot(slug)
const externalResources = pageResources(baseDir, resources, ctx)
const componentData: QuartzComponentProps = {
ctx,
fileData,
externalResources,
cfg,
children: [],
tree,
allFiles,
}
return write({
ctx,
content: renderPage(cfg, slug, componentData, layout, externalResources, treeTransforms),
slug,
ext: ".html",
})
}
/**
* Render each virtual page's Body component to HTML and parse it to a hast tree,
* populating both the ProcessedContent tree and vfile.data.htmlAst so that
* transclusion (e.g. ![[file.canvas]]) can inline the virtual page's content.
*/
function populateVirtualPageHtmlAst(
virtualEntries: Array<{
tree: ProcessedContent[0]
vfile: ProcessedContent[1]
layout: FullPageLayout
vpSlug: FullSlug
}>,
ctx: BuildCtx,
allFiles: ProcessedContent[1]["data"][],
resources: StaticResources,
) {
const cfg = ctx.cfg.configuration
for (const ve of virtualEntries) {
const BodyComponent = ve.layout.pageBody
const externalResources = pageResources(pathToRoot(ve.vpSlug), resources, ctx)
const componentData: QuartzComponentProps = {
ctx,
fileData: ve.vfile.data,
externalResources,
cfg,
children: [],
tree: ve.tree,
allFiles,
}
try {
const htmlString = render(BodyComponent(componentData))
const htmlAst = fromHtml(htmlString, { fragment: true }) as HtmlRoot
ve.vfile.data.htmlAst = htmlAst
} catch {
// Body rendering failed — leave htmlAst empty so transclusion falls
// back to the default title-only display.
}
}
}
export const PageTypeDispatcher: QuartzEmitterPlugin<Partial<DispatcherOptions>> = (userOpts) => {
const defaults = userOpts?.defaults ?? {}
const byPageType = userOpts?.byPageType ?? {}
return {
name: "PageTypeDispatcher",
getQuartzComponents(ctx) {
const pageTypes = getPageTypes(ctx)
return collectComponents(pageTypes, defaults, byPageType)
},
async *emit(ctx, content, resources) {
const pageTypes = [...getPageTypes(ctx)].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
const cfg = ctx.cfg.configuration
const allFiles = content.map((c) => c[1].data)
// Collect tree transforms from all page type plugins
const treeTransforms: TreeTransform[] = pageTypes.flatMap(
(pt) => pt.treeTransforms?.(ctx) ?? [],
)
// Ensure trie is available for components that need folder hierarchy (e.g. FolderContent)
ctx.trie ??= trieFromAllFiles(allFiles)
// Phase 1: Generate all virtual pages first so their data is available in allFiles
// for transclude resolution in renderPage (e.g. ![[file.canvas]], ![[file.base]])
const virtualEntries: Array<{
tree: ProcessedContent[0]
vfile: ProcessedContent[1]
layout: FullPageLayout
vpSlug: FullSlug
}> = []
for (const pt of pageTypes) {
if (!pt.generate) continue
const virtualPages = pt.generate({ content, cfg, ctx })
const layout = resolveLayout(pt, defaults, byPageType)
for (const vp of virtualPages) {
const vpSlug = vp.slug as FullSlug
const vpRelativePath = (vpSlug + ".md") as FilePath
const [tree, vfile] = defaultProcessedContent({
slug: vpSlug,
relativePath: vpRelativePath,
frontmatter: { title: vp.title, tags: [] },
...vp.data,
})
if (vpSlug !== "404") {
ctx.virtualPages.push([tree, vfile])
}
virtualEntries.push({ tree, vfile, layout, vpSlug })
}
}
// Merge virtual page data into allFiles before populating htmlAst so that
// Body components rendered during populateVirtualPageHtmlAst can resolve
// cross-virtual-page embeds (e.g. a .base file embedded in a .canvas file).
// The vfile.data objects are shared by reference, so htmlAst set on earlier
// entries becomes visible to later entries in the same pass.
const allFilesWithVirtual = [...allFiles, ...virtualEntries.map((ve) => ve.vfile.data)]
// Render Body components to populate htmlAst for transclusion
populateVirtualPageHtmlAst(virtualEntries, ctx, allFilesWithVirtual, resources)
// Phase 2: Emit regular pages (with virtual page data available for transclusion)
for (const [tree, file] of content) {
const slug = file.data.slug!
const fileData = file.data
for (const pt of pageTypes) {
if (pt.match({ slug, fileData, cfg })) {
const layout = resolveLayout(pt, defaults, byPageType)
yield emitPage(
ctx,
slug,
tree,
fileData,
allFilesWithVirtual,
layout,
resources,
treeTransforms,
)
break
}
}
}
// Phase 3: Emit virtual pages
for (const ve of virtualEntries) {
yield emitPage(
ctx,
ve.vpSlug,
ve.tree,
ve.vfile.data,
allFilesWithVirtual,
ve.layout,
resources,
treeTransforms,
)
}
},
async *partialEmit(ctx, content, resources, changeEvents) {
const pageTypes = [...getPageTypes(ctx)].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
const cfg = ctx.cfg.configuration
const allFiles = content.map((c) => c[1].data)
// Collect tree transforms from all page type plugins
const treeTransforms: TreeTransform[] = pageTypes.flatMap(
(pt) => pt.treeTransforms?.(ctx) ?? [],
)
// Rebuild trie on partial emit to reflect file changes
ctx.trie = trieFromAllFiles(allFiles)
const changedSlugs = new Set<string>()
for (const changeEvent of changeEvents) {
if (!changeEvent.file) continue
if (changeEvent.type === "add" || changeEvent.type === "change") {
changedSlugs.add(changeEvent.file.data.slug!)
}
}
// Phase 1: Generate all virtual pages first so their data is available in allFiles
const virtualEntries: Array<{
tree: ProcessedContent[0]
vfile: ProcessedContent[1]
layout: FullPageLayout
vpSlug: FullSlug
}> = []
for (const pt of pageTypes) {
if (!pt.generate) continue
const virtualPages = pt.generate({ content, cfg, ctx })
const layout = resolveLayout(pt, defaults, byPageType)
for (const vp of virtualPages) {
const vpSlug = vp.slug as FullSlug
const vpRelativePath = (vpSlug + ".md") as FilePath
const [tree, vfile] = defaultProcessedContent({
slug: vpSlug,
relativePath: vpRelativePath,
frontmatter: { title: vp.title, tags: [] },
...vp.data,
})
if (vpSlug !== "404") {
ctx.virtualPages.push([tree, vfile])
}
virtualEntries.push({ tree, vfile, layout, vpSlug })
}
}
const allFilesWithVirtual = [...allFiles, ...virtualEntries.map((ve) => ve.vfile.data)]
// Render Body components to populate htmlAst for transclusion
populateVirtualPageHtmlAst(virtualEntries, ctx, allFilesWithVirtual, resources)
// Phase 2: Emit changed regular pages
for (const [tree, file] of content) {
const slug = file.data.slug!
if (!changedSlugs.has(slug)) continue
const fileData = file.data
for (const pt of pageTypes) {
if (pt.match({ slug, fileData, cfg })) {
const layout = resolveLayout(pt, defaults, byPageType)
yield emitPage(
ctx,
slug,
tree,
fileData,
allFilesWithVirtual,
layout,
resources,
treeTransforms,
)
break
}
}
}
// Phase 3: Emit virtual pages
for (const ve of virtualEntries) {
yield emitPage(
ctx,
ve.vpSlug,
ve.tree,
ve.vfile.data,
allFilesWithVirtual,
ve.layout,
resources,
treeTransforms,
)
}
},
}
}
@@ -0,0 +1,3 @@
export { match } from "./matchers"
export { NotFoundPageType } from "./404"
export { PageTypeDispatcher } from "./dispatcher"
@@ -0,0 +1,39 @@
import { PageMatcher } from "../types"
export const match = {
ext: (extension: string): PageMatcher => {
const normalized = extension.startsWith(".") ? extension : `.${extension}`
return ({ slug }) => slug.endsWith(normalized) || !slug.includes(".")
},
slugPrefix: (prefix: string): PageMatcher => {
return ({ slug }) => slug.startsWith(prefix)
},
frontmatter: (key: string, predicate: (value: unknown) => boolean): PageMatcher => {
return ({ fileData }) => {
const fm = fileData.frontmatter as Record<string, unknown> | undefined
return fm ? predicate(fm[key]) : false
}
},
and: (...matchers: PageMatcher[]): PageMatcher => {
return (args) => matchers.every((m) => m(args))
},
or: (...matchers: PageMatcher[]): PageMatcher => {
return (args) => matchers.some((m) => m(args))
},
not: (matcher: PageMatcher): PageMatcher => {
return (args) => !matcher(args)
},
all: (): PageMatcher => {
return () => true
},
none: (): PageMatcher => {
return () => false
},
}
@@ -0,0 +1,345 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Quartz Plugins Configuration Schema",
"description": "Schema for validating quartz.plugins.json configuration files",
"type": "object",
"required": ["configuration", "plugins"],
"additionalProperties": false,
"properties": {
"$schema": {
"type": "string",
"description": "JSON Schema reference"
},
"configuration": {
"type": "object",
"required": ["pageTitle", "enableSPA", "locale", "theme"],
"additionalProperties": false,
"properties": {
"pageTitle": {
"type": "string",
"description": "The title of the website"
},
"pageTitleSuffix": {
"type": "string",
"description": "Suffix appended to page titles"
},
"enableSPA": {
"type": "boolean",
"description": "Enable single-page application mode"
},
"enablePopovers": {
"type": "boolean",
"description": "Enable hover popovers for links"
},
"locale": {
"type": "string",
"description": "Locale code for the site"
},
"baseUrl": {
"type": "string",
"description": "Base URL for the site"
},
"theme": {
"type": "object",
"required": ["fontOrigin", "cdnCaching", "typography", "colors"],
"additionalProperties": false,
"properties": {
"fontOrigin": {
"type": "string",
"enum": ["googleFonts", "local"],
"description": "Source of fonts"
},
"cdnCaching": {
"type": "boolean",
"description": "Enable CDN caching"
},
"typography": {
"type": "object",
"additionalProperties": false,
"properties": {
"header": {
"type": "string",
"description": "Font family for headers"
},
"body": {
"type": "string",
"description": "Font family for body text"
},
"code": {
"type": "string",
"description": "Font family for code"
}
}
},
"colors": {
"type": "object",
"required": ["lightMode", "darkMode"],
"additionalProperties": false,
"properties": {
"lightMode": {
"type": "object",
"additionalProperties": false,
"properties": {
"light": {
"type": "string",
"description": "Light color"
},
"lightgray": {
"type": "string",
"description": "Light gray color"
},
"gray": {
"type": "string",
"description": "Gray color"
},
"darkgray": {
"type": "string",
"description": "Dark gray color"
},
"dark": {
"type": "string",
"description": "Dark color"
},
"secondary": {
"type": "string",
"description": "Secondary color"
},
"tertiary": {
"type": "string",
"description": "Tertiary color"
},
"highlight": {
"type": "string",
"description": "Highlight color"
},
"textHighlight": {
"type": "string",
"description": "Text highlight color"
}
}
},
"darkMode": {
"type": "object",
"additionalProperties": false,
"properties": {
"light": {
"type": "string",
"description": "Light color"
},
"lightgray": {
"type": "string",
"description": "Light gray color"
},
"gray": {
"type": "string",
"description": "Gray color"
},
"darkgray": {
"type": "string",
"description": "Dark gray color"
},
"dark": {
"type": "string",
"description": "Dark color"
},
"secondary": {
"type": "string",
"description": "Secondary color"
},
"tertiary": {
"type": "string",
"description": "Tertiary color"
},
"highlight": {
"type": "string",
"description": "Highlight color"
},
"textHighlight": {
"type": "string",
"description": "Text highlight color"
}
}
}
}
}
}
},
"analytics": {
"type": "object",
"description": "Analytics configuration"
},
"ignorePatterns": {
"type": "array",
"items": {
"type": "string"
},
"description": "Patterns to ignore during processing"
}
}
},
"plugins": {
"type": "array",
"description": "Array of plugin configurations",
"items": {
"type": "object",
"required": ["source", "enabled"],
"additionalProperties": false,
"properties": {
"source": {
"oneOf": [
{
"type": "string",
"description": "Plugin source path or identifier. Supports github:user/repo, git+https://, and https:// URLs. Append #ref to pin to a specific branch or tag (e.g., github:user/repo#my-branch). Local file paths (e.g., ./my-plugin, ../sibling-plugin, /absolute/path) are also supported for local development or airgapped environments."
},
{
"type": "object",
"required": ["repo"],
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"description": "Override name for the plugin directory. If omitted, the name is derived from the repository URL."
},
"repo": {
"type": "string",
"description": "Git repository URL (e.g., https://github.com/user/repo.git). Supports the same URL formats as the string source."
},
"subdir": {
"type": "string",
"description": "Subdirectory within the repository containing the plugin. Used when the plugin is not at the repository root."
},
"ref": {
"type": "string",
"description": "Git ref (branch or tag) to pin to. If omitted, the default branch is used."
}
},
"description": "Object source with explicit repo URL and optional subdirectory, ref, and name overrides for monorepo-style plugins."
}
],
"description": "Plugin source: either a string path/identifier or an object with repo/subdir configuration."
},
"enabled": {
"type": "boolean",
"description": "Whether the plugin is enabled"
},
"order": {
"type": "number",
"minimum": 0,
"description": "Plugin execution order"
},
"options": {
"type": "object",
"additionalProperties": true,
"description": "Plugin-specific options"
},
"layout": {
"type": "object",
"additionalProperties": false,
"properties": {
"position": {
"type": "string",
"enum": ["left", "right", "beforeBody", "afterBody", "body"],
"description": "Layout position"
},
"priority": {
"type": "number",
"description": "Layout priority"
},
"display": {
"type": "string",
"enum": ["all", "mobile-only", "desktop-only"],
"description": "Display mode"
},
"condition": {
"type": "string",
"description": "Conditional display logic"
},
"group": {
"type": "string",
"description": "Layout group name"
},
"groupOptions": {
"type": "object",
"additionalProperties": false,
"properties": {
"grow": {
"type": "boolean",
"description": "Flex grow"
},
"shrink": {
"type": "boolean",
"description": "Flex shrink"
},
"basis": {
"type": "string",
"description": "Flex basis"
},
"order": {
"type": "number",
"description": "Flex order"
},
"align": {
"type": "string",
"description": "Alignment"
},
"justify": {
"type": "string",
"description": "Justification"
}
}
}
}
}
}
}
},
"layout": {
"type": "object",
"additionalProperties": false,
"properties": {
"groups": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"direction": {
"type": "string",
"description": "Flex direction"
},
"wrap": {
"type": "boolean",
"description": "Flex wrap"
},
"gap": {
"type": "string",
"description": "Gap between items"
}
}
},
"description": "Layout groups configuration"
},
"byPageType": {
"type": "object",
"additionalProperties": {
"type": "object",
"additionalProperties": false,
"properties": {
"exclude": {
"type": "array",
"items": {
"type": "string"
},
"description": "Excluded plugins for this page type"
},
"positions": {
"type": "object",
"description": "Position overrides for this page type"
}
}
},
"description": "Layout configuration by page type"
}
}
}
}
}
@@ -0,0 +1 @@
export {}
@@ -0,0 +1,140 @@
import { PluggableList } from "unified"
import { StaticResources } from "../util/resources"
import { ProcessedContent, QuartzPluginData } from "./vfile"
import {
QuartzComponent,
QuartzComponentConstructor,
QuartzComponentProps,
} from "../components/types"
import { FilePath, FullSlug } from "../util/path"
import { BuildCtx } from "../util/ctx"
import { GlobalConfiguration } from "../cfg"
import { VFile } from "vfile"
import { Root } from "hast"
export interface PluginTypes {
transformers: QuartzTransformerPluginInstance[]
filters: QuartzFilterPluginInstance[]
emitters: QuartzEmitterPluginInstance[]
pageTypes?: PageTypePluginEntry[]
}
type OptionType = object | undefined
type ExternalResourcesFn = (ctx: BuildCtx) => Partial<StaticResources> | undefined
export type QuartzTransformerPlugin<Options extends OptionType = undefined> = (
opts?: Options,
) => QuartzTransformerPluginInstance
export type QuartzTransformerPluginInstance = {
name: string
textTransform?: (ctx: BuildCtx, src: string) => string
markdownPlugins?: (ctx: BuildCtx) => PluggableList
htmlPlugins?: (ctx: BuildCtx) => PluggableList
externalResources?: ExternalResourcesFn
}
export type QuartzFilterPlugin<Options extends OptionType = undefined> = (
opts?: Options,
) => QuartzFilterPluginInstance
export type QuartzFilterPluginInstance = {
name: string
shouldPublish(ctx: BuildCtx, content: ProcessedContent): boolean
}
export type ChangeEvent = {
type: "add" | "change" | "delete"
path: FilePath
file?: VFile
}
export type QuartzEmitterPlugin<Options extends OptionType = undefined> = (
opts?: Options,
) => QuartzEmitterPluginInstance
export type QuartzEmitterPluginInstance = {
name: string
emit: (
ctx: BuildCtx,
content: ProcessedContent[],
resources: StaticResources,
) => Promise<FilePath[]> | AsyncGenerator<FilePath>
partialEmit?: (
ctx: BuildCtx,
content: ProcessedContent[],
resources: StaticResources,
changeEvents: ChangeEvent[],
) => Promise<FilePath[]> | AsyncGenerator<FilePath> | null
/**
* Returns the components (if any) that are used in rendering the page.
* This helps Quartz optimize the page by only including necessary resources
* for components that are actually used.
*/
getQuartzComponents?: (ctx: BuildCtx) => QuartzComponent[]
externalResources?: ExternalResourcesFn
}
// ============================================================================
// PageType Plugin Types
// ============================================================================
export type PageMatcher = (args: {
slug: string
fileData: QuartzPluginData
cfg: GlobalConfiguration
[key: string]: unknown
}) => boolean
export interface VirtualPage {
slug: string
title: string
data: Partial<QuartzPluginData> & Record<string, unknown>
}
export type PageGenerator = (args: {
content: ProcessedContent[]
cfg: GlobalConfiguration
ctx: BuildCtx
[key: string]: unknown
}) => VirtualPage[]
/** A function that mutates a HAST tree at render time, when allFiles is available. */
export type TreeTransform = (
root: Root,
slug: FullSlug,
componentData: QuartzComponentProps,
) => void
export type QuartzPageTypePlugin<Options extends OptionType = undefined> = (
opts?: Options,
) => QuartzPageTypePluginInstance
export interface QuartzPageTypePluginInstance {
name: string
priority?: number
fileExtensions?: string[]
match: PageMatcher
generate?: PageGenerator
layout: string
/** Optional page frame name (e.g. "default", "full-width", "minimal"). Defaults to "default". */
frame?: string
body: QuartzComponentConstructor
/** Optional render-time HAST tree transforms (e.g. resolving inline codeblocks). */
treeTransforms?: (ctx: BuildCtx) => TreeTransform[]
}
// Structural supertype accepted in plugin configuration arrays.
// Community plugins use a differently-branded FullSlug in their PageMatcher,
// making them incompatible with the internal PageMatcher under strict
// function-parameter contravariance. This wider entry type avoids forcing
// casts in quartz.ts while the dispatcher safely calls match/generate
// with the correct arguments at runtime.
export interface PageTypePluginEntry {
name: string
priority?: number
fileExtensions?: string[]
match: (...args: never[]) => boolean
generate?: (...args: never[]) => VirtualPage[]
layout: string
/** Optional page frame name (e.g. "default", "full-width", "minimal"). Defaults to "default". */
frame?: string
body: QuartzComponentConstructor
treeTransforms?: (...args: never[]) => TreeTransform[]
}

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