feat: add quartz infra

This commit is contained in:
Youwen Wu 2024-12-29 02:11:31 -08:00
parent b2708695c2
commit e8a89ae2a9
Signed by: youwen5
GPG key ID: 865658ED1FE61EC3
165 changed files with 25798 additions and 0 deletions

3
.gitignore vendored
View file

@ -1,2 +1,5 @@
result result
main.pdf main.pdf
public
node_modules

6
content/index.md Normal file
View file

@ -0,0 +1,6 @@
---
title: Welcome to Quartz
---
This is a blank Quartz installation.
See the [documentation](https://quartz.jzhao.xyz) for how to get started.

17
globals.d.ts vendored Normal file
View file

@ -0,0 +1,17 @@
export declare global {
interface Document {
addEventListener<K extends keyof CustomEventMap>(
type: K,
listener: (this: Document, ev: CustomEventMap[K]) => void,
): void
removeEventListener<K extends keyof CustomEventMap>(
type: K,
listener: (this: Document, ev: CustomEventMap[K]) => void,
): void
dispatchEvent<K extends keyof CustomEventMap>(ev: CustomEventMap[K] | UIEvent): void
}
interface Window {
spaNavigate(url: URL, isBack: boolean = false)
addCleanup(fn: (...args: any[]) => void)
}
}

12
index.d.ts vendored Normal file
View file

@ -0,0 +1,12 @@
declare module "*.scss" {
const content: string
export = content
}
// dom custom event
interface CustomEventMap {
nav: CustomEvent<{ url: FullSlug }>
themechange: CustomEvent<{ theme: "light" | "dark" }>
}
declare const fetchData: Promise<ContentIndex>

8610
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

116
package.json Normal file
View file

@ -0,0 +1,116 @@
{
"name": "@jackyzha0/quartz",
"description": "🌱 publish your digital garden and notes as a website",
"private": true,
"version": "4.4.0",
"type": "module",
"author": "jackyzha0 <j.zhao2k19@gmail.com>",
"license": "MIT",
"homepage": "https://quartz.jzhao.xyz",
"repository": {
"type": "git",
"url": "https://github.com/jackyzha0/quartz.git"
},
"scripts": {
"quartz": "./quartz/bootstrap-cli.mjs",
"docs": "npx quartz build --serve -d docs",
"check": "tsc --noEmit && npx prettier . --check",
"format": "npx prettier . --write",
"test": "tsx ./quartz/util/path.test.ts && tsx ./quartz/depgraph.test.ts",
"profile": "0x -D prof ./quartz/bootstrap-cli.mjs build --concurrency=1"
},
"engines": {
"npm": ">=9.3.1",
"node": "20 || >=22"
},
"keywords": [
"site generator",
"ssg",
"digital-garden",
"markdown",
"blog",
"quartz"
],
"bin": {
"quartz": "./quartz/bootstrap-cli.mjs"
},
"dependencies": {
"@clack/prompts": "^0.9.0",
"@floating-ui/dom": "^1.6.12",
"@myriaddreamin/rehype-typst": "^0.5.0-rc9",
"@napi-rs/simple-git": "0.1.19",
"@tweenjs/tween.js": "^25.0.0",
"async-mutex": "^0.5.0",
"chalk": "^5.4.1",
"chokidar": "^4.0.3",
"cli-spinner": "^0.2.10",
"d3": "^7.9.0",
"esbuild-sass-plugin": "^3.3.1",
"flexsearch": "0.7.43",
"github-slugger": "^2.0.0",
"globby": "^14.0.2",
"gray-matter": "^4.0.3",
"hast-util-to-html": "^9.0.4",
"hast-util-to-jsx-runtime": "^2.3.2",
"hast-util-to-string": "^3.0.1",
"is-absolute-url": "^4.0.1",
"js-yaml": "^4.1.0",
"lightningcss": "^1.28.2",
"mdast-util-find-and-replace": "^3.0.1",
"mdast-util-to-hast": "^13.2.0",
"mdast-util-to-string": "^4.0.0",
"mermaid": "^11.4.1",
"micromorph": "^0.4.5",
"pixi.js": "^8.6.6",
"preact": "^10.25.3",
"preact-render-to-string": "^6.5.12",
"pretty-bytes": "^6.1.1",
"pretty-time": "^1.1.0",
"reading-time": "^1.5.0",
"rehype-autolink-headings": "^7.1.0",
"rehype-citation": "^2.2.2",
"rehype-katex": "^7.0.1",
"rehype-mathjax": "^6.0.0",
"rehype-pretty-code": "^0.14.0",
"rehype-raw": "^7.0.0",
"rehype-slug": "^6.0.0",
"remark": "^15.0.1",
"remark-breaks": "^4.0.0",
"remark-frontmatter": "^5.0.0",
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.1",
"remark-smartypants": "^3.0.2",
"rfdc": "^1.4.1",
"rimraf": "^6.0.1",
"satori": "^0.12.0",
"serve-handler": "^6.1.6",
"sharp": "^0.33.5",
"shiki": "^1.24.4",
"source-map-support": "^0.5.21",
"to-vfile": "^8.0.0",
"toml": "^3.0.0",
"unified": "^11.0.5",
"unist-util-visit": "^5.0.0",
"vfile": "^6.0.3",
"workerpool": "^9.2.0",
"ws": "^8.18.0",
"yargs": "^17.7.2"
},
"devDependencies": {
"@types/cli-spinner": "^0.2.3",
"@types/d3": "^7.4.3",
"@types/hast": "^3.0.4",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.10.2",
"@types/pretty-time": "^1.1.5",
"@types/source-map-support": "^0.5.10",
"@types/ws": "^8.5.13",
"@types/yargs": "^17.0.33",
"esbuild": "^0.24.2",
"prettier": "^3.4.2",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}

95
quartz.config.ts Normal file
View file

@ -0,0 +1,95 @@
import { QuartzConfig } from "./quartz/cfg"
import * as Plugin from "./quartz/plugins"
/**
* Quartz 4.0 Configuration
*
* See https://quartz.jzhao.xyz/configuration for more information.
*/
const config: QuartzConfig = {
configuration: {
pageTitle: "🪴 Quartz 4.0",
pageTitleSuffix: "",
enableSPA: true,
enablePopovers: true,
analytics: {
provider: "plausible",
},
locale: "en-US",
baseUrl: "quartz.jzhao.xyz",
ignorePatterns: ["private", "templates", ".obsidian"],
defaultDateType: "created",
generateSocialImages: false,
theme: {
fontOrigin: "googleFonts",
cdnCaching: true,
typography: {
header: "Schibsted Grotesk",
body: "Source Sans Pro",
code: "IBM Plex Mono",
},
colors: {
lightMode: {
light: "#faf8f8",
lightgray: "#e5e5e5",
gray: "#b8b8b8",
darkgray: "#4e4e4e",
dark: "#2b2b2b",
secondary: "#284b63",
tertiary: "#84a59d",
highlight: "rgba(143, 159, 169, 0.15)",
textHighlight: "#fff23688",
},
darkMode: {
light: "#161618",
lightgray: "#393639",
gray: "#646464",
darkgray: "#d4d4d4",
dark: "#ebebec",
secondary: "#7b97aa",
tertiary: "#84a59d",
highlight: "rgba(143, 159, 169, 0.15)",
textHighlight: "#b3aa0288",
},
},
},
},
plugins: {
transformers: [
Plugin.FrontMatter(),
Plugin.CreatedModifiedDate({
priority: ["frontmatter", "filesystem"],
}),
Plugin.SyntaxHighlighting({
theme: {
light: "github-light",
dark: "github-dark",
},
keepBackground: false,
}),
Plugin.ObsidianFlavoredMarkdown({ enableInHtmlEmbed: false }),
Plugin.GitHubFlavoredMarkdown(),
Plugin.TableOfContents(),
Plugin.CrawlLinks({ markdownLinkResolution: "shortest" }),
Plugin.Description(),
Plugin.Latex({ renderEngine: "katex" }),
],
filters: [Plugin.RemoveDrafts()],
emitters: [
Plugin.AliasRedirects(),
Plugin.ComponentResources(),
Plugin.ContentPage(),
Plugin.FolderPage(),
Plugin.TagPage(),
Plugin.ContentIndex({
enableSiteMap: true,
enableRSS: true,
}),
Plugin.Assets(),
Plugin.Static(),
Plugin.NotFoundPage(),
],
},
}
export default config

50
quartz.layout.ts Normal file
View file

@ -0,0 +1,50 @@
import { PageLayout, SharedLayout } from "./quartz/cfg"
import * as Component from "./quartz/components"
// components shared across all pages
export const sharedPageComponents: SharedLayout = {
head: Component.Head(),
header: [],
afterBody: [],
footer: Component.Footer({
links: {
GitHub: "https://github.com/jackyzha0/quartz",
"Discord Community": "https://discord.gg/cRFFHYye7t",
},
}),
}
// components for pages that display a single page (e.g. a single note)
export const defaultContentPageLayout: PageLayout = {
beforeBody: [
Component.Breadcrumbs(),
Component.ArticleTitle(),
Component.ContentMeta(),
Component.TagList(),
],
left: [
Component.PageTitle(),
Component.MobileOnly(Component.Spacer()),
Component.Search(),
Component.Darkmode(),
Component.DesktopOnly(Component.Explorer()),
],
right: [
Component.Graph(),
Component.DesktopOnly(Component.TableOfContents()),
Component.Backlinks(),
],
}
// components for pages that display lists of pages (e.g. tags or folders)
export const defaultListPageLayout: PageLayout = {
beforeBody: [Component.Breadcrumbs(), Component.ArticleTitle(), Component.ContentMeta()],
left: [
Component.PageTitle(),
Component.MobileOnly(Component.Spacer()),
Component.Search(),
Component.Darkmode(),
Component.DesktopOnly(Component.Explorer()),
],
right: [],
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

21
quartz/LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 jackyzha0
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

41
quartz/bootstrap-cli.mjs Executable file
View file

@ -0,0 +1,41 @@
#!/usr/bin/env node
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

View file

@ -0,0 +1,7 @@
#!/usr/bin/env node
import workerpool from "workerpool"
const cacheFile = "./.quartz-cache/transpiled-worker.mjs"
const { parseFiles } = await import(cacheFile)
workerpool.worker({
parseFiles,
})

422
quartz/build.ts Normal file
View file

@ -0,0 +1,422 @@
import sourceMapSupport from "source-map-support"
sourceMapSupport.install(options)
import path from "path"
import { PerfTimer } from "./util/perf"
import { rimraf } from "rimraf"
import { GlobbyFilterFunction, isGitIgnored } from "globby"
import chalk from "chalk"
import { parseMarkdown } from "./processors/parse"
import { filterContent } from "./processors/filter"
import { emitContent } from "./processors/emit"
import cfg from "../quartz.config"
import { FilePath, FullSlug, joinSegments, slugifyFilePath } from "./util/path"
import chokidar from "chokidar"
import { ProcessedContent } from "./plugins/vfile"
import { Argv, BuildCtx } from "./util/ctx"
import { glob, toPosixPath } from "./util/glob"
import { trace } from "./util/trace"
import { options } from "./util/sourcemap"
import { Mutex } from "async-mutex"
import DepGraph from "./depgraph"
import { getStaticResourcesFromPlugins } from "./plugins"
type Dependencies = Record<string, DepGraph<FilePath> | null>
type BuildData = {
ctx: BuildCtx
ignored: GlobbyFilterFunction
mut: Mutex
initialSlugs: FullSlug[]
// TODO merge contentMap and trackedAssets
contentMap: Map<FilePath, ProcessedContent>
trackedAssets: Set<FilePath>
toRebuild: Set<FilePath>
toRemove: Set<FilePath>
lastBuildMs: number
dependencies: Dependencies
}
type FileEvent = "add" | "change" | "delete"
function newBuildId() {
return Math.random().toString(36).substring(2, 8)
}
async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) {
const ctx: BuildCtx = {
buildId: newBuildId(),
argv,
cfg,
allSlugs: [],
}
const perf = new PerfTimer()
const output = argv.output
const pluginCount = Object.values(cfg.plugins).flat().length
const pluginNames = (key: "transformers" | "filters" | "emitters") =>
cfg.plugins[key].map((plugin) => plugin.name)
if (argv.verbose) {
console.log(`Loaded ${pluginCount} plugins`)
console.log(` Transformers: ${pluginNames("transformers").join(", ")}`)
console.log(` Filters: ${pluginNames("filters").join(", ")}`)
console.log(` Emitters: ${pluginNames("emitters").join(", ")}`)
}
const release = await mut.acquire()
perf.addEvent("clean")
await rimraf(path.join(output, "*"), { glob: true })
console.log(`Cleaned output directory \`${output}\` in ${perf.timeSince("clean")}`)
perf.addEvent("glob")
const allFiles = await glob("**/*.*", argv.directory, cfg.configuration.ignorePatterns)
const fps = allFiles.filter((fp) => fp.endsWith(".md")).sort()
console.log(
`Found ${fps.length} input files from \`${argv.directory}\` in ${perf.timeSince("glob")}`,
)
const filePaths = fps.map((fp) => joinSegments(argv.directory, fp) as FilePath)
ctx.allSlugs = allFiles.map((fp) => slugifyFilePath(fp as FilePath))
const parsedFiles = await parseMarkdown(ctx, filePaths)
const filteredContent = filterContent(ctx, parsedFiles)
const dependencies: Record<string, DepGraph<FilePath> | null> = {}
// Only build dependency graphs if we're doing a fast rebuild
if (argv.fastRebuild) {
const staticResources = getStaticResourcesFromPlugins(ctx)
for (const emitter of cfg.plugins.emitters) {
dependencies[emitter.name] =
(await emitter.getDependencyGraph?.(ctx, filteredContent, staticResources)) ?? null
}
}
await emitContent(ctx, filteredContent)
console.log(chalk.green(`Done processing ${fps.length} files in ${perf.timeSince()}`))
release()
if (argv.serve) {
return startServing(ctx, mut, parsedFiles, clientRefresh, dependencies)
}
}
// setup watcher for rebuilds
async function startServing(
ctx: BuildCtx,
mut: Mutex,
initialContent: ProcessedContent[],
clientRefresh: () => void,
dependencies: Dependencies, // emitter name: dep graph
) {
const { argv } = ctx
// cache file parse results
const contentMap = new Map<FilePath, ProcessedContent>()
for (const content of initialContent) {
const [_tree, vfile] = content
contentMap.set(vfile.data.filePath!, content)
}
const buildData: BuildData = {
ctx,
mut,
dependencies,
contentMap,
ignored: await isGitIgnored(),
initialSlugs: ctx.allSlugs,
toRebuild: new Set<FilePath>(),
toRemove: new Set<FilePath>(),
trackedAssets: new Set<FilePath>(),
lastBuildMs: 0,
}
const watcher = chokidar.watch(".", {
persistent: true,
cwd: argv.directory,
ignoreInitial: true,
})
const buildFromEntry = argv.fastRebuild ? partialRebuildFromEntrypoint : rebuildFromEntrypoint
watcher
.on("add", (fp) => buildFromEntry(fp as string, "add", clientRefresh, buildData))
.on("change", (fp) => buildFromEntry(fp as string, "change", clientRefresh, buildData))
.on("unlink", (fp) => buildFromEntry(fp as string, "delete", clientRefresh, buildData))
return async () => {
await watcher.close()
}
}
async function partialRebuildFromEntrypoint(
filepath: string,
action: FileEvent,
clientRefresh: () => void,
buildData: BuildData, // note: this function mutates buildData
) {
const { ctx, ignored, dependencies, contentMap, mut, toRemove } = buildData
const { argv, cfg } = ctx
// don't do anything for gitignored files
if (ignored(filepath)) {
return
}
const buildId = newBuildId()
ctx.buildId = buildId
buildData.lastBuildMs = new Date().getTime()
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()
console.log(chalk.yellow("Detected change, rebuilding..."))
// UPDATE DEP GRAPH
const fp = joinSegments(argv.directory, toPosixPath(filepath)) as FilePath
const staticResources = getStaticResourcesFromPlugins(ctx)
let processedFiles: ProcessedContent[] = []
switch (action) {
case "add":
// add to cache when new file is added
processedFiles = await parseMarkdown(ctx, [fp])
processedFiles.forEach(([tree, vfile]) => contentMap.set(vfile.data.filePath!, [tree, vfile]))
// update the dep graph by asking all emitters whether they depend on this file
for (const emitter of cfg.plugins.emitters) {
const emitterGraph =
(await emitter.getDependencyGraph?.(ctx, processedFiles, staticResources)) ?? null
if (emitterGraph) {
const existingGraph = dependencies[emitter.name]
if (existingGraph !== null) {
existingGraph.mergeGraph(emitterGraph)
} else {
// might be the first time we're adding a mardown file
dependencies[emitter.name] = emitterGraph
}
}
}
break
case "change":
// invalidate cache when file is changed
processedFiles = await parseMarkdown(ctx, [fp])
processedFiles.forEach(([tree, vfile]) => contentMap.set(vfile.data.filePath!, [tree, vfile]))
// only content files can have added/removed dependencies because of transclusions
if (path.extname(fp) === ".md") {
for (const emitter of cfg.plugins.emitters) {
// get new dependencies from all emitters for this file
const emitterGraph =
(await emitter.getDependencyGraph?.(ctx, processedFiles, staticResources)) ?? null
// only update the graph if the emitter plugin uses the changed file
// eg. Assets plugin ignores md files, so we skip updating the graph
if (emitterGraph?.hasNode(fp)) {
// merge the new dependencies into the dep graph
dependencies[emitter.name]?.updateIncomingEdgesForNode(emitterGraph, fp)
}
}
}
break
case "delete":
toRemove.add(fp)
break
}
if (argv.verbose) {
console.log(`Updated dependency graphs in ${perf.timeSince()}`)
}
// EMIT
perf.addEvent("rebuild")
let emittedFiles = 0
for (const emitter of cfg.plugins.emitters) {
const depGraph = dependencies[emitter.name]
// emitter hasn't defined a dependency graph. call it with all processed files
if (depGraph === null) {
if (argv.verbose) {
console.log(
`Emitter ${emitter.name} doesn't define a dependency graph. Calling it with all files...`,
)
}
const files = [...contentMap.values()].filter(
([_node, vfile]) => !toRemove.has(vfile.data.filePath!),
)
const emittedFps = await emitter.emit(ctx, files, staticResources)
if (ctx.argv.verbose) {
for (const file of emittedFps) {
console.log(`[emit:${emitter.name}] ${file}`)
}
}
emittedFiles += emittedFps.length
continue
}
// only call the emitter if it uses this file
if (depGraph.hasNode(fp)) {
// re-emit using all files that are needed for the downstream of this file
// eg. for ContentIndex, the dep graph could be:
// a.md --> contentIndex.json
// b.md ------^
//
// if a.md changes, we need to re-emit contentIndex.json,
// and supply [a.md, b.md] to the emitter
const upstreams = [...depGraph.getLeafNodeAncestors(fp)] as FilePath[]
const upstreamContent = upstreams
// filter out non-markdown files
.filter((file) => contentMap.has(file))
// if file was deleted, don't give it to the emitter
.filter((file) => !toRemove.has(file))
.map((file) => contentMap.get(file)!)
const emittedFps = await emitter.emit(ctx, upstreamContent, staticResources)
if (ctx.argv.verbose) {
for (const file of emittedFps) {
console.log(`[emit:${emitter.name}] ${file}`)
}
}
emittedFiles += emittedFps.length
}
}
console.log(`Emitted ${emittedFiles} files to \`${argv.output}\` in ${perf.timeSince("rebuild")}`)
// CLEANUP
const destinationsToDelete = new Set<FilePath>()
for (const file of toRemove) {
// remove from cache
contentMap.delete(file)
Object.values(dependencies).forEach((depGraph) => {
// remove the node from dependency graphs
depGraph?.removeNode(file)
// remove any orphan nodes. eg if a.md is deleted, a.html is orphaned and should be removed
const orphanNodes = depGraph?.removeOrphanNodes()
orphanNodes?.forEach((node) => {
// only delete files that are in the output directory
if (node.startsWith(argv.output)) {
destinationsToDelete.add(node)
}
})
})
}
await rimraf([...destinationsToDelete])
console.log(chalk.green(`Done rebuilding in ${perf.timeSince()}`))
toRemove.clear()
release()
clientRefresh()
}
async function rebuildFromEntrypoint(
fp: string,
action: FileEvent,
clientRefresh: () => void,
buildData: BuildData, // note: this function mutates buildData
) {
const { ctx, ignored, mut, initialSlugs, contentMap, toRebuild, toRemove, trackedAssets } =
buildData
const { argv } = ctx
// don't do anything for gitignored files
if (ignored(fp)) {
return
}
// dont bother rebuilding for non-content files, just track and refresh
fp = toPosixPath(fp)
const filePath = joinSegments(argv.directory, fp) as FilePath
if (path.extname(fp) !== ".md") {
if (action === "add" || action === "change") {
trackedAssets.add(filePath)
} else if (action === "delete") {
trackedAssets.delete(filePath)
}
clientRefresh()
return
}
if (action === "add" || action === "change") {
toRebuild.add(filePath)
} else if (action === "delete") {
toRemove.add(filePath)
}
const buildId = newBuildId()
ctx.buildId = buildId
buildData.lastBuildMs = new Date().getTime()
const release = await mut.acquire()
// there's another build after us, release and let them do it
if (ctx.buildId !== buildId) {
release()
return
}
const perf = new PerfTimer()
console.log(chalk.yellow("Detected change, rebuilding..."))
try {
const filesToRebuild = [...toRebuild].filter((fp) => !toRemove.has(fp))
const parsedContent = await parseMarkdown(ctx, filesToRebuild)
for (const content of parsedContent) {
const [_tree, vfile] = content
contentMap.set(vfile.data.filePath!, content)
}
for (const fp of toRemove) {
contentMap.delete(fp)
}
const parsedFiles = [...contentMap.values()]
const filteredContent = filterContent(ctx, parsedFiles)
// re-update slugs
const trackedSlugs = [...new Set([...contentMap.keys(), ...toRebuild, ...trackedAssets])]
.filter((fp) => !toRemove.has(fp))
.map((fp) => slugifyFilePath(path.posix.relative(argv.directory, fp) as FilePath))
ctx.allSlugs = [...new Set([...initialSlugs, ...trackedSlugs])]
// TODO: we can probably traverse the link graph to figure out what's safe to delete here
// instead of just deleting everything
await rimraf(path.join(argv.output, ".*"), { glob: true })
await emitContent(ctx, filteredContent)
console.log(chalk.green(`Done rebuilding in ${perf.timeSince()}`))
} catch (err) {
console.log(chalk.yellow(`Rebuild failed. Waiting on a change to fix the error...`))
if (argv.verbose) {
console.log(chalk.red(err))
}
}
clientRefresh()
toRebuild.clear()
toRemove.clear()
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)
}
}

