chore: Initial commit
This commit is contained in:
41
quartz/bootstrap-cli.mjs
Executable file
41
quartz/bootstrap-cli.mjs
Executable file
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env -S node --no-deprecation
|
||||
import yargs from "yargs"
|
||||
import { hideBin } from "yargs/helpers"
|
||||
import {
|
||||
handleBuild,
|
||||
handleCreate,
|
||||
handleUpdate,
|
||||
handleRestore,
|
||||
handleSync,
|
||||
} from "./cli/handlers.js"
|
||||
import { CommonArgv, BuildArgv, CreateArgv, SyncArgv } from "./cli/args.js"
|
||||
import { version } from "./cli/constants.js"
|
||||
|
||||
yargs(hideBin(process.argv))
|
||||
.scriptName("quartz")
|
||||
.version(version)
|
||||
.usage("$0 <cmd> [args]")
|
||||
.command("create", "Initialize Quartz", CreateArgv, async (argv) => {
|
||||
await handleCreate(argv)
|
||||
})
|
||||
.command("update", "Get the latest Quartz updates", CommonArgv, async (argv) => {
|
||||
await handleUpdate(argv)
|
||||
})
|
||||
.command(
|
||||
"restore",
|
||||
"Try to restore your content folder from the cache",
|
||||
CommonArgv,
|
||||
async (argv) => {
|
||||
await handleRestore(argv)
|
||||
},
|
||||
)
|
||||
.command("sync", "Sync your Quartz to and from GitHub.", SyncArgv, async (argv) => {
|
||||
await handleSync(argv)
|
||||
})
|
||||
.command("build", "Build Quartz into a bundle of static HTML files", BuildArgv, async (argv) => {
|
||||
await handleBuild(argv)
|
||||
})
|
||||
.showHelpOnFail(false)
|
||||
.help()
|
||||
.strict()
|
||||
.demandCommand().argv
|
||||
8
quartz/bootstrap-worker.mjs
Normal file
8
quartz/bootstrap-worker.mjs
Normal file
@@ -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,
|
||||
})
|
||||
305
quartz/build.ts
Normal file
305
quartz/build.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
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.config"
|
||||
import { FilePath, joinSegments, slugifyFilePath } from "./util/path"
|
||||
import chokidar from "chokidar"
|
||||
import { ProcessedContent } from "./plugins/vfile"
|
||||
import { Argv, BuildCtx } from "./util/ctx"
|
||||
import { glob, toPosixPath } from "./util/glob"
|
||||
import { trace } from "./util/trace"
|
||||
import { options } from "./util/sourcemap"
|
||||
import { Mutex } from "async-mutex"
|
||||
import { getStaticResourcesFromPlugins } from "./plugins"
|
||||
import { randomIdNonSecure } from "./util/random"
|
||||
import { ChangeEvent } from "./plugins/types"
|
||||
import { minimatch } from "minimatch"
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
const perf = new PerfTimer()
|
||||
const output = argv.output
|
||||
|
||||
const pluginCount = Object.values(cfg.plugins).flat().length
|
||||
const pluginNames = (key: "transformers" | "filters" | "emitters") =>
|
||||
cfg.plugins[key].map((plugin) => plugin.name)
|
||||
if (argv.verbose) {
|
||||
console.log(`Loaded ${pluginCount} plugins`)
|
||||
console.log(` Transformers: ${pluginNames("transformers").join(", ")}`)
|
||||
console.log(` Filters: ${pluginNames("filters").join(", ")}`)
|
||||
console.log(` Emitters: ${pluginNames("emitters").join(", ")}`)
|
||||
}
|
||||
|
||||
const release = await mut.acquire()
|
||||
perf.addEvent("clean")
|
||||
await 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)
|
||||
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
|
||||
contentMap.set(vfile.data.relativePath!, {
|
||||
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(".", {
|
||||
persistent: true,
|
||||
cwd: argv.directory,
|
||||
ignoreInitial: true,
|
||||
})
|
||||
|
||||
const changes: ChangeEvent[] = []
|
||||
watcher
|
||||
.on("add", (fp) => {
|
||||
fp = toPosixPath(fp)
|
||||
if (buildData.ignored(fp)) return
|
||||
changes.push({ path: fp as FilePath, type: "add" })
|
||||
void rebuild(changes, clientRefresh, buildData)
|
||||
})
|
||||
.on("change", (fp) => {
|
||||
fp = toPosixPath(fp)
|
||||
if (buildData.ignored(fp)) return
|
||||
changes.push({ path: fp as FilePath, type: "change" })
|
||||
void rebuild(changes, clientRefresh, buildData)
|
||||
})
|
||||
.on("unlink", (fp) => {
|
||||
fp = toPosixPath(fp)
|
||||
if (buildData.ignored(fp)) return
|
||||
changes.push({ path: fp as FilePath, type: "delete" })
|
||||
void rebuild(changes, clientRefresh, buildData)
|
||||
})
|
||||
|
||||
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()
|
||||
|
||||
// if there's another build after us, release and let them do it
|
||||
if (ctx.buildId !== buildId) {
|
||||
release()
|
||||
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) {
|
||||
contentMap.set(content[1].data.relativePath!, {
|
||||
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))
|
||||
let processedFiles = filterContent(
|
||||
ctx,
|
||||
Array.from(contentMap.values())
|
||||
.filter((file) => file.type === "markdown")
|
||||
.map((file) => file.content),
|
||||
)
|
||||
|
||||
let emittedFiles = 0
|
||||
for (const emitter of cfg.plugins.emitters) {
|
||||
// Try to use partialEmit if available, otherwise assume the output is static
|
||||
const emitFn = emitter.partialEmit ?? emitter.emit
|
||||
const emitted = await emitFn(ctx, processedFiles, 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()
|
||||
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)
|
||||
}
|
||||
}
|
||||
100
quartz/cfg.ts
Normal file
100
quartz/cfg.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { ValidDateType } from "./components/Date"
|
||||
import { QuartzComponent } from "./components/types"
|
||||
import { ValidLocale } from "./i18n"
|
||||
import { PluginTypes } from "./plugins/types"
|
||||
import { Theme } from "./util/theme"
|
||||
|
||||
export type Analytics =
|
||||
| null
|
||||
| {
|
||||
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"
|
||||
}
|
||||
|
||||
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[]
|
||||
/** Whether to use created, modified, or published as the default type of date */
|
||||
defaultDateType: ValidDateType
|
||||
/** Base URL to use for CNAME files, sitemaps, and RSS feeds that require an absolute URL.
|
||||
* Quartz will avoid using this as much as possible and use relative URLs most of the time
|
||||
*/
|
||||
baseUrl?: string
|
||||
theme: Theme
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
export interface FullPageLayout {
|
||||
head: QuartzComponent
|
||||
header: QuartzComponent[]
|
||||
beforeBody: QuartzComponent[]
|
||||
pageBody: QuartzComponent
|
||||
afterBody: QuartzComponent[]
|
||||
left: QuartzComponent[]
|
||||
right: QuartzComponent[]
|
||||
footer: QuartzComponent
|
||||
}
|
||||
|
||||
export type PageLayout = Pick<FullPageLayout, "beforeBody" | "left" | "right">
|
||||
export type SharedLayout = Pick<FullPageLayout, "head" | "header" | "footer" | "afterBody">
|
||||
108
quartz/cli/args.js
Normal file
108
quartz/cli/args.js
Normal file
@@ -0,0 +1,108 @@
|
||||
export const CommonArgv = {
|
||||
directory: {
|
||||
string: true,
|
||||
alias: ["d"],
|
||||
default: "content",
|
||||
describe: "directory to look for content files",
|
||||
},
|
||||
verbose: {
|
||||
boolean: true,
|
||||
alias: ["v"],
|
||||
default: false,
|
||||
describe: "print out extra logging information",
|
||||
},
|
||||
}
|
||||
|
||||
export const CreateArgv = {
|
||||
...CommonArgv,
|
||||
source: {
|
||||
string: true,
|
||||
alias: ["s"],
|
||||
describe: "source directory to copy/create symlink from",
|
||||
},
|
||||
strategy: {
|
||||
string: true,
|
||||
alias: ["X"],
|
||||
choices: ["new", "copy", "symlink"],
|
||||
describe: "strategy for content folder setup",
|
||||
},
|
||||
links: {
|
||||
string: true,
|
||||
alias: ["l"],
|
||||
choices: ["absolute", "shortest", "relative"],
|
||||
describe: "strategy to resolve links",
|
||||
},
|
||||
}
|
||||
|
||||
export const SyncArgv = {
|
||||
...CommonArgv,
|
||||
commit: {
|
||||
boolean: true,
|
||||
default: true,
|
||||
describe: "create a git commit for your unsaved changes",
|
||||
},
|
||||
message: {
|
||||
string: true,
|
||||
alias: ["m"],
|
||||
describe: "option to override the default Quartz commit message",
|
||||
},
|
||||
push: {
|
||||
boolean: true,
|
||||
default: true,
|
||||
describe: "push updates to your Quartz fork",
|
||||
},
|
||||
pull: {
|
||||
boolean: true,
|
||||
default: true,
|
||||
describe: "pull updates from your Quartz fork",
|
||||
},
|
||||
}
|
||||
|
||||
export const BuildArgv = {
|
||||
...CommonArgv,
|
||||
output: {
|
||||
string: true,
|
||||
alias: ["o"],
|
||||
default: "public",
|
||||
describe: "output folder for files",
|
||||
},
|
||||
serve: {
|
||||
boolean: true,
|
||||
default: false,
|
||||
describe: "run a local server to live-preview your Quartz",
|
||||
},
|
||||
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",
|
||||
},
|
||||
concurrency: {
|
||||
number: true,
|
||||
describe: "how many threads to use to parse notes",
|
||||
},
|
||||
}
|
||||
15
quartz/cli/constants.js
Normal file
15
quartz/cli/constants.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import path from "path"
|
||||
import { readFileSync } from "fs"
|
||||
|
||||
/**
|
||||
* All constants relating to helpers or handlers
|
||||
*/
|
||||
export const ORIGIN_NAME = "origin"
|
||||
export const UPSTREAM_NAME = "upstream"
|
||||
export const QUARTZ_SOURCE_BRANCH = "v4"
|
||||
export const cwd = process.cwd()
|
||||
export const cacheDir = path.join(cwd, ".quartz-cache")
|
||||
export const cacheFile = "./quartz/.quartz-cache/transpiled-build.mjs"
|
||||
export const fp = "./quartz/build.ts"
|
||||
export const { version } = JSON.parse(readFileSync("./package.json").toString())
|
||||
export const contentCacheFolder = path.join(cacheDir, "content-cache")
|
||||
620
quartz/cli/handlers.js
Normal file
620
quartz/cli/handlers.js
Normal file
@@ -0,0 +1,620 @@
|
||||
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,
|
||||
} from "./helpers.js"
|
||||
import {
|
||||
UPSTREAM_NAME,
|
||||
QUARTZ_SOURCE_BRANCH,
|
||||
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
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 fs.promises.symlink(originalFolder, contentFolder, "dir")
|
||||
}
|
||||
} else if (setupStrategy === "new") {
|
||||
await fs.promises.writeFile(
|
||||
path.join(contentFolder, "index.md"),
|
||||
`---
|
||||
title: Welcome to Quartz
|
||||
---
|
||||
|
||||
This is a blank Quartz installation.
|
||||
See the [documentation](https://quartz.jzhao.xyz) for how to get started.
|
||||
`,
|
||||
)
|
||||
}
|
||||
|
||||
// Use cli process if cmd args werent provided
|
||||
if (!linkResolutionStrategy) {
|
||||
// get a preferred link resolution strategy
|
||||
linkResolutionStrategy = exitIfCancel(
|
||||
await select({
|
||||
message: `Choose how Quartz should resolve links in your content. This should match Obsidian's link format. You can change this later in \`quartz.config.ts\`.`,
|
||||
options: [
|
||||
{
|
||||
value: "shortest",
|
||||
label: "Treat links as shortest path",
|
||||
hint: "(default)",
|
||||
},
|
||||
{
|
||||
value: "absolute",
|
||||
label: "Treat links as absolute path",
|
||||
},
|
||||
{
|
||||
value: "relative",
|
||||
label: "Treat links as relative paths",
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// now, do config changes
|
||||
const configFilePath = path.join(cwd, "quartz.config.ts")
|
||||
let configContent = await fs.promises.readFile(configFilePath, { encoding: "utf-8" })
|
||||
configContent = configContent.replace(
|
||||
/markdownLinkResolution: '(.+)'/,
|
||||
`markdownLinkResolution: '${linkResolutionStrategy}'`,
|
||||
)
|
||||
await fs.promises.writeFile(configFilePath, configContent)
|
||||
|
||||
// setup remote
|
||||
execSync(
|
||||
`git remote show upstream || git remote add upstream https://github.com/jackyzha0/quartz.git`,
|
||||
{ stdio: "ignore" },
|
||||
)
|
||||
|
||||
outro(`You're all set! Not sure what to do next? Try:
|
||||
• Customizing Quartz a bit more by editing \`quartz.config.ts\`
|
||||
• Running \`npx quartz build --serve\` to preview your Quartz locally
|
||||
• Hosting your Quartz online (see: https://quartz.jzhao.xyz/hosting)
|
||||
`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles `npx quartz build`
|
||||
* @param {*} argv arguments for `build`
|
||||
*/
|
||||
export async function handleBuild(argv) {
|
||||
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,
|
||||
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", "Couldn't parse Quartz configuration:")} ${fp}`)
|
||||
console.log(`Reason: ${styleText("grey", err)}`)
|
||||
process.exit(1)
|
||||
})
|
||||
release()
|
||||
|
||||
if (argv.bundleInfo) {
|
||||
const outputFileName = "quartz/.quartz-cache/transpiled-build.mjs"
|
||||
const meta = result.metafile.outputs[outputFileName]
|
||||
console.log(
|
||||
`Successfully transpiled ${Object.keys(meta.inputs).length} files (${prettyBytes(
|
||||
meta.bytes,
|
||||
)})`,
|
||||
)
|
||||
console.log(await esbuild.analyzeMetafile(result.metafile, { color: true }))
|
||||
}
|
||||
|
||||
// bypass module cache
|
||||
// https://github.com/nodejs/modules/issues/307
|
||||
const { default: buildQuartz } = await import(`../../${cacheFile}?update=${randomUUID()}`)
|
||||
// ^ this import is relative, so base "cacheFile" path can't be used
|
||||
|
||||
cleanupBuild = await buildQuartz(argv, buildMutex, clientRefresh)
|
||||
clientRefresh()
|
||||
}
|
||||
|
||||
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("grey", ` ${argv.baseDir}${req.url}`))
|
||||
release()
|
||||
}
|
||||
|
||||
const redirect = (newFp) => {
|
||||
newFp = argv.baseDir + newFp
|
||||
res.writeHead(302, {
|
||||
Location: newFp,
|
||||
})
|
||||
console.log(
|
||||
styleText("yellow", "[302]") +
|
||||
styleText("grey", ` ${argv.baseDir}${req.url} -> ${newFp}`),
|
||||
)
|
||||
res.end()
|
||||
}
|
||||
|
||||
let fp = req.url?.split("?")[0] ?? "/"
|
||||
|
||||
// handle redirects
|
||||
if (fp.endsWith("/")) {
|
||||
// /trailing/
|
||||
// does /trailing/index.html exist? if so, serve it
|
||||
const indexFp = path.posix.join(fp, "index.html")
|
||||
if (fs.existsSync(path.posix.join(argv.output, indexFp))) {
|
||||
req.url = fp
|
||||
return serve()
|
||||
}
|
||||
|
||||
// does /trailing.html exist? if so, redirect to /trailing
|
||||
let base = fp.slice(0, -1)
|
||||
if (path.extname(base) === "") {
|
||||
base += ".html"
|
||||
}
|
||||
if (fs.existsSync(path.posix.join(argv.output, base))) {
|
||||
return redirect(fp.slice(0, -1))
|
||||
}
|
||||
} else {
|
||||
// /regular
|
||||
// does /regular.html exist? if so, serve it
|
||||
let base = fp
|
||||
if (path.extname(base) === "") {
|
||||
base += ".html"
|
||||
}
|
||||
if (fs.existsSync(path.posix.join(argv.output, base))) {
|
||||
req.url = fp
|
||||
return serve()
|
||||
}
|
||||
|
||||
// does /regular/index.html exist? if so, redirect to /regular/
|
||||
let indexFp = path.posix.join(fp, "index.html")
|
||||
if (fs.existsSync(path.posix.join(argv.output, indexFp))) {
|
||||
return redirect(fp + "/")
|
||||
}
|
||||
}
|
||||
|
||||
return serve()
|
||||
})
|
||||
|
||||
server.listen(argv.port)
|
||||
const wss = new WebSocketServer({ port: argv.wsPort })
|
||||
wss.on("connection", (ws) => connections.push(ws))
|
||||
console.log(
|
||||
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",
|
||||
])
|
||||
chokidar
|
||||
.watch(paths, { ignoreInitial: true })
|
||||
.on("add", () => build(clientRefresh))
|
||||
.on("change", () => build(clientRefresh))
|
||||
.on("unlink", () => build(clientRefresh))
|
||||
|
||||
console.log(styleText("grey", "hint: exit with ctrl+c"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles `npx quartz update`
|
||||
* @param {*} argv arguments for `update`
|
||||
*/
|
||||
export async function handleUpdate(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 https://github.com/jackyzha0/quartz.git`,
|
||||
)
|
||||
await stashContentFolder(contentFolder)
|
||||
console.log(
|
||||
"Pulling updates... you may need to resolve some `git` conflicts if you've made changes to components or plugins.",
|
||||
)
|
||||
|
||||
try {
|
||||
gitPull(UPSTREAM_NAME, QUARTZ_SOURCE_BRANCH)
|
||||
} catch {
|
||||
console.log(styleText("red", "An error occurred above while pulling updates."))
|
||||
await popContentFolder(contentFolder)
|
||||
return
|
||||
}
|
||||
|
||||
await popContentFolder(contentFolder)
|
||||
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 update` 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", "Done!"))
|
||||
} else {
|
||||
console.log(styleText("red", "An error occurred above while installing dependencies."))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 above while pulling updates."))
|
||||
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 above while pushing to remote ${ORIGIN_NAME}.`),
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
console.log(styleText("green", "Done!"))
|
||||
}
|
||||
54
quartz/cli/helpers.js
Normal file
54
quartz/cli/helpers.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import { isCancel, outro } from "@clack/prompts"
|
||||
import { styleText } from "util"
|
||||
import { contentCacheFolder } from "./constants.js"
|
||||
import { spawnSync } from "child_process"
|
||||
import fs from "fs"
|
||||
|
||||
export function escapePath(fp) {
|
||||
return fp
|
||||
.replace(/\\ /g, " ") // unescape spaces
|
||||
.replace(/^".*"$/, "$1")
|
||||
.replace(/^'.*"$/, "$1")
|
||||
.trim()
|
||||
}
|
||||
|
||||
export function exitIfCancel(val) {
|
||||
if (isCancel(val)) {
|
||||
outro(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", "-s", "recursive", "-X", "ours", "--no-edit"]
|
||||
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 })
|
||||
}
|
||||
19
quartz/components/ArticleTitle.tsx
Normal file
19
quartz/components/ArticleTitle.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import { classNames } from "../util/lang"
|
||||
|
||||
const ArticleTitle: QuartzComponent = ({ fileData, displayClass }: QuartzComponentProps) => {
|
||||
const title = fileData.frontmatter?.title
|
||||
if (title) {
|
||||
return <h1 class={classNames(displayClass, "article-title")}>{title}</h1>
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
ArticleTitle.css = `
|
||||
.article-title {
|
||||
margin: 2rem 0 0 0;
|
||||
}
|
||||
`
|
||||
|
||||
export default (() => ArticleTitle) satisfies QuartzComponentConstructor
|
||||
55
quartz/components/Backlinks.tsx
Normal file
55
quartz/components/Backlinks.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import style from "./styles/backlinks.scss"
|
||||
import { resolveRelative, simplifySlug } from "../util/path"
|
||||
import { i18n } from "../i18n"
|
||||
import { classNames } from "../util/lang"
|
||||
import OverflowListFactory from "./OverflowList"
|
||||
|
||||
interface BacklinksOptions {
|
||||
hideWhenEmpty: boolean
|
||||
}
|
||||
|
||||
const defaultOptions: BacklinksOptions = {
|
||||
hideWhenEmpty: true,
|
||||
}
|
||||
|
||||
export default ((opts?: Partial<BacklinksOptions>) => {
|
||||
const options: BacklinksOptions = { ...defaultOptions, ...opts }
|
||||
const { OverflowList, overflowListAfterDOMLoaded } = OverflowListFactory()
|
||||
|
||||
const Backlinks: QuartzComponent = ({
|
||||
fileData,
|
||||
allFiles,
|
||||
displayClass,
|
||||
cfg,
|
||||
}: QuartzComponentProps) => {
|
||||
const slug = simplifySlug(fileData.slug!)
|
||||
const backlinkFiles = allFiles.filter((file) => file.links?.includes(slug))
|
||||
if (options.hideWhenEmpty && backlinkFiles.length == 0) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div class={classNames(displayClass, "backlinks")}>
|
||||
<h3>{i18n(cfg.locale).components.backlinks.title}</h3>
|
||||
<OverflowList>
|
||||
{backlinkFiles.length > 0 ? (
|
||||
backlinkFiles.map((f) => (
|
||||
<li>
|
||||
<a href={resolveRelative(fileData.slug!, f.slug!)} class="internal">
|
||||
{f.frontmatter?.title}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
<li>{i18n(cfg.locale).components.backlinks.noBacklinksFound}</li>
|
||||
)}
|
||||
</OverflowList>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Backlinks.css = style
|
||||
Backlinks.afterDOMLoaded = overflowListAfterDOMLoaded
|
||||
|
||||
return Backlinks
|
||||
}) satisfies QuartzComponentConstructor
|
||||
13
quartz/components/Body.tsx
Normal file
13
quartz/components/Body.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
// @ts-ignore
|
||||
import clipboardScript from "./scripts/clipboard.inline"
|
||||
import clipboardStyle from "./styles/clipboard.scss"
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
|
||||
const Body: QuartzComponent = ({ children }: QuartzComponentProps) => {
|
||||
return <div id="quartz-body">{children}</div>
|
||||
}
|
||||
|
||||
Body.afterDOMLoaded = clipboardScript
|
||||
Body.css = clipboardStyle
|
||||
|
||||
export default (() => Body) satisfies QuartzComponentConstructor
|
||||
93
quartz/components/Breadcrumbs.tsx
Normal file
93
quartz/components/Breadcrumbs.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import breadcrumbsStyle from "./styles/breadcrumbs.scss"
|
||||
import { FullSlug, SimpleSlug, resolveRelative, simplifySlug } from "../util/path"
|
||||
import { classNames } from "../util/lang"
|
||||
import { trieFromAllFiles } from "../util/ctx"
|
||||
|
||||
type CrumbData = {
|
||||
displayName: string
|
||||
path: string
|
||||
}
|
||||
|
||||
interface BreadcrumbOptions {
|
||||
/**
|
||||
* Symbol between crumbs
|
||||
*/
|
||||
spacerSymbol: string
|
||||
/**
|
||||
* Name of first crumb
|
||||
*/
|
||||
rootName: string
|
||||
/**
|
||||
* Whether to look up frontmatter title for folders (could cause performance problems with big vaults)
|
||||
*/
|
||||
resolveFrontmatterTitle: boolean
|
||||
/**
|
||||
* Whether to display the current page in the breadcrumbs.
|
||||
*/
|
||||
showCurrentPage: boolean
|
||||
}
|
||||
|
||||
const defaultOptions: BreadcrumbOptions = {
|
||||
spacerSymbol: "❯",
|
||||
rootName: "Home",
|
||||
resolveFrontmatterTitle: true,
|
||||
showCurrentPage: true,
|
||||
}
|
||||
|
||||
function formatCrumb(displayName: string, baseSlug: FullSlug, currentSlug: SimpleSlug): CrumbData {
|
||||
return {
|
||||
displayName: displayName.replaceAll("-", " "),
|
||||
path: resolveRelative(baseSlug, currentSlug),
|
||||
}
|
||||
}
|
||||
|
||||
export default ((opts?: Partial<BreadcrumbOptions>) => {
|
||||
const options: BreadcrumbOptions = { ...defaultOptions, ...opts }
|
||||
const Breadcrumbs: QuartzComponent = ({
|
||||
fileData,
|
||||
allFiles,
|
||||
displayClass,
|
||||
ctx,
|
||||
}: QuartzComponentProps) => {
|
||||
const trie = (ctx.trie ??= trieFromAllFiles(allFiles))
|
||||
const slugParts = fileData.slug!.split("/")
|
||||
const pathNodes = trie.ancestryChain(slugParts)
|
||||
|
||||
if (!pathNodes) {
|
||||
return null
|
||||
}
|
||||
|
||||
const crumbs: CrumbData[] = pathNodes.map((node, idx) => {
|
||||
const crumb = formatCrumb(node.displayName, fileData.slug!, simplifySlug(node.slug))
|
||||
if (idx === 0) {
|
||||
crumb.displayName = options.rootName
|
||||
}
|
||||
|
||||
// For last node (current page), set empty path
|
||||
if (idx === pathNodes.length - 1) {
|
||||
crumb.path = ""
|
||||
}
|
||||
|
||||
return crumb
|
||||
})
|
||||
|
||||
if (!options.showCurrentPage) {
|
||||
crumbs.pop()
|
||||
}
|
||||
|
||||
return (
|
||||
<nav class={classNames(displayClass, "breadcrumb-container")} aria-label="breadcrumbs">
|
||||
{crumbs.map((crumb, index) => (
|
||||
<div class="breadcrumb-element">
|
||||
<a href={crumb.path}>{crumb.displayName}</a>
|
||||
{index !== crumbs.length - 1 && <p>{` ${options.spacerSymbol} `}</p>}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
Breadcrumbs.css = breadcrumbsStyle
|
||||
|
||||
return Breadcrumbs
|
||||
}) satisfies QuartzComponentConstructor
|
||||
62
quartz/components/Comments.tsx
Normal file
62
quartz/components/Comments.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import { classNames } from "../util/lang"
|
||||
// @ts-ignore
|
||||
import script from "./scripts/comments.inline"
|
||||
|
||||
type Options = {
|
||||
provider: "giscus"
|
||||
options: {
|
||||
repo: `${string}/${string}`
|
||||
repoId: string
|
||||
category: string
|
||||
categoryId: string
|
||||
themeUrl?: string
|
||||
lightTheme?: string
|
||||
darkTheme?: string
|
||||
mapping?: "url" | "title" | "og:title" | "specific" | "number" | "pathname"
|
||||
strict?: boolean
|
||||
reactionsEnabled?: boolean
|
||||
inputPosition?: "top" | "bottom"
|
||||
lang?: string
|
||||
}
|
||||
}
|
||||
|
||||
function boolToStringBool(b: boolean): string {
|
||||
return b ? "1" : "0"
|
||||
}
|
||||
|
||||
export default ((opts: Options) => {
|
||||
const Comments: QuartzComponent = ({ displayClass, fileData, cfg }: QuartzComponentProps) => {
|
||||
// check if comments should be displayed according to frontmatter
|
||||
const disableComment: boolean =
|
||||
typeof fileData.frontmatter?.comments !== "undefined" &&
|
||||
(!fileData.frontmatter?.comments || fileData.frontmatter?.comments === "false")
|
||||
if (disableComment) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class={classNames(displayClass, "giscus")}
|
||||
data-repo={opts.options.repo}
|
||||
data-repo-id={opts.options.repoId}
|
||||
data-category={opts.options.category}
|
||||
data-category-id={opts.options.categoryId}
|
||||
data-mapping={opts.options.mapping ?? "url"}
|
||||
data-strict={boolToStringBool(opts.options.strict ?? true)}
|
||||
data-reactions-enabled={boolToStringBool(opts.options.reactionsEnabled ?? true)}
|
||||
data-input-position={opts.options.inputPosition ?? "bottom"}
|
||||
data-light-theme={opts.options.lightTheme ?? "light"}
|
||||
data-dark-theme={opts.options.darkTheme ?? "dark"}
|
||||
data-theme-url={
|
||||
opts.options.themeUrl ?? `https://${cfg.baseUrl ?? "example.com"}/static/giscus`
|
||||
}
|
||||
data-lang={opts.options.lang ?? "en"}
|
||||
></div>
|
||||
)
|
||||
}
|
||||
|
||||
Comments.afterDOMLoaded = script
|
||||
|
||||
return Comments
|
||||
}) satisfies QuartzComponentConstructor<Options>
|
||||
22
quartz/components/ConditionalRender.tsx
Normal file
22
quartz/components/ConditionalRender.tsx
Normal file
@@ -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>
|
||||
58
quartz/components/ContentMeta.tsx
Normal file
58
quartz/components/ContentMeta.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Date, getDate } from "./Date"
|
||||
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import readingTime from "reading-time"
|
||||
import { classNames } from "../util/lang"
|
||||
import { i18n } from "../i18n"
|
||||
import { JSX } from "preact"
|
||||
import style from "./styles/contentMeta.scss"
|
||||
|
||||
interface ContentMetaOptions {
|
||||
/**
|
||||
* Whether to display reading time
|
||||
*/
|
||||
showReadingTime: boolean
|
||||
showComma: boolean
|
||||
}
|
||||
|
||||
const defaultOptions: ContentMetaOptions = {
|
||||
showReadingTime: true,
|
||||
showComma: true,
|
||||
}
|
||||
|
||||
export default ((opts?: Partial<ContentMetaOptions>) => {
|
||||
// Merge options with defaults
|
||||
const options: ContentMetaOptions = { ...defaultOptions, ...opts }
|
||||
|
||||
function ContentMetadata({ cfg, fileData, displayClass }: QuartzComponentProps) {
|
||||
const text = fileData.text
|
||||
|
||||
if (text) {
|
||||
const segments: (string | JSX.Element)[] = []
|
||||
|
||||
if (fileData.dates) {
|
||||
segments.push(<Date date={getDate(cfg, fileData)!} locale={cfg.locale} />)
|
||||
}
|
||||
|
||||
// Display reading time if enabled
|
||||
if (options.showReadingTime) {
|
||||
const { minutes, words: _words } = readingTime(text)
|
||||
const displayedTime = i18n(cfg.locale).components.contentMeta.readingTime({
|
||||
minutes: Math.ceil(minutes),
|
||||
})
|
||||
segments.push(<span>{displayedTime}</span>)
|
||||
}
|
||||
|
||||
return (
|
||||
<p show-comma={options.showComma} class={classNames(displayClass, "content-meta")}>
|
||||
{segments}
|
||||
</p>
|
||||
)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
ContentMetadata.css = style
|
||||
|
||||
return ContentMetadata
|
||||
}) satisfies QuartzComponentConstructor
|
||||
48
quartz/components/Darkmode.tsx
Normal file
48
quartz/components/Darkmode.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
// @ts-ignore
|
||||
import darkmodeScript from "./scripts/darkmode.inline"
|
||||
import styles from "./styles/darkmode.scss"
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import { i18n } from "../i18n"
|
||||
import { classNames } from "../util/lang"
|
||||
|
||||
const Darkmode: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps) => {
|
||||
return (
|
||||
<button class={classNames(displayClass, "darkmode")}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
version="1.1"
|
||||
class="dayIcon"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 35 35"
|
||||
style="enable-background:new 0 0 35 35"
|
||||
xmlSpace="preserve"
|
||||
aria-label={i18n(cfg.locale).components.themeToggle.darkMode}
|
||||
>
|
||||
<title>{i18n(cfg.locale).components.themeToggle.darkMode}</title>
|
||||
<path d="M6,17.5C6,16.672,5.328,16,4.5,16h-3C0.672,16,0,16.672,0,17.5 S0.672,19,1.5,19h3C5.328,19,6,18.328,6,17.5z M7.5,26c-0.414,0-0.789,0.168-1.061,0.439l-2,2C4.168,28.711,4,29.086,4,29.5 C4,30.328,4.671,31,5.5,31c0.414,0,0.789-0.168,1.06-0.44l2-2C8.832,28.289,9,27.914,9,27.5C9,26.672,8.329,26,7.5,26z M17.5,6 C18.329,6,19,5.328,19,4.5v-3C19,0.672,18.329,0,17.5,0S16,0.672,16,1.5v3C16,5.328,16.671,6,17.5,6z M27.5,9 c0.414,0,0.789-0.168,1.06-0.439l2-2C30.832,6.289,31,5.914,31,5.5C31,4.672,30.329,4,29.5,4c-0.414,0-0.789,0.168-1.061,0.44 l-2,2C26.168,6.711,26,7.086,26,7.5C26,8.328,26.671,9,27.5,9z M6.439,8.561C6.711,8.832,7.086,9,7.5,9C8.328,9,9,8.328,9,7.5 c0-0.414-0.168-0.789-0.439-1.061l-2-2C6.289,4.168,5.914,4,5.5,4C4.672,4,4,4.672,4,5.5c0,0.414,0.168,0.789,0.439,1.06 L6.439,8.561z M33.5,16h-3c-0.828,0-1.5,0.672-1.5,1.5s0.672,1.5,1.5,1.5h3c0.828,0,1.5-0.672,1.5-1.5S34.328,16,33.5,16z M28.561,26.439C28.289,26.168,27.914,26,27.5,26c-0.828,0-1.5,0.672-1.5,1.5c0,0.414,0.168,0.789,0.439,1.06l2,2 C28.711,30.832,29.086,31,29.5,31c0.828,0,1.5-0.672,1.5-1.5c0-0.414-0.168-0.789-0.439-1.061L28.561,26.439z M17.5,29 c-0.829,0-1.5,0.672-1.5,1.5v3c0,0.828,0.671,1.5,1.5,1.5s1.5-0.672,1.5-1.5v-3C19,29.672,18.329,29,17.5,29z M17.5,7 C11.71,7,7,11.71,7,17.5S11.71,28,17.5,28S28,23.29,28,17.5S23.29,7,17.5,7z M17.5,25c-4.136,0-7.5-3.364-7.5-7.5 c0-4.136,3.364-7.5,7.5-7.5c4.136,0,7.5,3.364,7.5,7.5C25,21.636,21.636,25,17.5,25z"></path>
|
||||
</svg>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
version="1.1"
|
||||
class="nightIcon"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 100 100"
|
||||
style="enable-background:new 0 0 100 100"
|
||||
xmlSpace="preserve"
|
||||
aria-label={i18n(cfg.locale).components.themeToggle.lightMode}
|
||||
>
|
||||
<title>{i18n(cfg.locale).components.themeToggle.lightMode}</title>
|
||||
<path d="M96.76,66.458c-0.853-0.852-2.15-1.064-3.23-0.534c-6.063,2.991-12.858,4.571-19.655,4.571 C62.022,70.495,50.88,65.88,42.5,57.5C29.043,44.043,25.658,23.536,34.076,6.47c0.532-1.08,0.318-2.379-0.534-3.23 c-0.851-0.852-2.15-1.064-3.23-0.534c-4.918,2.427-9.375,5.619-13.246,9.491c-9.447,9.447-14.65,22.008-14.65,35.369 c0,13.36,5.203,25.921,14.65,35.368s22.008,14.65,35.368,14.65c13.361,0,25.921-5.203,35.369-14.65 c3.872-3.871,7.064-8.328,9.491-13.246C97.826,68.608,97.611,67.309,96.76,66.458z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
Darkmode.beforeDOMLoaded = darkmodeScript
|
||||
Darkmode.css = styles
|
||||
|
||||
export default (() => Darkmode) satisfies QuartzComponentConstructor
|
||||
31
quartz/components/Date.tsx
Normal file
31
quartz/components/Date.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { GlobalConfiguration } from "../cfg"
|
||||
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(cfg: GlobalConfiguration, data: QuartzPluginData): Date | undefined {
|
||||
if (!cfg.defaultDateType) {
|
||||
throw new Error(
|
||||
`Field 'defaultDateType' was not set in the configuration object of quartz.config.ts. See https://quartz.jzhao.xyz/configuration#general-configuration for more details.`,
|
||||
)
|
||||
}
|
||||
return data.dates?.[cfg.defaultDateType]
|
||||
}
|
||||
|
||||
export function formatDate(d: Date, 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>
|
||||
}
|
||||
14
quartz/components/DesktopOnly.tsx
Normal file
14
quartz/components/DesktopOnly.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
|
||||
export default ((component: QuartzComponent) => {
|
||||
const Component = component
|
||||
const DesktopOnly: QuartzComponent = (props: QuartzComponentProps) => {
|
||||
return <Component displayClass="desktop-only" {...props} />
|
||||
}
|
||||
|
||||
DesktopOnly.displayName = component.displayName
|
||||
DesktopOnly.afterDOMLoaded = component?.afterDOMLoaded
|
||||
DesktopOnly.beforeDOMLoaded = component?.beforeDOMLoaded
|
||||
DesktopOnly.css = component?.css
|
||||
return DesktopOnly
|
||||
}) satisfies QuartzComponentConstructor<QuartzComponent>
|
||||
165
quartz/components/Explorer.tsx
Normal file
165
quartz/components/Explorer.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import style from "./styles/explorer.scss"
|
||||
|
||||
// @ts-ignore
|
||||
import script from "./scripts/explorer.inline"
|
||||
import { classNames } from "../util/lang"
|
||||
import { i18n } from "../i18n"
|
||||
import { FileTrieNode } from "../util/fileTrie"
|
||||
import OverflowListFactory from "./OverflowList"
|
||||
import { concatenateResources } from "../util/resources"
|
||||
|
||||
type OrderEntries = "sort" | "filter" | "map"
|
||||
|
||||
export interface Options {
|
||||
title?: string
|
||||
folderDefaultState: "collapsed" | "open"
|
||||
folderClickBehavior: "collapse" | "link"
|
||||
useSavedState: boolean
|
||||
sortFn: (a: FileTrieNode, b: FileTrieNode) => number
|
||||
filterFn: (node: FileTrieNode) => boolean
|
||||
mapFn: (node: FileTrieNode) => void
|
||||
order: OrderEntries[]
|
||||
}
|
||||
|
||||
const defaultOptions: Options = {
|
||||
folderDefaultState: "collapsed",
|
||||
folderClickBehavior: "link",
|
||||
useSavedState: true,
|
||||
mapFn: (node) => {
|
||||
return node
|
||||
},
|
||||
sortFn: (a, b) => {
|
||||
// Sort order: folders first, then files. Sort folders and files alphabeticall
|
||||
if ((!a.isFolder && !b.isFolder) || (a.isFolder && b.isFolder)) {
|
||||
// numeric: true: Whether numeric collation should be used, such that "1" < "2" < "10"
|
||||
// sensitivity: "base": Only strings that differ in base letters compare as unequal. Examples: a ≠ b, a = á, a = A
|
||||
return a.displayName.localeCompare(b.displayName, undefined, {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
})
|
||||
}
|
||||
|
||||
if (!a.isFolder && b.isFolder) {
|
||||
return 1
|
||||
} else {
|
||||
return -1
|
||||
}
|
||||
},
|
||||
filterFn: (node) => node.slugSegment !== "tags",
|
||||
order: ["filter", "map", "sort"],
|
||||
}
|
||||
|
||||
export type FolderState = {
|
||||
path: string
|
||||
collapsed: boolean
|
||||
}
|
||||
|
||||
let numExplorers = 0
|
||||
export default ((userOpts?: Partial<Options>) => {
|
||||
const opts: Options = { ...defaultOptions, ...userOpts }
|
||||
const { OverflowList, overflowListAfterDOMLoaded } = OverflowListFactory()
|
||||
|
||||
const Explorer: QuartzComponent = ({ cfg, displayClass }: QuartzComponentProps) => {
|
||||
const id = `explorer-${numExplorers++}`
|
||||
|
||||
return (
|
||||
<div
|
||||
class={classNames(displayClass, "explorer")}
|
||||
data-behavior={opts.folderClickBehavior}
|
||||
data-collapsed={opts.folderDefaultState}
|
||||
data-savestate={opts.useSavedState}
|
||||
data-data-fns={JSON.stringify({
|
||||
order: opts.order,
|
||||
sortFn: opts.sortFn.toString(),
|
||||
filterFn: opts.filterFn.toString(),
|
||||
mapFn: opts.mapFn.toString(),
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="explorer-toggle mobile-explorer hide-until-loaded"
|
||||
data-mobile={true}
|
||||
aria-controls={id}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide-menu"
|
||||
>
|
||||
<line x1="4" x2="20" y1="12" y2="12" />
|
||||
<line x1="4" x2="20" y1="6" y2="6" />
|
||||
<line x1="4" x2="20" y1="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="title-button explorer-toggle desktop-explorer"
|
||||
data-mobile={false}
|
||||
aria-expanded={true}
|
||||
>
|
||||
<h2>{opts.title ?? i18n(cfg.locale).components.explorer.title}</h2>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="5 8 14 8"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="fold"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<div id={id} class="explorer-content" aria-expanded={false} role="group">
|
||||
<OverflowList class="explorer-ul" />
|
||||
</div>
|
||||
<template id="template-file">
|
||||
<li>
|
||||
<a href="#"></a>
|
||||
</li>
|
||||
</template>
|
||||
<template id="template-folder">
|
||||
<li>
|
||||
<div class="folder-container">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="5 8 14 8"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="folder-icon"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
<div>
|
||||
<button class="folder-button">
|
||||
<span class="folder-title"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="folder-outer">
|
||||
<ul class="content"></ul>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Explorer.css = style
|
||||
Explorer.afterDOMLoaded = concatenateResources(script, overflowListAfterDOMLoaded)
|
||||
return Explorer
|
||||
}) satisfies QuartzComponentConstructor
|
||||
59
quartz/components/Flex.tsx
Normal file
59
quartz/components/Flex.tsx
Normal file
@@ -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>
|
||||
33
quartz/components/Footer.tsx
Normal file
33
quartz/components/Footer.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import style from "./styles/footer.scss"
|
||||
import { version } from "../../package.json"
|
||||
import { i18n } from "../i18n"
|
||||
|
||||
interface Options {
|
||||
links: Record<string, string>
|
||||
}
|
||||
|
||||
export default ((opts?: Options) => {
|
||||
const Footer: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps) => {
|
||||
const year = new Date().getFullYear()
|
||||
const links = opts?.links ?? []
|
||||
return (
|
||||
<footer class={`${displayClass ?? ""}`}>
|
||||
<p>
|
||||
{i18n(cfg.locale).components.footer.createdWith}{" "}
|
||||
<a href="https://quartz.jzhao.xyz/">Quartz v{version}</a> © {year}
|
||||
</p>
|
||||
<ul>
|
||||
{Object.entries(links).map(([text, link]) => (
|
||||
<li>
|
||||
<a href={link}>{text}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
Footer.css = style
|
||||
return Footer
|
||||
}) satisfies QuartzComponentConstructor
|
||||
109
quartz/components/Graph.tsx
Normal file
109
quartz/components/Graph.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
// @ts-ignore
|
||||
import script from "./scripts/graph.inline"
|
||||
import style from "./styles/graph.scss"
|
||||
import { i18n } from "../i18n"
|
||||
import { classNames } from "../util/lang"
|
||||
|
||||
export interface D3Config {
|
||||
drag: boolean
|
||||
zoom: boolean
|
||||
depth: number
|
||||
scale: number
|
||||
repelForce: number
|
||||
centerForce: number
|
||||
linkDistance: number
|
||||
fontSize: number
|
||||
opacityScale: number
|
||||
removeTags: string[]
|
||||
showTags: boolean
|
||||
focusOnHover?: boolean
|
||||
enableRadial?: boolean
|
||||
}
|
||||
|
||||
interface GraphOptions {
|
||||
localGraph: Partial<D3Config> | undefined
|
||||
globalGraph: Partial<D3Config> | undefined
|
||||
}
|
||||
|
||||
const defaultOptions: GraphOptions = {
|
||||
localGraph: {
|
||||
drag: true,
|
||||
zoom: true,
|
||||
depth: 1,
|
||||
scale: 1.1,
|
||||
repelForce: 0.5,
|
||||
centerForce: 0.3,
|
||||
linkDistance: 30,
|
||||
fontSize: 0.6,
|
||||
opacityScale: 1,
|
||||
showTags: true,
|
||||
removeTags: [],
|
||||
focusOnHover: false,
|
||||
enableRadial: false,
|
||||
},
|
||||
globalGraph: {
|
||||
drag: true,
|
||||
zoom: true,
|
||||
depth: -1,
|
||||
scale: 0.9,
|
||||
repelForce: 0.5,
|
||||
centerForce: 0.2,
|
||||
linkDistance: 30,
|
||||
fontSize: 0.6,
|
||||
opacityScale: 1,
|
||||
showTags: true,
|
||||
removeTags: [],
|
||||
focusOnHover: true,
|
||||
enableRadial: true,
|
||||
},
|
||||
}
|
||||
|
||||
export default ((opts?: Partial<GraphOptions>) => {
|
||||
const Graph: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps) => {
|
||||
const localGraph = { ...defaultOptions.localGraph, ...opts?.localGraph }
|
||||
const globalGraph = { ...defaultOptions.globalGraph, ...opts?.globalGraph }
|
||||
return (
|
||||
<div class={classNames(displayClass, "graph")}>
|
||||
<h3>{i18n(cfg.locale).components.graph.title}</h3>
|
||||
<div class="graph-outer">
|
||||
<div class="graph-container" data-cfg={JSON.stringify(localGraph)}></div>
|
||||
<button class="global-graph-icon" aria-label="Global Graph">
|
||||
<svg
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 55 55"
|
||||
fill="currentColor"
|
||||
xmlSpace="preserve"
|
||||
>
|
||||
<path
|
||||
d="M49,0c-3.309,0-6,2.691-6,6c0,1.035,0.263,2.009,0.726,2.86l-9.829,9.829C32.542,17.634,30.846,17,29,17
|
||||
s-3.542,0.634-4.898,1.688l-7.669-7.669C16.785,10.424,17,9.74,17,9c0-2.206-1.794-4-4-4S9,6.794,9,9s1.794,4,4,4
|
||||
c0.74,0,1.424-0.215,2.019-0.567l7.669,7.669C21.634,21.458,21,23.154,21,25s0.634,3.542,1.688,4.897L10.024,42.562
|
||||
C8.958,41.595,7.549,41,6,41c-3.309,0-6,2.691-6,6s2.691,6,6,6s6-2.691,6-6c0-1.035-0.263-2.009-0.726-2.86l12.829-12.829
|
||||
c1.106,0.86,2.44,1.436,3.898,1.619v10.16c-2.833,0.478-5,2.942-5,5.91c0,3.309,2.691,6,6,6s6-2.691,6-6c0-2.967-2.167-5.431-5-5.91
|
||||
v-10.16c1.458-0.183,2.792-0.759,3.898-1.619l7.669,7.669C41.215,39.576,41,40.26,41,41c0,2.206,1.794,4,4,4s4-1.794,4-4
|
||||
s-1.794-4-4-4c-0.74,0-1.424,0.215-2.019,0.567l-7.669-7.669C36.366,28.542,37,26.846,37,25s-0.634-3.542-1.688-4.897l9.665-9.665
|
||||
C46.042,11.405,47.451,12,49,12c3.309,0,6-2.691,6-6S52.309,0,49,0z M11,9c0-1.103,0.897-2,2-2s2,0.897,2,2s-0.897,2-2,2
|
||||
S11,10.103,11,9z M6,51c-2.206,0-4-1.794-4-4s1.794-4,4-4s4,1.794,4,4S8.206,51,6,51z M33,49c0,2.206-1.794,4-4,4s-4-1.794-4-4
|
||||
s1.794-4,4-4S33,46.794,33,49z M29,31c-3.309,0-6-2.691-6-6s2.691-6,6-6s6,2.691,6,6S32.309,31,29,31z M47,41c0,1.103-0.897,2-2,2
|
||||
s-2-0.897-2-2s0.897-2,2-2S47,39.897,47,41z M49,10c-2.206,0-4-1.794-4-4s1.794-4,4-4s4,1.794,4,4S51.206,10,49,10z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="global-graph-outer">
|
||||
<div class="global-graph-container" data-cfg={JSON.stringify(globalGraph)}></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Graph.css = style
|
||||
Graph.afterDOMLoaded = script
|
||||
|
||||
return Graph
|
||||
}) satisfies QuartzComponentConstructor
|
||||
105
quartz/components/Head.tsx
Normal file
105
quartz/components/Head.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
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 "../plugins/emitters/ogImage"
|
||||
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`
|
||||
|
||||
return (
|
||||
<head>
|
||||
<title>{title}</title>
|
||||
<meta charSet="utf-8" />
|
||||
{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
|
||||
22
quartz/components/Header.tsx
Normal file
22
quartz/components/Header.tsx
Normal file
@@ -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
|
||||
14
quartz/components/MobileOnly.tsx
Normal file
14
quartz/components/MobileOnly.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
|
||||
export default ((component: QuartzComponent) => {
|
||||
const Component = component
|
||||
const MobileOnly: QuartzComponent = (props: QuartzComponentProps) => {
|
||||
return <Component displayClass="mobile-only" {...props} />
|
||||
}
|
||||
|
||||
MobileOnly.displayName = component.displayName
|
||||
MobileOnly.afterDOMLoaded = component?.afterDOMLoaded
|
||||
MobileOnly.beforeDOMLoaded = component?.beforeDOMLoaded
|
||||
MobileOnly.css = component?.css
|
||||
return MobileOnly
|
||||
}) satisfies QuartzComponentConstructor<QuartzComponent>
|
||||
48
quartz/components/OverflowList.tsx
Normal file
48
quartz/components/OverflowList.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { JSX } from "preact"
|
||||
|
||||
const OverflowList = ({
|
||||
children,
|
||||
...props
|
||||
}: JSX.HTMLAttributes<HTMLUListElement> & { id: string }) => {
|
||||
return (
|
||||
<ul {...props} class={[props.class, "overflow"].filter(Boolean).join(" ")} id={props.id}>
|
||||
{children}
|
||||
<li class="overflow-end" />
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
let numLists = 0
|
||||
export default () => {
|
||||
const id = `list-${numLists++}`
|
||||
|
||||
return {
|
||||
OverflowList: (props: JSX.HTMLAttributes<HTMLUListElement>) => (
|
||||
<OverflowList {...props} id={id} />
|
||||
),
|
||||
overflowListAfterDOMLoaded: `
|
||||
document.addEventListener("nav", (e) => {
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const parentUl = entry.target.parentElement
|
||||
if (!parentUl) return
|
||||
if (entry.isIntersecting) {
|
||||
parentUl.classList.remove("gradient-active")
|
||||
} else {
|
||||
parentUl.classList.add("gradient-active")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const ul = document.getElementById("${id}")
|
||||
if (!ul) return
|
||||
|
||||
const end = ul.querySelector(".overflow-end")
|
||||
if (!end) return
|
||||
|
||||
observer.observe(end)
|
||||
window.addCleanup(() => observer.disconnect())
|
||||
})
|
||||
`,
|
||||
}
|
||||
}
|
||||
114
quartz/components/PageList.tsx
Normal file
114
quartz/components/PageList.tsx
Normal file
@@ -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"
|
||||
import { GlobalConfiguration } from "../cfg"
|
||||
|
||||
export type SortFn = (f1: QuartzPluginData, f2: QuartzPluginData) => number
|
||||
|
||||
export function byDateAndAlphabetical(cfg: GlobalConfiguration): SortFn {
|
||||
return (f1, f2) => {
|
||||
// Sort by date/alphabetical
|
||||
if (f1.dates && f2.dates) {
|
||||
// sort descending
|
||||
return getDate(cfg, f2)!.getTime() - getDate(cfg, f1)!.getTime()
|
||||
} else if (f1.dates && !f2.dates) {
|
||||
// prioritize files with dates
|
||||
return -1
|
||||
} else if (!f1.dates && f2.dates) {
|
||||
return 1
|
||||
}
|
||||
|
||||
// otherwise, sort lexographically by title
|
||||
const f1Title = f1.frontmatter?.title.toLowerCase() ?? ""
|
||||
const f2Title = f2.frontmatter?.title.toLowerCase() ?? ""
|
||||
return f1Title.localeCompare(f2Title)
|
||||
}
|
||||
}
|
||||
|
||||
export function byDateAndAlphabeticalFolderFirst(cfg: GlobalConfiguration): 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(cfg, f2)!.getTime() - getDate(cfg, f1)!.getTime()
|
||||
} else if (f1.dates && !f2.dates) {
|
||||
// prioritize files with dates
|
||||
return -1
|
||||
} else if (!f1.dates && f2.dates) {
|
||||
return 1
|
||||
}
|
||||
|
||||
// otherwise, sort lexographically by title
|
||||
const f1Title = f1.frontmatter?.title.toLowerCase() ?? ""
|
||||
const f2Title = f2.frontmatter?.title.toLowerCase() ?? ""
|
||||
return f1Title.localeCompare(f2Title)
|
||||
}
|
||||
}
|
||||
|
||||
type Props = {
|
||||
limit?: number
|
||||
sort?: SortFn
|
||||
} & QuartzComponentProps
|
||||
|
||||
export const PageList: QuartzComponent = ({ cfg, fileData, allFiles, limit, sort }: Props) => {
|
||||
const sorter = sort ?? byDateAndAlphabeticalFolderFirst(cfg)
|
||||
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(cfg, page)!} locale={cfg.locale} />}
|
||||
</p>
|
||||
<div class="desc">
|
||||
<h3>
|
||||
<a href={resolveRelative(fileData.slug!, page.slug!)} class="internal">
|
||||
{title}
|
||||
</a>
|
||||
</h3>
|
||||
</div>
|
||||
<ul class="tags">
|
||||
{tags.map((tag) => (
|
||||
<li>
|
||||
<a
|
||||
class="internal tag-link"
|
||||
href={resolveRelative(fileData.slug!, `tags/${tag}` as FullSlug)}
|
||||
>
|
||||
{tag}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
PageList.css = `
|
||||
.section h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section > .tags {
|
||||
margin: 0;
|
||||
}
|
||||
`
|
||||
24
quartz/components/PageTitle.tsx
Normal file
24
quartz/components/PageTitle.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { pathToRoot } from "../util/path"
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import { classNames } from "../util/lang"
|
||||
import { i18n } from "../i18n"
|
||||
|
||||
const PageTitle: QuartzComponent = ({ fileData, cfg, displayClass }: QuartzComponentProps) => {
|
||||
const title = cfg?.pageTitle ?? i18n(cfg.locale).propertyDefaults.title
|
||||
const baseDir = pathToRoot(fileData.slug!)
|
||||
return (
|
||||
<h2 class={classNames(displayClass, "page-title")}>
|
||||
<a href={baseDir}>{title}</a>
|
||||
</h2>
|
||||
)
|
||||
}
|
||||
|
||||
PageTitle.css = `
|
||||
.page-title {
|
||||
font-size: 1.75rem;
|
||||
margin: 0;
|
||||
font-family: var(--titleFont);
|
||||
}
|
||||
`
|
||||
|
||||
export default (() => PageTitle) satisfies QuartzComponentConstructor
|
||||
38
quartz/components/ReaderMode.tsx
Normal file
38
quartz/components/ReaderMode.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
// @ts-ignore
|
||||
import readerModeScript from "./scripts/readermode.inline"
|
||||
import styles from "./styles/readermode.scss"
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import { i18n } from "../i18n"
|
||||
import { classNames } from "../util/lang"
|
||||
|
||||
const ReaderMode: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps) => {
|
||||
return (
|
||||
<button class={classNames(displayClass, "readermode")}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
version="1.1"
|
||||
class="readerIcon"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
stroke-width="0.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
width="64px"
|
||||
height="64px"
|
||||
viewBox="0 0 24 24"
|
||||
aria-label={i18n(cfg.locale).components.readerMode.title}
|
||||
>
|
||||
<title>{i18n(cfg.locale).components.readerMode.title}</title>
|
||||
<g transform="translate(-1.8, -1.8) scale(1.15, 1.2)">
|
||||
<path d="M8.9891247,2.5 C10.1384702,2.5 11.2209868,2.96705384 12.0049645,3.76669482 C12.7883914,2.96705384 13.8709081,2.5 15.0202536,2.5 L18.7549359,2.5 C19.1691495,2.5 19.5049359,2.83578644 19.5049359,3.25 L19.5046891,4.004 L21.2546891,4.00457396 C21.6343849,4.00457396 21.9481801,4.28672784 21.9978425,4.6528034 L22.0046891,4.75457396 L22.0046891,20.25 C22.0046891,20.6296958 21.7225353,20.943491 21.3564597,20.9931534 L21.2546891,21 L2.75468914,21 C2.37499337,21 2.06119817,20.7178461 2.01153575,20.3517706 L2.00468914,20.25 L2.00468914,4.75457396 C2.00468914,4.37487819 2.28684302,4.061083 2.65291858,4.01142057 L2.75468914,4.00457396 L4.50368914,4.004 L4.50444233,3.25 C4.50444233,2.87030423 4.78659621,2.55650904 5.15267177,2.50684662 L5.25444233,2.5 L8.9891247,2.5 Z M4.50368914,5.504 L3.50468914,5.504 L3.50468914,19.5 L10.9478955,19.4998273 C10.4513189,18.9207296 9.73864328,18.5588115 8.96709342,18.5065584 L8.77307039,18.5 L5.25444233,18.5 C4.87474657,18.5 4.56095137,18.2178461 4.51128895,17.8517706 L4.50444233,17.75 L4.50368914,5.504 Z M19.5049359,17.75 C19.5049359,18.1642136 19.1691495,18.5 18.7549359,18.5 L15.2363079,18.5 C14.3910149,18.5 13.5994408,18.8724714 13.0614828,19.4998273 L20.5046891,19.5 L20.5046891,5.504 L19.5046891,5.504 L19.5049359,17.75 Z M18.0059359,3.999 L15.0202536,4 L14.8259077,4.00692283 C13.9889509,4.06666544 13.2254227,4.50975805 12.7549359,5.212 L12.7549359,17.777 L12.7782651,17.7601316 C13.4923805,17.2719483 14.3447024,17 15.2363079,17 L18.0059359,16.999 L18.0056891,4.798 L18.0033792,4.75457396 L18.0056891,4.71 L18.0059359,3.999 Z M8.9891247,4 L6.00368914,3.999 L6.00599909,4.75457396 L6.00599909,4.75457396 L6.00368914,4.783 L6.00368914,16.999 L8.77307039,17 C9.57551536,17 10.3461406,17.2202781 11.0128313,17.6202194 L11.2536891,17.776 L11.2536891,5.211 C10.8200889,4.56369974 10.1361548,4.13636104 9.37521067,4.02745763 L9.18347055,4.00692283 L8.9891247,4 Z" />
|
||||
</g>
|
||||
</svg>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
ReaderMode.beforeDOMLoaded = readerModeScript
|
||||
ReaderMode.css = styles
|
||||
|
||||
export default (() => ReaderMode) satisfies QuartzComponentConstructor
|
||||
93
quartz/components/RecentNotes.tsx
Normal file
93
quartz/components/RecentNotes.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import { FullSlug, SimpleSlug, resolveRelative } from "../util/path"
|
||||
import { QuartzPluginData } from "../plugins/vfile"
|
||||
import { byDateAndAlphabetical } from "./PageList"
|
||||
import style from "./styles/recentNotes.scss"
|
||||
import { Date, getDate } from "./Date"
|
||||
import { GlobalConfiguration } from "../cfg"
|
||||
import { i18n } from "../i18n"
|
||||
import { classNames } from "../util/lang"
|
||||
|
||||
interface Options {
|
||||
title?: string
|
||||
limit: number
|
||||
linkToMore: SimpleSlug | false
|
||||
showTags: boolean
|
||||
filter: (f: QuartzPluginData) => boolean
|
||||
sort: (f1: QuartzPluginData, f2: QuartzPluginData) => number
|
||||
}
|
||||
|
||||
const defaultOptions = (cfg: GlobalConfiguration): Options => ({
|
||||
limit: 3,
|
||||
linkToMore: false,
|
||||
showTags: true,
|
||||
filter: () => true,
|
||||
sort: byDateAndAlphabetical(cfg),
|
||||
})
|
||||
|
||||
export default ((userOpts?: Partial<Options>) => {
|
||||
const RecentNotes: QuartzComponent = ({
|
||||
allFiles,
|
||||
fileData,
|
||||
displayClass,
|
||||
cfg,
|
||||
}: QuartzComponentProps) => {
|
||||
const opts = { ...defaultOptions(cfg), ...userOpts }
|
||||
const pages = allFiles.filter(opts.filter).sort(opts.sort)
|
||||
const remaining = Math.max(0, pages.length - opts.limit)
|
||||
return (
|
||||
<div class={classNames(displayClass, "recent-notes")}>
|
||||
<h3>{opts.title ?? i18n(cfg.locale).components.recentNotes.title}</h3>
|
||||
<ul class="recent-ul">
|
||||
{pages.slice(0, opts.limit).map((page) => {
|
||||
const title = page.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title
|
||||
const tags = page.frontmatter?.tags ?? []
|
||||
|
||||
return (
|
||||
<li class="recent-li">
|
||||
<div class="section">
|
||||
<div class="desc">
|
||||
<h3>
|
||||
<a href={resolveRelative(fileData.slug!, page.slug!)} class="internal">
|
||||
{title}
|
||||
</a>
|
||||
</h3>
|
||||
</div>
|
||||
{page.dates && (
|
||||
<p class="meta">
|
||||
<Date date={getDate(cfg, page)!} locale={cfg.locale} />
|
||||
</p>
|
||||
)}
|
||||
{opts.showTags && (
|
||||
<ul class="tags">
|
||||
{tags.map((tag) => (
|
||||
<li>
|
||||
<a
|
||||
class="internal tag-link"
|
||||
href={resolveRelative(fileData.slug!, `tags/${tag}` as FullSlug)}
|
||||
>
|
||||
{tag}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
{opts.linkToMore && remaining > 0 && (
|
||||
<p>
|
||||
<a href={resolveRelative(fileData.slug!, opts.linkToMore)}>
|
||||
{i18n(cfg.locale).components.recentNotes.seeRemainingMore({ remaining })}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
RecentNotes.css = style
|
||||
return RecentNotes
|
||||
}) satisfies QuartzComponentConstructor
|
||||
53
quartz/components/Search.tsx
Normal file
53
quartz/components/Search.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import style from "./styles/search.scss"
|
||||
// @ts-ignore
|
||||
import script from "./scripts/search.inline"
|
||||
import { classNames } from "../util/lang"
|
||||
import { i18n } from "../i18n"
|
||||
|
||||
export interface SearchOptions {
|
||||
enablePreview: boolean
|
||||
}
|
||||
|
||||
const defaultOptions: SearchOptions = {
|
||||
enablePreview: true,
|
||||
}
|
||||
|
||||
export default ((userOpts?: Partial<SearchOptions>) => {
|
||||
const Search: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps) => {
|
||||
const opts = { ...defaultOptions, ...userOpts }
|
||||
const searchPlaceholder = i18n(cfg.locale).components.search.searchBarPlaceholder
|
||||
return (
|
||||
<div class={classNames(displayClass, "search")}>
|
||||
<button class="search-button">
|
||||
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 19.9 19.7">
|
||||
<title>Search</title>
|
||||
<g class="search-path" fill="none">
|
||||
<path stroke-linecap="square" d="M18.5 18.3l-5.4-5.4" />
|
||||
<circle cx="8" cy="8" r="7" />
|
||||
</g>
|
||||
</svg>
|
||||
<p>{i18n(cfg.locale).components.search.title}</p>
|
||||
</button>
|
||||
<div class="search-container">
|
||||
<div class="search-space">
|
||||
<input
|
||||
autocomplete="off"
|
||||
class="search-bar"
|
||||
name="search"
|
||||
type="text"
|
||||
aria-label={searchPlaceholder}
|
||||
placeholder={searchPlaceholder}
|
||||
/>
|
||||
<div class="search-layout" data-preview={opts.enablePreview}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Search.afterDOMLoaded = script
|
||||
Search.css = style
|
||||
|
||||
return Search
|
||||
}) satisfies QuartzComponentConstructor
|
||||
8
quartz/components/Spacer.tsx
Normal file
8
quartz/components/Spacer.tsx
Normal file
@@ -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
|
||||
101
quartz/components/TableOfContents.tsx
Normal file
101
quartz/components/TableOfContents.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import legacyStyle from "./styles/legacyToc.scss"
|
||||
import modernStyle from "./styles/toc.scss"
|
||||
import { classNames } from "../util/lang"
|
||||
|
||||
// @ts-ignore
|
||||
import script from "./scripts/toc.inline"
|
||||
import { i18n } from "../i18n"
|
||||
import OverflowListFactory from "./OverflowList"
|
||||
import { concatenateResources } from "../util/resources"
|
||||
|
||||
interface Options {
|
||||
layout: "modern" | "legacy"
|
||||
}
|
||||
|
||||
const defaultOptions: Options = {
|
||||
layout: "modern",
|
||||
}
|
||||
|
||||
let numTocs = 0
|
||||
export default ((opts?: Partial<Options>) => {
|
||||
const layout = opts?.layout ?? defaultOptions.layout
|
||||
const { OverflowList, overflowListAfterDOMLoaded } = OverflowListFactory()
|
||||
const TableOfContents: QuartzComponent = ({
|
||||
fileData,
|
||||
displayClass,
|
||||
cfg,
|
||||
}: QuartzComponentProps) => {
|
||||
if (!fileData.toc) {
|
||||
return null
|
||||
}
|
||||
|
||||
const id = `toc-${numTocs++}`
|
||||
return (
|
||||
<div class={classNames(displayClass, "toc")}>
|
||||
<button
|
||||
type="button"
|
||||
class={fileData.collapseToc ? "collapsed toc-header" : "toc-header"}
|
||||
aria-controls={id}
|
||||
aria-expanded={!fileData.collapseToc}
|
||||
>
|
||||
<h3>{i18n(cfg.locale).components.tableOfContents.title}</h3>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="fold"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<OverflowList
|
||||
id={id}
|
||||
class={fileData.collapseToc ? "collapsed toc-content" : "toc-content"}
|
||||
>
|
||||
{fileData.toc.map((tocEntry) => (
|
||||
<li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}>
|
||||
<a href={`#${tocEntry.slug}`} data-for={tocEntry.slug}>
|
||||
{tocEntry.text}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</OverflowList>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
TableOfContents.css = modernStyle
|
||||
TableOfContents.afterDOMLoaded = concatenateResources(script, overflowListAfterDOMLoaded)
|
||||
|
||||
const LegacyTableOfContents: QuartzComponent = ({ fileData, cfg }: QuartzComponentProps) => {
|
||||
if (!fileData.toc) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<details class="toc" open={!fileData.collapseToc}>
|
||||
<summary>
|
||||
<h3>{i18n(cfg.locale).components.tableOfContents.title}</h3>
|
||||
</summary>
|
||||
<ul>
|
||||
{fileData.toc.map((tocEntry) => (
|
||||
<li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}>
|
||||
<a href={`#${tocEntry.slug}`} data-for={tocEntry.slug}>
|
||||
{tocEntry.text}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
LegacyTableOfContents.css = legacyStyle
|
||||
|
||||
return layout === "modern" ? TableOfContents : LegacyTableOfContents
|
||||
}) satisfies QuartzComponentConstructor
|
||||
56
quartz/components/TagList.tsx
Normal file
56
quartz/components/TagList.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { FullSlug, resolveRelative } from "../util/path"
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import { classNames } from "../util/lang"
|
||||
|
||||
const TagList: QuartzComponent = ({ fileData, displayClass }: QuartzComponentProps) => {
|
||||
const tags = fileData.frontmatter?.tags
|
||||
if (tags && tags.length > 0) {
|
||||
return (
|
||||
<ul class={classNames(displayClass, "tags")}>
|
||||
{tags.map((tag) => {
|
||||
const linkDest = resolveRelative(fileData.slug!, `tags/${tag}` as FullSlug)
|
||||
return (
|
||||
<li>
|
||||
<a href={linkDest} class="internal tag-link">
|
||||
{tag}
|
||||
</a>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
TagList.css = `
|
||||
.tags {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
padding-left: 0;
|
||||
gap: 0.4rem;
|
||||
margin: 1rem 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.section-li > .section > .tags {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.tags > li {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
margin: 0;
|
||||
overflow-wrap: normal;
|
||||
}
|
||||
|
||||
a.internal.tag-link {
|
||||
border-radius: 8px;
|
||||
background-color: var(--highlight);
|
||||
padding: 0.2rem 0.4rem;
|
||||
margin: 0 0.1rem;
|
||||
}
|
||||
`
|
||||
|
||||
export default (() => TagList) satisfies QuartzComponentConstructor
|
||||
53
quartz/components/index.ts
Normal file
53
quartz/components/index.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import Content from "./pages/Content"
|
||||
import TagContent from "./pages/TagContent"
|
||||
import FolderContent from "./pages/FolderContent"
|
||||
import NotFound from "./pages/404"
|
||||
import ArticleTitle from "./ArticleTitle"
|
||||
import Darkmode from "./Darkmode"
|
||||
import ReaderMode from "./ReaderMode"
|
||||
import Head from "./Head"
|
||||
import PageTitle from "./PageTitle"
|
||||
import ContentMeta from "./ContentMeta"
|
||||
import Spacer from "./Spacer"
|
||||
import TableOfContents from "./TableOfContents"
|
||||
import Explorer from "./Explorer"
|
||||
import TagList from "./TagList"
|
||||
import Graph from "./Graph"
|
||||
import Backlinks from "./Backlinks"
|
||||
import Search from "./Search"
|
||||
import Footer from "./Footer"
|
||||
import DesktopOnly from "./DesktopOnly"
|
||||
import MobileOnly from "./MobileOnly"
|
||||
import RecentNotes from "./RecentNotes"
|
||||
import Breadcrumbs from "./Breadcrumbs"
|
||||
import Comments from "./Comments"
|
||||
import Flex from "./Flex"
|
||||
import ConditionalRender from "./ConditionalRender"
|
||||
|
||||
export {
|
||||
ArticleTitle,
|
||||
Content,
|
||||
TagContent,
|
||||
FolderContent,
|
||||
Darkmode,
|
||||
ReaderMode,
|
||||
Head,
|
||||
PageTitle,
|
||||
ContentMeta,
|
||||
Spacer,
|
||||
TableOfContents,
|
||||
Explorer,
|
||||
TagList,
|
||||
Graph,
|
||||
Backlinks,
|
||||
Search,
|
||||
Footer,
|
||||
DesktopOnly,
|
||||
MobileOnly,
|
||||
RecentNotes,
|
||||
NotFound,
|
||||
Breadcrumbs,
|
||||
Comments,
|
||||
Flex,
|
||||
ConditionalRender,
|
||||
}
|
||||
18
quartz/components/pages/404.tsx
Normal file
18
quartz/components/pages/404.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { i18n } from "../../i18n"
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types"
|
||||
|
||||
const NotFound: QuartzComponent = ({ cfg }: QuartzComponentProps) => {
|
||||
// If baseUrl contains a pathname after the domain, use this as the home link
|
||||
const url = new URL(`https://${cfg.baseUrl ?? "example.com"}`)
|
||||
const baseDir = 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>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export default (() => NotFound) satisfies QuartzComponentConstructor
|
||||
12
quartz/components/pages/Content.tsx
Normal file
12
quartz/components/pages/Content.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { ComponentChildren } from "preact"
|
||||
import { htmlToJsx } from "../../util/jsx"
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types"
|
||||
|
||||
const Content: QuartzComponent = ({ fileData, tree }: QuartzComponentProps) => {
|
||||
const content = htmlToJsx(fileData.filePath!, tree) as ComponentChildren
|
||||
const classes: string[] = fileData.frontmatter?.cssclasses ?? []
|
||||
const classString = ["popover-hint", ...classes].join(" ")
|
||||
return <article class={classString}>{content}</article>
|
||||
}
|
||||
|
||||
export default (() => Content) satisfies QuartzComponentConstructor
|
||||
126
quartz/components/pages/FolderContent.tsx
Normal file
126
quartz/components/pages/FolderContent.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types"
|
||||
|
||||
import style from "../styles/listPage.scss"
|
||||
import { PageList, SortFn } from "../PageList"
|
||||
import { Root } from "hast"
|
||||
import { htmlToJsx } from "../../util/jsx"
|
||||
import { i18n } from "../../i18n"
|
||||
import { QuartzPluginData } from "../../plugins/vfile"
|
||||
import { ComponentChildren } from "preact"
|
||||
import { concatenateResources } from "../../util/resources"
|
||||
import { trieFromAllFiles } from "../../util/ctx"
|
||||
|
||||
interface FolderContentOptions {
|
||||
/**
|
||||
* Whether to display number of folders
|
||||
*/
|
||||
showFolderCount: boolean
|
||||
showSubfolders: boolean
|
||||
sort?: SortFn
|
||||
}
|
||||
|
||||
const defaultOptions: FolderContentOptions = {
|
||||
showFolderCount: true,
|
||||
showSubfolders: true,
|
||||
}
|
||||
|
||||
export default ((opts?: Partial<FolderContentOptions>) => {
|
||||
const options: FolderContentOptions = { ...defaultOptions, ...opts }
|
||||
|
||||
const FolderContent: QuartzComponent = (props: QuartzComponentProps) => {
|
||||
const { tree, fileData, allFiles, cfg } = props
|
||||
|
||||
const trie = (props.ctx.trie ??= trieFromAllFiles(allFiles))
|
||||
const folder = trie.findNode(fileData.slug!.split("/"))
|
||||
if (!folder) {
|
||||
return null
|
||||
}
|
||||
|
||||
const allPagesInFolder: QuartzPluginData[] =
|
||||
folder.children
|
||||
.map((node) => {
|
||||
// regular file, proceed
|
||||
if (node.data) {
|
||||
return node.data
|
||||
}
|
||||
|
||||
if (node.isFolder && options.showSubfolders) {
|
||||
// folders that dont have data need synthetic files
|
||||
const getMostRecentDates = (): QuartzPluginData["dates"] => {
|
||||
let maybeDates: QuartzPluginData["dates"] | undefined = undefined
|
||||
for (const child of node.children) {
|
||||
if (child.data?.dates) {
|
||||
// compare all dates and assign to maybeDates if its more recent or its not set
|
||||
if (!maybeDates) {
|
||||
maybeDates = { ...child.data.dates }
|
||||
} else {
|
||||
if (child.data.dates.created > maybeDates.created) {
|
||||
maybeDates.created = child.data.dates.created
|
||||
}
|
||||
|
||||
if (child.data.dates.modified > maybeDates.modified) {
|
||||
maybeDates.modified = child.data.dates.modified
|
||||
}
|
||||
|
||||
if (child.data.dates.published > maybeDates.published) {
|
||||
maybeDates.published = child.data.dates.published
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return (
|
||||
maybeDates ?? {
|
||||
created: new Date(),
|
||||
modified: new Date(),
|
||||
published: new Date(),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
slug: node.slug,
|
||||
dates: getMostRecentDates(),
|
||||
frontmatter: {
|
||||
title: node.displayName,
|
||||
tags: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
})
|
||||
.filter((page) => page !== undefined) ?? []
|
||||
const cssClasses: string[] = fileData.frontmatter?.cssclasses ?? []
|
||||
const classes = cssClasses.join(" ")
|
||||
const listProps = {
|
||||
...props,
|
||||
sort: options.sort,
|
||||
allFiles: allPagesInFolder,
|
||||
}
|
||||
|
||||
const content = (
|
||||
(tree as Root).children.length === 0
|
||||
? fileData.description
|
||||
: htmlToJsx(fileData.filePath!, tree)
|
||||
) as ComponentChildren
|
||||
|
||||
return (
|
||||
<div class="popover-hint">
|
||||
<article class={classes}>{content}</article>
|
||||
<div class="page-listing">
|
||||
{options.showFolderCount && (
|
||||
<p>
|
||||
{i18n(cfg.locale).pages.folderContent.itemsUnderFolder({
|
||||
count: allPagesInFolder.length,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<div>
|
||||
<PageList {...listProps} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
FolderContent.css = concatenateResources(style, PageList.css)
|
||||
return FolderContent
|
||||
}) satisfies QuartzComponentConstructor
|
||||
133
quartz/components/pages/TagContent.tsx
Normal file
133
quartz/components/pages/TagContent.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types"
|
||||
import style from "../styles/listPage.scss"
|
||||
import { PageList, SortFn } from "../PageList"
|
||||
import { FullSlug, getAllSegmentPrefixes, resolveRelative, simplifySlug } from "../../util/path"
|
||||
import { QuartzPluginData } from "../../plugins/vfile"
|
||||
import { Root } from "hast"
|
||||
import { htmlToJsx } from "../../util/jsx"
|
||||
import { i18n } from "../../i18n"
|
||||
import { ComponentChildren } from "preact"
|
||||
import { concatenateResources } from "../../util/resources"
|
||||
|
||||
interface TagContentOptions {
|
||||
sort?: SortFn
|
||||
numPages: number
|
||||
}
|
||||
|
||||
const defaultOptions: TagContentOptions = {
|
||||
numPages: 10,
|
||||
}
|
||||
|
||||
export default ((opts?: Partial<TagContentOptions>) => {
|
||||
const options: TagContentOptions = { ...defaultOptions, ...opts }
|
||||
|
||||
const TagContent: QuartzComponent = (props: QuartzComponentProps) => {
|
||||
const { tree, fileData, allFiles, cfg } = props
|
||||
const slug = fileData.slug
|
||||
|
||||
if (!(slug?.startsWith("tags/") || slug === "tags")) {
|
||||
throw new Error(`Component "TagContent" tried to render a non-tag page: ${slug}`)
|
||||
}
|
||||
|
||||
const tag = simplifySlug(slug.slice("tags/".length) as FullSlug)
|
||||
const allPagesWithTag = (tag: string) =>
|
||||
allFiles.filter((file) =>
|
||||
(file.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes).includes(tag),
|
||||
)
|
||||
|
||||
const content = (
|
||||
(tree as Root).children.length === 0
|
||||
? fileData.description
|
||||
: htmlToJsx(fileData.filePath!, tree)
|
||||
) as ComponentChildren
|
||||
const cssClasses: string[] = fileData.frontmatter?.cssclasses ?? []
|
||||
const classes = cssClasses.join(" ")
|
||||
if (tag === "/") {
|
||||
const tags = [
|
||||
...new Set(
|
||||
allFiles.flatMap((data) => data.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes),
|
||||
),
|
||||
].sort((a, b) => a.localeCompare(b))
|
||||
const tagItemMap: Map<string, QuartzPluginData[]> = new Map()
|
||||
for (const tag of tags) {
|
||||
tagItemMap.set(tag, allPagesWithTag(tag))
|
||||
}
|
||||
return (
|
||||
<div class="popover-hint">
|
||||
<article class={classes}>
|
||||
<p>{content}</p>
|
||||
</article>
|
||||
<p>{i18n(cfg.locale).pages.tagContent.totalTags({ count: tags.length })}</p>
|
||||
<div>
|
||||
{tags.map((tag) => {
|
||||
const pages = tagItemMap.get(tag)!
|
||||
const listProps = {
|
||||
...props,
|
||||
allFiles: pages,
|
||||
}
|
||||
|
||||
const contentPage = allFiles.filter((file) => file.slug === `tags/${tag}`).at(0)
|
||||
|
||||
const root = contentPage?.htmlAst
|
||||
const content =
|
||||
!root || root?.children.length === 0
|
||||
? contentPage?.description
|
||||
: htmlToJsx(contentPage.filePath!, root)
|
||||
|
||||
const tagListingPage = `/tags/${tag}` as FullSlug
|
||||
const href = resolveRelative(fileData.slug!, tagListingPage)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>
|
||||
<a class="internal tag-link" href={href}>
|
||||
{tag}
|
||||
</a>
|
||||
</h2>
|
||||
{content && <p>{content}</p>}
|
||||
<div class="page-listing">
|
||||
<p>
|
||||
{i18n(cfg.locale).pages.tagContent.itemsUnderTag({ count: pages.length })}
|
||||
{pages.length > options.numPages && (
|
||||
<>
|
||||
{" "}
|
||||
<span>
|
||||
{i18n(cfg.locale).pages.tagContent.showingFirst({
|
||||
count: options.numPages,
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<PageList limit={options.numPages} {...listProps} sort={options?.sort} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
const pages = allPagesWithTag(tag)
|
||||
const listProps = {
|
||||
...props,
|
||||
allFiles: pages,
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="popover-hint">
|
||||
<article class={classes}>{content}</article>
|
||||
<div class="page-listing">
|
||||
<p>{i18n(cfg.locale).pages.tagContent.itemsUnderTag({ count: pages.length })}</p>
|
||||
<div>
|
||||
<PageList {...listProps} sort={options?.sort} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
TagContent.css = concatenateResources(style, PageList.css)
|
||||
return TagContent
|
||||
}) satisfies QuartzComponentConstructor
|
||||
275
quartz/components/renderPage.tsx
Normal file
275
quartz/components/renderPage.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
import { render } from "preact-render-to-string"
|
||||
import { QuartzComponent, QuartzComponentProps } from "./types"
|
||||
import HeaderConstructor from "./Header"
|
||||
import BodyConstructor from "./Body"
|
||||
import { JSResourceToScriptElement, StaticResources } from "../util/resources"
|
||||
import { FullSlug, RelativeURL, joinSegments, normalizeHastElement } from "../util/path"
|
||||
import { clone } from "../util/clone"
|
||||
import { visit } from "unist-util-visit"
|
||||
import { Root, Element, ElementContent } from "hast"
|
||||
import { GlobalConfiguration } from "../cfg"
|
||||
import { i18n } from "../i18n"
|
||||
|
||||
interface RenderComponents {
|
||||
head: QuartzComponent
|
||||
header: QuartzComponent[]
|
||||
beforeBody: QuartzComponent[]
|
||||
pageBody: QuartzComponent
|
||||
afterBody: QuartzComponent[]
|
||||
left: QuartzComponent[]
|
||||
right: QuartzComponent[]
|
||||
footer: QuartzComponent
|
||||
}
|
||||
|
||||
const headerRegex = new RegExp(/h[1-6]/)
|
||||
export function pageResources(
|
||||
baseDir: FullSlug | RelativeURL,
|
||||
staticResources: StaticResources,
|
||||
): StaticResources {
|
||||
const contentIndexPath = joinSegments(baseDir, "static/contentIndex.json")
|
||||
const contentIndexScript = `const fetchData = fetch("${contentIndexPath}").then(data => data.json())`
|
||||
|
||||
const resources: StaticResources = {
|
||||
css: [
|
||||
{
|
||||
content: joinSegments(baseDir, "index.css"),
|
||||
},
|
||||
...staticResources.css,
|
||||
],
|
||||
js: [
|
||||
{
|
||||
src: joinSegments(baseDir, "prescript.js"),
|
||||
loadTime: "beforeDOMReady",
|
||||
contentType: "external",
|
||||
},
|
||||
{
|
||||
loadTime: "beforeDOMReady",
|
||||
contentType: "inline",
|
||||
spaPreserve: true,
|
||||
script: contentIndexScript,
|
||||
},
|
||||
...staticResources.js,
|
||||
],
|
||||
additionalHead: staticResources.additionalHead,
|
||||
}
|
||||
|
||||
resources.js.push({
|
||||
src: joinSegments(baseDir, "postscript.js"),
|
||||
loadTime: "afterDOMReady",
|
||||
moduleType: "module",
|
||||
contentType: "external",
|
||||
})
|
||||
|
||||
return resources
|
||||
}
|
||||
|
||||
function renderTranscludes(
|
||||
root: Root,
|
||||
cfg: GlobalConfiguration,
|
||||
slug: FullSlug,
|
||||
componentData: QuartzComponentProps,
|
||||
) {
|
||||
// process transcludes in componentData
|
||||
visit(root, "element", (node, _index, _parent) => {
|
||||
if (node.tagName === "blockquote") {
|
||||
const classNames = (node.properties?.className ?? []) as string[]
|
||||
if (classNames.includes("transclude")) {
|
||||
const inner = node.children[0] as Element
|
||||
const transcludeTarget = (inner.properties["data-slug"] ?? slug) as FullSlug
|
||||
const page = componentData.allFiles.find((f) => f.slug === transcludeTarget)
|
||||
if (!page) {
|
||||
return
|
||||
}
|
||||
|
||||
let blockRef = node.properties.dataBlock as string | undefined
|
||||
if (blockRef?.startsWith("#^")) {
|
||||
// block transclude
|
||||
blockRef = blockRef.slice("#^".length)
|
||||
let blockNode = page.blocks?.[blockRef]
|
||||
if (blockNode) {
|
||||
if (blockNode.tagName === "li") {
|
||||
blockNode = {
|
||||
type: "element",
|
||||
tagName: "ul",
|
||||
properties: {},
|
||||
children: [blockNode],
|
||||
}
|
||||
}
|
||||
|
||||
node.children = [
|
||||
normalizeHastElement(blockNode, slug, transcludeTarget),
|
||||
{
|
||||
type: "element",
|
||||
tagName: "a",
|
||||
properties: { href: inner.properties?.href, class: ["internal", "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, el] of page.htmlAst.children.entries()) {
|
||||
// skip non-headers
|
||||
if (!(el.type === "element" && el.tagName.match(headerRegex))) continue
|
||||
const depth = Number(el.tagName.substring(1))
|
||||
|
||||
// lookin for our blockref
|
||||
if (startIdx === undefined || startDepth === undefined) {
|
||||
// skip until we find the blockref that matches
|
||||
if (el.properties?.id === blockRef) {
|
||||
startIdx = i
|
||||
startDepth = depth
|
||||
}
|
||||
} else if (depth <= startDepth) {
|
||||
// looking for new header that is same level or higher
|
||||
endIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (startIdx === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
node.children = [
|
||||
...(page.htmlAst.children.slice(startIdx, endIdx) as ElementContent[]).map((child) =>
|
||||
normalizeHastElement(child as Element, slug, transcludeTarget),
|
||||
),
|
||||
{
|
||||
type: "element",
|
||||
tagName: "a",
|
||||
properties: { href: inner.properties?.href, class: ["internal", "transclude-src"] },
|
||||
children: [
|
||||
{ type: "text", value: i18n(cfg.locale).components.transcludes.linkToOriginal },
|
||||
],
|
||||
},
|
||||
]
|
||||
} else if (page.htmlAst) {
|
||||
// page transclude
|
||||
node.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((child) =>
|
||||
normalizeHastElement(child as Element, slug, transcludeTarget),
|
||||
),
|
||||
{
|
||||
type: "element",
|
||||
tagName: "a",
|
||||
properties: { href: inner.properties?.href, class: ["internal", "transclude-src"] },
|
||||
children: [
|
||||
{ type: "text", value: i18n(cfg.locale).components.transcludes.linkToOriginal },
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function renderPage(
|
||||
cfg: GlobalConfiguration,
|
||||
slug: FullSlug,
|
||||
componentData: QuartzComponentProps,
|
||||
components: RenderComponents,
|
||||
pageResources: StaticResources,
|
||||
): 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
|
||||
renderTranscludes(root, cfg, 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,
|
||||
} = components
|
||||
const Header = HeaderConstructor()
|
||||
const Body = BodyConstructor()
|
||||
|
||||
const LeftComponent = (
|
||||
<div class="left sidebar">
|
||||
{left.map((BodyComponent) => (
|
||||
<BodyComponent {...componentData} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
const RightComponent = (
|
||||
<div class="right sidebar">
|
||||
{right.map((BodyComponent) => (
|
||||
<BodyComponent {...componentData} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
const lang = componentData.fileData.frontmatter?.lang ?? cfg.locale?.split("-")[0] ?? "en"
|
||||
const direction = i18n(cfg.locale).direction ?? "ltr"
|
||||
const doc = (
|
||||
<html lang={lang} dir={direction}>
|
||||
<Head {...componentData} />
|
||||
<body data-slug={slug}>
|
||||
<div id="quartz-root" class="page">
|
||||
<Body {...componentData}>
|
||||
{LeftComponent}
|
||||
<div class="center">
|
||||
<div class="page-header">
|
||||
<Header {...componentData}>
|
||||
{header.map((HeaderComponent) => (
|
||||
<HeaderComponent {...componentData} />
|
||||
))}
|
||||
</Header>
|
||||
<div class="popover-hint">
|
||||
{beforeBody.map((BodyComponent) => (
|
||||
<BodyComponent {...componentData} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Content {...componentData} />
|
||||
<hr />
|
||||
<div class="page-footer">
|
||||
{afterBody.map((BodyComponent) => (
|
||||
<BodyComponent {...componentData} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{RightComponent}
|
||||
<Footer {...componentData} />
|
||||
</Body>
|
||||
</div>
|
||||
</body>
|
||||
{pageResources.js
|
||||
.filter((resource) => resource.loadTime === "afterDOMReady")
|
||||
.map((res) => JSResourceToScriptElement(res))}
|
||||
</html>
|
||||
)
|
||||
|
||||
return "<!DOCTYPE html>\n" + render(doc)
|
||||
}
|
||||
27
quartz/components/scripts/callout.inline.ts
Normal file
27
quartz/components/scripts/callout.inline.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
function toggleCallout(this: HTMLElement) {
|
||||
const outerBlock = this.parentElement!
|
||||
outerBlock.classList.toggle("is-collapsed")
|
||||
const content = outerBlock.getElementsByClassName("callout-content")[0] as HTMLElement
|
||||
if (!content) return
|
||||
const collapsed = outerBlock.classList.contains("is-collapsed")
|
||||
content.style.gridTemplateRows = collapsed ? "0fr" : "1fr"
|
||||
}
|
||||
|
||||
function setupCallout() {
|
||||
const collapsible = document.getElementsByClassName(
|
||||
`callout is-collapsible`,
|
||||
) as HTMLCollectionOf<HTMLElement>
|
||||
for (const div of collapsible) {
|
||||
const title = div.getElementsByClassName("callout-title")[0] as HTMLElement
|
||||
const content = div.getElementsByClassName("callout-content")[0] as HTMLElement
|
||||
if (!title || !content) continue
|
||||
|
||||
title.addEventListener("click", toggleCallout)
|
||||
window.addCleanup(() => title.removeEventListener("click", toggleCallout))
|
||||
|
||||
const collapsed = div.classList.contains("is-collapsed")
|
||||
content.style.gridTemplateRows = collapsed ? "0fr" : "1fr"
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("nav", setupCallout)
|
||||
23
quartz/components/scripts/checkbox.inline.ts
Normal file
23
quartz/components/scripts/checkbox.inline.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { getFullSlug } from "../../util/path"
|
||||
|
||||
const checkboxId = (index: number) => `${getFullSlug(window)}-checkbox-${index}`
|
||||
|
||||
document.addEventListener("nav", () => {
|
||||
const checkboxes = document.querySelectorAll(
|
||||
"input.checkbox-toggle",
|
||||
) as NodeListOf<HTMLInputElement>
|
||||
checkboxes.forEach((el, index) => {
|
||||
const elId = checkboxId(index)
|
||||
|
||||
const switchState = (e: Event) => {
|
||||
const newCheckboxState = (e.target as HTMLInputElement)?.checked ? "true" : "false"
|
||||
localStorage.setItem(elId, newCheckboxState)
|
||||
}
|
||||
|
||||
el.addEventListener("change", switchState)
|
||||
window.addCleanup(() => el.removeEventListener("change", switchState))
|
||||
if (localStorage.getItem(elId) === "true") {
|
||||
el.checked = true
|
||||
}
|
||||
})
|
||||
})
|
||||
37
quartz/components/scripts/clipboard.inline.ts
Normal file
37
quartz/components/scripts/clipboard.inline.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
const svgCopy =
|
||||
'<svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true"><path fill-rule="evenodd" d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 010 1.5h-1.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 019.25 16h-7.5A1.75 1.75 0 010 14.25v-7.5z"></path><path fill-rule="evenodd" d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0114.25 11h-7.5A1.75 1.75 0 015 9.25v-7.5zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25h-7.5z"></path></svg>'
|
||||
const svgCheck =
|
||||
'<svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true"><path fill-rule="evenodd" fill="rgb(63, 185, 80)" d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"></path></svg>'
|
||||
|
||||
document.addEventListener("nav", () => {
|
||||
const els = document.getElementsByTagName("pre")
|
||||
for (let i = 0; i < els.length; i++) {
|
||||
const codeBlock = els[i].getElementsByTagName("code")[0]
|
||||
if (codeBlock) {
|
||||
const source = (
|
||||
codeBlock.dataset.clipboard ? JSON.parse(codeBlock.dataset.clipboard) : codeBlock.innerText
|
||||
).replace(/\n\n/g, "\n")
|
||||
const button = document.createElement("button")
|
||||
button.className = "clipboard-button"
|
||||
button.type = "button"
|
||||
button.innerHTML = svgCopy
|
||||
button.ariaLabel = "Copy source"
|
||||
function onClick() {
|
||||
navigator.clipboard.writeText(source).then(
|
||||
() => {
|
||||
button.blur()
|
||||
button.innerHTML = svgCheck
|
||||
setTimeout(() => {
|
||||
button.innerHTML = svgCopy
|
||||
button.style.borderColor = ""
|
||||
}, 2000)
|
||||
},
|
||||
(error) => console.error(error),
|
||||
)
|
||||
}
|
||||
button.addEventListener("click", onClick)
|
||||
window.addCleanup(() => button.removeEventListener("click", onClick))
|
||||
els[i].prepend(button)
|
||||
}
|
||||
}
|
||||
})
|
||||
92
quartz/components/scripts/comments.inline.ts
Normal file
92
quartz/components/scripts/comments.inline.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
const changeTheme = (e: CustomEventMap["themechange"]) => {
|
||||
const theme = e.detail.theme
|
||||
const iframe = document.querySelector("iframe.giscus-frame") as HTMLIFrameElement
|
||||
if (!iframe) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!iframe.contentWindow) {
|
||||
return
|
||||
}
|
||||
|
||||
iframe.contentWindow.postMessage(
|
||||
{
|
||||
giscus: {
|
||||
setConfig: {
|
||||
theme: getThemeUrl(getThemeName(theme)),
|
||||
},
|
||||
},
|
||||
},
|
||||
"https://giscus.app",
|
||||
)
|
||||
}
|
||||
|
||||
const getThemeName = (theme: string) => {
|
||||
if (theme !== "dark" && theme !== "light") {
|
||||
return theme
|
||||
}
|
||||
const giscusContainer = document.querySelector(".giscus") as GiscusElement
|
||||
if (!giscusContainer) {
|
||||
return theme
|
||||
}
|
||||
const darkGiscus = giscusContainer.dataset.darkTheme ?? "dark"
|
||||
const lightGiscus = giscusContainer.dataset.lightTheme ?? "light"
|
||||
return theme === "dark" ? darkGiscus : lightGiscus
|
||||
}
|
||||
|
||||
const getThemeUrl = (theme: string) => {
|
||||
const giscusContainer = document.querySelector(".giscus") as GiscusElement
|
||||
if (!giscusContainer) {
|
||||
return `https://giscus.app/themes/${theme}.css`
|
||||
}
|
||||
return `${giscusContainer.dataset.themeUrl ?? "https://giscus.app/themes"}/${theme}.css`
|
||||
}
|
||||
|
||||
type GiscusElement = Omit<HTMLElement, "dataset"> & {
|
||||
dataset: DOMStringMap & {
|
||||
repo: `${string}/${string}`
|
||||
repoId: string
|
||||
category: string
|
||||
categoryId: string
|
||||
themeUrl: string
|
||||
lightTheme: string
|
||||
darkTheme: string
|
||||
mapping: "url" | "title" | "og:title" | "specific" | "number" | "pathname"
|
||||
strict: string
|
||||
reactionsEnabled: string
|
||||
inputPosition: "top" | "bottom"
|
||||
lang: string
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("nav", () => {
|
||||
const giscusContainer = document.querySelector(".giscus") as GiscusElement
|
||||
if (!giscusContainer) {
|
||||
return
|
||||
}
|
||||
|
||||
const giscusScript = document.createElement("script")
|
||||
giscusScript.src = "https://giscus.app/client.js"
|
||||
giscusScript.async = true
|
||||
giscusScript.crossOrigin = "anonymous"
|
||||
giscusScript.setAttribute("data-loading", "lazy")
|
||||
giscusScript.setAttribute("data-emit-metadata", "0")
|
||||
giscusScript.setAttribute("data-repo", giscusContainer.dataset.repo)
|
||||
giscusScript.setAttribute("data-repo-id", giscusContainer.dataset.repoId)
|
||||
giscusScript.setAttribute("data-category", giscusContainer.dataset.category)
|
||||
giscusScript.setAttribute("data-category-id", giscusContainer.dataset.categoryId)
|
||||
giscusScript.setAttribute("data-mapping", giscusContainer.dataset.mapping)
|
||||
giscusScript.setAttribute("data-strict", giscusContainer.dataset.strict)
|
||||
giscusScript.setAttribute("data-reactions-enabled", giscusContainer.dataset.reactionsEnabled)
|
||||
giscusScript.setAttribute("data-input-position", giscusContainer.dataset.inputPosition)
|
||||
giscusScript.setAttribute("data-lang", giscusContainer.dataset.lang)
|
||||
const theme = document.documentElement.getAttribute("saved-theme")
|
||||
if (theme) {
|
||||
giscusScript.setAttribute("data-theme", getThemeUrl(getThemeName(theme)))
|
||||
}
|
||||
|
||||
giscusContainer.appendChild(giscusScript)
|
||||
|
||||
document.addEventListener("themechange", changeTheme)
|
||||
window.addCleanup(() => document.removeEventListener("themechange", changeTheme))
|
||||
})
|
||||
37
quartz/components/scripts/darkmode.inline.ts
Normal file
37
quartz/components/scripts/darkmode.inline.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
const userPref = window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark"
|
||||
const currentTheme = localStorage.getItem("theme") ?? userPref
|
||||
document.documentElement.setAttribute("saved-theme", currentTheme)
|
||||
|
||||
const emitThemeChangeEvent = (theme: "light" | "dark") => {
|
||||
const event: CustomEventMap["themechange"] = new CustomEvent("themechange", {
|
||||
detail: { theme },
|
||||
})
|
||||
document.dispatchEvent(event)
|
||||
}
|
||||
|
||||
document.addEventListener("nav", () => {
|
||||
const switchTheme = () => {
|
||||
const newTheme =
|
||||
document.documentElement.getAttribute("saved-theme") === "dark" ? "light" : "dark"
|
||||
document.documentElement.setAttribute("saved-theme", newTheme)
|
||||
localStorage.setItem("theme", newTheme)
|
||||
emitThemeChangeEvent(newTheme)
|
||||
}
|
||||
|
||||
const themeChange = (e: MediaQueryListEvent) => {
|
||||
const newTheme = e.matches ? "dark" : "light"
|
||||
document.documentElement.setAttribute("saved-theme", newTheme)
|
||||
localStorage.setItem("theme", newTheme)
|
||||
emitThemeChangeEvent(newTheme)
|
||||
}
|
||||
|
||||
for (const darkmodeButton of document.getElementsByClassName("darkmode")) {
|
||||
darkmodeButton.addEventListener("click", switchTheme)
|
||||
window.addCleanup(() => darkmodeButton.removeEventListener("click", switchTheme))
|
||||
}
|
||||
|
||||
// Listen for changes in prefers-color-scheme
|
||||
const colorSchemeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
colorSchemeMediaQuery.addEventListener("change", themeChange)
|
||||
window.addCleanup(() => colorSchemeMediaQuery.removeEventListener("change", themeChange))
|
||||
})
|
||||
301
quartz/components/scripts/explorer.inline.ts
Normal file
301
quartz/components/scripts/explorer.inline.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
import { FileTrieNode } from "../../util/fileTrie"
|
||||
import { FullSlug, resolveRelative, simplifySlug } from "../../util/path"
|
||||
import { ContentDetails } from "../../plugins/emitters/contentIndex"
|
||||
|
||||
type MaybeHTMLElement = HTMLElement | undefined
|
||||
|
||||
interface ParsedOptions {
|
||||
folderClickBehavior: "collapse" | "link"
|
||||
folderDefaultState: "collapsed" | "open"
|
||||
useSavedState: boolean
|
||||
sortFn: (a: FileTrieNode, b: FileTrieNode) => number
|
||||
filterFn: (node: FileTrieNode) => boolean
|
||||
mapFn: (node: FileTrieNode) => void
|
||||
order: "sort" | "filter" | "map"[]
|
||||
}
|
||||
|
||||
type FolderState = {
|
||||
path: string
|
||||
collapsed: boolean
|
||||
}
|
||||
|
||||
let currentExplorerState: Array<FolderState>
|
||||
function toggleExplorer(this: HTMLElement) {
|
||||
const nearestExplorer = this.closest(".explorer") as HTMLElement
|
||||
if (!nearestExplorer) return
|
||||
const explorerCollapsed = nearestExplorer.classList.toggle("collapsed")
|
||||
nearestExplorer.setAttribute(
|
||||
"aria-expanded",
|
||||
nearestExplorer.getAttribute("aria-expanded") === "true" ? "false" : "true",
|
||||
)
|
||||
|
||||
if (!explorerCollapsed) {
|
||||
// Stop <html> from being scrollable when mobile explorer is open
|
||||
document.documentElement.classList.add("mobile-no-scroll")
|
||||
} else {
|
||||
document.documentElement.classList.remove("mobile-no-scroll")
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFolder(evt: MouseEvent) {
|
||||
evt.stopPropagation()
|
||||
const target = evt.target as MaybeHTMLElement
|
||||
if (!target) return
|
||||
|
||||
// Check if target was svg icon or button
|
||||
const isSvg = target.nodeName === "svg"
|
||||
|
||||
// corresponding <ul> element relative to clicked button/folder
|
||||
const folderContainer = (
|
||||
isSvg
|
||||
? // svg -> div.folder-container
|
||||
target.parentElement
|
||||
: // button.folder-button -> div -> div.folder-container
|
||||
target.parentElement?.parentElement
|
||||
) as MaybeHTMLElement
|
||||
if (!folderContainer) return
|
||||
const childFolderContainer = folderContainer.nextElementSibling as MaybeHTMLElement
|
||||
if (!childFolderContainer) return
|
||||
|
||||
childFolderContainer.classList.toggle("open")
|
||||
|
||||
// Collapse folder container
|
||||
const isCollapsed = !childFolderContainer.classList.contains("open")
|
||||
setFolderState(childFolderContainer, isCollapsed)
|
||||
|
||||
const currentFolderState = currentExplorerState.find(
|
||||
(item) => item.path === folderContainer.dataset.folderpath,
|
||||
)
|
||||
if (currentFolderState) {
|
||||
currentFolderState.collapsed = isCollapsed
|
||||
} else {
|
||||
currentExplorerState.push({
|
||||
path: folderContainer.dataset.folderpath as FullSlug,
|
||||
collapsed: isCollapsed,
|
||||
})
|
||||
}
|
||||
|
||||
const stringifiedFileTree = JSON.stringify(currentExplorerState)
|
||||
localStorage.setItem("fileTree", stringifiedFileTree)
|
||||
}
|
||||
|
||||
function createFileNode(currentSlug: FullSlug, node: FileTrieNode): HTMLLIElement {
|
||||
const template = document.getElementById("template-file") as HTMLTemplateElement
|
||||
const clone = template.content.cloneNode(true) as DocumentFragment
|
||||
const li = clone.querySelector("li") as HTMLLIElement
|
||||
const a = li.querySelector("a") as HTMLAnchorElement
|
||||
a.href = resolveRelative(currentSlug, node.slug)
|
||||
a.dataset.for = node.slug
|
||||
a.textContent = node.displayName
|
||||
|
||||
if (currentSlug === node.slug) {
|
||||
a.classList.add("active")
|
||||
}
|
||||
|
||||
return li
|
||||
}
|
||||
|
||||
function createFolderNode(
|
||||
currentSlug: FullSlug,
|
||||
node: FileTrieNode,
|
||||
opts: ParsedOptions,
|
||||
): HTMLLIElement {
|
||||
const template = document.getElementById("template-folder") as HTMLTemplateElement
|
||||
const clone = template.content.cloneNode(true) as DocumentFragment
|
||||
const li = clone.querySelector("li") as HTMLLIElement
|
||||
const folderContainer = li.querySelector(".folder-container") as HTMLElement
|
||||
const titleContainer = folderContainer.querySelector("div") as HTMLElement
|
||||
const folderOuter = li.querySelector(".folder-outer") as HTMLElement
|
||||
const ul = folderOuter.querySelector("ul") as HTMLUListElement
|
||||
|
||||
const folderPath = node.slug
|
||||
folderContainer.dataset.folderpath = folderPath
|
||||
|
||||
if (opts.folderClickBehavior === "link") {
|
||||
// Replace button with link for link behavior
|
||||
const button = titleContainer.querySelector(".folder-button") as HTMLElement
|
||||
const a = document.createElement("a")
|
||||
a.href = resolveRelative(currentSlug, folderPath)
|
||||
a.dataset.for = folderPath
|
||||
a.className = "folder-title"
|
||||
a.textContent = node.displayName
|
||||
button.replaceWith(a)
|
||||
} else {
|
||||
const span = titleContainer.querySelector(".folder-title") as HTMLElement
|
||||
span.textContent = node.displayName
|
||||
}
|
||||
|
||||
// if the saved state is collapsed or the default state is collapsed
|
||||
const isCollapsed =
|
||||
currentExplorerState.find((item) => item.path === folderPath)?.collapsed ??
|
||||
opts.folderDefaultState === "collapsed"
|
||||
|
||||
// if this folder is a prefix of the current path we
|
||||
// want to open it anyways
|
||||
const simpleFolderPath = simplifySlug(folderPath)
|
||||
const folderIsPrefixOfCurrentSlug =
|
||||
simpleFolderPath === currentSlug.slice(0, simpleFolderPath.length)
|
||||
|
||||
if (!isCollapsed || folderIsPrefixOfCurrentSlug) {
|
||||
folderOuter.classList.add("open")
|
||||
}
|
||||
|
||||
for (const child of node.children) {
|
||||
const childNode = child.isFolder
|
||||
? createFolderNode(currentSlug, child, opts)
|
||||
: createFileNode(currentSlug, child)
|
||||
ul.appendChild(childNode)
|
||||
}
|
||||
|
||||
return li
|
||||
}
|
||||
|
||||
async function setupExplorer(currentSlug: FullSlug) {
|
||||
const allExplorers = document.querySelectorAll("div.explorer") as NodeListOf<HTMLElement>
|
||||
|
||||
for (const explorer of allExplorers) {
|
||||
const dataFns = JSON.parse(explorer.dataset.dataFns || "{}")
|
||||
const opts: ParsedOptions = {
|
||||
folderClickBehavior: (explorer.dataset.behavior || "collapse") as "collapse" | "link",
|
||||
folderDefaultState: (explorer.dataset.collapsed || "collapsed") as "collapsed" | "open",
|
||||
useSavedState: explorer.dataset.savestate === "true",
|
||||
order: dataFns.order || ["filter", "map", "sort"],
|
||||
sortFn: new Function("return " + (dataFns.sortFn || "undefined"))(),
|
||||
filterFn: new Function("return " + (dataFns.filterFn || "undefined"))(),
|
||||
mapFn: new Function("return " + (dataFns.mapFn || "undefined"))(),
|
||||
}
|
||||
|
||||
// Get folder state from local storage
|
||||
const storageTree = localStorage.getItem("fileTree")
|
||||
const serializedExplorerState = storageTree && opts.useSavedState ? JSON.parse(storageTree) : []
|
||||
const oldIndex = new Map<string, boolean>(
|
||||
serializedExplorerState.map((entry: FolderState) => [entry.path, entry.collapsed]),
|
||||
)
|
||||
|
||||
const data = await fetchData
|
||||
const entries = [...Object.entries(data)] as [FullSlug, ContentDetails][]
|
||||
const trie = FileTrieNode.fromEntries(entries)
|
||||
|
||||
// Apply functions in order
|
||||
for (const fn of opts.order) {
|
||||
switch (fn) {
|
||||
case "filter":
|
||||
if (opts.filterFn) trie.filter(opts.filterFn)
|
||||
break
|
||||
case "map":
|
||||
if (opts.mapFn) trie.map(opts.mapFn)
|
||||
break
|
||||
case "sort":
|
||||
if (opts.sortFn) trie.sort(opts.sortFn)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Get folder paths for state management
|
||||
const folderPaths = trie.getFolderPaths()
|
||||
currentExplorerState = folderPaths.map((path) => {
|
||||
const previousState = oldIndex.get(path)
|
||||
return {
|
||||
path,
|
||||
collapsed:
|
||||
previousState === undefined ? opts.folderDefaultState === "collapsed" : previousState,
|
||||
}
|
||||
})
|
||||
|
||||
const explorerUl = explorer.querySelector(".explorer-ul")
|
||||
if (!explorerUl) continue
|
||||
|
||||
// Create and insert new content
|
||||
const fragment = document.createDocumentFragment()
|
||||
for (const child of trie.children) {
|
||||
const node = child.isFolder
|
||||
? createFolderNode(currentSlug, child, opts)
|
||||
: createFileNode(currentSlug, child)
|
||||
|
||||
fragment.appendChild(node)
|
||||
}
|
||||
explorerUl.insertBefore(fragment, explorerUl.firstChild)
|
||||
|
||||
// restore explorer scrollTop position if it exists
|
||||
const scrollTop = sessionStorage.getItem("explorerScrollTop")
|
||||
if (scrollTop) {
|
||||
explorerUl.scrollTop = parseInt(scrollTop)
|
||||
} else {
|
||||
// try to scroll to the active element if it exists
|
||||
const activeElement = explorerUl.querySelector(".active")
|
||||
if (activeElement) {
|
||||
activeElement.scrollIntoView({ behavior: "smooth" })
|
||||
}
|
||||
}
|
||||
|
||||
// Set up event handlers
|
||||
const explorerButtons = explorer.getElementsByClassName(
|
||||
"explorer-toggle",
|
||||
) as HTMLCollectionOf<HTMLElement>
|
||||
for (const button of explorerButtons) {
|
||||
button.addEventListener("click", toggleExplorer)
|
||||
window.addCleanup(() => button.removeEventListener("click", toggleExplorer))
|
||||
}
|
||||
|
||||
// Set up folder click handlers
|
||||
if (opts.folderClickBehavior === "collapse") {
|
||||
const folderButtons = explorer.getElementsByClassName(
|
||||
"folder-button",
|
||||
) as HTMLCollectionOf<HTMLElement>
|
||||
for (const button of folderButtons) {
|
||||
button.addEventListener("click", toggleFolder)
|
||||
window.addCleanup(() => button.removeEventListener("click", toggleFolder))
|
||||
}
|
||||
}
|
||||
|
||||
const folderIcons = explorer.getElementsByClassName(
|
||||
"folder-icon",
|
||||
) as HTMLCollectionOf<HTMLElement>
|
||||
for (const icon of folderIcons) {
|
||||
icon.addEventListener("click", toggleFolder)
|
||||
window.addCleanup(() => icon.removeEventListener("click", toggleFolder))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("prenav", async () => {
|
||||
// save explorer scrollTop position
|
||||
const explorer = document.querySelector(".explorer-ul")
|
||||
if (!explorer) return
|
||||
sessionStorage.setItem("explorerScrollTop", explorer.scrollTop.toString())
|
||||
})
|
||||
|
||||
document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
|
||||
const currentSlug = e.detail.url
|
||||
await setupExplorer(currentSlug)
|
||||
|
||||
// if mobile hamburger is visible, collapse by default
|
||||
for (const explorer of document.getElementsByClassName("explorer")) {
|
||||
const mobileExplorer = explorer.querySelector(".mobile-explorer")
|
||||
if (!mobileExplorer) return
|
||||
|
||||
if (mobileExplorer.checkVisibility()) {
|
||||
explorer.classList.add("collapsed")
|
||||
explorer.setAttribute("aria-expanded", "false")
|
||||
|
||||
// Allow <html> to be scrollable when mobile explorer is collapsed
|
||||
document.documentElement.classList.remove("mobile-no-scroll")
|
||||
}
|
||||
|
||||
mobileExplorer.classList.remove("hide-until-loaded")
|
||||
}
|
||||
})
|
||||
|
||||
window.addEventListener("resize", function () {
|
||||
// Desktop explorer opens by default, and it stays open when the window is resized
|
||||
// to mobile screen size. Applies `no-scroll` to <html> in this edge case.
|
||||
const explorer = document.querySelector(".explorer")
|
||||
if (explorer && !explorer.classList.contains("collapsed")) {
|
||||
document.documentElement.classList.add("mobile-no-scroll")
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
function setFolderState(folderElement: HTMLElement, collapsed: boolean) {
|
||||
return collapsed ? folderElement.classList.remove("open") : folderElement.classList.add("open")
|
||||
}
|
||||
649
quartz/components/scripts/graph.inline.ts
Normal file
649
quartz/components/scripts/graph.inline.ts
Normal file
@@ -0,0 +1,649 @@
|
||||
import type { ContentDetails } from "../../plugins/emitters/contentIndex"
|
||||
import {
|
||||
SimulationNodeDatum,
|
||||
SimulationLinkDatum,
|
||||
Simulation,
|
||||
forceSimulation,
|
||||
forceManyBody,
|
||||
forceCenter,
|
||||
forceLink,
|
||||
forceCollide,
|
||||
forceRadial,
|
||||
zoomIdentity,
|
||||
select,
|
||||
drag,
|
||||
zoom,
|
||||
} from "d3"
|
||||
import { Text, Graphics, Application, Container, Circle } from "pixi.js"
|
||||
import { Group as TweenGroup, Tween as Tweened } from "@tweenjs/tween.js"
|
||||
import { registerEscapeHandler, removeAllChildren } from "./util"
|
||||
import { FullSlug, SimpleSlug, getFullSlug, resolveRelative, simplifySlug } from "../../util/path"
|
||||
import { D3Config } from "../Graph"
|
||||
|
||||
type GraphicsInfo = {
|
||||
color: string
|
||||
gfx: Graphics
|
||||
alpha: number
|
||||
active: boolean
|
||||
}
|
||||
|
||||
type NodeData = {
|
||||
id: SimpleSlug
|
||||
text: string
|
||||
tags: string[]
|
||||
} & SimulationNodeDatum
|
||||
|
||||
type SimpleLinkData = {
|
||||
source: SimpleSlug
|
||||
target: SimpleSlug
|
||||
}
|
||||
|
||||
type LinkData = {
|
||||
source: NodeData
|
||||
target: NodeData
|
||||
} & SimulationLinkDatum<NodeData>
|
||||
|
||||
type LinkRenderData = GraphicsInfo & {
|
||||
simulationData: LinkData
|
||||
}
|
||||
|
||||
type NodeRenderData = GraphicsInfo & {
|
||||
simulationData: NodeData
|
||||
label: Text
|
||||
}
|
||||
|
||||
const localStorageKey = "graph-visited"
|
||||
function getVisited(): Set<SimpleSlug> {
|
||||
return new Set(JSON.parse(localStorage.getItem(localStorageKey) ?? "[]"))
|
||||
}
|
||||
|
||||
function addToVisited(slug: SimpleSlug) {
|
||||
const visited = getVisited()
|
||||
visited.add(slug)
|
||||
localStorage.setItem(localStorageKey, JSON.stringify([...visited]))
|
||||
}
|
||||
|
||||
type TweenNode = {
|
||||
update: (time: number) => void
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
async function renderGraph(graph: HTMLElement, fullSlug: FullSlug) {
|
||||
const slug = simplifySlug(fullSlug)
|
||||
const visited = getVisited()
|
||||
removeAllChildren(graph)
|
||||
|
||||
let {
|
||||
drag: enableDrag,
|
||||
zoom: enableZoom,
|
||||
depth,
|
||||
scale,
|
||||
repelForce,
|
||||
centerForce,
|
||||
linkDistance,
|
||||
fontSize,
|
||||
opacityScale,
|
||||
removeTags,
|
||||
showTags,
|
||||
focusOnHover,
|
||||
enableRadial,
|
||||
} = JSON.parse(graph.dataset["cfg"]!) as D3Config
|
||||
|
||||
const data: Map<SimpleSlug, ContentDetails> = new Map(
|
||||
Object.entries<ContentDetails>(await fetchData).map(([k, v]) => [
|
||||
simplifySlug(k as FullSlug),
|
||||
v,
|
||||
]),
|
||||
)
|
||||
const links: SimpleLinkData[] = []
|
||||
const tags: SimpleSlug[] = []
|
||||
const validLinks = new Set(data.keys())
|
||||
|
||||
const tweens = new Map<string, TweenNode>()
|
||||
for (const [source, details] of data.entries()) {
|
||||
const outgoing = details.links ?? []
|
||||
|
||||
for (const dest of outgoing) {
|
||||
if (validLinks.has(dest)) {
|
||||
links.push({ source: source, target: dest })
|
||||
}
|
||||
}
|
||||
|
||||
if (showTags) {
|
||||
const localTags = details.tags
|
||||
.filter((tag) => !removeTags.includes(tag))
|
||||
.map((tag) => simplifySlug(("tags/" + tag) as FullSlug))
|
||||
|
||||
tags.push(...localTags.filter((tag) => !tags.includes(tag)))
|
||||
|
||||
for (const tag of localTags) {
|
||||
links.push({ source: source, target: tag })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const neighbourhood = new Set<SimpleSlug>()
|
||||
const wl: (SimpleSlug | "__SENTINEL")[] = [slug, "__SENTINEL"]
|
||||
if (depth >= 0) {
|
||||
while (depth >= 0 && wl.length > 0) {
|
||||
// compute neighbours
|
||||
const cur = wl.shift()!
|
||||
if (cur === "__SENTINEL") {
|
||||
depth--
|
||||
wl.push("__SENTINEL")
|
||||
} else {
|
||||
neighbourhood.add(cur)
|
||||
const outgoing = links.filter((l) => l.source === cur)
|
||||
const incoming = links.filter((l) => l.target === cur)
|
||||
wl.push(...outgoing.map((l) => l.target), ...incoming.map((l) => l.source))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
validLinks.forEach((id) => neighbourhood.add(id))
|
||||
if (showTags) tags.forEach((tag) => neighbourhood.add(tag))
|
||||
}
|
||||
|
||||
const nodes = [...neighbourhood].map((url) => {
|
||||
const text = url.startsWith("tags/") ? "#" + url.substring(5) : (data.get(url)?.title ?? url)
|
||||
return {
|
||||
id: url,
|
||||
text,
|
||||
tags: data.get(url)?.tags ?? [],
|
||||
}
|
||||
})
|
||||
const graphData: { nodes: NodeData[]; links: LinkData[] } = {
|
||||
nodes,
|
||||
links: links
|
||||
.filter((l) => neighbourhood.has(l.source) && neighbourhood.has(l.target))
|
||||
.map((l) => ({
|
||||
source: nodes.find((n) => n.id === l.source)!,
|
||||
target: nodes.find((n) => n.id === l.target)!,
|
||||
})),
|
||||
}
|
||||
|
||||
const width = graph.offsetWidth
|
||||
const height = Math.max(graph.offsetHeight, 250)
|
||||
|
||||
// we virtualize the simulation and use pixi to actually render it
|
||||
const simulation: Simulation<NodeData, LinkData> = forceSimulation<NodeData>(graphData.nodes)
|
||||
.force("charge", forceManyBody().strength(-100 * repelForce))
|
||||
.force("center", forceCenter().strength(centerForce))
|
||||
.force("link", forceLink(graphData.links).distance(linkDistance))
|
||||
.force("collide", forceCollide<NodeData>((n) => nodeRadius(n)).iterations(3))
|
||||
|
||||
const radius = (Math.min(width, height) / 2) * 0.8
|
||||
if (enableRadial) simulation.force("radial", forceRadial(radius).strength(0.2))
|
||||
|
||||
// precompute style prop strings as pixi doesn't support css variables
|
||||
const cssVars = [
|
||||
"--secondary",
|
||||
"--tertiary",
|
||||
"--gray",
|
||||
"--light",
|
||||
"--lightgray",
|
||||
"--dark",
|
||||
"--darkgray",
|
||||
"--bodyFont",
|
||||
] as const
|
||||
const computedStyleMap = cssVars.reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = getComputedStyle(document.documentElement).getPropertyValue(key)
|
||||
return acc
|
||||
},
|
||||
{} as Record<(typeof cssVars)[number], string>,
|
||||
)
|
||||
|
||||
// calculate color
|
||||
const color = (d: NodeData) => {
|
||||
const isCurrent = d.id === slug
|
||||
if (isCurrent) {
|
||||
return computedStyleMap["--secondary"]
|
||||
} else if (visited.has(d.id) || d.id.startsWith("tags/")) {
|
||||
return computedStyleMap["--tertiary"]
|
||||
} else {
|
||||
return computedStyleMap["--gray"]
|
||||
}
|
||||
}
|
||||
|
||||
function nodeRadius(d: NodeData) {
|
||||
const numLinks = graphData.links.filter(
|
||||
(l) => l.source.id === d.id || l.target.id === d.id,
|
||||
).length
|
||||
return 2 + Math.sqrt(numLinks)
|
||||
}
|
||||
|
||||
let hoveredNodeId: string | null = null
|
||||
let hoveredNeighbours: Set<string> = new Set()
|
||||
const linkRenderData: LinkRenderData[] = []
|
||||
const nodeRenderData: NodeRenderData[] = []
|
||||
function updateHoverInfo(newHoveredId: string | null) {
|
||||
hoveredNodeId = newHoveredId
|
||||
|
||||
if (newHoveredId === null) {
|
||||
hoveredNeighbours = new Set()
|
||||
for (const n of nodeRenderData) {
|
||||
n.active = false
|
||||
}
|
||||
|
||||
for (const l of linkRenderData) {
|
||||
l.active = false
|
||||
}
|
||||
} else {
|
||||
hoveredNeighbours = new Set()
|
||||
for (const l of linkRenderData) {
|
||||
const linkData = l.simulationData
|
||||
if (linkData.source.id === newHoveredId || linkData.target.id === newHoveredId) {
|
||||
hoveredNeighbours.add(linkData.source.id)
|
||||
hoveredNeighbours.add(linkData.target.id)
|
||||
}
|
||||
|
||||
l.active = linkData.source.id === newHoveredId || linkData.target.id === newHoveredId
|
||||
}
|
||||
|
||||
for (const n of nodeRenderData) {
|
||||
n.active = hoveredNeighbours.has(n.simulationData.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let dragStartTime = 0
|
||||
let dragging = false
|
||||
|
||||
function renderLinks() {
|
||||
tweens.get("link")?.stop()
|
||||
const tweenGroup = new TweenGroup()
|
||||
|
||||
for (const l of linkRenderData) {
|
||||
let alpha = 1
|
||||
|
||||
// if we are hovering over a node, we want to highlight the immediate neighbours
|
||||
// with full alpha and the rest with default alpha
|
||||
if (hoveredNodeId) {
|
||||
alpha = l.active ? 1 : 0.2
|
||||
}
|
||||
|
||||
l.color = l.active ? computedStyleMap["--gray"] : computedStyleMap["--lightgray"]
|
||||
tweenGroup.add(new Tweened<LinkRenderData>(l).to({ alpha }, 200))
|
||||
}
|
||||
|
||||
tweenGroup.getAll().forEach((tw) => tw.start())
|
||||
tweens.set("link", {
|
||||
update: tweenGroup.update.bind(tweenGroup),
|
||||
stop() {
|
||||
tweenGroup.getAll().forEach((tw) => tw.stop())
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function renderLabels() {
|
||||
tweens.get("label")?.stop()
|
||||
const tweenGroup = new TweenGroup()
|
||||
|
||||
const defaultScale = 1 / scale
|
||||
const activeScale = defaultScale * 1.1
|
||||
for (const n of nodeRenderData) {
|
||||
const nodeId = n.simulationData.id
|
||||
|
||||
if (hoveredNodeId === nodeId) {
|
||||
tweenGroup.add(
|
||||
new Tweened<Text>(n.label).to(
|
||||
{
|
||||
alpha: 1,
|
||||
scale: { x: activeScale, y: activeScale },
|
||||
},
|
||||
100,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
tweenGroup.add(
|
||||
new Tweened<Text>(n.label).to(
|
||||
{
|
||||
alpha: n.label.alpha,
|
||||
scale: { x: defaultScale, y: defaultScale },
|
||||
},
|
||||
100,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
tweenGroup.getAll().forEach((tw) => tw.start())
|
||||
tweens.set("label", {
|
||||
update: tweenGroup.update.bind(tweenGroup),
|
||||
stop() {
|
||||
tweenGroup.getAll().forEach((tw) => tw.stop())
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function renderNodes() {
|
||||
tweens.get("hover")?.stop()
|
||||
|
||||
const tweenGroup = new TweenGroup()
|
||||
for (const n of nodeRenderData) {
|
||||
let alpha = 1
|
||||
|
||||
// if we are hovering over a node, we want to highlight the immediate neighbours
|
||||
if (hoveredNodeId !== null && focusOnHover) {
|
||||
alpha = n.active ? 1 : 0.2
|
||||
}
|
||||
|
||||
tweenGroup.add(new Tweened<Graphics>(n.gfx, tweenGroup).to({ alpha }, 200))
|
||||
}
|
||||
|
||||
tweenGroup.getAll().forEach((tw) => tw.start())
|
||||
tweens.set("hover", {
|
||||
update: tweenGroup.update.bind(tweenGroup),
|
||||
stop() {
|
||||
tweenGroup.getAll().forEach((tw) => tw.stop())
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function renderPixiFromD3() {
|
||||
renderNodes()
|
||||
renderLinks()
|
||||
renderLabels()
|
||||
}
|
||||
|
||||
tweens.forEach((tween) => tween.stop())
|
||||
tweens.clear()
|
||||
|
||||
const app = new Application()
|
||||
await app.init({
|
||||
width,
|
||||
height,
|
||||
antialias: true,
|
||||
autoStart: false,
|
||||
autoDensity: true,
|
||||
backgroundAlpha: 0,
|
||||
preference: "webgpu",
|
||||
resolution: window.devicePixelRatio,
|
||||
eventMode: "static",
|
||||
})
|
||||
graph.appendChild(app.canvas)
|
||||
|
||||
const stage = app.stage
|
||||
stage.interactive = false
|
||||
|
||||
const labelsContainer = new Container<Text>({ zIndex: 3, isRenderGroup: true })
|
||||
const nodesContainer = new Container<Graphics>({ zIndex: 2, isRenderGroup: true })
|
||||
const linkContainer = new Container<Graphics>({ zIndex: 1, isRenderGroup: true })
|
||||
stage.addChild(nodesContainer, labelsContainer, linkContainer)
|
||||
|
||||
for (const n of graphData.nodes) {
|
||||
const nodeId = n.id
|
||||
|
||||
const label = new Text({
|
||||
interactive: false,
|
||||
eventMode: "none",
|
||||
text: n.text,
|
||||
alpha: 0,
|
||||
anchor: { x: 0.5, y: 1.2 },
|
||||
style: {
|
||||
fontSize: fontSize * 15,
|
||||
fill: computedStyleMap["--dark"],
|
||||
fontFamily: computedStyleMap["--bodyFont"],
|
||||
},
|
||||
resolution: window.devicePixelRatio * 4,
|
||||
})
|
||||
label.scale.set(1 / scale)
|
||||
|
||||
let oldLabelOpacity = 0
|
||||
const isTagNode = nodeId.startsWith("tags/")
|
||||
const gfx = new Graphics({
|
||||
interactive: true,
|
||||
label: nodeId,
|
||||
eventMode: "static",
|
||||
hitArea: new Circle(0, 0, nodeRadius(n)),
|
||||
cursor: "pointer",
|
||||
})
|
||||
.circle(0, 0, nodeRadius(n))
|
||||
.fill({ color: isTagNode ? computedStyleMap["--light"] : color(n) })
|
||||
.on("pointerover", (e) => {
|
||||
updateHoverInfo(e.target.label)
|
||||
oldLabelOpacity = label.alpha
|
||||
if (!dragging) {
|
||||
renderPixiFromD3()
|
||||
}
|
||||
})
|
||||
.on("pointerleave", () => {
|
||||
updateHoverInfo(null)
|
||||
label.alpha = oldLabelOpacity
|
||||
if (!dragging) {
|
||||
renderPixiFromD3()
|
||||
}
|
||||
})
|
||||
|
||||
if (isTagNode) {
|
||||
gfx.stroke({ width: 2, color: computedStyleMap["--tertiary"] })
|
||||
}
|
||||
|
||||
nodesContainer.addChild(gfx)
|
||||
labelsContainer.addChild(label)
|
||||
|
||||
const nodeRenderDatum: NodeRenderData = {
|
||||
simulationData: n,
|
||||
gfx,
|
||||
label,
|
||||
color: color(n),
|
||||
alpha: 1,
|
||||
active: false,
|
||||
}
|
||||
|
||||
nodeRenderData.push(nodeRenderDatum)
|
||||
}
|
||||
|
||||
for (const l of graphData.links) {
|
||||
const gfx = new Graphics({ interactive: false, eventMode: "none" })
|
||||
linkContainer.addChild(gfx)
|
||||
|
||||
const linkRenderDatum: LinkRenderData = {
|
||||
simulationData: l,
|
||||
gfx,
|
||||
color: computedStyleMap["--lightgray"],
|
||||
alpha: 1,
|
||||
active: false,
|
||||
}
|
||||
|
||||
linkRenderData.push(linkRenderDatum)
|
||||
}
|
||||
|
||||
let currentTransform = zoomIdentity
|
||||
if (enableDrag) {
|
||||
select<HTMLCanvasElement, NodeData | undefined>(app.canvas).call(
|
||||
drag<HTMLCanvasElement, NodeData | undefined>()
|
||||
.container(() => app.canvas)
|
||||
.subject(() => graphData.nodes.find((n) => n.id === hoveredNodeId))
|
||||
.on("start", function dragstarted(event) {
|
||||
if (!event.active) simulation.alphaTarget(1).restart()
|
||||
event.subject.fx = event.subject.x
|
||||
event.subject.fy = event.subject.y
|
||||
event.subject.__initialDragPos = {
|
||||
x: event.subject.x,
|
||||
y: event.subject.y,
|
||||
fx: event.subject.fx,
|
||||
fy: event.subject.fy,
|
||||
}
|
||||
dragStartTime = Date.now()
|
||||
dragging = true
|
||||
})
|
||||
.on("drag", function dragged(event) {
|
||||
const initPos = event.subject.__initialDragPos
|
||||
event.subject.fx = initPos.x + (event.x - initPos.x) / currentTransform.k
|
||||
event.subject.fy = initPos.y + (event.y - initPos.y) / currentTransform.k
|
||||
})
|
||||
.on("end", function dragended(event) {
|
||||
if (!event.active) simulation.alphaTarget(0)
|
||||
event.subject.fx = null
|
||||
event.subject.fy = null
|
||||
dragging = false
|
||||
|
||||
// if the time between mousedown and mouseup is short, we consider it a click
|
||||
if (Date.now() - dragStartTime < 500) {
|
||||
const node = graphData.nodes.find((n) => n.id === event.subject.id) as NodeData
|
||||
const targ = resolveRelative(fullSlug, node.id)
|
||||
window.spaNavigate(new URL(targ, window.location.toString()))
|
||||
}
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
for (const node of nodeRenderData) {
|
||||
node.gfx.on("click", () => {
|
||||
const targ = resolveRelative(fullSlug, node.simulationData.id)
|
||||
window.spaNavigate(new URL(targ, window.location.toString()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (enableZoom) {
|
||||
select<HTMLCanvasElement, NodeData>(app.canvas).call(
|
||||
zoom<HTMLCanvasElement, NodeData>()
|
||||
.extent([
|
||||
[0, 0],
|
||||
[width, height],
|
||||
])
|
||||
.scaleExtent([0.25, 4])
|
||||
.on("zoom", ({ transform }) => {
|
||||
currentTransform = transform
|
||||
stage.scale.set(transform.k, transform.k)
|
||||
stage.position.set(transform.x, transform.y)
|
||||
|
||||
// zoom adjusts opacity of labels too
|
||||
const scale = transform.k * opacityScale
|
||||
let scaleOpacity = Math.max((scale - 1) / 3.75, 0)
|
||||
const activeNodes = nodeRenderData.filter((n) => n.active).flatMap((n) => n.label)
|
||||
|
||||
for (const label of labelsContainer.children) {
|
||||
if (!activeNodes.includes(label)) {
|
||||
label.alpha = scaleOpacity
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
let stopAnimation = false
|
||||
function animate(time: number) {
|
||||
if (stopAnimation) return
|
||||
for (const n of nodeRenderData) {
|
||||
const { x, y } = n.simulationData
|
||||
if (!x || !y) continue
|
||||
n.gfx.position.set(x + width / 2, y + height / 2)
|
||||
if (n.label) {
|
||||
n.label.position.set(x + width / 2, y + height / 2)
|
||||
}
|
||||
}
|
||||
|
||||
for (const l of linkRenderData) {
|
||||
const linkData = l.simulationData
|
||||
l.gfx.clear()
|
||||
l.gfx.moveTo(linkData.source.x! + width / 2, linkData.source.y! + height / 2)
|
||||
l.gfx
|
||||
.lineTo(linkData.target.x! + width / 2, linkData.target.y! + height / 2)
|
||||
.stroke({ alpha: l.alpha, width: 1, color: l.color })
|
||||
}
|
||||
|
||||
tweens.forEach((t) => t.update(time))
|
||||
app.renderer.render(stage)
|
||||
requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
requestAnimationFrame(animate)
|
||||
return () => {
|
||||
stopAnimation = true
|
||||
app.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
let localGraphCleanups: (() => void)[] = []
|
||||
let globalGraphCleanups: (() => void)[] = []
|
||||
|
||||
function cleanupLocalGraphs() {
|
||||
for (const cleanup of localGraphCleanups) {
|
||||
cleanup()
|
||||
}
|
||||
localGraphCleanups = []
|
||||
}
|
||||
|
||||
function cleanupGlobalGraphs() {
|
||||
for (const cleanup of globalGraphCleanups) {
|
||||
cleanup()
|
||||
}
|
||||
globalGraphCleanups = []
|
||||
}
|
||||
|
||||
document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
|
||||
const slug = e.detail.url
|
||||
addToVisited(simplifySlug(slug))
|
||||
|
||||
async function renderLocalGraph() {
|
||||
cleanupLocalGraphs()
|
||||
const localGraphContainers = document.getElementsByClassName("graph-container")
|
||||
for (const container of localGraphContainers) {
|
||||
localGraphCleanups.push(await renderGraph(container as HTMLElement, slug))
|
||||
}
|
||||
}
|
||||
|
||||
await renderLocalGraph()
|
||||
const handleThemeChange = () => {
|
||||
void renderLocalGraph()
|
||||
}
|
||||
|
||||
document.addEventListener("themechange", handleThemeChange)
|
||||
window.addCleanup(() => {
|
||||
document.removeEventListener("themechange", handleThemeChange)
|
||||
})
|
||||
|
||||
const containers = [...document.getElementsByClassName("global-graph-outer")] as HTMLElement[]
|
||||
async function renderGlobalGraph() {
|
||||
const slug = getFullSlug(window)
|
||||
for (const container of containers) {
|
||||
container.classList.add("active")
|
||||
const sidebar = container.closest(".sidebar") as HTMLElement
|
||||
if (sidebar) {
|
||||
sidebar.style.zIndex = "1"
|
||||
}
|
||||
|
||||
const graphContainer = container.querySelector(".global-graph-container") as HTMLElement
|
||||
registerEscapeHandler(container, hideGlobalGraph)
|
||||
if (graphContainer) {
|
||||
globalGraphCleanups.push(await renderGraph(graphContainer, slug))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hideGlobalGraph() {
|
||||
cleanupGlobalGraphs()
|
||||
for (const container of containers) {
|
||||
container.classList.remove("active")
|
||||
const sidebar = container.closest(".sidebar") as HTMLElement
|
||||
if (sidebar) {
|
||||
sidebar.style.zIndex = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function shortcutHandler(e: HTMLElementEventMap["keydown"]) {
|
||||
if (e.key === "g" && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
const anyGlobalGraphOpen = containers.some((container) =>
|
||||
container.classList.contains("active"),
|
||||
)
|
||||
anyGlobalGraphOpen ? hideGlobalGraph() : renderGlobalGraph()
|
||||
}
|
||||
}
|
||||
|
||||
const containerIcons = document.getElementsByClassName("global-graph-icon")
|
||||
Array.from(containerIcons).forEach((icon) => {
|
||||
icon.addEventListener("click", renderGlobalGraph)
|
||||
window.addCleanup(() => icon.removeEventListener("click", renderGlobalGraph))
|
||||
})
|
||||
|
||||
document.addEventListener("keydown", shortcutHandler)
|
||||
window.addCleanup(() => {
|
||||
document.removeEventListener("keydown", shortcutHandler)
|
||||
cleanupLocalGraphs()
|
||||
cleanupGlobalGraphs()
|
||||
})
|
||||
})
|
||||
258
quartz/components/scripts/mermaid.inline.ts
Normal file
258
quartz/components/scripts/mermaid.inline.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { registerEscapeHandler, removeAllChildren } from "./util"
|
||||
|
||||
interface Position {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
class DiagramPanZoom {
|
||||
private isDragging = false
|
||||
private startPan: Position = { x: 0, y: 0 }
|
||||
private currentPan: Position = { x: 0, y: 0 }
|
||||
private scale = 1
|
||||
private readonly MIN_SCALE = 0.5
|
||||
private readonly MAX_SCALE = 3
|
||||
|
||||
cleanups: (() => void)[] = []
|
||||
|
||||
constructor(
|
||||
private container: HTMLElement,
|
||||
private content: HTMLElement,
|
||||
) {
|
||||
this.setupEventListeners()
|
||||
this.setupNavigationControls()
|
||||
this.resetTransform()
|
||||
}
|
||||
|
||||
private setupEventListeners() {
|
||||
// Mouse drag events
|
||||
const mouseDownHandler = this.onMouseDown.bind(this)
|
||||
const mouseMoveHandler = this.onMouseMove.bind(this)
|
||||
const mouseUpHandler = this.onMouseUp.bind(this)
|
||||
const resizeHandler = this.resetTransform.bind(this)
|
||||
|
||||
this.container.addEventListener("mousedown", mouseDownHandler)
|
||||
document.addEventListener("mousemove", mouseMoveHandler)
|
||||
document.addEventListener("mouseup", mouseUpHandler)
|
||||
window.addEventListener("resize", resizeHandler)
|
||||
|
||||
this.cleanups.push(
|
||||
() => this.container.removeEventListener("mousedown", mouseDownHandler),
|
||||
() => document.removeEventListener("mousemove", mouseMoveHandler),
|
||||
() => document.removeEventListener("mouseup", mouseUpHandler),
|
||||
() => window.removeEventListener("resize", resizeHandler),
|
||||
)
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
for (const cleanup of this.cleanups) {
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
private setupNavigationControls() {
|
||||
const controls = document.createElement("div")
|
||||
controls.className = "mermaid-controls"
|
||||
|
||||
// Zoom controls
|
||||
const zoomIn = this.createButton("+", () => this.zoom(0.1))
|
||||
const zoomOut = this.createButton("-", () => this.zoom(-0.1))
|
||||
const resetBtn = this.createButton("Reset", () => this.resetTransform())
|
||||
|
||||
controls.appendChild(zoomOut)
|
||||
controls.appendChild(resetBtn)
|
||||
controls.appendChild(zoomIn)
|
||||
|
||||
this.container.appendChild(controls)
|
||||
}
|
||||
|
||||
private createButton(text: string, onClick: () => void): HTMLButtonElement {
|
||||
const button = document.createElement("button")
|
||||
button.textContent = text
|
||||
button.className = "mermaid-control-button"
|
||||
button.addEventListener("click", onClick)
|
||||
window.addCleanup(() => button.removeEventListener("click", onClick))
|
||||
return button
|
||||
}
|
||||
|
||||
private onMouseDown(e: MouseEvent) {
|
||||
if (e.button !== 0) return // Only handle left click
|
||||
this.isDragging = true
|
||||
this.startPan = { x: e.clientX - this.currentPan.x, y: e.clientY - this.currentPan.y }
|
||||
this.container.style.cursor = "grabbing"
|
||||
}
|
||||
|
||||
private onMouseMove(e: MouseEvent) {
|
||||
if (!this.isDragging) return
|
||||
e.preventDefault()
|
||||
|
||||
this.currentPan = {
|
||||
x: e.clientX - this.startPan.x,
|
||||
y: e.clientY - this.startPan.y,
|
||||
}
|
||||
|
||||
this.updateTransform()
|
||||
}
|
||||
|
||||
private onMouseUp() {
|
||||
this.isDragging = false
|
||||
this.container.style.cursor = "grab"
|
||||
}
|
||||
|
||||
private zoom(delta: number) {
|
||||
const newScale = Math.min(Math.max(this.scale + delta, this.MIN_SCALE), this.MAX_SCALE)
|
||||
|
||||
// Zoom around center
|
||||
const rect = this.content.getBoundingClientRect()
|
||||
const centerX = rect.width / 2
|
||||
const centerY = rect.height / 2
|
||||
|
||||
const scaleDiff = newScale - this.scale
|
||||
this.currentPan.x -= centerX * scaleDiff
|
||||
this.currentPan.y -= centerY * scaleDiff
|
||||
|
||||
this.scale = newScale
|
||||
this.updateTransform()
|
||||
}
|
||||
|
||||
private updateTransform() {
|
||||
this.content.style.transform = `translate(${this.currentPan.x}px, ${this.currentPan.y}px) scale(${this.scale})`
|
||||
}
|
||||
|
||||
private resetTransform() {
|
||||
this.scale = 1
|
||||
const svg = this.content.querySelector("svg")!
|
||||
this.currentPan = {
|
||||
x: svg.getBoundingClientRect().width / 2,
|
||||
y: svg.getBoundingClientRect().height / 2,
|
||||
}
|
||||
this.updateTransform()
|
||||
}
|
||||
}
|
||||
|
||||
const cssVars = [
|
||||
"--secondary",
|
||||
"--tertiary",
|
||||
"--gray",
|
||||
"--light",
|
||||
"--lightgray",
|
||||
"--highlight",
|
||||
"--dark",
|
||||
"--darkgray",
|
||||
"--codeFont",
|
||||
] as const
|
||||
|
||||
let mermaidImport = undefined
|
||||
document.addEventListener("nav", async () => {
|
||||
const center = document.querySelector(".center") as HTMLElement
|
||||
const nodes = center.querySelectorAll("code.mermaid") as NodeListOf<HTMLElement>
|
||||
if (nodes.length === 0) return
|
||||
|
||||
mermaidImport ||= await import(
|
||||
// @ts-ignore
|
||||
"https://cdnjs.cloudflare.com/ajax/libs/mermaid/11.4.0/mermaid.esm.min.mjs"
|
||||
)
|
||||
const mermaid = mermaidImport.default
|
||||
|
||||
const textMapping: WeakMap<HTMLElement, string> = new WeakMap()
|
||||
for (const node of nodes) {
|
||||
textMapping.set(node, node.innerText)
|
||||
}
|
||||
|
||||
async function renderMermaid() {
|
||||
// de-init any other diagrams
|
||||
for (const node of nodes) {
|
||||
node.removeAttribute("data-processed")
|
||||
const oldText = textMapping.get(node)
|
||||
if (oldText) {
|
||||
node.innerHTML = oldText
|
||||
}
|
||||
}
|
||||
|
||||
const computedStyleMap = cssVars.reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = window.getComputedStyle(document.documentElement).getPropertyValue(key)
|
||||
return acc
|
||||
},
|
||||
{} as Record<(typeof cssVars)[number], string>,
|
||||
)
|
||||
|
||||
const darkMode = document.documentElement.getAttribute("saved-theme") === "dark"
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
securityLevel: "loose",
|
||||
theme: darkMode ? "dark" : "base",
|
||||
themeVariables: {
|
||||
fontFamily: computedStyleMap["--codeFont"],
|
||||
primaryColor: computedStyleMap["--light"],
|
||||
primaryTextColor: computedStyleMap["--darkgray"],
|
||||
primaryBorderColor: computedStyleMap["--tertiary"],
|
||||
lineColor: computedStyleMap["--darkgray"],
|
||||
secondaryColor: computedStyleMap["--secondary"],
|
||||
tertiaryColor: computedStyleMap["--tertiary"],
|
||||
clusterBkg: computedStyleMap["--light"],
|
||||
edgeLabelBackground: computedStyleMap["--highlight"],
|
||||
},
|
||||
})
|
||||
|
||||
await mermaid.run({ nodes })
|
||||
}
|
||||
|
||||
await renderMermaid()
|
||||
document.addEventListener("themechange", renderMermaid)
|
||||
window.addCleanup(() => document.removeEventListener("themechange", renderMermaid))
|
||||
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const codeBlock = nodes[i] as HTMLElement
|
||||
const pre = codeBlock.parentElement as HTMLPreElement
|
||||
const clipboardBtn = pre.querySelector(".clipboard-button") as HTMLButtonElement
|
||||
const expandBtn = pre.querySelector(".expand-button") as HTMLButtonElement
|
||||
|
||||
const clipboardStyle = window.getComputedStyle(clipboardBtn)
|
||||
const clipboardWidth =
|
||||
clipboardBtn.offsetWidth +
|
||||
parseFloat(clipboardStyle.marginLeft || "0") +
|
||||
parseFloat(clipboardStyle.marginRight || "0")
|
||||
|
||||
// Set expand button position
|
||||
expandBtn.style.right = `calc(${clipboardWidth}px + 0.3rem)`
|
||||
pre.prepend(expandBtn)
|
||||
|
||||
// query popup container
|
||||
const popupContainer = pre.querySelector("#mermaid-container") as HTMLElement
|
||||
if (!popupContainer) return
|
||||
|
||||
let panZoom: DiagramPanZoom | null = null
|
||||
function showMermaid() {
|
||||
const container = popupContainer.querySelector("#mermaid-space") as HTMLElement
|
||||
const content = popupContainer.querySelector(".mermaid-content") as HTMLElement
|
||||
if (!content) return
|
||||
removeAllChildren(content)
|
||||
|
||||
// Clone the mermaid content
|
||||
const mermaidContent = codeBlock.querySelector("svg")!.cloneNode(true) as SVGElement
|
||||
content.appendChild(mermaidContent)
|
||||
|
||||
// Show container
|
||||
popupContainer.classList.add("active")
|
||||
container.style.cursor = "grab"
|
||||
|
||||
// Initialize pan-zoom after showing the popup
|
||||
panZoom = new DiagramPanZoom(container, content)
|
||||
}
|
||||
|
||||
function hideMermaid() {
|
||||
popupContainer.classList.remove("active")
|
||||
panZoom?.cleanup()
|
||||
panZoom = null
|
||||
}
|
||||
|
||||
expandBtn.addEventListener("click", showMermaid)
|
||||
registerEscapeHandler(popupContainer, hideMermaid)
|
||||
|
||||
window.addCleanup(() => {
|
||||
panZoom?.cleanup()
|
||||
expandBtn.removeEventListener("click", showMermaid)
|
||||
})
|
||||
}
|
||||
})
|
||||
133
quartz/components/scripts/popover.inline.ts
Normal file
133
quartz/components/scripts/popover.inline.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
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 targetAnchor = `#popover-internal-${hash.slice(1)}`
|
||||
const heading = popoverInner.querySelector(targetAnchor) as HTMLElement | null
|
||||
if (heading) {
|
||||
// leave ~12px of buffer when scrolling to a heading
|
||||
popoverInner.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 [contentType] = response.headers.get("Content-Type")!.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"))
|
||||
}
|
||||
|
||||
document.addEventListener("nav", () => {
|
||||
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)
|
||||
})
|
||||
}
|
||||
})
|
||||
25
quartz/components/scripts/readermode.inline.ts
Normal file
25
quartz/components/scripts/readermode.inline.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
let isReaderMode = false
|
||||
|
||||
const emitReaderModeChangeEvent = (mode: "on" | "off") => {
|
||||
const event: CustomEventMap["readermodechange"] = new CustomEvent("readermodechange", {
|
||||
detail: { mode },
|
||||
})
|
||||
document.dispatchEvent(event)
|
||||
}
|
||||
|
||||
document.addEventListener("nav", () => {
|
||||
const switchReaderMode = () => {
|
||||
isReaderMode = !isReaderMode
|
||||
const newMode = isReaderMode ? "on" : "off"
|
||||
document.documentElement.setAttribute("reader-mode", newMode)
|
||||
emitReaderModeChangeEvent(newMode)
|
||||
}
|
||||
|
||||
for (const readerModeButton of document.getElementsByClassName("readermode")) {
|
||||
readerModeButton.addEventListener("click", switchReaderMode)
|
||||
window.addCleanup(() => readerModeButton.removeEventListener("click", switchReaderMode))
|
||||
}
|
||||
|
||||
// Set initial state
|
||||
document.documentElement.setAttribute("reader-mode", isReaderMode ? "on" : "off")
|
||||
})
|
||||
502
quartz/components/scripts/search.inline.ts
Normal file
502
quartz/components/scripts/search.inline.ts
Normal file
@@ -0,0 +1,502 @@
|
||||
import FlexSearch, { DefaultDocumentSearchResults } from "flexsearch"
|
||||
import { ContentDetails } from "../../plugins/emitters/contentIndex"
|
||||
import { registerEscapeHandler, removeAllChildren } from "./util"
|
||||
import { FullSlug, normalizeRelativeURLs, resolveRelative } from "../../util/path"
|
||||
|
||||
interface Item {
|
||||
id: number
|
||||
slug: FullSlug
|
||||
title: string
|
||||
content: string
|
||||
tags: string[]
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
// Can be expanded with things like "term" in the future
|
||||
type SearchType = "basic" | "tags"
|
||||
let searchType: SearchType = "basic"
|
||||
let currentSearchTerm: string = ""
|
||||
const encoder = (str: string) => {
|
||||
return str
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.filter((token) => token.length > 0)
|
||||
}
|
||||
|
||||
let index = new FlexSearch.Document<Item>({
|
||||
encode: encoder,
|
||||
document: {
|
||||
id: "id",
|
||||
tag: "tags",
|
||||
index: [
|
||||
{
|
||||
field: "title",
|
||||
tokenize: "forward",
|
||||
},
|
||||
{
|
||||
field: "content",
|
||||
tokenize: "forward",
|
||||
},
|
||||
{
|
||||
field: "tags",
|
||||
tokenize: "forward",
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const p = new DOMParser()
|
||||
const fetchContentCache: Map<FullSlug, Element[]> = new Map()
|
||||
const contextWindowWords = 30
|
||||
const numSearchResults = 8
|
||||
const numTagResults = 5
|
||||
|
||||
const tokenizeTerm = (term: string) => {
|
||||
const tokens = term.split(/\s+/).filter((t) => t.trim() !== "")
|
||||
const tokenLen = tokens.length
|
||||
if (tokenLen > 1) {
|
||||
for (let i = 1; i < tokenLen; i++) {
|
||||
tokens.push(tokens.slice(0, i + 1).join(" "))
|
||||
}
|
||||
}
|
||||
|
||||
return tokens.sort((a, b) => b.length - a.length) // always highlight longest terms first
|
||||
}
|
||||
|
||||
function highlight(searchTerm: string, text: string, trim?: boolean) {
|
||||
const tokenizedTerms = tokenizeTerm(searchTerm)
|
||||
let tokenizedText = text.split(/\s+/).filter((t) => t !== "")
|
||||
|
||||
let startIndex = 0
|
||||
let endIndex = tokenizedText.length - 1
|
||||
if (trim) {
|
||||
const includesCheck = (tok: string) =>
|
||||
tokenizedTerms.some((term) => tok.toLowerCase().startsWith(term.toLowerCase()))
|
||||
const occurrencesIndices = tokenizedText.map(includesCheck)
|
||||
|
||||
let bestSum = 0
|
||||
let bestIndex = 0
|
||||
for (let i = 0; i < Math.max(tokenizedText.length - contextWindowWords, 0); i++) {
|
||||
const window = occurrencesIndices.slice(i, i + contextWindowWords)
|
||||
const windowSum = window.reduce((total, cur) => total + (cur ? 1 : 0), 0)
|
||||
if (windowSum >= bestSum) {
|
||||
bestSum = windowSum
|
||||
bestIndex = i
|
||||
}
|
||||
}
|
||||
|
||||
startIndex = Math.max(bestIndex - contextWindowWords, 0)
|
||||
endIndex = Math.min(startIndex + 2 * contextWindowWords, tokenizedText.length - 1)
|
||||
tokenizedText = tokenizedText.slice(startIndex, endIndex)
|
||||
}
|
||||
|
||||
const slice = tokenizedText
|
||||
.map((tok) => {
|
||||
// see if this tok is prefixed by any search terms
|
||||
for (const searchTok of tokenizedTerms) {
|
||||
if (tok.toLowerCase().includes(searchTok.toLowerCase())) {
|
||||
const regex = new RegExp(searchTok.toLowerCase(), "gi")
|
||||
return tok.replace(regex, `<span class="highlight">$&</span>`)
|
||||
}
|
||||
}
|
||||
return tok
|
||||
})
|
||||
.join(" ")
|
||||
|
||||
return `${startIndex === 0 ? "" : "..."}${slice}${
|
||||
endIndex === tokenizedText.length - 1 ? "" : "..."
|
||||
}`
|
||||
}
|
||||
|
||||
function highlightHTML(searchTerm: string, el: HTMLElement) {
|
||||
const p = new DOMParser()
|
||||
const tokenizedTerms = tokenizeTerm(searchTerm)
|
||||
const html = p.parseFromString(el.innerHTML, "text/html")
|
||||
|
||||
const createHighlightSpan = (text: string) => {
|
||||
const span = document.createElement("span")
|
||||
span.className = "highlight"
|
||||
span.textContent = text
|
||||
return span
|
||||
}
|
||||
|
||||
const highlightTextNodes = (node: Node, term: string) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const nodeText = node.nodeValue ?? ""
|
||||
const regex = new RegExp(term.toLowerCase(), "gi")
|
||||
const matches = nodeText.match(regex)
|
||||
if (!matches || matches.length === 0) return
|
||||
const spanContainer = document.createElement("span")
|
||||
let lastIndex = 0
|
||||
for (const match of matches) {
|
||||
const matchIndex = nodeText.indexOf(match, lastIndex)
|
||||
spanContainer.appendChild(document.createTextNode(nodeText.slice(lastIndex, matchIndex)))
|
||||
spanContainer.appendChild(createHighlightSpan(match))
|
||||
lastIndex = matchIndex + match.length
|
||||
}
|
||||
spanContainer.appendChild(document.createTextNode(nodeText.slice(lastIndex)))
|
||||
node.parentNode?.replaceChild(spanContainer, node)
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
if ((node as HTMLElement).classList.contains("highlight")) return
|
||||
Array.from(node.childNodes).forEach((child) => highlightTextNodes(child, term))
|
||||
}
|
||||
}
|
||||
|
||||
for (const term of tokenizedTerms) {
|
||||
highlightTextNodes(html.body, term)
|
||||
}
|
||||
|
||||
return html.body
|
||||
}
|
||||
|
||||
async function setupSearch(searchElement: Element, currentSlug: FullSlug, data: ContentIndex) {
|
||||
const container = searchElement.querySelector(".search-container") as HTMLElement
|
||||
if (!container) return
|
||||
|
||||
const sidebar = container.closest(".sidebar") as HTMLElement | null
|
||||
|
||||
const searchButton = searchElement.querySelector(".search-button") as HTMLButtonElement
|
||||
if (!searchButton) return
|
||||
|
||||
const searchBar = searchElement.querySelector(".search-bar") as HTMLInputElement
|
||||
if (!searchBar) return
|
||||
|
||||
const searchLayout = searchElement.querySelector(".search-layout") as HTMLElement
|
||||
if (!searchLayout) return
|
||||
|
||||
const idDataMap = Object.keys(data) as FullSlug[]
|
||||
const appendLayout = (el: HTMLElement) => {
|
||||
searchLayout.appendChild(el)
|
||||
}
|
||||
|
||||
const enablePreview = searchLayout.dataset.preview === "true"
|
||||
let preview: HTMLDivElement | undefined = undefined
|
||||
let previewInner: HTMLDivElement | undefined = undefined
|
||||
const results = document.createElement("div")
|
||||
results.className = "results-container"
|
||||
appendLayout(results)
|
||||
|
||||
if (enablePreview) {
|
||||
preview = document.createElement("div")
|
||||
preview.className = "preview-container"
|
||||
appendLayout(preview)
|
||||
}
|
||||
|
||||
function hideSearch() {
|
||||
container.classList.remove("active")
|
||||
searchBar.value = "" // clear the input when we dismiss the search
|
||||
if (sidebar) sidebar.style.zIndex = ""
|
||||
removeAllChildren(results)
|
||||
if (preview) {
|
||||
removeAllChildren(preview)
|
||||
}
|
||||
searchLayout.classList.remove("display-results")
|
||||
searchType = "basic" // reset search type after closing
|
||||
searchButton.focus()
|
||||
}
|
||||
|
||||
function showSearch(searchTypeNew: SearchType) {
|
||||
searchType = searchTypeNew
|
||||
if (sidebar) sidebar.style.zIndex = "1"
|
||||
container.classList.add("active")
|
||||
searchBar.focus()
|
||||
}
|
||||
|
||||
let currentHover: HTMLInputElement | null = null
|
||||
async function shortcutHandler(e: HTMLElementEventMap["keydown"]) {
|
||||
if (e.key === "k" && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
const searchBarOpen = container.classList.contains("active")
|
||||
searchBarOpen ? hideSearch() : showSearch("basic")
|
||||
return
|
||||
} else if (e.shiftKey && (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
|
||||
// Hotkey to open tag search
|
||||
e.preventDefault()
|
||||
const searchBarOpen = container.classList.contains("active")
|
||||
searchBarOpen ? hideSearch() : showSearch("tags")
|
||||
|
||||
// add "#" prefix for tag search
|
||||
searchBar.value = "#"
|
||||
return
|
||||
}
|
||||
|
||||
if (currentHover) {
|
||||
currentHover.classList.remove("focus")
|
||||
}
|
||||
|
||||
// If search is active, then we will render the first result and display accordingly
|
||||
if (!container.classList.contains("active")) return
|
||||
if (e.key === "Enter" && !e.isComposing) {
|
||||
// If result has focus, navigate to that one, otherwise pick first result
|
||||
if (results.contains(document.activeElement)) {
|
||||
const active = document.activeElement as HTMLInputElement
|
||||
if (active.classList.contains("no-match")) return
|
||||
await displayPreview(active)
|
||||
active.click()
|
||||
} else {
|
||||
const anchor = document.getElementsByClassName("result-card")[0] as HTMLInputElement | null
|
||||
if (!anchor || anchor.classList.contains("no-match")) return
|
||||
await displayPreview(anchor)
|
||||
anchor.click()
|
||||
}
|
||||
} else if (e.key === "ArrowUp" || (e.shiftKey && e.key === "Tab")) {
|
||||
e.preventDefault()
|
||||
if (results.contains(document.activeElement)) {
|
||||
// If an element in results-container already has focus, focus previous one
|
||||
const currentResult = currentHover
|
||||
? currentHover
|
||||
: (document.activeElement as HTMLInputElement | null)
|
||||
const prevResult = currentResult?.previousElementSibling as HTMLInputElement | null
|
||||
currentResult?.classList.remove("focus")
|
||||
prevResult?.focus()
|
||||
if (prevResult) currentHover = prevResult
|
||||
await displayPreview(prevResult)
|
||||
}
|
||||
} else if (e.key === "ArrowDown" || e.key === "Tab") {
|
||||
e.preventDefault()
|
||||
// The results should already been focused, so we need to find the next one.
|
||||
// The activeElement is the search bar, so we need to find the first result and focus it.
|
||||
if (document.activeElement === searchBar || currentHover !== null) {
|
||||
const firstResult = currentHover
|
||||
? currentHover
|
||||
: (document.getElementsByClassName("result-card")[0] as HTMLInputElement | null)
|
||||
const secondResult = firstResult?.nextElementSibling as HTMLInputElement | null
|
||||
firstResult?.classList.remove("focus")
|
||||
secondResult?.focus()
|
||||
if (secondResult) currentHover = secondResult
|
||||
await displayPreview(secondResult)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const formatForDisplay = (term: string, id: number) => {
|
||||
const slug = idDataMap[id]
|
||||
return {
|
||||
id,
|
||||
slug,
|
||||
title: searchType === "tags" ? data[slug].title : highlight(term, data[slug].title ?? ""),
|
||||
content: highlight(term, data[slug].content ?? "", true),
|
||||
tags: highlightTags(term.substring(1), data[slug].tags),
|
||||
}
|
||||
}
|
||||
|
||||
function highlightTags(term: string, tags: string[]) {
|
||||
if (!tags || searchType !== "tags") {
|
||||
return []
|
||||
}
|
||||
|
||||
return tags
|
||||
.map((tag) => {
|
||||
if (tag.toLowerCase().includes(term.toLowerCase())) {
|
||||
return `<li><p class="match-tag">#${tag}</p></li>`
|
||||
} else {
|
||||
return `<li><p>#${tag}</p></li>`
|
||||
}
|
||||
})
|
||||
.slice(0, numTagResults)
|
||||
}
|
||||
|
||||
function resolveUrl(slug: FullSlug): URL {
|
||||
return new URL(resolveRelative(currentSlug, slug), location.toString())
|
||||
}
|
||||
|
||||
const resultToHTML = ({ slug, title, content, tags }: Item) => {
|
||||
const htmlTags = tags.length > 0 ? `<ul class="tags">${tags.join("")}</ul>` : ``
|
||||
const itemTile = document.createElement("a")
|
||||
itemTile.classList.add("result-card")
|
||||
itemTile.id = slug
|
||||
itemTile.href = resolveUrl(slug).toString()
|
||||
itemTile.innerHTML = `
|
||||
<h3 class="card-title">${title}</h3>
|
||||
${htmlTags}
|
||||
<p class="card-description">${content}</p>
|
||||
`
|
||||
itemTile.addEventListener("click", (event) => {
|
||||
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
|
||||
hideSearch()
|
||||
})
|
||||
|
||||
const handler = (event: MouseEvent) => {
|
||||
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
|
||||
hideSearch()
|
||||
}
|
||||
|
||||
async function onMouseEnter(ev: MouseEvent) {
|
||||
if (!ev.target) return
|
||||
const target = ev.target as HTMLInputElement
|
||||
await displayPreview(target)
|
||||
}
|
||||
|
||||
itemTile.addEventListener("mouseenter", onMouseEnter)
|
||||
window.addCleanup(() => itemTile.removeEventListener("mouseenter", onMouseEnter))
|
||||
itemTile.addEventListener("click", handler)
|
||||
window.addCleanup(() => itemTile.removeEventListener("click", handler))
|
||||
|
||||
return itemTile
|
||||
}
|
||||
|
||||
async function displayResults(finalResults: Item[]) {
|
||||
removeAllChildren(results)
|
||||
if (finalResults.length === 0) {
|
||||
results.innerHTML = `<a class="result-card no-match">
|
||||
<h3>No results.</h3>
|
||||
<p>Try another search term?</p>
|
||||
</a>`
|
||||
} else {
|
||||
results.append(...finalResults.map(resultToHTML))
|
||||
}
|
||||
|
||||
if (finalResults.length === 0 && preview) {
|
||||
// no results, clear previous preview
|
||||
removeAllChildren(preview)
|
||||
} else {
|
||||
// focus on first result, then also dispatch preview immediately
|
||||
const firstChild = results.firstElementChild as HTMLElement
|
||||
firstChild.classList.add("focus")
|
||||
currentHover = firstChild as HTMLInputElement
|
||||
await displayPreview(firstChild)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchContent(slug: FullSlug): Promise<Element[]> {
|
||||
if (fetchContentCache.has(slug)) {
|
||||
return fetchContentCache.get(slug) as Element[]
|
||||
}
|
||||
|
||||
const targetUrl = resolveUrl(slug).toString()
|
||||
const contents = await fetch(targetUrl)
|
||||
.then((res) => res.text())
|
||||
.then((contents) => {
|
||||
if (contents === undefined) {
|
||||
throw new Error(`Could not fetch ${targetUrl}`)
|
||||
}
|
||||
const html = p.parseFromString(contents ?? "", "text/html")
|
||||
normalizeRelativeURLs(html, targetUrl)
|
||||
return [...html.getElementsByClassName("popover-hint")]
|
||||
})
|
||||
|
||||
fetchContentCache.set(slug, contents)
|
||||
return contents
|
||||
}
|
||||
|
||||
async function displayPreview(el: HTMLElement | null) {
|
||||
if (!searchLayout || !enablePreview || !el || !preview) return
|
||||
const slug = el.id as FullSlug
|
||||
const innerDiv = await fetchContent(slug).then((contents) =>
|
||||
contents.flatMap((el) => [...highlightHTML(currentSearchTerm, el as HTMLElement).children]),
|
||||
)
|
||||
previewInner = document.createElement("div")
|
||||
previewInner.classList.add("preview-inner")
|
||||
previewInner.append(...innerDiv)
|
||||
preview.replaceChildren(previewInner)
|
||||
|
||||
// scroll to longest
|
||||
const highlights = [...preview.getElementsByClassName("highlight")].sort(
|
||||
(a, b) => b.innerHTML.length - a.innerHTML.length,
|
||||
)
|
||||
highlights[0]?.scrollIntoView({ block: "start" })
|
||||
}
|
||||
|
||||
async function onType(e: HTMLElementEventMap["input"]) {
|
||||
if (!searchLayout || !index) return
|
||||
currentSearchTerm = (e.target as HTMLInputElement).value
|
||||
searchLayout.classList.toggle("display-results", currentSearchTerm !== "")
|
||||
searchType = currentSearchTerm.startsWith("#") ? "tags" : "basic"
|
||||
|
||||
let searchResults: DefaultDocumentSearchResults<Item>
|
||||
if (searchType === "tags") {
|
||||
currentSearchTerm = currentSearchTerm.substring(1).trim()
|
||||
const separatorIndex = currentSearchTerm.indexOf(" ")
|
||||
if (separatorIndex != -1) {
|
||||
// search by title and content index and then filter by tag (implemented in flexsearch)
|
||||
const tag = currentSearchTerm.substring(0, separatorIndex)
|
||||
const query = currentSearchTerm.substring(separatorIndex + 1).trim()
|
||||
searchResults = await index.searchAsync({
|
||||
query: query,
|
||||
// return at least 10000 documents, so it is enough to filter them by tag (implemented in flexsearch)
|
||||
limit: Math.max(numSearchResults, 10000),
|
||||
index: ["title", "content"],
|
||||
tag: { tags: tag },
|
||||
})
|
||||
for (let searchResult of searchResults) {
|
||||
searchResult.result = searchResult.result.slice(0, numSearchResults)
|
||||
}
|
||||
// set search type to basic and remove tag from term for proper highlightning and scroll
|
||||
searchType = "basic"
|
||||
currentSearchTerm = query
|
||||
} else {
|
||||
// default search by tags index
|
||||
searchResults = await index.searchAsync({
|
||||
query: currentSearchTerm,
|
||||
limit: numSearchResults,
|
||||
index: ["tags"],
|
||||
})
|
||||
}
|
||||
} else if (searchType === "basic") {
|
||||
searchResults = await index.searchAsync({
|
||||
query: currentSearchTerm,
|
||||
limit: numSearchResults,
|
||||
index: ["title", "content"],
|
||||
})
|
||||
}
|
||||
|
||||
const getByField = (field: string): number[] => {
|
||||
const results = searchResults.filter((x) => x.field === field)
|
||||
return results.length === 0 ? [] : ([...results[0].result] as number[])
|
||||
}
|
||||
|
||||
// order titles ahead of content
|
||||
const allIds: Set<number> = new Set([
|
||||
...getByField("title"),
|
||||
...getByField("content"),
|
||||
...getByField("tags"),
|
||||
])
|
||||
const finalResults = [...allIds].map((id) => formatForDisplay(currentSearchTerm, id))
|
||||
await displayResults(finalResults)
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", shortcutHandler)
|
||||
window.addCleanup(() => document.removeEventListener("keydown", shortcutHandler))
|
||||
searchButton.addEventListener("click", () => showSearch("basic"))
|
||||
window.addCleanup(() => searchButton.removeEventListener("click", () => showSearch("basic")))
|
||||
searchBar.addEventListener("input", onType)
|
||||
window.addCleanup(() => searchBar.removeEventListener("input", onType))
|
||||
|
||||
registerEscapeHandler(container, hideSearch)
|
||||
await fillDocument(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills flexsearch document with data
|
||||
* @param index index to fill
|
||||
* @param data data to fill index with
|
||||
*/
|
||||
let indexPopulated = false
|
||||
async function fillDocument(data: ContentIndex) {
|
||||
if (indexPopulated) return
|
||||
let id = 0
|
||||
const promises: Array<Promise<unknown>> = []
|
||||
for (const [slug, fileData] of Object.entries<ContentDetails>(data)) {
|
||||
promises.push(
|
||||
index.addAsync(id++, {
|
||||
id,
|
||||
slug: slug as FullSlug,
|
||||
title: fileData.title,
|
||||
content: fileData.content,
|
||||
tags: fileData.tags,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
await Promise.all(promises)
|
||||
indexPopulated = true
|
||||
}
|
||||
|
||||
document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
|
||||
const currentSlug = e.detail.url
|
||||
const data = await fetchData
|
||||
const searchElement = document.getElementsByClassName("search")
|
||||
for (const element of searchElement) {
|
||||
await setupSearch(element, currentSlug, data)
|
||||
}
|
||||
})
|
||||
214
quartz/components/scripts/spa.inline.ts
Normal file
214
quartz/components/scripts/spa.inline.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
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() {
|
||||
const loadingBar = document.createElement("div")
|
||||
loadingBar.className = "navigation-progress"
|
||||
loadingBar.style.width = "0"
|
||||
if (!document.body.contains(loadingBar)) {
|
||||
document.body.appendChild(loadingBar)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
loadingBar.style.width = "80%"
|
||||
}, 100)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// morph body
|
||||
micromorph(document.body, html.body)
|
||||
|
||||
// scroll into place and add history
|
||||
if (!isBack) {
|
||||
if (url.hash) {
|
||||
const el = document.getElementById(decodeURIComponent(url.hash.substring(1)))
|
||||
el?.scrollIntoView()
|
||||
} else {
|
||||
window.scrollTo({ top: 0 })
|
||||
}
|
||||
}
|
||||
|
||||
// now, patch head, re-executing scripts
|
||||
const elementsToRemove = document.head.querySelectorAll(":not([spa-preserve])")
|
||||
elementsToRemove.forEach((el) => el.remove())
|
||||
const elementsToAdd = html.head.querySelectorAll(":not([spa-preserve])")
|
||||
elementsToAdd.forEach((el) => document.head.appendChild(el))
|
||||
|
||||
// delay setting the url until now
|
||||
// at this point everything is loaded so changing the url should resolve to the correct addresses
|
||||
if (!isBack) {
|
||||
history.pushState({}, "", url)
|
||||
}
|
||||
|
||||
notifyNav(getFullSlug(window))
|
||||
delete announcer.dataset.persist
|
||||
}
|
||||
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
44
quartz/components/scripts/toc.inline.ts
Normal file
44
quartz/components/scripts/toc.inline.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const slug = entry.target.id
|
||||
const tocEntryElements = document.querySelectorAll(`a[data-for="${slug}"]`)
|
||||
const windowHeight = entry.rootBounds?.height
|
||||
if (windowHeight && tocEntryElements.length > 0) {
|
||||
if (entry.boundingClientRect.y < windowHeight) {
|
||||
tocEntryElements.forEach((tocEntryElement) => tocEntryElement.classList.add("in-view"))
|
||||
} else {
|
||||
tocEntryElements.forEach((tocEntryElement) => tocEntryElement.classList.remove("in-view"))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function toggleToc(this: HTMLElement) {
|
||||
this.classList.toggle("collapsed")
|
||||
this.setAttribute(
|
||||
"aria-expanded",
|
||||
this.getAttribute("aria-expanded") === "true" ? "false" : "true",
|
||||
)
|
||||
const content = this.nextElementSibling as HTMLElement | undefined
|
||||
if (!content) return
|
||||
content.classList.toggle("collapsed")
|
||||
}
|
||||
|
||||
function setupToc() {
|
||||
for (const toc of document.getElementsByClassName("toc")) {
|
||||
const button = toc.querySelector(".toc-header")
|
||||
const content = toc.querySelector(".toc-content")
|
||||
if (!button || !content) return
|
||||
button.addEventListener("click", toggleToc)
|
||||
window.addCleanup(() => button.removeEventListener("click", toggleToc))
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("nav", () => {
|
||||
setupToc()
|
||||
|
||||
// update toc entry highlighting
|
||||
observer.disconnect()
|
||||
const headers = document.querySelectorAll("h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]")
|
||||
headers.forEach((header) => observer.observe(header))
|
||||
})
|
||||
46
quartz/components/scripts/util.ts
Normal file
46
quartz/components/scripts/util.ts
Normal file
@@ -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
|
||||
}
|
||||
24
quartz/components/styles/backlinks.scss
Normal file
24
quartz/components/styles/backlinks.scss
Normal file
@@ -0,0 +1,24 @@
|
||||
@use "../../styles/variables.scss" as *;
|
||||
|
||||
.backlinks {
|
||||
flex-direction: column;
|
||||
|
||||
& > h3 {
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
& > ul.overflow {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0.5rem 0;
|
||||
max-height: calc(100% - 2rem);
|
||||
overscroll-behavior: contain;
|
||||
|
||||
& > li {
|
||||
& > a {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
22
quartz/components/styles/breadcrumbs.scss
Normal file
22
quartz/components/styles/breadcrumbs.scss
Normal file
@@ -0,0 +1,22 @@
|
||||
.breadcrumb-container {
|
||||
margin: 0;
|
||||
margin-top: 0.75rem;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.breadcrumb-element {
|
||||
p {
|
||||
margin: 0;
|
||||
margin-left: 0.5rem;
|
||||
padding: 0;
|
||||
line-height: normal;
|
||||
}
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
36
quartz/components/styles/clipboard.scss
Normal file
36
quartz/components/styles/clipboard.scss
Normal file
@@ -0,0 +1,36 @@
|
||||
.clipboard-button {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
float: right;
|
||||
right: 0;
|
||||
padding: 0.4rem;
|
||||
margin: 0.3rem;
|
||||
color: var(--gray);
|
||||
border-color: var(--dark);
|
||||
background-color: var(--light);
|
||||
border: 1px solid;
|
||||
border-radius: 5px;
|
||||
opacity: 0;
|
||||
transition: 0.2s;
|
||||
|
||||
& > svg {
|
||||
fill: var(--light);
|
||||
filter: contrast(0.3);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
border-color: var(--secondary);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
}
|
||||
}
|
||||
|
||||
pre {
|
||||
&:hover > .clipboard-button {
|
||||
opacity: 1;
|
||||
transition: 0.2s;
|
||||
}
|
||||
}
|
||||
14
quartz/components/styles/contentMeta.scss
Normal file
14
quartz/components/styles/contentMeta.scss
Normal file
@@ -0,0 +1,14 @@
|
||||
.content-meta {
|
||||
margin-top: 0;
|
||||
color: var(--darkgray);
|
||||
|
||||
&[show-comma="true"] {
|
||||
> *:not(:last-child) {
|
||||
margin-right: 8px;
|
||||
|
||||
&::after {
|
||||
content: ",";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
47
quartz/components/styles/darkmode.scss
Normal file
47
quartz/components/styles/darkmode.scss
Normal file
@@ -0,0 +1,47 @@
|
||||
.darkmode {
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
background: none;
|
||||
border: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin: 0;
|
||||
text-align: inherit;
|
||||
flex-shrink: 0;
|
||||
|
||||
& svg {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
top: calc(50% - 10px);
|
||||
fill: var(--darkgray);
|
||||
transition: opacity 0.1s ease;
|
||||
}
|
||||
}
|
||||
|
||||
:root[saved-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
:root[saved-theme="light"] {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
:root[saved-theme="dark"] .darkmode {
|
||||
& > .dayIcon {
|
||||
display: none;
|
||||
}
|
||||
& > .nightIcon {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
:root .darkmode {
|
||||
& > .dayIcon {
|
||||
display: inline;
|
||||
}
|
||||
& > .nightIcon {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
274
quartz/components/styles/explorer.scss
Normal file
274
quartz/components/styles/explorer.scss
Normal file
@@ -0,0 +1,274 @@
|
||||
@use "../../styles/variables.scss" as *;
|
||||
|
||||
@media all and ($mobile) {
|
||||
.page > #quartz-body {
|
||||
// Shift page position when toggling Explorer on mobile.
|
||||
& > :not(.sidebar.left:has(.explorer)) {
|
||||
transition: transform 300ms ease-in-out;
|
||||
}
|
||||
&.lock-scroll > :not(.sidebar.left:has(.explorer)) {
|
||||
transform: translateX(100dvw);
|
||||
transition: transform 300ms ease-in-out;
|
||||
}
|
||||
|
||||
// Sticky top bar (stays in place when scrolling down on mobile).
|
||||
.sidebar.left:has(.explorer) {
|
||||
box-sizing: border-box;
|
||||
position: sticky;
|
||||
background-color: var(--light);
|
||||
padding: 1rem 0 1rem 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hide-until-loaded ~ .explorer-content {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.explorer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: hidden;
|
||||
|
||||
min-height: 1.2rem;
|
||||
flex: 0 1 auto;
|
||||
&.collapsed {
|
||||
flex: 0 1 1.2rem;
|
||||
& .fold {
|
||||
transform: rotateZ(-90deg);
|
||||
}
|
||||
}
|
||||
|
||||
& .fold {
|
||||
margin-left: 0.5rem;
|
||||
transition: transform 0.3s ease;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
@media all and ($mobile) {
|
||||
order: -1;
|
||||
height: initial;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
align-self: flex-start;
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
}
|
||||
|
||||
button.mobile-explorer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
button.desktop-explorer {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@media all and ($mobile) {
|
||||
button.mobile-explorer {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
button.desktop-explorer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.desktop-only {
|
||||
@media all and not ($mobile) {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
pointer-events: all;
|
||||
transition: transform 0.35s ease;
|
||||
|
||||
& > polyline {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
button.mobile-explorer,
|
||||
button.desktop-explorer {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
color: var(--dark);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
& h2 {
|
||||
font-size: 1rem;
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.explorer-content {
|
||||
list-style: none;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
margin-top: 0.5rem;
|
||||
|
||||
& ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overscroll-behavior: contain;
|
||||
|
||||
& li > a {
|
||||
color: var(--dark);
|
||||
opacity: 0.75;
|
||||
pointer-events: all;
|
||||
|
||||
&.active {
|
||||
opacity: 1;
|
||||
color: var(--tertiary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.folder-outer {
|
||||
visibility: collapse;
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition-property: grid-template-rows, visibility;
|
||||
transition-duration: 0.3s;
|
||||
transition-timing-function: ease-in-out;
|
||||
}
|
||||
|
||||
.folder-outer.open {
|
||||
visibility: visible;
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.folder-outer > ul {
|
||||
overflow: hidden;
|
||||
margin-left: 6px;
|
||||
padding-left: 0.8rem;
|
||||
border-left: 1px solid var(--lightgray);
|
||||
}
|
||||
}
|
||||
|
||||
.folder-container {
|
||||
flex-direction: row;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
|
||||
& div > a {
|
||||
color: var(--secondary);
|
||||
font-family: var(--headerFont);
|
||||
font-size: 0.95rem;
|
||||
font-weight: $semiBoldWeight;
|
||||
line-height: 1.5rem;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
& div > a:hover {
|
||||
color: var(--tertiary);
|
||||
}
|
||||
|
||||
& div > button {
|
||||
color: var(--dark);
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-family: var(--headerFont);
|
||||
|
||||
& span {
|
||||
font-size: 0.95rem;
|
||||
display: inline-block;
|
||||
color: var(--secondary);
|
||||
font-weight: $semiBoldWeight;
|
||||
margin: 0;
|
||||
line-height: 1.5rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.folder-icon {
|
||||
margin-right: 5px;
|
||||
color: var(--secondary);
|
||||
cursor: pointer;
|
||||
transition: transform 0.3s ease;
|
||||
backface-visibility: visible;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
li:has(> .folder-outer:not(.open)) > .folder-container > svg {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.folder-icon:hover {
|
||||
color: var(--tertiary);
|
||||
}
|
||||
|
||||
.explorer {
|
||||
@media all and ($mobile) {
|
||||
&.collapsed {
|
||||
flex: 0 0 34px;
|
||||
|
||||
& > .explorer-content {
|
||||
transform: translateX(-100vw);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.collapsed) {
|
||||
flex: 0 0 34px;
|
||||
|
||||
& > .explorer-content {
|
||||
transform: translateX(0);
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.explorer-content {
|
||||
box-sizing: border-box;
|
||||
z-index: 100;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
margin-top: 0;
|
||||
background-color: var(--light);
|
||||
max-width: 100vw;
|
||||
width: 100vw;
|
||||
transform: translateX(-100vw);
|
||||
transition:
|
||||
transform 200ms ease,
|
||||
visibility 200ms ease;
|
||||
overflow: hidden;
|
||||
padding: 4rem 0 2rem 0;
|
||||
height: 100dvh;
|
||||
max-height: 100dvh;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.mobile-explorer {
|
||||
margin: 0;
|
||||
padding: 5px;
|
||||
z-index: 101;
|
||||
|
||||
.lucide-menu {
|
||||
stroke: var(--darkgray);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-no-scroll {
|
||||
@media all and ($mobile) {
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
}
|
||||
15
quartz/components/styles/footer.scss
Normal file
15
quartz/components/styles/footer.scss
Normal file
@@ -0,0 +1,15 @@
|
||||
footer {
|
||||
text-align: left;
|
||||
margin-bottom: 4rem;
|
||||
opacity: 0.7;
|
||||
|
||||
& ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1rem;
|
||||
margin-top: -1rem;
|
||||
}
|
||||
}
|
||||
73
quartz/components/styles/graph.scss
Normal file
73
quartz/components/styles/graph.scss
Normal file
@@ -0,0 +1,73 @@
|
||||
@use "../../styles/variables.scss" as *;
|
||||
|
||||
.graph {
|
||||
& > h3 {
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
& > .graph-outer {
|
||||
border-radius: 5px;
|
||||
border: 1px solid var(--lightgray);
|
||||
box-sizing: border-box;
|
||||
height: 250px;
|
||||
margin: 0.5em 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
& > .global-graph-icon {
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--dark);
|
||||
opacity: 0.5;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
position: absolute;
|
||||
padding: 0.2rem;
|
||||
margin: 0.3rem;
|
||||
top: 0;
|
||||
right: 0;
|
||||
border-radius: 4px;
|
||||
background-color: transparent;
|
||||
transition: background-color 0.5s ease;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
background-color: var(--lightgray);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& > .global-graph-outer {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100vw;
|
||||
height: 100%;
|
||||
backdrop-filter: blur(4px);
|
||||
display: none;
|
||||
overflow: hidden;
|
||||
|
||||
&.active {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
& > .global-graph-container {
|
||||
border: 1px solid var(--lightgray);
|
||||
background-color: var(--light);
|
||||
border-radius: 5px;
|
||||
box-sizing: border-box;
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
height: 80vh;
|
||||
width: 80vw;
|
||||
|
||||
@media all and not ($desktop) {
|
||||
width: 90%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
27
quartz/components/styles/legacyToc.scss
Normal file
27
quartz/components/styles/legacyToc.scss
Normal file
@@ -0,0 +1,27 @@
|
||||
details.toc {
|
||||
& summary {
|
||||
cursor: pointer;
|
||||
|
||||
&::marker {
|
||||
color: var(--dark);
|
||||
}
|
||||
|
||||
& > * {
|
||||
padding-left: 0.25rem;
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
& ul {
|
||||
list-style: none;
|
||||
margin: 0.5rem 1.25rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@for $i from 1 through 6 {
|
||||
& .depth-#{$i} {
|
||||
padding-left: calc(1rem * #{$i});
|
||||
}
|
||||
}
|
||||
}
|
||||
40
quartz/components/styles/listPage.scss
Normal file
40
quartz/components/styles/listPage.scss
Normal file
@@ -0,0 +1,40 @@
|
||||
@use "../../styles/variables.scss" as *;
|
||||
|
||||
ul.section-ul {
|
||||
list-style: none;
|
||||
margin-top: 2em;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
li.section-li {
|
||||
margin-bottom: 1em;
|
||||
|
||||
& > .section {
|
||||
display: grid;
|
||||
grid-template-columns: fit-content(8em) 3fr 1fr;
|
||||
|
||||
@media all and ($mobile) {
|
||||
& > .tags {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
& > .desc > h3 > a {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
& .meta {
|
||||
margin: 0 1em 0 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// modifications in popover context
|
||||
.popover .section {
|
||||
grid-template-columns: fit-content(8em) 1fr !important;
|
||||
|
||||
& > .tags {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
133
quartz/components/styles/mermaid.inline.scss
Normal file
133
quartz/components/styles/mermaid.inline.scss
Normal file
@@ -0,0 +1,133 @@
|
||||
.expand-button {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
float: right;
|
||||
padding: 0.4rem;
|
||||
margin: 0.3rem;
|
||||
right: 0; // NOTE: right will be set in mermaid.inline.ts
|
||||
color: var(--gray);
|
||||
border-color: var(--dark);
|
||||
background-color: var(--light);
|
||||
border: 1px solid;
|
||||
border-radius: 5px;
|
||||
opacity: 0;
|
||||
transition: 0.2s;
|
||||
|
||||
& > svg {
|
||||
fill: var(--light);
|
||||
filter: contrast(0.3);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
border-color: var(--secondary);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
}
|
||||
}
|
||||
|
||||
pre {
|
||||
&:hover > .expand-button {
|
||||
opacity: 1;
|
||||
transition: 0.2s;
|
||||
}
|
||||
}
|
||||
|
||||
#mermaid-container {
|
||||
position: fixed;
|
||||
contain: layout;
|
||||
z-index: 999;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
display: none;
|
||||
backdrop-filter: blur(4px);
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
|
||||
&.active {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
& > #mermaid-space {
|
||||
border: 1px solid var(--lightgray);
|
||||
background-color: var(--light);
|
||||
border-radius: 5px;
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
height: 80vh;
|
||||
width: 80vw;
|
||||
overflow: hidden;
|
||||
|
||||
& > .mermaid-content {
|
||||
padding: 2rem;
|
||||
position: relative;
|
||||
transform-origin: 0 0;
|
||||
transition: transform 0.1s ease;
|
||||
overflow: visible;
|
||||
min-height: 200px;
|
||||
min-width: 200px;
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
svg {
|
||||
max-width: none;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
& > .mermaid-controls {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
background: var(--light);
|
||||
border: 1px solid var(--lightgray);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
z-index: 2;
|
||||
|
||||
.mermaid-control-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--lightgray);
|
||||
background: var(--light);
|
||||
color: var(--dark);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-family: var(--bodyFont);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--lightgray);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
// Style the reset button differently
|
||||
&:nth-child(2) {
|
||||
width: auto;
|
||||
padding: 0 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
89
quartz/components/styles/popover.scss
Normal file
89
quartz/components/styles/popover.scss
Normal file
@@ -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;
|
||||
}
|
||||
34
quartz/components/styles/readermode.scss
Normal file
34
quartz/components/styles/readermode.scss
Normal file
@@ -0,0 +1,34 @@
|
||||
.readermode {
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
background: none;
|
||||
border: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin: 0;
|
||||
text-align: inherit;
|
||||
flex-shrink: 0;
|
||||
|
||||
& svg {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
top: calc(50% - 10px);
|
||||
fill: var(--darkgray);
|
||||
stroke: var(--darkgray);
|
||||
transition: opacity 0.1s ease;
|
||||
}
|
||||
}
|
||||
|
||||
:root[reader-mode="on"] {
|
||||
& .sidebar.left,
|
||||
& .sidebar.right {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
24
quartz/components/styles/recentNotes.scss
Normal file
24
quartz/components/styles/recentNotes.scss
Normal file
@@ -0,0 +1,24 @@
|
||||
.recent-notes {
|
||||
& > h3 {
|
||||
margin: 0.5rem 0 0 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
& > ul.recent-ul {
|
||||
list-style: none;
|
||||
margin-top: 1rem;
|
||||
padding-left: 0;
|
||||
|
||||
& > li {
|
||||
margin: 1rem 0;
|
||||
.section > .desc > h3 > a {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.section > .meta {
|
||||
margin: 0 0 0.5rem 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
241
quartz/components/styles/search.scss
Normal file
241
quartz/components/styles/search.scss
Normal file
@@ -0,0 +1,241 @@
|
||||
@use "../../styles/variables.scss" as *;
|
||||
|
||||
.search {
|
||||
min-width: fit-content;
|
||||
max-width: 14rem;
|
||||
@media all and ($mobile) {
|
||||
flex-grow: 0.3;
|
||||
}
|
||||
|
||||
& > .search-button {
|
||||
background-color: transparent;
|
||||
border: 1px var(--lightgray) solid;
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
height: 2rem;
|
||||
padding: 0 1rem 0 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: inherit;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
|
||||
& > p {
|
||||
display: inline;
|
||||
color: var(--gray);
|
||||
}
|
||||
|
||||
& svg {
|
||||
cursor: pointer;
|
||||
width: 18px;
|
||||
min-width: 18px;
|
||||
margin: 0 0.5rem;
|
||||
|
||||
.search-path {
|
||||
stroke: var(--darkgray);
|
||||
stroke-width: 1.5px;
|
||||
transition: stroke 0.5s ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& > .search-container {
|
||||
position: fixed;
|
||||
contain: layout;
|
||||
z-index: 999;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
display: none;
|
||||
backdrop-filter: blur(4px);
|
||||
|
||||
&.active {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
& > .search-space {
|
||||
width: 65%;
|
||||
margin-top: 12vh;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
|
||||
@media all and not ($desktop) {
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
& > * {
|
||||
width: 100%;
|
||||
border-radius: 7px;
|
||||
background: var(--light);
|
||||
box-shadow:
|
||||
0 14px 50px rgba(27, 33, 48, 0.12),
|
||||
0 10px 30px rgba(27, 33, 48, 0.16);
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
|
||||
& > input {
|
||||
box-sizing: border-box;
|
||||
padding: 0.5em 1em;
|
||||
font-family: var(--bodyFont);
|
||||
color: var(--dark);
|
||||
font-size: 1.1em;
|
||||
border: 1px solid var(--lightgray);
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
& > .search-layout {
|
||||
display: none;
|
||||
flex-direction: row;
|
||||
border: 1px solid var(--lightgray);
|
||||
flex: 0 0 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
&.display-results {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&[data-preview] > .results-container {
|
||||
flex: 0 0 min(30%, 450px);
|
||||
}
|
||||
|
||||
@media all and not ($mobile) {
|
||||
&[data-preview] {
|
||||
& .result-card > p.preview {
|
||||
display: none;
|
||||
}
|
||||
|
||||
& > div {
|
||||
&:first-child {
|
||||
border-right: 1px solid var(--lightgray);
|
||||
border-top-right-radius: unset;
|
||||
border-bottom-right-radius: unset;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-top-left-radius: unset;
|
||||
border-bottom-left-radius: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& > div {
|
||||
height: calc(75vh - 12vh);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
@media all and ($mobile) {
|
||||
flex-direction: column;
|
||||
|
||||
& > .preview-container {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
&[data-preview] > .results-container {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
flex: 0 0 100%;
|
||||
}
|
||||
}
|
||||
|
||||
& .highlight {
|
||||
background: color-mix(in srgb, var(--tertiary) 60%, rgba(255, 255, 255, 0));
|
||||
border-radius: 5px;
|
||||
scroll-margin-top: 2rem;
|
||||
}
|
||||
|
||||
& > .preview-container {
|
||||
flex-grow: 1;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
font-family: inherit;
|
||||
color: var(--dark);
|
||||
line-height: 1.5em;
|
||||
font-weight: $normalWeight;
|
||||
overflow-y: auto;
|
||||
padding: 0 2rem;
|
||||
|
||||
& .preview-inner {
|
||||
margin: 0 auto;
|
||||
width: min($pageWidth, 100%);
|
||||
}
|
||||
|
||||
a[role="anchor"] {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
& > .results-container {
|
||||
overflow-y: auto;
|
||||
|
||||
& .result-card {
|
||||
overflow: hidden;
|
||||
padding: 1em;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
border-bottom: 1px solid var(--lightgray);
|
||||
width: 100%;
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
|
||||
// normalize card props
|
||||
font-family: inherit;
|
||||
font-size: 100%;
|
||||
line-height: 1.15;
|
||||
margin: 0;
|
||||
text-transform: none;
|
||||
text-align: left;
|
||||
outline: none;
|
||||
font-weight: inherit;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&.focus {
|
||||
background: var(--lightgray);
|
||||
}
|
||||
|
||||
& > h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media all and not ($mobile) {
|
||||
& > p.card-description {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
& > ul.tags {
|
||||
margin-top: 0.45rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
& > ul > li > p {
|
||||
border-radius: 8px;
|
||||
background-color: var(--highlight);
|
||||
padding: 0.2rem 0.4rem;
|
||||
margin: 0 0.1rem;
|
||||
line-height: 1.4rem;
|
||||
font-weight: $boldWeight;
|
||||
color: var(--secondary);
|
||||
|
||||
&.match-tag {
|
||||
color: var(--tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
& > p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
66
quartz/components/styles/toc.scss
Normal file
66
quartz/components/styles/toc.scss
Normal file
@@ -0,0 +1,66 @@
|
||||
@use "../../styles/variables.scss" as *;
|
||||
|
||||
.toc {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: hidden;
|
||||
min-height: 1.4rem;
|
||||
flex: 0 0.5 auto;
|
||||
&:has(button.toc-header.collapsed) {
|
||||
flex: 0 1 1.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
button.toc-header {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
color: var(--dark);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
& h3 {
|
||||
font-size: 1rem;
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
& .fold {
|
||||
margin-left: 0.5rem;
|
||||
transition: transform 0.3s ease;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&.collapsed .fold {
|
||||
transform: rotateZ(-90deg);
|
||||
}
|
||||
}
|
||||
|
||||
ul.toc-content.overflow {
|
||||
list-style: none;
|
||||
position: relative;
|
||||
margin: 0.5rem 0;
|
||||
padding: 0;
|
||||
max-height: calc(100% - 2rem);
|
||||
overscroll-behavior: contain;
|
||||
list-style: none;
|
||||
|
||||
& > li > a {
|
||||
color: var(--dark);
|
||||
opacity: 0.35;
|
||||
transition:
|
||||
0.5s ease opacity,
|
||||
0.3s ease color;
|
||||
&.in-view {
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
|
||||
@for $i from 0 through 6 {
|
||||
& .depth-#{$i} {
|
||||
padding-left: calc(1rem * #{$i});
|
||||
}
|
||||
}
|
||||
}
|
||||
29
quartz/components/types.ts
Normal file
29
quartz/components/types.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { ComponentType, 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 = ComponentType<QuartzComponentProps> & {
|
||||
css?: StringResource
|
||||
beforeDOMLoaded?: StringResource
|
||||
afterDOMLoaded?: StringResource
|
||||
}
|
||||
|
||||
export type QuartzComponentConstructor<Options extends object | undefined = undefined> = (
|
||||
opts: Options,
|
||||
) => QuartzComponent
|
||||
86
quartz/i18n/index.ts
Normal file
86
quartz/i18n/index.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
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"
|
||||
|
||||
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,
|
||||
} 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
|
||||
93
quartz/i18n/locales/ar-SA.ts
Normal file
93
quartz/i18n/locales/ar-SA.ts
Normal file
@@ -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
|
||||
87
quartz/i18n/locales/ca-ES.ts
Normal file
87
quartz/i18n/locales/ca-ES.ts
Normal file
@@ -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
|
||||
87
quartz/i18n/locales/cs-CZ.ts
Normal file
87
quartz/i18n/locales/cs-CZ.ts
Normal file
@@ -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
|
||||
87
quartz/i18n/locales/de-DE.ts
Normal file
87
quartz/i18n/locales/de-DE.ts
Normal file
@@ -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
|
||||
88
quartz/i18n/locales/definition.ts
Normal file
88
quartz/i18n/locales/definition.ts
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
87
quartz/i18n/locales/en-GB.ts
Normal file
87
quartz/i18n/locales/en-GB.ts
Normal file
@@ -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
|
||||
87
quartz/i18n/locales/en-US.ts
Normal file
87
quartz/i18n/locales/en-US.ts
Normal file
@@ -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
|
||||
87
quartz/i18n/locales/es-ES.ts
Normal file
87
quartz/i18n/locales/es-ES.ts
Normal file
@@ -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
|
||||
88
quartz/i18n/locales/fa-IR.ts
Normal file
88
quartz/i18n/locales/fa-IR.ts
Normal file
@@ -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
|
||||
87
quartz/i18n/locales/fi-FI.ts
Normal file
87
quartz/i18n/locales/fi-FI.ts
Normal file
@@ -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
|
||||
87
quartz/i18n/locales/fr-FR.ts
Normal file
87
quartz/i18n/locales/fr-FR.ts
Normal file
@@ -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
|
||||
85
quartz/i18n/locales/hu-HU.ts
Normal file
85
quartz/i18n/locales/hu-HU.ts
Normal file
@@ -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
|
||||
87
quartz/i18n/locales/id-ID.ts
Normal file
87
quartz/i18n/locales/id-ID.ts
Normal file
@@ -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
|
||||
87
quartz/i18n/locales/it-IT.ts
Normal file
87
quartz/i18n/locales/it-IT.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Translation } from "./definition"
|
||||
|
||||
export default {
|
||||
propertyDefaults: {
|
||||
title: "Senza titolo",
|
||||
description: "Nessuna descrizione",
|
||||
},
|
||||
components: {
|
||||
callout: {
|
||||
note: "Nota",
|
||||
abstract: "Astratto",
|
||||
info: "Info",
|
||||
todo: "Da fare",
|
||||
tip: "Consiglio",
|
||||
success: "Completato",
|
||||
question: "Domanda",
|
||||
warning: "Attenzione",
|
||||
failure: "Errore",
|
||||
danger: "Pericolo",
|
||||
bug: "Bug",
|
||||
example: "Esempio",
|
||||
quote: "Citazione",
|
||||
},
|
||||
backlinks: {
|
||||
title: "Link entranti",
|
||||
noBacklinksFound: "Nessun link entrante",
|
||||
},
|
||||
themeToggle: {
|
||||
lightMode: "Tema chiaro",
|
||||
darkMode: "Tema scuro",
|
||||
},
|
||||
readerMode: {
|
||||
title: "Modalità lettura",
|
||||
},
|
||||
explorer: {
|
||||
title: "Esplora",
|
||||
},
|
||||
footer: {
|
||||
createdWith: "Creato con",
|
||||
},
|
||||
graph: {
|
||||
title: "Vista grafico",
|
||||
},
|
||||
recentNotes: {
|
||||
title: "Note recenti",
|
||||
seeRemainingMore: ({ remaining }) => `Vedi ${remaining} altro →`,
|
||||
},
|
||||
transcludes: {
|
||||
transcludeOf: ({ targetSlug }) => `Transclusione di ${targetSlug}`,
|
||||
linkToOriginal: "Link all'originale",
|
||||
},
|
||||
search: {
|
||||
title: "Cerca",
|
||||
searchBarPlaceholder: "Cerca qualcosa",
|
||||
},
|
||||
tableOfContents: {
|
||||
title: "Tabella dei contenuti",
|
||||
},
|
||||
contentMeta: {
|
||||
readingTime: ({ minutes }) => `${minutes} minuti`,
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
recentNotes: "Note recenti",
|
||||
lastFewNotes: ({ count }) => `Ultime ${count} note`,
|
||||
},
|
||||
error: {
|
||||
title: "Non trovato",
|
||||
notFound: "Questa pagina è privata o non esiste.",
|
||||
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 }) => `Prime ${count} etichette.`,
|
||||
totalTags: ({ count }) => `Trovate ${count} etichette totali.`,
|
||||
},
|
||||
},
|
||||
} as const satisfies Translation
|
||||
85
quartz/i18n/locales/ja-JP.ts
Normal file
85
quartz/i18n/locales/ja-JP.ts
Normal file
@@ -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
|
||||
85
quartz/i18n/locales/ko-KR.ts
Normal file
85
quartz/i18n/locales/ko-KR.ts
Normal file
@@ -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
|
||||
107
quartz/i18n/locales/lt-LT.ts
Normal file
107
quartz/i18n/locales/lt-LT.ts
Normal file
@@ -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
|
||||
87
quartz/i18n/locales/nb-NO.ts
Normal file
87
quartz/i18n/locales/nb-NO.ts
Normal file
@@ -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
|
||||
89
quartz/i18n/locales/nl-NL.ts
Normal file
89
quartz/i18n/locales/nl-NL.ts
Normal file
@@ -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
|
||||
87
quartz/i18n/locales/pl-PL.ts
Normal file
87
quartz/i18n/locales/pl-PL.ts
Normal file
@@ -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
|
||||
87
quartz/i18n/locales/pt-BR.ts
Normal file
87
quartz/i18n/locales/pt-BR.ts
Normal file
@@ -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
|
||||
88
quartz/i18n/locales/ro-RO.ts
Normal file
88
quartz/i18n/locales/ro-RO.ts
Normal file
@@ -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
|
||||
99
quartz/i18n/locales/ru-RU.ts
Normal file
99
quartz/i18n/locales/ru-RU.ts
Normal file
@@ -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
|
||||
}
|
||||
85
quartz/i18n/locales/th-TH.ts
Normal file
85
quartz/i18n/locales/th-TH.ts
Normal file
@@ -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
|
||||
87
quartz/i18n/locales/tr-TR.ts
Normal file
87
quartz/i18n/locales/tr-TR.ts
Normal file
@@ -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
|
||||
87
quartz/i18n/locales/uk-UA.ts
Normal file
87
quartz/i18n/locales/uk-UA.ts
Normal file
@@ -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
|
||||
87
quartz/i18n/locales/vi-VN.ts
Normal file
87
quartz/i18n/locales/vi-VN.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Translation } from "./definition"
|
||||
|
||||
export default {
|
||||
propertyDefaults: {
|
||||
title: "Không có tiêu đề",
|
||||
description: "Không có mô tả được cung cấp",
|
||||
},
|
||||
components: {
|
||||
callout: {
|
||||
note: "Ghi Chú",
|
||||
abstract: "Tóm Tắt",
|
||||
info: "Thông tin",
|
||||
todo: "Cần Làm",
|
||||
tip: "Gợi Ý",
|
||||
success: "Thành Công",
|
||||
question: "Nghi Vấn",
|
||||
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 được tìm thấy",
|
||||
},
|
||||
themeToggle: {
|
||||
lightMode: "Sáng",
|
||||
darkMode: "Tối",
|
||||
},
|
||||
readerMode: {
|
||||
title: "Chế độ đọc",
|
||||
},
|
||||
explorer: {
|
||||
title: "Trong bài này",
|
||||
},
|
||||
footer: {
|
||||
createdWith: "Được tạo bởi",
|
||||
},
|
||||
graph: {
|
||||
title: "Biểu Đồ",
|
||||
},
|
||||
recentNotes: {
|
||||
title: "Bài viết gần đây",
|
||||
seeRemainingMore: ({ remaining }) => `Xem ${remaining} thêm →`,
|
||||
},
|
||||
transcludes: {
|
||||
transcludeOf: ({ targetSlug }) => `Bao gồm ${targetSlug}`,
|
||||
linkToOriginal: "Liên Kết Gốc",
|
||||
},
|
||||
search: {
|
||||
title: "Tìm Kiếm",
|
||||
searchBarPlaceholder: "Tìm kiếm thông tin",
|
||||
},
|
||||
tableOfContents: {
|
||||
title: "Bảng Nội Dung",
|
||||
},
|
||||
contentMeta: {
|
||||
readingTime: ({ minutes }) => `đọc ${minutes} phút`,
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
rss: {
|
||||
recentNotes: "Những bài gần đây",
|
||||
lastFewNotes: ({ count }) => `${count} Bài gần đây`,
|
||||
},
|
||||
error: {
|
||||
title: "Không Tìm Thấy",
|
||||
notFound: "Trang này được bảo mật hoặc không tồn tại.",
|
||||
home: "Trở về trang chủ",
|
||||
},
|
||||
folderContent: {
|
||||
folder: "Thư Mục",
|
||||
itemsUnderFolder: ({ count }) =>
|
||||
count === 1 ? "1 mục trong thư mục này." : `${count} mục trong thư mục này.`,
|
||||
},
|
||||
tagContent: {
|
||||
tag: "Thẻ",
|
||||
tagIndex: "Thẻ Mục Lục",
|
||||
itemsUnderTag: ({ count }) =>
|
||||
count === 1 ? "1 mục gắn thẻ này." : `${count} mục gắn thẻ này.`,
|
||||
showingFirst: ({ count }) => `Hiển thị trước ${count} thẻ.`,
|
||||
totalTags: ({ count }) => `Tìm thấy ${count} thẻ tổng cộng.`,
|
||||
},
|
||||
},
|
||||
} as const satisfies Translation
|
||||
85
quartz/i18n/locales/zh-CN.ts
Normal file
85
quartz/i18n/locales/zh-CN.ts
Normal file
@@ -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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user