Initial import
This commit is contained in:
+284
@@ -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 }) => `Có ${count} trang trong thư mục này.`,
|
||||
},
|
||||
tagContent: {
|
||||
tag: "Thẻ",
|
||||
tagIndex: "Danh sách thẻ",
|
||||
itemsUnderTag: ({ count }) => `Có ${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
Reference in New Issue
Block a user