97
quartz/cfg.ts Normal file
View file

@ -0,0 +1,97 @@
import { ValidDateType } from "./components/Date"
import { QuartzComponent } from "./components/types"
import { ValidLocale } from "./i18n"
import { PluginTypes } from "./plugins/types"
import { SocialImageOptions } from "./util/og"
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
}
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
/**
* Whether to generate social images (Open Graph and Twitter standard) for link previews
*/
generateSocialImages: boolean | Partial<SocialImageOptions>
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
View 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",
},
fastRebuild: {
boolean: true,
default: false,
describe: "[experimental] rebuild only the changed files",
},
baseDir: {
string: true,
default: "",
describe: "base path to serve your local server on",
},
port: {
number: true,
default: 8080,
describe: "port to serve Quartz on",
},
wsPort: {
number: true,
default: 3001,
describe: "port to use for WebSocket-based hot-reload notifications",
},
remoteDevHost: {
string: true,
default: "",
describe: "A URL override for the websocket connection if you are not developing on localhost",
},
bundleInfo: {
boolean: true,
default: false,
describe: "show detailed bundle information",
},
concurrency: {
number: true,
describe: "how many threads to use to parse notes",
},
}

15
quartz/cli/constants.js Normal file
View file

@ -0,0 +1,15 @@
import path from "path"
import { readFileSync } from "fs"
/**
* All constants relating to helpers or handlers
*/
export const ORIGIN_NAME = "origin"
export const UPSTREAM_NAME = "upstream"
export const QUARTZ_SOURCE_BRANCH = "v4"
export const cwd = process.cwd()
export const cacheDir = path.join(cwd, ".quartz-cache")
export const cacheFile = "./quartz/.quartz-cache/transpiled-build.mjs"
export const fp = "./quartz/build.ts"
export const { version } = JSON.parse(readFileSync("./package.json").toString())
export const contentCacheFolder = path.join(cacheDir, "content-cache")

576
quartz/cli/handlers.js Normal file
View file

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

54
quartz/cli/helpers.js Normal file
View file

@ -0,0 +1,54 @@
import { isCancel, outro } from "@clack/prompts"
import chalk from "chalk"
import { contentCacheFolder } from "./constants.js"
import { spawnSync } from "child_process"
import fs from "fs"
export function escapePath(fp) {
return fp
.replace(/\\ /g, " ") // unescape spaces
.replace(/^".*"$/, "$1")
.replace(/^'.*"$/, "$1")
.trim()
}
export function exitIfCancel(val) {
if (isCancel(val)) {
outro(chalk.red("Exiting"))
process.exit(0)
} else {
return val
}
}
export async function stashContentFolder(contentFolder) {
await fs.promises.rm(contentCacheFolder, { force: true, recursive: true })
await fs.promises.cp(contentFolder, contentCacheFolder, {
force: true,
recursive: true,
verbatimSymlinks: true,
preserveTimestamps: true,
})
await fs.promises.rm(contentFolder, { force: true, recursive: true })
}
export function gitPull(origin, branch) {
const flags = ["--no-rebase", "--autostash", "-s", "recursive", "-X", "ours", "--no-edit"]
const out = spawnSync("git", ["pull", ...flags, origin, branch], { stdio: "inherit" })
if (out.stderr) {
throw new Error(chalk.red(`Error while pulling updates: ${out.stderr}`))
} else if (out.status !== 0) {
throw new Error(chalk.red("Error while pulling updates"))
}
}
export async function popContentFolder(contentFolder) {
await fs.promises.rm(contentFolder, { force: true, recursive: true })
await fs.promises.cp(contentCacheFolder, contentFolder, {
force: true,
recursive: true,
verbatimSymlinks: true,
preserveTimestamps: true,
})
await fs.promises.rm(contentCacheFolder, { force: true, recursive: true })
}

View 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

View file

@ -0,0 +1,52 @@
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"
interface BacklinksOptions {
hideWhenEmpty: boolean
}
const defaultOptions: BacklinksOptions = {
hideWhenEmpty: true,
}
export default ((opts?: Partial<BacklinksOptions>) => {
const options: BacklinksOptions = { ...defaultOptions, ...opts }
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>
<ul class="overflow">
{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>
)}
</ul>
</div>
)
}
Backlinks.css = style
return Backlinks
}) satisfies QuartzComponentConstructor

View 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

View file

@ -0,0 +1,139 @@
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
import breadcrumbsStyle from "./styles/breadcrumbs.scss"
import { FullSlug, SimpleSlug, joinSegments, resolveRelative } from "../util/path"
import { QuartzPluginData } from "../plugins/vfile"
import { classNames } from "../util/lang"
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 breadcrumbs on root `index.md`
*/
hideOnRoot: boolean
/**
* Whether to display the current page in the breadcrumbs.
*/
showCurrentPage: boolean
}
const defaultOptions: BreadcrumbOptions = {
spacerSymbol: "",
rootName: "Home",
resolveFrontmatterTitle: true,
hideOnRoot: 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>) => {
// Merge options with defaults
const options: BreadcrumbOptions = { ...defaultOptions, ...opts }
// computed index of folder name to its associated file data
let folderIndex: Map<string, QuartzPluginData> | undefined
const Breadcrumbs: QuartzComponent = ({
fileData,
allFiles,
displayClass,
}: QuartzComponentProps) => {
// Hide crumbs on root if enabled
if (options.hideOnRoot && fileData.slug === "index") {
return <></>
}
// Format entry for root element
const firstEntry = formatCrumb(options.rootName, fileData.slug!, "/" as SimpleSlug)
const crumbs: CrumbData[] = [firstEntry]
if (!folderIndex && options.resolveFrontmatterTitle) {
folderIndex = new Map()
// construct the index for the first time
for (const file of allFiles) {
const folderParts = file.slug?.split("/")
if (folderParts?.at(-1) === "index") {
folderIndex.set(folderParts.slice(0, -1).join("/"), file)
}
}
}
// Split slug into hierarchy/parts
const slugParts = fileData.slug?.split("/")
if (slugParts) {
// is tag breadcrumb?
const isTagPath = slugParts[0] === "tags"
// full path until current part
let currentPath = ""
for (let i = 0; i < slugParts.length - 1; i++) {
let curPathSegment = slugParts[i]
// Try to resolve frontmatter folder title
const currentFile = folderIndex?.get(slugParts.slice(0, i + 1).join("/"))
if (currentFile) {
const title = currentFile.frontmatter!.title
if (title !== "index") {
curPathSegment = title
}
}
// Add current slug to full path
currentPath = joinSegments(currentPath, slugParts[i])
const includeTrailingSlash = !isTagPath || i < 1
// Format and add current crumb
const crumb = formatCrumb(
curPathSegment,
fileData.slug!,
(currentPath + (includeTrailingSlash ? "/" : "")) as SimpleSlug,
)
crumbs.push(crumb)
}
// Add current file to crumb (can directly use frontmatter title)
if (options.showCurrentPage && slugParts.at(-1) !== "index") {
crumbs.push({
displayName: fileData.frontmatter!.title,
path: "",
})
}
}
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

View file

@ -0,0 +1,60 @@
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"
}
}
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`
}
></div>
)
}
Comments.afterDOMLoaded = script
return Comments
}) satisfies QuartzComponentConstructor<Options>

View 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

View file

@ -0,0 +1,50 @@
// @ts-ignore: this is safe, we don't want to actually make darkmode.inline.ts a module as
// modules are automatically deferred and we don't want that to happen for critical beforeDOMLoads
// see: https://v8.dev/features/modules#defer
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")} id="darkmode">
<svg
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
version="1.1"
id="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"
id="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

View 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>
}

View file

@ -0,0 +1,18 @@
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
export default ((component?: QuartzComponent) => {
if (component) {
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
} else {
return () => <></>
}
}) satisfies QuartzComponentConstructor

View file

@ -0,0 +1,128 @@
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
import explorerStyle from "./styles/explorer.scss"
// @ts-ignore
import script from "./scripts/explorer.inline"
import { ExplorerNode, FileNode, Options } from "./ExplorerNode"
import { QuartzPluginData } from "../plugins/vfile"
import { classNames } from "../util/lang"
import { i18n } from "../i18n"
// Options interface defined in `ExplorerNode` to avoid circular dependency
const defaultOptions = {
folderClickBehavior: "collapse",
folderDefaultState: "collapsed",
useSavedState: true,
mapFn: (node) => {
return node
},
sortFn: (a, b) => {
// Sort order: folders first, then files. Sort folders and files alphabetically
if ((!a.file && !b.file) || (a.file && b.file)) {
// numeric: true: Whether numeric collation should be used, such that "1" < "2" < "10"
// sensitivity: "base": Only strings that differ in base letters compare as unequal. Examples: a ≠ b, a = á, a = A
return a.displayName.localeCompare(b.displayName, undefined, {
numeric: true,
sensitivity: "base",
})
}
if (a.file && !b.file) {
return 1
} else {
return -1
}
},
filterFn: (node) => node.name !== "tags",
order: ["filter", "map", "sort"],
} satisfies Options
export default ((userOpts?: Partial<Options>) => {
// Parse config
const opts: Options = { ...defaultOptions, ...userOpts }
// memoized
let fileTree: FileNode
let jsonTree: string
let lastBuildId: string = ""
function constructFileTree(allFiles: QuartzPluginData[]) {
// Construct tree from allFiles
fileTree = new FileNode("")
allFiles.forEach((file) => fileTree.add(file))
// Execute all functions (sort, filter, map) that were provided (if none were provided, only default "sort" is applied)
if (opts.order) {
// Order is important, use loop with index instead of order.map()
for (let i = 0; i < opts.order.length; i++) {
const functionName = opts.order[i]
if (functionName === "map") {
fileTree.map(opts.mapFn)
} else if (functionName === "sort") {
fileTree.sort(opts.sortFn)
} else if (functionName === "filter") {
fileTree.filter(opts.filterFn)
}
}
}
// Get all folders of tree. Initialize with collapsed state
// Stringify to pass json tree as data attribute ([data-tree])
const folders = fileTree.getFolderPaths(opts.folderDefaultState === "collapsed")
jsonTree = JSON.stringify(folders)
}
const Explorer: QuartzComponent = ({
ctx,
cfg,
allFiles,
displayClass,
fileData,
}: QuartzComponentProps) => {
if (ctx.buildId !== lastBuildId) {
lastBuildId = ctx.buildId
constructFileTree(allFiles)
}
return (
<div class={classNames(displayClass, "explorer")}>
<button
type="button"
id="explorer"
data-behavior={opts.folderClickBehavior}
data-collapsed={opts.folderDefaultState}
data-savestate={opts.useSavedState}
data-tree={jsonTree}
aria-controls="explorer-content"
aria-expanded={opts.folderDefaultState === "open"}
>
<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="explorer-content">
<ul class="overflow" id="explorer-ul">
<ExplorerNode node={fileTree} opts={opts} fileData={fileData} />
<li id="explorer-end" />
</ul>
</div>
</div>
)
}
Explorer.css = explorerStyle
Explorer.afterDOMLoaded = script
return Explorer
}) satisfies QuartzComponentConstructor

View file

@ -0,0 +1,242 @@
// @ts-ignore
import { QuartzPluginData } from "../plugins/vfile"
import {
joinSegments,
resolveRelative,
clone,
simplifySlug,
SimpleSlug,
FilePath,
} from "../util/path"
type OrderEntries = "sort" | "filter" | "map"
export interface Options {
title?: string
folderDefaultState: "collapsed" | "open"
folderClickBehavior: "collapse" | "link"
useSavedState: boolean
sortFn: (a: FileNode, b: FileNode) => number
filterFn: (node: FileNode) => boolean
mapFn: (node: FileNode) => void
order: OrderEntries[]
}
type DataWrapper = {
file: QuartzPluginData
path: string[]
}
export type FolderState = {
path: string
collapsed: boolean
}
function getPathSegment(fp: FilePath | undefined, idx: number): string | undefined {
if (!fp) {
return undefined
}
return fp.split("/").at(idx)
}
// Structure to add all files into a tree
export class FileNode {
children: Array<FileNode>
name: string // this is the slug segment
displayName: string
file: QuartzPluginData | null
depth: number
constructor(slugSegment: string, displayName?: string, file?: QuartzPluginData, depth?: number) {
this.children = []
this.name = slugSegment
this.displayName = displayName ?? file?.frontmatter?.title ?? slugSegment
this.file = file ? clone(file) : null
this.depth = depth ?? 0
}
private insert(fileData: DataWrapper) {
if (fileData.path.length === 0) {
return
}
const nextSegment = fileData.path[0]
// base case, insert here
if (fileData.path.length === 1) {
if (nextSegment === "") {
// index case (we are the root and we just found index.md), set our data appropriately
const title = fileData.file.frontmatter?.title
if (title && title !== "index") {
this.displayName = title
}
} else {
// direct child
this.children.push(new FileNode(nextSegment, undefined, fileData.file, this.depth + 1))
}
return
}
// find the right child to insert into
fileData.path = fileData.path.splice(1)
const child = this.children.find((c) => c.name === nextSegment)
if (child) {
child.insert(fileData)
return
}
const newChild = new FileNode(
nextSegment,
getPathSegment(fileData.file.relativePath, this.depth),
undefined,
this.depth + 1,
)
newChild.insert(fileData)
this.children.push(newChild)
}
// Add new file to tree
add(file: QuartzPluginData) {
this.insert({ file: file, path: simplifySlug(file.slug!).split("/") })
}
/**
* Filter FileNode tree. Behaves similar to `Array.prototype.filter()`, but modifies tree in place
* @param filterFn function to filter tree with
*/
filter(filterFn: (node: FileNode) => boolean) {
this.children = this.children.filter(filterFn)
this.children.forEach((child) => child.filter(filterFn))
}
/**
* Filter FileNode tree. Behaves similar to `Array.prototype.map()`, but modifies tree in place
* @param mapFn function to use for mapping over tree
*/
map(mapFn: (node: FileNode) => void) {
mapFn(this)
this.children.forEach((child) => child.map(mapFn))
}
/**
* Get folder representation with state of tree.
* Intended to only be called on root node before changes to the tree are made
* @param collapsed default state of folders (collapsed by default or not)
* @returns array containing folder state for tree
*/
getFolderPaths(collapsed: boolean): FolderState[] {
const folderPaths: FolderState[] = []
const traverse = (node: FileNode, currentPath: string) => {
if (!node.file) {
const folderPath = joinSegments(currentPath, node.name)
if (folderPath !== "") {
folderPaths.push({ path: folderPath, collapsed })
}
node.children.forEach((child) => traverse(child, folderPath))
}
}
traverse(this, "")
return folderPaths
}
// Sort order: folders first, then files. Sort folders and files alphabetically
/**
* Sorts tree according to sort/compare function
* @param sortFn compare function used for `.sort()`, also used recursively for children
*/
sort(sortFn: (a: FileNode, b: FileNode) => number) {
this.children = this.children.sort(sortFn)
this.children.forEach((e) => e.sort(sortFn))
}
}
type ExplorerNodeProps = {
node: FileNode
opts: Options
fileData: QuartzPluginData
fullPath?: string
}
export function ExplorerNode({ node, opts, fullPath, fileData }: ExplorerNodeProps) {
// Get options
const folderBehavior = opts.folderClickBehavior
const isDefaultOpen = opts.folderDefaultState === "open"
// Calculate current folderPath
const folderPath = node.name !== "" ? joinSegments(fullPath ?? "", node.name) : ""
const href = resolveRelative(fileData.slug!, folderPath as SimpleSlug) + "/"
return (
<>
{node.file ? (
// Single file node
<li key={node.file.slug}>
<a href={resolveRelative(fileData.slug!, node.file.slug!)} data-for={node.file.slug}>
{node.displayName}
</a>
</li>
) : (
<li>
{node.name !== "" && (
// Node with entire folder
// Render svg button + folder name, then children
<div class="folder-container">
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="5 8 14 8"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="folder-icon"
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
{/* render <a> tag if folderBehavior is "link", otherwise render <button> with collapse click event */}
<div key={node.name} data-folderpath={folderPath}>
{folderBehavior === "link" ? (
<a href={href} data-for={node.name} class="folder-title">
{node.displayName}
</a>
) : (
<button class="folder-button">
<span class="folder-title">{node.displayName}</span>
</button>
)}
</div>
</div>
)}
{/* Recursively render children of folder */}
<div class={`folder-outer ${node.depth === 0 || isDefaultOpen ? "open" : ""}`}>
<ul
// Inline style for left folder paddings
style={{
paddingLeft: node.name !== "" ? "1.4rem" : "0",
}}
class="content"
data-folderul={folderPath}
>
{node.children.map((childNode, i) => (
<ExplorerNode
node={childNode}
key={i}
opts={opts}
fullPath={folderPath}
fileData={fileData}
/>
))}
</ul>
</div>
</li>
)}
</>
)
}

View 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

106
quartz/components/Graph.tsx Normal file
View file

@ -0,0 +1,106 @@
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
}
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,
},
globalGraph: {
drag: true,
zoom: true,
depth: -1,
scale: 0.9,
repelForce: 0.5,
centerForce: 0.3,
linkDistance: 30,
fontSize: 0.6,
opacityScale: 1,
showTags: true,
removeTags: [],
focusOnHover: true,
},
}
export default ((opts?: 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 id="graph-container" data-cfg={JSON.stringify(localGraph)}></div>
<button id="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 id="global-graph-outer">
<div id="global-graph-container" data-cfg={JSON.stringify(globalGraph)}></div>
</div>
</div>
)
}
Graph.css = style
Graph.afterDOMLoaded = script
return Graph
}) satisfies QuartzComponentConstructor

208
quartz/components/Head.tsx Normal file
View file

@ -0,0 +1,208 @@
import { i18n } from "../i18n"
import { FullSlug, joinSegments, pathToRoot } from "../util/path"
import { CSSResourceToStyleElement, JSResourceToScriptElement } from "../util/resources"
import { googleFontHref } from "../util/theme"
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
import satori, { SatoriOptions } from "satori"
import fs from "fs"
import sharp from "sharp"
import { ImageOptions, SocialImageOptions, getSatoriFont, defaultImage } from "../util/og"
import { unescapeHTML } from "../util/escape"
/**
* Generates social image (OG/twitter standard) and saves it as `.webp` inside the public folder
* @param opts options for generating image
*/
async function generateSocialImage(
{ cfg, description, fileName, fontsPromise, title, fileData }: ImageOptions,
userOpts: SocialImageOptions,
imageDir: string,
) {
const fonts = await fontsPromise
const { width, height } = userOpts
// JSX that will be used to generate satori svg
const imageComponent = userOpts.imageStructure(cfg, userOpts, title, description, fonts, fileData)
const svg = await satori(imageComponent, { width, height, fonts })
// Convert svg directly to webp (with additional compression)
const compressed = await sharp(Buffer.from(svg)).webp({ quality: 40 }).toBuffer()
// Write to file system
const filePath = joinSegments(imageDir, `${fileName}.${extension}`)
fs.writeFileSync(filePath, compressed)
}
const extension = "webp"
const defaultOptions: SocialImageOptions = {
colorScheme: "lightMode",
width: 1200,
height: 630,
imageStructure: defaultImage,
excludeRoot: false,
}
export default (() => {
let fontsPromise: Promise<SatoriOptions["fonts"]>
let fullOptions: SocialImageOptions
const Head: QuartzComponent = ({
cfg,
fileData,
externalResources,
ctx,
}: QuartzComponentProps) => {
// Initialize options if not set
if (!fullOptions) {
if (typeof cfg.generateSocialImages !== "boolean") {
fullOptions = { ...defaultOptions, ...cfg.generateSocialImages }
} else {
fullOptions = defaultOptions
}
}
// Memoize google fonts
if (!fontsPromise && cfg.generateSocialImages) {
fontsPromise = getSatoriFont(cfg.theme.typography.header, cfg.theme.typography.body)
}
const slug = fileData.filePath
// since "/" is not a valid character in file names, replace with "-"
const fileName = slug?.replaceAll("/", "-")
// Get file description (priority: frontmatter > fileData > default)
const fdDescription =
fileData.description?.trim() ?? i18n(cfg.locale).propertyDefaults.description
const titleSuffix = cfg.pageTitleSuffix ?? ""
const title =
(fileData.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title) + titleSuffix
let description = ""
if (fdDescription) {
description = unescapeHTML(fdDescription)
}
if (fileData.frontmatter?.socialDescription) {
description = fileData.frontmatter?.socialDescription as string
} else if (fileData.frontmatter?.description) {
description = fileData.frontmatter?.description
}
const fileDir = joinSegments(ctx.argv.output, "static", "social-images")
if (cfg.generateSocialImages) {
// Generate folders for social images (if they dont exist yet)
if (!fs.existsSync(fileDir)) {
fs.mkdirSync(fileDir, { recursive: true })
}
if (fileName) {
// Generate social image (happens async)
generateSocialImage(
{
title,
description,
fileName,
fileDir,
fileExt: extension,
fontsPromise,
cfg,
fileData,
},
fullOptions,
fileDir,
)
}
}
const { css, js } = 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")
const ogImageDefaultPath = `https://${cfg.baseUrl}/static/og-image.png`
// "static/social-images/slug-filename.md.webp"
const ogImageGeneratedPath = `https://${cfg.baseUrl}/${fileDir.replace(
`${ctx.argv.output}/`,
"",
)}/${fileName}.${extension}`
// Use default og image if filePath doesnt exist (for autogenerated paths with no .md file)
const useDefaultOgImage = fileName === undefined || !cfg.generateSocialImages
// Path to og/social image (priority: frontmatter > generated image (if enabled) > default image)
let ogImagePath = useDefaultOgImage ? ogImageDefaultPath : ogImageGeneratedPath
// TODO: could be improved to support external images in the future
// Aliases for image and cover handled in `frontmatter.ts`
const frontmatterImgUrl = fileData.frontmatter?.socialImage
// Override with default og image if config option is set
if (fileData.slug === "index") {
ogImagePath = ogImageDefaultPath
}
// Override with frontmatter url if existing
if (frontmatterImgUrl) {
ogImagePath = `https://${cfg.baseUrl}/static/${frontmatterImgUrl}`
}
// Url of current page
const socialUrl =
fileData.slug === "404" ? url.toString() : joinSegments(url.toString(), fileData.slug!)
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)} />
</>
)}
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
{/* OG/Twitter meta tags */}
<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:type" content={`image/${extension}`} />
<meta property="og:image:alt" content={description} />
{/* Dont set width and height if unknown (when using custom frontmatter image) */}
{!frontmatterImgUrl && (
<>
<meta property="og:image:width" content={fullOptions.width.toString()} />
<meta property="og:image:height" content={fullOptions.height.toString()} />
</>
)}
<meta property="og:image:url" content={ogImagePath} />
{cfg.baseUrl && (
<>
<meta name="twitter:image" content={ogImagePath} />
<meta property="og:image" content={ogImagePath} />
<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))}
</head>
)
}
return Head
}) satisfies QuartzComponentConstructor

View 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

View file

@ -0,0 +1,18 @@
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
export default ((component?: QuartzComponent) => {
if (component) {
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
} else {
return () => <></>
}
}) satisfies QuartzComponentConstructor

View file

@ -0,0 +1,87 @@
import { FullSlug, 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) => {
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 ?? byDateAndAlphabetical(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;
}
`

View file

@ -0,0 +1,23 @@
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;
}
`
export default (() => PageTitle) satisfies QuartzComponentConstructor

View 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

View 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" id="search-button">
<p>{i18n(cfg.locale).components.search.title}</p>
<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>
</button>
<div id="search-container">
<div id="search-space">
<input
autocomplete="off"
id="search-bar"
name="search"
type="text"
aria-label={searchPlaceholder}
placeholder={searchPlaceholder}
/>
<div id="search-layout" data-preview={opts.enablePreview}></div>
</div>
</div>
</div>
)
}
Search.afterDOMLoaded = script
Search.css = style
return Search
}) satisfies QuartzComponentConstructor

View 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

View file

@ -0,0 +1,95 @@
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"
interface Options {
layout: "modern" | "legacy"
}
const defaultOptions: Options = {
layout: "modern",
}
const TableOfContents: QuartzComponent = ({
fileData,
displayClass,
cfg,
}: QuartzComponentProps) => {
if (!fileData.toc) {
return null
}
return (
<div class={classNames(displayClass, "toc")}>
<button
type="button"
id="toc"
class={fileData.collapseToc ? "collapsed" : ""}
aria-controls="toc-content"
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>
<div id="toc-content" class={fileData.collapseToc ? "collapsed" : ""}>
<ul class="overflow">
{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>
</div>
</div>
)
}
TableOfContents.css = modernStyle
TableOfContents.afterDOMLoaded = script
const LegacyTableOfContents: QuartzComponent = ({ fileData, cfg }: QuartzComponentProps) => {
if (!fileData.toc) {
return null
}
return (
<details id="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
export default ((opts?: Partial<Options>) => {
const layout = opts?.layout ?? defaultOptions.layout
return layout === "modern" ? TableOfContents : LegacyTableOfContents
}) satisfies QuartzComponentConstructor

View file

@ -0,0 +1,57 @@
import { pathToRoot, slugTag } 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
const baseDir = pathToRoot(fileData.slug!)
if (tags && tags.length > 0) {
return (
<ul class={classNames(displayClass, "tags")}>
{tags.map((tag) => {
const linkDest = baseDir + `/tags/${slugTag(tag)}`
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

View file

@ -0,0 +1,47 @@
import Content from "./pages/Content"
import TagContent from "./pages/TagContent"
import FolderContent from "./pages/FolderContent"
import NotFound from "./pages/404"
import ArticleTitle from "./ArticleTitle"
import Darkmode from "./Darkmode"
import Head from "./Head"
import PageTitle from "./PageTitle"
import ContentMeta from "./ContentMeta"
import Spacer from "./Spacer"
import TableOfContents from "./TableOfContents"
import Explorer from "./Explorer"
import TagList from "./TagList"
import Graph from "./Graph"
import Backlinks from "./Backlinks"
import Search from "./Search"
import Footer from "./Footer"
import DesktopOnly from "./DesktopOnly"
import MobileOnly from "./MobileOnly"
import RecentNotes from "./RecentNotes"
import Breadcrumbs from "./Breadcrumbs"
import Comments from "./Comments"
export {
ArticleTitle,
Content,
TagContent,
FolderContent,
Darkmode,
Head,
PageTitle,
ContentMeta,
Spacer,
TableOfContents,
Explorer,
TagList,
Graph,
Backlinks,
Search,
Footer,
DesktopOnly,
MobileOnly,
RecentNotes,
NotFound,
Breadcrumbs,
Comments,
}

View 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

View file

@ -0,0 +1,11 @@
import { htmlToJsx } from "../../util/jsx"
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types"
const Content: QuartzComponent = ({ fileData, tree }: QuartzComponentProps) => {
const content = htmlToJsx(fileData.filePath!, tree)
const classes: string[] = fileData.frontmatter?.cssclasses ?? []
const classString = ["popover-hint", ...classes].join(" ")
return <article class={classString}>{content}</article>
}
export default (() => Content) satisfies QuartzComponentConstructor

View file

@ -0,0 +1,107 @@
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types"
import path from "path"
import style from "../styles/listPage.scss"
import { byDateAndAlphabetical, PageList, SortFn } from "../PageList"
import { stripSlashes, simplifySlug, joinSegments, FullSlug } from "../../util/path"
import { Root } from "hast"
import { htmlToJsx } from "../../util/jsx"
import { i18n } from "../../i18n"
import { QuartzPluginData } from "../../plugins/vfile"
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 folderSlug = stripSlashes(simplifySlug(fileData.slug!))
const folderParts = folderSlug.split(path.posix.sep)
const allPagesInFolder: QuartzPluginData[] = []
const allPagesInSubfolders: Map<FullSlug, QuartzPluginData[]> = new Map()
allFiles.forEach((file) => {
const fileSlug = stripSlashes(simplifySlug(file.slug!))
const prefixed = fileSlug.startsWith(folderSlug) && fileSlug !== folderSlug
const fileParts = fileSlug.split(path.posix.sep)
const isDirectChild = fileParts.length === folderParts.length + 1
if (!prefixed) {
return
}
if (isDirectChild) {
allPagesInFolder.push(file)
} else if (options.showSubfolders) {
const subfolderSlug = joinSegments(
...fileParts.slice(0, folderParts.length + 1),
) as FullSlug
const pagesInFolder = allPagesInSubfolders.get(subfolderSlug) || []
allPagesInSubfolders.set(subfolderSlug, [...pagesInFolder, file])
}
})
allPagesInSubfolders.forEach((files, subfolderSlug) => {
const hasIndex = allPagesInFolder.some(
(file) => subfolderSlug === stripSlashes(simplifySlug(file.slug!)),
)
if (!hasIndex) {
const subfolderDates = files.sort(byDateAndAlphabetical(cfg))[0].dates
const subfolderTitle = subfolderSlug.split(path.posix.sep).at(-1)!
allPagesInFolder.push({
slug: subfolderSlug,
dates: subfolderDates,
frontmatter: { title: subfolderTitle, tags: ["folder"] },
})
}
})
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)
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 = style + PageList.css
return FolderContent
}) satisfies QuartzComponentConstructor

View file

@ -0,0 +1,127 @@
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types"
import style from "../styles/listPage.scss"
import { PageList, SortFn } from "../PageList"
import { FullSlug, getAllSegmentPrefixes, simplifySlug } from "../../util/path"
import { QuartzPluginData } from "../../plugins/vfile"
import { Root } from "hast"
import { htmlToJsx } from "../../util/jsx"
import { i18n } from "../../i18n"
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)
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)
return (
<div>
<h2>
<a class="internal tag-link" href={`../tags/${tag}`}>
{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={opts?.sort} />
</div>
</div>
)
})}
</div>
</div>
)
} else {
const pages = allPagesWithTag(tag)
const listProps = {
...props,
allFiles: pages,
}
return (
<div class={classes}>
<article class="popover-hint">{content}</article>
<div class="page-listing">
<p>{i18n(cfg.locale).pages.tagContent.itemsUnderTag({ count: pages.length })}</p>
<div>
<PageList {...listProps} />
</div>
</div>
</div>
)
}
}
TagContent.css = style + PageList.css
return TagContent
}) satisfies QuartzComponentConstructor

View file

@ -0,0 +1,261 @@
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 { clone, FullSlug, RelativeURL, joinSegments, normalizeHastElement } from "../util/path"
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())`
return {
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,
{
src: joinSegments(baseDir, "postscript.js"),
loadTime: "afterDOMReady",
moduleType: "module",
contentType: "external",
},
],
}
}
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
// 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"] 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 },
],
},
]
}
}
}
})
// 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 doc = (
<html lang={lang}>
<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)
}

View file

@ -0,0 +1,44 @@
function toggleCallout(this: HTMLElement) {
const outerBlock = this.parentElement!
outerBlock.classList.toggle("is-collapsed")
const collapsed = outerBlock.classList.contains("is-collapsed")
const height = collapsed ? this.scrollHeight : outerBlock.scrollHeight
outerBlock.style.maxHeight = height + "px"
// walk and adjust height of all parents
let current = outerBlock
let parent = outerBlock.parentElement
while (parent) {
if (!parent.classList.contains("callout")) {
return
}
const collapsed = parent.classList.contains("is-collapsed")
const height = collapsed ? parent.scrollHeight : parent.scrollHeight + current.scrollHeight
parent.style.maxHeight = height + "px"
current = parent
parent = parent.parentElement
}
}
function setupCallout() {
const collapsible = document.getElementsByClassName(
`callout is-collapsible`,
) as HTMLCollectionOf<HTMLElement>
for (const div of collapsible) {
const title = div.firstElementChild
if (title) {
title.addEventListener("click", toggleCallout)
window.addCleanup(() => title.removeEventListener("click", toggleCallout))
const collapsed = div.classList.contains("is-collapsed")
const height = collapsed ? title.scrollHeight : div.scrollHeight
div.style.maxHeight = height + "px"
}
}
}
document.addEventListener("nav", setupCallout)
window.addEventListener("resize", setupCallout)

View 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
}
})
})

View 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)
}
}
})

View file

@ -0,0 +1,91 @@
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"
}
}
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)
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))
})

View file

@ -0,0 +1,38 @@
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 = (e: Event) => {
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)
}
// Darkmode toggle
const themeButton = document.querySelector("#darkmode") as HTMLButtonElement
if (themeButton) {
themeButton.addEventListener("click", switchTheme)
window.addCleanup(() => themeButton.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))
})

View file

@ -0,0 +1,135 @@
import { FolderState } from "../ExplorerNode"
type MaybeHTMLElement = HTMLElement | undefined
let currentExplorerState: FolderState[]
const observer = new IntersectionObserver((entries) => {
// If last element is observed, remove gradient of "overflow" class so element is visible
const explorerUl = document.getElementById("explorer-ul")
if (!explorerUl) return
for (const entry of entries) {
if (entry.isIntersecting) {
explorerUl.classList.add("no-background")
} else {
explorerUl.classList.remove("no-background")
}
}
})
function toggleExplorer(this: HTMLElement) {
this.classList.toggle("collapsed")
this.setAttribute(
"aria-expanded",
this.getAttribute("aria-expanded") === "true" ? "false" : "true",
)
const content = this.nextElementSibling as MaybeHTMLElement
if (!content) return
content.classList.toggle("collapsed")
}
function toggleFolder(evt: MouseEvent) {
evt.stopPropagation()
const target = evt.target as MaybeHTMLElement
if (!target) return
const isSvg = target.nodeName === "svg"
const childFolderContainer = (
isSvg
? target.parentElement?.nextSibling
: target.parentElement?.parentElement?.nextElementSibling
) as MaybeHTMLElement
const currentFolderParent = (
isSvg ? target.nextElementSibling : target.parentElement
) as MaybeHTMLElement
if (!(childFolderContainer && currentFolderParent)) return
childFolderContainer.classList.toggle("open")
const isCollapsed = childFolderContainer.classList.contains("open")
setFolderState(childFolderContainer, !isCollapsed)
const fullFolderPath = currentFolderParent.dataset.folderpath as string
toggleCollapsedByPath(currentExplorerState, fullFolderPath)
const stringifiedFileTree = JSON.stringify(currentExplorerState)
localStorage.setItem("fileTree", stringifiedFileTree)
}
function setupExplorer() {
const explorer = document.getElementById("explorer")
if (!explorer) return
if (explorer.dataset.behavior === "collapse") {
for (const item of document.getElementsByClassName(
"folder-button",
) as HTMLCollectionOf<HTMLElement>) {
item.addEventListener("click", toggleFolder)
window.addCleanup(() => item.removeEventListener("click", toggleFolder))
}
}
explorer.addEventListener("click", toggleExplorer)
window.addCleanup(() => explorer.removeEventListener("click", toggleExplorer))
// Set up click handlers for each folder (click handler on folder "icon")
for (const item of document.getElementsByClassName(
"folder-icon",
) as HTMLCollectionOf<HTMLElement>) {
item.addEventListener("click", toggleFolder)
window.addCleanup(() => item.removeEventListener("click", toggleFolder))
}
// Get folder state from local storage
const storageTree = localStorage.getItem("fileTree")
const useSavedFolderState = explorer?.dataset.savestate === "true"
const oldExplorerState: FolderState[] =
storageTree && useSavedFolderState ? JSON.parse(storageTree) : []
const oldIndex = new Map(oldExplorerState.map((entry) => [entry.path, entry.collapsed]))
const newExplorerState: FolderState[] = explorer.dataset.tree
? JSON.parse(explorer.dataset.tree)
: []
currentExplorerState = []
for (const { path, collapsed } of newExplorerState) {
currentExplorerState.push({ path, collapsed: oldIndex.get(path) ?? collapsed })
}
currentExplorerState.map((folderState) => {
const folderLi = document.querySelector(
`[data-folderpath='${folderState.path}']`,
) as MaybeHTMLElement
const folderUl = folderLi?.parentElement?.nextElementSibling as MaybeHTMLElement
if (folderUl) {
setFolderState(folderUl, folderState.collapsed)
}
})
}
window.addEventListener("resize", setupExplorer)
document.addEventListener("nav", () => {
setupExplorer()
observer.disconnect()
// select pseudo element at end of list
const lastItem = document.getElementById("explorer-end")
if (lastItem) {
observer.observe(lastItem)
}
})
/**
* Toggles the state of a given folder
* @param folderElement <div class="folder-outer"> Element of folder (parent)
* @param collapsed if folder should be set to collapsed or not
*/
function setFolderState(folderElement: HTMLElement, collapsed: boolean) {
return collapsed ? folderElement.classList.remove("open") : folderElement.classList.add("open")
}
/**
* Toggles visibility of a folder
* @param array array of FolderState (`fileTree`, either get from local storage or data attribute)
* @param path path to folder (e.g. 'advanced/more/more2')
*/
function toggleCollapsedByPath(array: FolderState[], path: string) {
const entry = array.find((item) => item.path === path)
if (entry) {
entry.collapsed = !entry.collapsed
}
}

View file

@ -0,0 +1,601 @@
import type { ContentDetails } from "../../plugins/emitters/contentIndex"
import {
SimulationNodeDatum,
SimulationLinkDatum,
Simulation,
forceSimulation,
forceManyBody,
forceCenter,
forceLink,
forceCollide,
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(container: string, fullSlug: FullSlug) {
const slug = simplifySlug(fullSlug)
const visited = getVisited()
const graph = document.getElementById(container)
if (!graph) return
removeAllChildren(graph)
let {
drag: enableDrag,
zoom: enableZoom,
depth,
scale,
repelForce,
centerForce,
linkDistance,
fontSize,
opacityScale,
removeTags,
showTags,
focusOnHover,
} = 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)!,
})),
}
// 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 width = graph.offsetWidth
const height = Math.max(graph.offsetHeight, 250)
// 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 })
const nodesContainer = new Container<Graphics>({ zIndex: 2 })
const linkContainer = new Container<Graphics>({ zIndex: 1 })
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) })
.stroke({ width: isTagNode ? 2 : 0, color: 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()
}
})
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
}
}
}),
)
}
function animate(time: number) {
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)
}
const graphAnimationFrameHandle = requestAnimationFrame(animate)
window.addCleanup(() => cancelAnimationFrame(graphAnimationFrameHandle))
}
document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
const slug = e.detail.url
addToVisited(simplifySlug(slug))
await renderGraph("graph-container", slug)
// Function to re-render the graph when the theme changes
const handleThemeChange = () => {
renderGraph("graph-container", slug)
}
// event listener for theme change
document.addEventListener("themechange", handleThemeChange)
// cleanup for the event listener
window.addCleanup(() => {
document.removeEventListener("themechange", handleThemeChange)
})
const container = document.getElementById("global-graph-outer")
const sidebar = container?.closest(".sidebar") as HTMLElement
function renderGlobalGraph() {
const slug = getFullSlug(window)
container?.classList.add("active")
if (sidebar) {
sidebar.style.zIndex = "1"
}
renderGraph("global-graph-container", slug)
registerEscapeHandler(container, hideGlobalGraph)
}
function hideGlobalGraph() {
container?.classList.remove("active")
if (sidebar) {
sidebar.style.zIndex = ""
}
}
async function shortcutHandler(e: HTMLElementEventMap["keydown"]) {
if (e.key === "g" && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
e.preventDefault()
const globalGraphOpen = container?.classList.contains("active")
globalGraphOpen ? hideGlobalGraph() : renderGlobalGraph()
}
}
const containerIcon = document.getElementById("global-graph-icon")
containerIcon?.addEventListener("click", renderGlobalGraph)
window.addCleanup(() => containerIcon?.removeEventListener("click", renderGlobalGraph))
document.addEventListener("keydown", shortcutHandler)
window.addCleanup(() => document.removeEventListener("keydown", shortcutHandler))
})

View file

@ -0,0 +1,242 @@
import { removeAllChildren } from "./util"
import mermaid from "mermaid"
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
private readonly ZOOM_SENSITIVITY = 0.001
constructor(
private container: HTMLElement,
private content: HTMLElement,
) {
this.setupEventListeners()
this.setupNavigationControls()
}
private setupEventListeners() {
// Mouse drag events
this.container.addEventListener("mousedown", this.onMouseDown.bind(this))
document.addEventListener("mousemove", this.onMouseMove.bind(this))
document.addEventListener("mouseup", this.onMouseUp.bind(this))
// Wheel zoom events
this.container.addEventListener("wheel", this.onWheel.bind(this), { passive: false })
// Reset on window resize
window.addEventListener("resize", this.resetTransform.bind(this))
}
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 onWheel(e: WheelEvent) {
e.preventDefault()
const delta = -e.deltaY * this.ZOOM_SENSITIVITY
const newScale = Math.min(Math.max(this.scale + delta, this.MIN_SCALE), this.MAX_SCALE)
// Calculate mouse position relative to content
const rect = this.content.getBoundingClientRect()
const mouseX = e.clientX - rect.left
const mouseY = e.clientY - rect.top
// Adjust pan to zoom around mouse position
const scaleDiff = newScale - this.scale
this.currentPan.x -= mouseX * scaleDiff
this.currentPan.y -= mouseY * scaleDiff
this.scale = newScale
this.updateTransform()
}
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
this.currentPan = { x: 0, y: 0 }
this.updateTransform()
}
}
const cssVars = [
"--secondary",
"--tertiary",
"--gray",
"--light",
"--lightgray",
"--highlight",
"--dark",
"--darkgray",
"--codeFont",
] as const
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
const computedStyleMap = cssVars.reduce(
(acc, key) => {
acc[key] = 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 })
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 = null
}
function handleEscape(e: any) {
if (e.key === "Escape") {
hideMermaid()
}
}
const closeBtn = popupContainer.querySelector(".close-button") as HTMLButtonElement
closeBtn.addEventListener("click", hideMermaid)
expandBtn.addEventListener("click", showMermaid)
document.addEventListener("keydown", handleEscape)
window.addCleanup(() => {
closeBtn.removeEventListener("click", hideMermaid)
expandBtn.removeEventListener("click", showMermaid)
document.removeEventListener("keydown", handleEscape)
})
}
})

View file

@ -0,0 +1,109 @@
import { computePosition, flip, inline, shift } from "@floating-ui/dom"
import { normalizeRelativeURLs } from "../../util/path"
import { fetchCanonical } from "./util"
const p = new DOMParser()
async function mouseEnterHandler(
this: HTMLAnchorElement,
{ clientX, clientY }: { clientX: number; clientY: number },
) {
const link = this
if (link.dataset.noPopover === "true") {
return
}
async function setPosition(popoverElement: HTMLElement) {
const { x, y } = await computePosition(link, popoverElement, {
middleware: [inline({ x: clientX, y: clientY }), shift(), flip()],
})
Object.assign(popoverElement.style, {
left: `${x}px`,
top: `${y}px`,
})
}
const hasAlreadyBeenFetched = () =>
[...link.children].some((child) => child.classList.contains("popover"))
// dont refetch if there's already a popover
if (hasAlreadyBeenFetched()) {
return setPosition(link.lastChild as HTMLElement)
}
const thisUrl = new URL(document.location.href)
thisUrl.hash = ""
thisUrl.search = ""
const targetUrl = new URL(link.href)
const hash = decodeURIComponent(targetUrl.hash)
targetUrl.hash = ""
targetUrl.search = ""
const response = await fetchCanonical(targetUrl).catch((err) => {
console.error(err)
})
// bailout if another popover exists
if (hasAlreadyBeenFetched()) {
return
}
if (!response) return
const [contentType] = response.headers.get("Content-Type")!.split(";")
const [contentTypeCategory, typeInfo] = contentType.split("/")
const popoverElement = document.createElement("div")
popoverElement.classList.add("popover")
const popoverInner = document.createElement("div")
popoverInner.classList.add("popover-inner")
popoverElement.appendChild(popoverInner)
popoverInner.dataset.contentType = contentType ?? undefined
switch (contentTypeCategory) {
case "image":
const img = document.createElement("img")
img.src = targetUrl.toString()
img.alt = targetUrl.pathname
popoverInner.appendChild(img)
break
case "application":
switch (typeInfo) {
case "pdf":
const pdf = document.createElement("iframe")
pdf.src = targetUrl.toString()
popoverInner.appendChild(pdf)
break
default:
break
}
break
default:
const contents = await response.text()
const html = p.parseFromString(contents, "text/html")
normalizeRelativeURLs(html, targetUrl)
const elts = [...html.getElementsByClassName("popover-hint")]
if (elts.length === 0) return
elts.forEach((elt) => popoverInner.appendChild(elt))
}
setPosition(popoverElement)
link.appendChild(popoverElement)
if (hash !== "") {
const heading = popoverInner.querySelector(hash) as HTMLElement | null
if (heading) {
// leave ~12px of buffer when scrolling to a heading
popoverInner.scroll({ top: heading.offsetTop - 12, behavior: "instant" })
}
}
}
document.addEventListener("nav", () => {
const links = [...document.getElementsByClassName("internal")] as HTMLAnchorElement[]
for (const link of links) {
link.addEventListener("mouseenter", mouseEnterHandler)
window.addCleanup(() => link.removeEventListener("mouseenter", mouseEnterHandler))
}
})

View file

@ -0,0 +1,493 @@
import FlexSearch 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[]
}
// Can be expanded with things like "term" in the future
type SearchType = "basic" | "tags"
let searchType: SearchType = "basic"
let currentSearchTerm: string = ""
const encoder = (str: string) => str.toLowerCase().split(/([^a-z]|[^\x00-\x7F])/)
let index = new FlexSearch.Document<Item>({
charset: "latin:extra",
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
}
document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
const currentSlug = e.detail.url
const data = await fetchData
const container = document.getElementById("search-container")
const sidebar = container?.closest(".sidebar") as HTMLElement
const searchButton = document.getElementById("search-button")
const searchBar = document.getElementById("search-bar") as HTMLInputElement | null
const searchLayout = document.getElementById("search-layout")
const idDataMap = Object.keys(data) as FullSlug[]
const appendLayout = (el: HTMLElement) => {
if (searchLayout?.querySelector(`#${el.id}`) === null) {
searchLayout?.appendChild(el)
}
}
const enablePreview = searchLayout?.dataset?.preview === "true"
let preview: HTMLDivElement | undefined = undefined
let previewInner: HTMLDivElement | undefined = undefined
const results = document.createElement("div")
results.id = "results-container"
appendLayout(results)
if (enablePreview) {
preview = document.createElement("div")
preview.id = "preview-container"
appendLayout(preview)
}
function hideSearch() {
container?.classList.remove("active")
if (searchBar) {
searchBar.value = "" // clear the input when we dismiss the search
}
if (sidebar) {
sidebar.style.zIndex = ""
}
if (results) {
removeAllChildren(results)
}
if (preview) {
removeAllChildren(preview)
}
if (searchLayout) {
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
if (searchBar) searchBar.value = "#"
return
}
if (currentHover) {
currentHover.classList.remove("focus")
}
// If search is active, then we will render the first result and display accordingly
if (!container?.classList.contains("active")) return
if (e.key === "Enter") {
// If result has focus, navigate to that one, otherwise pick first result
if (results?.contains(document.activeElement)) {
const active = document.activeElement as HTMLInputElement
if (active.classList.contains("no-match")) return
await displayPreview(active)
active.click()
} else {
const anchor = document.getElementsByClassName("result-card")[0] as HTMLInputElement | null
if (!anchor || anchor?.classList.contains("no-match")) return
await displayPreview(anchor)
anchor.click()
}
} else if (e.key === "ArrowUp" || (e.shiftKey && e.key === "Tab")) {
e.preventDefault()
if (results?.contains(document.activeElement)) {
// If an element in results-container already has focus, focus previous one
const currentResult = currentHover
? currentHover
: (document.activeElement as HTMLInputElement | null)
const prevResult = currentResult?.previousElementSibling as HTMLInputElement | null
currentResult?.classList.remove("focus")
prevResult?.focus()
if (prevResult) currentHover = prevResult
await displayPreview(prevResult)
}
} else if (e.key === "ArrowDown" || e.key === "Tab") {
e.preventDefault()
// The results should already been focused, so we need to find the next one.
// The activeElement is the search bar, so we need to find the first result and focus it.
if (document.activeElement === searchBar || currentHover !== null) {
const firstResult = currentHover
? currentHover
: (document.getElementsByClassName("result-card")[0] as HTMLInputElement | null)
const secondResult = firstResult?.nextElementSibling as HTMLInputElement | null
firstResult?.classList.remove("focus")
secondResult?.focus()
if (secondResult) currentHover = secondResult
await displayPreview(secondResult)
}
}
}
const formatForDisplay = (term: string, id: number) => {
const slug = idDataMap[id]
return {
id,
slug,
title: searchType === "tags" ? data[slug].title : highlight(term, data[slug].title ?? ""),
content: highlight(term, data[slug].content ?? "", true),
tags: highlightTags(term.substring(1), data[slug].tags),
}
}
function highlightTags(term: string, tags: string[]) {
if (!tags || searchType !== "tags") {
return []
}
return tags
.map((tag) => {
if (tag.toLowerCase().includes(term.toLowerCase())) {
return `<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>${title}</h3>${htmlTags}${
enablePreview && window.innerWidth > 600 ? "" : `<p>${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[]) {
if (!results) return
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.querySelectorAll(".highlight")].sort(
(a, b) => b.innerHTML.length - a.innerHTML.length,
)
highlights[0]?.scrollIntoView({ block: "start" })
}
async function onType(e: HTMLElementEventMap["input"]) {
if (!searchLayout || !index) return
currentSearchTerm = (e.target as HTMLInputElement).value
searchLayout.classList.toggle("display-results", currentSearchTerm !== "")
searchType = currentSearchTerm.startsWith("#") ? "tags" : "basic"
let searchResults: FlexSearch.SimpleDocumentSearchResultSetUnit[]
if (searchType === "tags") {
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: 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
*/
async function fillDocument(data: { [key: FullSlug]: ContentDetails }) {
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,
}),
)
}
return await Promise.all(promises)
}

View file

@ -0,0 +1,203 @@
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 p: DOMParser
async function navigate(url: URL, isBack: boolean = false) {
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
// cleanup old
cleanupFns.forEach((fn) => fn())
cleanupFns.clear()
const html = p.parseFromString(contents, "text/html")
normalizeRelativeURLs(html, url)
let title = html.querySelector("title")?.textContent
if (title) {
document.title = title
} else {
const h1 = document.querySelector("h1")
title = h1?.innerText ?? h1?.textContent ?? url.pathname
}
if (announcer.textContent !== title) {
announcer.textContent = title
}
announcer.dataset.persist = ""
html.body.appendChild(announcer)
// morph body
micromorph(document.body, html.body)
// scroll into place and add history
if (!isBack) {
if (url.hash) {
const el = document.getElementById(decodeURIComponent(url.hash.substring(1)))
el?.scrollIntoView()
} else {
window.scrollTo({ top: 0 })
}
}
// now, patch head
const elementsToRemove = document.head.querySelectorAll(":not([spa-preserve])")
elementsToRemove.forEach((el) => el.remove())
const elementsToAdd = html.head.querySelectorAll(":not([spa-preserve])")
elementsToAdd.forEach((el) => document.head.appendChild(el))
// delay setting the url until now
// at this point everything is loaded so changing the url should resolve to the correct addresses
if (!isBack) {
history.pushState({}, "", url)
}
notifyNav(getFullSlug(window))
delete announcer.dataset.persist
}
window.spaNavigate = navigate
function createRouter() {
if (typeof window !== "undefined") {
window.addEventListener("click", async (event) => {
const { url } = getOpts(event) ?? {}
// dont hijack behaviour, just let browser act normally
if (!url || event.ctrlKey || event.metaKey) return
event.preventDefault()
if (isSamePage(url) && url.hash) {
const el = document.getElementById(decodeURIComponent(url.hash.substring(1)))
el?.scrollIntoView()
history.pushState({}, "", url)
return
}
try {
navigate(url, false)
} catch (e) {
window.location.assign(url)
}
})
window.addEventListener("popstate", (event) => {
const { url } = getOpts(event) ?? {}
if (window.location.hash && window.location.pathname === url?.pathname) return
try {
navigate(new URL(window.location.toString()), true)
} catch (e) {
window.location.reload()
}
return
})
}
return new (class Router {
go(pathname: RelativeURL) {
const url = new URL(pathname, window.location.toString())
return navigate(url, false)
}
back() {
return window.history.back()
}
forward() {
return window.history.forward()
}
})()
}
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)
}
}
},
)
}

View file

@ -0,0 +1,47 @@
const bufferPx = 150
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
const slug = entry.target.id
const tocEntryElement = document.querySelector(`a[data-for="${slug}"]`)
const windowHeight = entry.rootBounds?.height
if (windowHeight && tocEntryElement) {
if (entry.boundingClientRect.y < windowHeight) {
tocEntryElement.classList.add("in-view")
} else {
tocEntryElement.classList.remove("in-view")
}
}
}
})
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() {
const toc = document.getElementById("toc")
if (toc) {
const collapsed = toc.classList.contains("collapsed")
const content = toc.nextElementSibling as HTMLElement | undefined
if (!content) return
toc.addEventListener("click", toggleToc)
window.addCleanup(() => toc.removeEventListener("click", toggleToc))
}
}
window.addEventListener("resize", setupToc)
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))
})

View file

@ -0,0 +1,45 @@
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(redirect) : res
}

View file

@ -0,0 +1,44 @@
@use "../../styles/variables.scss" as *;
.backlinks {
flex-direction: column;
/*&:after {
pointer-events: none;
content: "";
width: 100%;
height: 50px;
position: absolute;
left: 0;
bottom: 0;
opacity: 1;
transition: opacity 0.3s ease;
background: linear-gradient(transparent 0px, var(--light));
}*/
& > h3 {
font-size: 1rem;
margin: 0;
}
& > ul {
list-style: none;
padding: 0;
margin: 0.5rem 0;
& > li {
& > a {
background-color: transparent;
}
}
}
& > .overflow {
&:after {
display: none;
}
height: auto;
@media all and not ($desktop) {
height: 250px;
}
}
}

View file

@ -0,0 +1,22 @@
.breadcrumb-container {
margin: 0;
margin-top: 0.75rem;
padding: 0;
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 0.5rem;
}
.breadcrumb-element {
p {
margin: 0;
margin-left: 0.5rem;
padding: 0;
line-height: normal;
}
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}

View file

@ -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;
}
}

View file

@ -0,0 +1,14 @@
.content-meta {
margin-top: 0;
color: var(--gray);
&[show-comma="true"] {
> *:not(:last-child) {
margin-right: 8px;
&::after {
content: ",";
}
}
}
}

View file

@ -0,0 +1,46 @@
.darkmode {
cursor: pointer;
padding: 0;
position: relative;
background: none;
border: none;
width: 20px;
height: 20px;
margin: 0 10px;
text-align: inherit;
& 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;
}
}

View file

@ -0,0 +1,181 @@
@use "../../styles/variables.scss" as *;
.explorer {
display: flex;
flex-direction: column;
overflow-y: hidden;
&.desktop-only {
@media all and not ($mobile) {
display: flex;
}
}
/*&:after {
pointer-events: none;
content: "";
width: 100%;
height: 50px;
position: absolute;
left: 0;
bottom: 0;
opacity: 1;
transition: opacity 0.3s ease;
background: linear-gradient(transparent 0px, var(--light));
}*/
}
button#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;
}
& .fold {
margin-left: 0.5rem;
transition: transform 0.3s ease;
opacity: 0.8;
}
&.collapsed .fold {
transform: rotateZ(-90deg);
}
}
.folder-outer {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.3s ease-in-out;
}
.folder-outer.open {
grid-template-rows: 1fr;
}
.folder-outer > ul {
overflow: hidden;
}
#explorer-content {
list-style: none;
overflow: hidden;
overflow-y: auto;
max-height: 100%;
transition:
max-height 0.35s ease,
visibility 0s linear 0s;
margin-top: 0.5rem;
visibility: visible;
&.collapsed {
max-height: 0;
transition:
max-height 0.35s ease,
visibility 0s linear 0.35s;
visibility: hidden;
}
& ul {
list-style: none;
margin: 0.08rem 0;
padding: 0;
transition:
max-height 0.35s ease,
transform 0.35s ease,
opacity 0.2s ease;
& li > a {
color: var(--dark);
opacity: 0.75;
pointer-events: all;
}
}
> #explorer-ul {
max-height: none;
}
}
svg {
pointer-events: all;
& > polyline {
pointer-events: none;
}
}
.folder-container {
flex-direction: row;
display: flex;
align-items: center;
user-select: none;
& div > a {
color: var(--secondary);
font-family: var(--headerFont);
font-size: 0.95rem;
font-weight: $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;
}
li:has(> .folder-outer:not(.open)) > .folder-container > svg {
transform: rotate(-90deg);
}
.folder-icon:hover {
color: var(--tertiary);
}
.no-background::after {
background: none !important;
}
#explorer-end {
// needs height so IntersectionObserver gets triggered
height: 4px;
// remove default margin from li
margin: 0;
}

View file

@ -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;
}
}

View 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%;
}
}
}
}

View 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});
}
}
}

View 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;
}
}

View file

@ -0,0 +1,163 @@
.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 {
display: grid;
width: 90%;
height: 90vh;
margin: 5vh auto;
background: var(--light);
box-shadow:
0 14px 50px rgba(27, 33, 48, 0.12),
0 10px 30px rgba(27, 33, 48, 0.16);
overflow: hidden;
position: relative;
& > .mermaid-header {
display: flex;
justify-content: flex-end;
padding: 1rem;
border-bottom: 1px solid var(--lightgray);
background: var(--light);
z-index: 2;
max-height: fit-content;
& > .close-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
background: transparent;
border: none;
border-radius: 4px;
color: var(--darkgray);
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: var(--lightgray);
color: var(--dark);
}
}
}
& > .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;
}
}
}
}
}

View file

@ -0,0 +1,83 @@
@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: absolute;
overflow: visible;
padding: 1rem;
& > .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;
white-space: normal;
}
& > .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;
}
}
a:hover .popover,
.popover:hover {
animation: dropin 0.3s ease;
animation-fill-mode: forwards;
animation-delay: 0.2s;
}

View 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;
}
}
}
}

View file

@ -0,0 +1,233 @@
@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: var(--lightgray);
border: none;
border-radius: 4px;
font-family: inherit;
font-size: inherit;
height: 2rem;
padding: 0;
display: flex;
align-items: center;
text-align: inherit;
cursor: pointer;
white-space: nowrap;
width: 100%;
justify-content: space-between;
& > p {
display: inline;
padding: 0 1rem;
}
& svg {
cursor: pointer;
width: 18px;
min-width: 18px;
margin: 0 0.5rem;
.search-path {
stroke: var(--darkgray);
stroke-width: 2px;
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) {
& > #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 {
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;
}
& > 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;
}
}
}
}
}
}
}

View file

@ -0,0 +1,93 @@
@use "../../styles/variables.scss" as *;
.toc {
display: flex;
flex-direction: column;
&.desktop-only {
max-height: 40%;
}
}
@media all and not ($mobile) {
.toc {
display: flex;
}
}
button#toc {
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);
}
}
#toc-content {
list-style: none;
overflow: hidden;
overflow-y: auto;
max-height: 100%;
transition:
max-height 0.35s ease,
visibility 0s linear 0s;
position: relative;
visibility: visible;
&.collapsed {
max-height: 0;
transition:
max-height 0.35s ease,
visibility 0s linear 0.35s;
visibility: hidden;
}
&.collapsed > .overflow::after {
opacity: 0;
}
& ul {
list-style: none;
margin: 0.5rem 0;
padding: 0;
& > li > a {
color: var(--dark);
opacity: 0.35;
transition:
0.5s ease opacity,
0.3s ease color;
&.in-view {
opacity: 0.75;
}
}
}
> ul.overflow {
max-height: none;
width: 100%;
}
@for $i from 0 through 6 {
& .depth-#{$i} {
padding-left: calc(1rem * #{$i});
}
}
}

View file

@ -0,0 +1,29 @@
import { ComponentType, JSX } from "preact"
import { StaticResources } from "../util/resources"
import { QuartzPluginData } from "../plugins/vfile"
import { GlobalConfiguration } from "../cfg"
import { Node } from "hast"
import { BuildCtx } from "../util/ctx"
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?: string
beforeDOMLoaded?: string
afterDOMLoaded?: string
}
export type QuartzComponentConstructor<Options extends object | undefined = undefined> = (
opts: Options,
) => QuartzComponent

118
quartz/depgraph.test.ts Normal file
View file

@ -0,0 +1,118 @@
import test, { describe } from "node:test"
import DepGraph from "./depgraph"
import assert from "node:assert"
describe("DepGraph", () => {
test("getLeafNodes", () => {
const graph = new DepGraph<string>()
graph.addEdge("A", "B")
graph.addEdge("B", "C")
graph.addEdge("D", "C")
assert.deepStrictEqual(graph.getLeafNodes("A"), new Set(["C"]))
assert.deepStrictEqual(graph.getLeafNodes("B"), new Set(["C"]))
assert.deepStrictEqual(graph.getLeafNodes("C"), new Set(["C"]))
assert.deepStrictEqual(graph.getLeafNodes("D"), new Set(["C"]))
})
describe("getLeafNodeAncestors", () => {
test("gets correct ancestors in a graph without cycles", () => {
const graph = new DepGraph<string>()
graph.addEdge("A", "B")
graph.addEdge("B", "C")
graph.addEdge("D", "B")
assert.deepStrictEqual(graph.getLeafNodeAncestors("A"), new Set(["A", "B", "D"]))
assert.deepStrictEqual(graph.getLeafNodeAncestors("B"), new Set(["A", "B", "D"]))
assert.deepStrictEqual(graph.getLeafNodeAncestors("C"), new Set(["A", "B", "D"]))
assert.deepStrictEqual(graph.getLeafNodeAncestors("D"), new Set(["A", "B", "D"]))
})
test("gets correct ancestors in a graph with cycles", () => {
const graph = new DepGraph<string>()
graph.addEdge("A", "B")
graph.addEdge("B", "C")
graph.addEdge("C", "A")
graph.addEdge("C", "D")
assert.deepStrictEqual(graph.getLeafNodeAncestors("A"), new Set(["A", "B", "C"]))
assert.deepStrictEqual(graph.getLeafNodeAncestors("B"), new Set(["A", "B", "C"]))
assert.deepStrictEqual(graph.getLeafNodeAncestors("C"), new Set(["A", "B", "C"]))
assert.deepStrictEqual(graph.getLeafNodeAncestors("D"), new Set(["A", "B", "C"]))
})
})
describe("mergeGraph", () => {
test("merges two graphs", () => {
const graph = new DepGraph<string>()
graph.addEdge("A.md", "A.html")
const other = new DepGraph<string>()
other.addEdge("B.md", "B.html")
graph.mergeGraph(other)
const expected = {
nodes: ["A.md", "A.html", "B.md", "B.html"],
edges: [
["A.md", "A.html"],
["B.md", "B.html"],
],
}
assert.deepStrictEqual(graph.export(), expected)
})
})
describe("updateIncomingEdgesForNode", () => {
test("merges when node exists", () => {
// A.md -> B.md -> B.html
const graph = new DepGraph<string>()
graph.addEdge("A.md", "B.md")
graph.addEdge("B.md", "B.html")
// B.md is edited so it removes the A.md transclusion
// and adds C.md transclusion
// C.md -> B.md
const other = new DepGraph<string>()
other.addEdge("C.md", "B.md")
other.addEdge("B.md", "B.html")
// A.md -> B.md removed, C.md -> B.md added
// C.md -> B.md -> B.html
graph.updateIncomingEdgesForNode(other, "B.md")
const expected = {
nodes: ["A.md", "B.md", "B.html", "C.md"],
edges: [
["B.md", "B.html"],
["C.md", "B.md"],
],
}
assert.deepStrictEqual(graph.export(), expected)
})
test("adds node if it does not exist", () => {
// A.md -> B.md
const graph = new DepGraph<string>()
graph.addEdge("A.md", "B.md")
// Add a new file C.md that transcludes B.md
// B.md -> C.md
const other = new DepGraph<string>()
other.addEdge("B.md", "C.md")
// B.md -> C.md added
// A.md -> B.md -> C.md
graph.updateIncomingEdgesForNode(other, "C.md")
const expected = {
nodes: ["A.md", "B.md", "C.md"],
edges: [
["A.md", "B.md"],
["B.md", "C.md"],
],
}
assert.deepStrictEqual(graph.export(), expected)
})
})
})

228
quartz/depgraph.ts Normal file
View file

@ -0,0 +1,228 @@
export default class DepGraph<T> {
// node: incoming and outgoing edges
_graph = new Map<T, { incoming: Set<T>; outgoing: Set<T> }>()
constructor() {
this._graph = new Map()
}
export(): Object {
return {
nodes: this.nodes,
edges: this.edges,
}
}
toString(): string {
return JSON.stringify(this.export(), null, 2)
}
// BASIC GRAPH OPERATIONS
get nodes(): T[] {
return Array.from(this._graph.keys())
}
get edges(): [T, T][] {
let edges: [T, T][] = []
this.forEachEdge((edge) => edges.push(edge))
return edges
}
hasNode(node: T): boolean {
return this._graph.has(node)
}
addNode(node: T): void {
if (!this._graph.has(node)) {
this._graph.set(node, { incoming: new Set(), outgoing: new Set() })
}
}
// Remove node and all edges connected to it
removeNode(node: T): void {
if (this._graph.has(node)) {
// first remove all edges so other nodes don't have references to this node
for (const target of this._graph.get(node)!.outgoing) {
this.removeEdge(node, target)
}
for (const source of this._graph.get(node)!.incoming) {
this.removeEdge(source, node)
}
this._graph.delete(node)
}
}
forEachNode(callback: (node: T) => void): void {
for (const node of this._graph.keys()) {
callback(node)
}
}
hasEdge(from: T, to: T): boolean {
return Boolean(this._graph.get(from)?.outgoing.has(to))
}
addEdge(from: T, to: T): void {
this.addNode(from)
this.addNode(to)
this._graph.get(from)!.outgoing.add(to)
this._graph.get(to)!.incoming.add(from)
}
removeEdge(from: T, to: T): void {
if (this._graph.has(from) && this._graph.has(to)) {
this._graph.get(from)!.outgoing.delete(to)
this._graph.get(to)!.incoming.delete(from)
}
}
// returns -1 if node does not exist
outDegree(node: T): number {
return this.hasNode(node) ? this._graph.get(node)!.outgoing.size : -1
}
// returns -1 if node does not exist
inDegree(node: T): number {
return this.hasNode(node) ? this._graph.get(node)!.incoming.size : -1
}
forEachOutNeighbor(node: T, callback: (neighbor: T) => void): void {
this._graph.get(node)?.outgoing.forEach(callback)
}
forEachInNeighbor(node: T, callback: (neighbor: T) => void): void {
this._graph.get(node)?.incoming.forEach(callback)
}
forEachEdge(callback: (edge: [T, T]) => void): void {
for (const [source, { outgoing }] of this._graph.entries()) {
for (const target of outgoing) {
callback([source, target])
}
}
}
// DEPENDENCY ALGORITHMS
// Add all nodes and edges from other graph to this graph
mergeGraph(other: DepGraph<T>): void {
other.forEachEdge(([source, target]) => {
this.addNode(source)
this.addNode(target)
this.addEdge(source, target)
})
}
// For the node provided:
// If node does not exist, add it
// If an incoming edge was added in other, it is added in this graph
// If an incoming edge was deleted in other, it is deleted in this graph
updateIncomingEdgesForNode(other: DepGraph<T>, node: T): void {
this.addNode(node)
// Add edge if it is present in other
other.forEachInNeighbor(node, (neighbor) => {
this.addEdge(neighbor, node)
})
// For node provided, remove incoming edge if it is absent in other
this.forEachEdge(([source, target]) => {
if (target === node && !other.hasEdge(source, target)) {
this.removeEdge(source, target)
}
})
}
// Remove all nodes that do not have any incoming or outgoing edges
// A node may be orphaned if the only node pointing to it was removed
removeOrphanNodes(): Set<T> {
let orphanNodes = new Set<T>()
this.forEachNode((node) => {
if (this.inDegree(node) === 0 && this.outDegree(node) === 0) {
orphanNodes.add(node)
}
})
orphanNodes.forEach((node) => {
this.removeNode(node)
})
return orphanNodes
}
// Get all leaf nodes (i.e. destination paths) reachable from the node provided
// Eg. if the graph is A -> B -> C
// D ---^
// and the node is B, this function returns [C]
getLeafNodes(node: T): Set<T> {
let stack: T[] = [node]
let visited = new Set<T>()
let leafNodes = new Set<T>()
// DFS
while (stack.length > 0) {
let node = stack.pop()!
// If the node is already visited, skip it
if (visited.has(node)) {
continue
}
visited.add(node)
// Check if the node is a leaf node (i.e. destination path)
if (this.outDegree(node) === 0) {
leafNodes.add(node)
}
// Add all unvisited neighbors to the stack
this.forEachOutNeighbor(node, (neighbor) => {
if (!visited.has(neighbor)) {
stack.push(neighbor)
}
})
}
return leafNodes
}
// Get all ancestors of the leaf nodes reachable from the node provided
// Eg. if the graph is A -> B -> C
// D ---^
// and the node is B, this function returns [A, B, D]
getLeafNodeAncestors(node: T): Set<T> {
const leafNodes = this.getLeafNodes(node)
let visited = new Set<T>()
let upstreamNodes = new Set<T>()
// Backwards DFS for each leaf node
leafNodes.forEach((leafNode) => {
let stack: T[] = [leafNode]
while (stack.length > 0) {
let node = stack.pop()!
if (visited.has(node)) {
continue
}
visited.add(node)
// Add node if it's not a leaf node (i.e. destination path)
// Assumes destination file cannot depend on another destination file
if (this.outDegree(node) !== 0) {
upstreamNodes.add(node)
}
// Add all unvisited parents to the stack
this.forEachInNeighbor(node, (parentNode) => {
if (!visited.has(parentNode)) {
stack.push(parentNode)
}
})
}
})
return upstreamNodes
}
}

76
quartz/i18n/index.ts Normal file
View file

@ -0,0 +1,76 @@
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"
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,
} 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

View file

@ -0,0 +1,89 @@
import { Translation } from "./definition"
export default {
propertyDefaults: {
title: "غير معنون",
description: "لم يتم تقديم أي وصف",
},
components: {
callout: {
note: "ملاحظة",
abstract: "ملخص",
info: "معلومات",
todo: "للقيام",
tip: "نصيحة",
success: "نجاح",
question: "سؤال",
warning: "تحذير",
failure: "فشل",
danger: "خطر",
bug: "خلل",
example: "مثال",
quote: "اقتباس",
},
backlinks: {
title: "وصلات العودة",
noBacklinksFound: "لا يوجد وصلات عودة",
},
themeToggle: {
lightMode: "الوضع النهاري",
darkMode: "الوضع الليلي",
},
explorer: {
title: "المستعرض",
},
footer: {
createdWith: "أُنشئ باستخدام",
},
graph: {
title: "التمثيل التفاعلي",
},
recentNotes: {
title: "آخر الملاحظات",
seeRemainingMore: ({ remaining }) => `تصفح ${remaining} أكثر →`,
},
transcludes: {
transcludeOf: ({ targetSlug }) => `مقتبس من ${targetSlug}`,
linkToOriginal: "وصلة للملاحظة الرئيسة",
},
search: {
title: "بحث",
searchBarPlaceholder: "ابحث عن شيء ما",
},
tableOfContents: {
title: "فهرس المحتويات",
},
contentMeta: {
readingTime: ({ minutes }) =>
minutes == 1
? `دقيقة أو أقل للقراءة`
: minutes == 2
? `دقيقتان للقراءة`
: `${minutes} دقائق للقراءة`,
},
},
pages: {
rss: {
recentNotes: "آخر الملاحظات",
lastFewNotes: ({ count }) => `آخر ${count} ملاحظة`,
},
error: {
title: "غير موجود",
notFound: "إما أن هذه الصفحة خاصة أو غير موجودة.",
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

View file

@ -0,0 +1,84 @@
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",
},
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

View file

@ -0,0 +1,84 @@
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",
},
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

View file

@ -0,0 +1,84 @@
import { Translation } from "./definition"
export default {
propertyDefaults: {
title: "Unbenannt",
description: "Keine Beschreibung angegeben",
},
components: {
callout: {
note: "Hinweis",
abstract: "Zusammenfassung",
info: "Info",
todo: "Zu erledigen",
tip: "Tipp",
success: "Erfolg",
question: "Frage",
warning: "Warnung",
failure: "Misserfolg",
danger: "Gefahr",
bug: "Fehler",
example: "Beispiel",
quote: "Zitat",
},
backlinks: {
title: "Backlinks",
noBacklinksFound: "Keine Backlinks gefunden",
},
themeToggle: {
lightMode: "Light Mode",
darkMode: "Dark Mode",
},
explorer: {
title: "Explorer",
},
footer: {
createdWith: "Erstellt mit",
},
graph: {
title: "Graphansicht",
},
recentNotes: {
title: "Zuletzt bearbeitete Seiten",
seeRemainingMore: ({ remaining }) => `${remaining} weitere ansehen →`,
},
transcludes: {
transcludeOf: ({ targetSlug }) => `Transklusion von ${targetSlug}`,
linkToOriginal: "Link zum Original",
},
search: {
title: "Suche",
searchBarPlaceholder: "Suche nach etwas",
},
tableOfContents: {
title: "Inhaltsverzeichnis",
},
contentMeta: {
readingTime: ({ minutes }) => `${minutes} min read`,
},
},
pages: {
rss: {
recentNotes: "Zuletzt bearbeitete Seiten",
lastFewNotes: ({ count }) => `Letzte ${count} Seiten`,
},
error: {
title: "Nicht gefunden",
notFound: "Diese Seite ist entweder nicht öffentlich oder existiert nicht.",
home: "Return to Homepage",
},
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

View file

@ -0,0 +1,84 @@
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
}
components: {
callout: CalloutTranslation
backlinks: {
title: string
noBacklinksFound: string
}
themeToggle: {
lightMode: string
darkMode: 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
}
}
}

View file

@ -0,0 +1,84 @@
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",
},
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

View file

@ -0,0 +1,84 @@
import { Translation } from "./definition"
export default {
propertyDefaults: {
title: "Untitled",
description: "No description provided",
},
components: {
callout: {
note: "Note",
abstract: "Abstract",
info: "Info",
todo: "Todo",
tip: "Tip",
success: "Success",
question: "Question",
warning: "Warning",
failure: "Failure",
danger: "Danger",
bug: "Bug",
example: "Example",
quote: "Quote",
},
backlinks: {
title: "Backlinks",
noBacklinksFound: "No backlinks found",
},
themeToggle: {
lightMode: "Light mode",
darkMode: "Dark mode",
},
explorer: {
title: "Explorer",
},
footer: {
createdWith: "Created with",
},
graph: {
title: "Graph View",
},
recentNotes: {
title: "Recent Notes",
seeRemainingMore: ({ remaining }) => `See ${remaining} more →`,
},
transcludes: {
transcludeOf: ({ targetSlug }) => `Transclude of ${targetSlug}`,
linkToOriginal: "Link to original",
},
search: {
title: "Search",
searchBarPlaceholder: "Search for something",
},
tableOfContents: {
title: "Table of Contents",
},
contentMeta: {
readingTime: ({ minutes }) => `${minutes} min read`,
},
},
pages: {
rss: {
recentNotes: "Recent notes",
lastFewNotes: ({ count }) => `Last ${count} notes`,
},
error: {
title: "Not Found",
notFound: "Either this page is private or doesn't exist.",
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

View file

@ -0,0 +1,84 @@
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",
},
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

View file

@ -0,0 +1,84 @@
import { Translation } from "./definition"
export default {
propertyDefaults: {
title: "بدون عنوان",
description: "توضیح خاصی اضافه نشده است",
},
components: {
callout: {
note: "یادداشت",
abstract: "چکیده",
info: "اطلاعات",
todo: "اقدام",
tip: "نکته",
success: "تیک",
question: "سؤال",
warning: "هشدار",
failure: "شکست",
danger: "خطر",
bug: "باگ",
example: "مثال",
quote: "نقل قول",
},
backlinks: {
title: "بک‌لینک‌ها",
noBacklinksFound: "بدون بک‌لینک",
},
themeToggle: {
lightMode: "حالت روشن",
darkMode: "حالت تاریک",
},
explorer: {
title: "مطالب",
},
footer: {
createdWith: "ساخته شده با",
},
graph: {
title: "نمای گراف",
},
recentNotes: {
title: "یادداشت‌های اخیر",
seeRemainingMore: ({ remaining }) => `${remaining} یادداشت دیگر →`,
},
transcludes: {
transcludeOf: ({ targetSlug }) => `از ${targetSlug}`,
linkToOriginal: "پیوند به اصلی",
},
search: {
title: "جستجو",
searchBarPlaceholder: "مطلبی را جستجو کنید",
},
tableOfContents: {
title: "فهرست",
},
contentMeta: {
readingTime: ({ minutes }) => `زمان تقریبی مطالعه: ${minutes} دقیقه`,
},
},
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

View file

@ -0,0 +1,84 @@
import { Translation } from "./definition"
export default {
propertyDefaults: {
title: "Sans titre",
description: "Aucune description fournie",
},
components: {
callout: {
note: "Note",
abstract: "Résumé",
info: "Info",
todo: "À faire",
tip: "Conseil",
success: "Succès",
question: "Question",
warning: "Avertissement",
failure: "Échec",
danger: "Danger",
bug: "Bogue",
example: "Exemple",
quote: "Citation",
},
backlinks: {
title: "Liens retour",
noBacklinksFound: "Aucun lien retour trouvé",
},
themeToggle: {
lightMode: "Mode clair",
darkMode: "Mode sombre",
},
explorer: {
title: "Explorateur",
},
footer: {
createdWith: "Créé avec",
},
graph: {
title: "Vue Graphique",
},
recentNotes: {
title: "Notes Récentes",
seeRemainingMore: ({ remaining }) => `Voir ${remaining} de plus →`,
},
transcludes: {
transcludeOf: ({ targetSlug }) => `Transclusion de ${targetSlug}`,
linkToOriginal: "Lien vers l'original",
},
search: {
title: "Recherche",
searchBarPlaceholder: "Rechercher quelque chose",
},
tableOfContents: {
title: "Table des Matières",
},
contentMeta: {
readingTime: ({ minutes }) => `${minutes} min 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

View file

@ -0,0 +1,82 @@
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",
},
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

View file

@ -0,0 +1,84 @@
import { Translation } from "./definition"
export default {
propertyDefaults: {
title: "Senza titolo",
description: "Nessuna descrizione",
},
components: {
callout: {
note: "Nota",
abstract: "Astratto",
info: "Info",
todo: "Da fare",
tip: "Consiglio",
success: "Completato",
question: "Domanda",
warning: "Attenzione",
failure: "Errore",
danger: "Pericolo",
bug: "Bug",
example: "Esempio",
quote: "Citazione",
},
backlinks: {
title: "Link entranti",
noBacklinksFound: "Nessun link entrante",
},
themeToggle: {
lightMode: "Tema chiaro",
darkMode: "Tema scuro",
},
explorer: {
title: "Esplora",
},
footer: {
createdWith: "Creato con",
},
graph: {
title: "Vista grafico",
},
recentNotes: {
title: "Note recenti",
seeRemainingMore: ({ remaining }) => `Vedi ${remaining} altro →`,
},
transcludes: {
transcludeOf: ({ targetSlug }) => `Transclusione di ${targetSlug}`,
linkToOriginal: "Link all'originale",
},
search: {
title: "Cerca",
searchBarPlaceholder: "Cerca qualcosa",
},
tableOfContents: {
title: "Tabella dei contenuti",
},
contentMeta: {
readingTime: ({ minutes }) => `${minutes} minuti`,
},
},
pages: {
rss: {
recentNotes: "Note recenti",
lastFewNotes: ({ count }) => `Ultime ${count} note`,
},
error: {
title: "Non trovato",
notFound: "Questa pagina è privata o non esiste.",
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

View file

@ -0,0 +1,82 @@
import { Translation } from "./definition"
export default {
propertyDefaults: {
title: "無題",
description: "説明なし",
},
components: {
callout: {
note: "ノート",
abstract: "抄録",
info: "情報",
todo: "やるべきこと",
tip: "ヒント",
success: "成功",
question: "質問",
warning: "警告",
failure: "失敗",
danger: "危険",
bug: "バグ",
example: "例",
quote: "引用",
},
backlinks: {
title: "バックリンク",
noBacklinksFound: "バックリンクはありません",
},
themeToggle: {
lightMode: "ライトモード",
darkMode: "ダークモード",
},
explorer: {
title: "エクスプローラー",
},
footer: {
createdWith: "作成",
},
graph: {
title: "グラフビュー",
},
recentNotes: {
title: "最近の記事",
seeRemainingMore: ({ remaining }) => `さらに${remaining}件 →`,
},
transcludes: {
transcludeOf: ({ targetSlug }) => `${targetSlug}のまとめ`,
linkToOriginal: "元記事へのリンク",
},
search: {
title: "検索",
searchBarPlaceholder: "検索ワードを入力",
},
tableOfContents: {
title: "目次",
},
contentMeta: {
readingTime: ({ minutes }) => `${minutes} min read`,
},
},
pages: {
rss: {
recentNotes: "最近の記事",
lastFewNotes: ({ count }) => `最新の${count}`,
},
error: {
title: "Not Found",
notFound: "ページが存在しないか、非公開設定になっています。",
home: "ホームページに戻る",
},
folderContent: {
folder: "フォルダ",
itemsUnderFolder: ({ count }) => `${count}件のページ`,
},
tagContent: {
tag: "タグ",
tagIndex: "タグ一覧",
itemsUnderTag: ({ count }) => `${count}件のページ`,
showingFirst: ({ count }) => `のうち最初の${count}件を表示しています`,
totalTags: ({ count }) => `${count}個のタグを表示中`,
},
},
} as const satisfies Translation

View file

@ -0,0 +1,82 @@
import { Translation } from "./definition"
export default {
propertyDefaults: {
title: "제목 없음",
description: "설명 없음",
},
components: {
callout: {
note: "노트",
abstract: "개요",
info: "정보",
todo: "할일",
tip: "팁",
success: "성공",
question: "질문",
warning: "주의",
failure: "실패",
danger: "위험",
bug: "버그",
example: "예시",
quote: "인용",
},
backlinks: {
title: "백링크",
noBacklinksFound: "백링크가 없습니다.",
},
themeToggle: {
lightMode: "라이트 모드",
darkMode: "다크 모드",
},
explorer: {
title: "탐색기",
},
footer: {
createdWith: "Created with",
},
graph: {
title: "그래프 뷰",
},
recentNotes: {
title: "최근 게시글",
seeRemainingMore: ({ remaining }) => `${remaining}건 더보기 →`,
},
transcludes: {
transcludeOf: ({ targetSlug }) => `${targetSlug}의 포함`,
linkToOriginal: "원본 링크",
},
search: {
title: "검색",
searchBarPlaceholder: "검색어를 입력하세요",
},
tableOfContents: {
title: "목차",
},
contentMeta: {
readingTime: ({ minutes }) => `${minutes} min read`,
},
},
pages: {
rss: {
recentNotes: "최근 게시글",
lastFewNotes: ({ count }) => `최근 ${count}`,
},
error: {
title: "Not Found",
notFound: "페이지가 존재하지 않거나 비공개 설정이 되어 있습니다.",
home: "홈페이지로 돌아가기",
},
folderContent: {
folder: "폴더",
itemsUnderFolder: ({ count }) => `${count}건의 항목`,
},
tagContent: {
tag: "태그",
tagIndex: "태그 목록",
itemsUnderTag: ({ count }) => `${count}건의 항목`,
showingFirst: ({ count }) => `처음 ${count}개의 태그`,
totalTags: ({ count }) => `${count}개의 태그를 찾았습니다.`,
},
},
} as const satisfies Translation

View file

@ -0,0 +1,86 @@
import { Translation } from "./definition"
export default {
propertyDefaults: {
title: "Naamloos",
description: "Geen beschrijving gegeven.",
},
components: {
callout: {
note: "Notitie",
abstract: "Samenvatting",
info: "Info",
todo: "Te doen",
tip: "Tip",
success: "Succes",
question: "Vraag",
warning: "Waarschuwing",
failure: "Mislukking",
danger: "Gevaar",
bug: "Bug",
example: "Voorbeeld",
quote: "Citaat",
},
backlinks: {
title: "Backlinks",
noBacklinksFound: "Geen backlinks gevonden",
},
themeToggle: {
lightMode: "Lichte modus",
darkMode: "Donkere modus",
},
explorer: {
title: "Verkenner",
},
footer: {
createdWith: "Gemaakt met",
},
graph: {
title: "Grafiekweergave",
},
recentNotes: {
title: "Recente notities",
seeRemainingMore: ({ remaining }) => `Zie ${remaining} meer →`,
},
transcludes: {
transcludeOf: ({ targetSlug }) => `Invoeging van ${targetSlug}`,
linkToOriginal: "Link naar origineel",
},
search: {
title: "Zoeken",
searchBarPlaceholder: "Doorzoek de website",
},
tableOfContents: {
title: "Inhoudsopgave",
},
contentMeta: {
readingTime: ({ minutes }) =>
minutes === 1 ? "1 minuut leestijd" : `${minutes} minuten leestijd`,
},
},
pages: {
rss: {
recentNotes: "Recente notities",
lastFewNotes: ({ count }) => `Laatste ${count} notities`,
},
error: {
title: "Niet gevonden",
notFound: "Deze pagina is niet zichtbaar of bestaat niet.",
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

View file

@ -0,0 +1,84 @@
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",
},
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: "Search for something",
},
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

View file

@ -0,0 +1,84 @@
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",
},
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

View file

@ -0,0 +1,85 @@
import { Translation } from "./definition"
export default {
propertyDefaults: {
title: "Fără titlu",
description: "Nici o descriere furnizată",
},
components: {
callout: {
note: "Notă",
abstract: "Rezumat",
info: "Informație",
todo: "De făcut",
tip: "Sfat",
success: "Succes",
question: "Întrebare",
warning: "Avertisment",
failure: "Eșec",
danger: "Pericol",
bug: "Bug",
example: "Exemplu",
quote: "Citat",
},
backlinks: {
title: "Legături înapoi",
noBacklinksFound: "Nu s-au găsit legături înapoi",
},
themeToggle: {
lightMode: "Modul luminos",
darkMode: "Modul întunecat",
},
explorer: {
title: "Explorator",
},
footer: {
createdWith: "Creat cu",
},
graph: {
title: "Graf",
},
recentNotes: {
title: "Notițe recente",
seeRemainingMore: ({ remaining }) => `Vezi încă ${remaining}`,
},
transcludes: {
transcludeOf: ({ targetSlug }) => `Extras din ${targetSlug}`,
linkToOriginal: "Legătură către original",
},
search: {
title: "Căutare",
searchBarPlaceholder: "Introduceți termenul de căutare...",
},
tableOfContents: {
title: "Cuprins",
},
contentMeta: {
readingTime: ({ minutes }) =>
minutes == 1 ? `lectură de 1 minut` : `lectură de ${minutes} minute`,
},
},
pages: {
rss: {
recentNotes: "Notițe recente",
lastFewNotes: ({ count }) => `Ultimele ${count} notițe`,
},
error: {
title: "Pagina nu a fost găsită",
notFound: "Fie această pagină este privată, fie nu există.",
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

View file

@ -0,0 +1,96 @@
import { Translation } from "./definition"
export default {
propertyDefaults: {
title: "Без названия",
description: "Описание отсутствует",
},
components: {
callout: {
note: "Заметка",
abstract: "Резюме",
info: "Инфо",
todo: "Сделать",
tip: "Подсказка",
success: "Успех",
question: "Вопрос",
warning: "Предупреждение",
failure: "Неудача",
danger: "Опасность",
bug: "Баг",
example: "Пример",
quote: "Цитата",
},
backlinks: {
title: "Обратные ссылки",
noBacklinksFound: "Обратные ссылки отсутствуют",
},
themeToggle: {
lightMode: "Светлый режим",
darkMode: "Тёмный режим",
},
explorer: {
title: "Проводник",
},
footer: {
createdWith: "Создано с помощью",
},
graph: {
title: "Вид графа",
},
recentNotes: {
title: "Недавние заметки",
seeRemainingMore: ({ remaining }) =>
`Посмотреть оставш${getForm(remaining, "уюся", "иеся", "иеся")} ${remaining}`,
},
transcludes: {
transcludeOf: ({ targetSlug }) => `Переход из ${targetSlug}`,
linkToOriginal: "Ссылка на оригинал",
},
search: {
title: "Поиск",
searchBarPlaceholder: "Найти что-нибудь",
},
tableOfContents: {
title: "Оглавление",
},
contentMeta: {
readingTime: ({ minutes }) => `время чтения ~${minutes} мин.`,
},
},
pages: {
rss: {
recentNotes: "Недавние заметки",
lastFewNotes: ({ count }) =>
`Последн${getForm(count, "яя", "ие", "ие")} ${count} замет${getForm(count, "ка", "ки", "ок")}`,
},
error: {
title: "Страница не найдена",
notFound: "Эта страница приватная или не существует",
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
}

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