feat: add quartz infra
This commit is contained in:
parent
b2708695c2
commit
e8a89ae2a9
165 changed files with 25798 additions and 0 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -1,2 +1,5 @@
|
||||||
result
|
result
|
||||||
main.pdf
|
main.pdf
|
||||||
|
|
||||||
|
public
|
||||||
|
node_modules
|
||||||
|
|
6
content/index.md
Normal file
6
content/index.md
Normal 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
17
globals.d.ts
vendored
Normal 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
12
index.d.ts
vendored
Normal 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
8610
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
116
package.json
Normal file
116
package.json
Normal 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
95
quartz.config.ts
Normal 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
50
quartz.layout.ts
Normal 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: [],
|
||||||
|
}
|
1957
quartz/.quartz-cache/transpiled-build.mjs
Normal file
1957
quartz/.quartz-cache/transpiled-build.mjs
Normal file
File diff suppressed because one or more lines are too long
6
quartz/.quartz-cache/transpiled-build.mjs.map
Normal file
6
quartz/.quartz-cache/transpiled-build.mjs.map
Normal file
File diff suppressed because one or more lines are too long
21
quartz/LICENSE
Normal file
21
quartz/LICENSE
Normal 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
41
quartz/bootstrap-cli.mjs
Executable 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
|
7
quartz/bootstrap-worker.mjs
Normal file
7
quartz/bootstrap-worker.mjs
Normal 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
422
quartz/build.ts
Normal 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
97
quartz/cfg.ts
Normal 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
108
quartz/cli/args.js
Normal file
|
@ -0,0 +1,108 @@
|
||||||
|
export const CommonArgv = {
|
||||||
|
directory: {
|
||||||
|
string: true,
|
||||||
|
alias: ["d"],
|
||||||
|
default: "content",
|
||||||
|
describe: "directory to look for content files",
|
||||||
|
},
|
||||||
|
verbose: {
|
||||||
|
boolean: true,
|
||||||
|
alias: ["v"],
|
||||||
|
default: false,
|
||||||
|
describe: "print out extra logging information",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CreateArgv = {
|
||||||
|
...CommonArgv,
|
||||||
|
source: {
|
||||||
|
string: true,
|
||||||
|
alias: ["s"],
|
||||||
|
describe: "source directory to copy/create symlink from",
|
||||||
|
},
|
||||||
|
strategy: {
|
||||||
|
string: true,
|
||||||
|
alias: ["X"],
|
||||||
|
choices: ["new", "copy", "symlink"],
|
||||||
|
describe: "strategy for content folder setup",
|
||||||
|
},
|
||||||
|
links: {
|
||||||
|
string: true,
|
||||||
|
alias: ["l"],
|
||||||
|
choices: ["absolute", "shortest", "relative"],
|
||||||
|
describe: "strategy to resolve links",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SyncArgv = {
|
||||||
|
...CommonArgv,
|
||||||
|
commit: {
|
||||||
|
boolean: true,
|
||||||
|
default: true,
|
||||||
|
describe: "create a git commit for your unsaved changes",
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
string: true,
|
||||||
|
alias: ["m"],
|
||||||
|
describe: "option to override the default Quartz commit message",
|
||||||
|
},
|
||||||
|
push: {
|
||||||
|
boolean: true,
|
||||||
|
default: true,
|
||||||
|
describe: "push updates to your Quartz fork",
|
||||||
|
},
|
||||||
|
pull: {
|
||||||
|
boolean: true,
|
||||||
|
default: true,
|
||||||
|
describe: "pull updates from your Quartz fork",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BuildArgv = {
|
||||||
|
...CommonArgv,
|
||||||
|
output: {
|
||||||
|
string: true,
|
||||||
|
alias: ["o"],
|
||||||
|
default: "public",
|
||||||
|
describe: "output folder for files",
|
||||||
|
},
|
||||||
|
serve: {
|
||||||
|
boolean: true,
|
||||||
|
default: false,
|
||||||
|
describe: "run a local server to live-preview your Quartz",
|
||||||
|
},
|
||||||
|
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
15
quartz/cli/constants.js
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import path from "path"
|
||||||
|
import { readFileSync } from "fs"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All constants relating to helpers or handlers
|
||||||
|
*/
|
||||||
|
export const ORIGIN_NAME = "origin"
|
||||||
|
export const UPSTREAM_NAME = "upstream"
|
||||||
|
export const QUARTZ_SOURCE_BRANCH = "v4"
|
||||||
|
export const cwd = process.cwd()
|
||||||
|
export const cacheDir = path.join(cwd, ".quartz-cache")
|
||||||
|
export const cacheFile = "./quartz/.quartz-cache/transpiled-build.mjs"
|
||||||
|
export const fp = "./quartz/build.ts"
|
||||||
|
export const { version } = JSON.parse(readFileSync("./package.json").toString())
|
||||||
|
export const contentCacheFolder = path.join(cacheDir, "content-cache")
|
576
quartz/cli/handlers.js
Normal file
576
quartz/cli/handlers.js
Normal 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
54
quartz/cli/helpers.js
Normal 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 })
|
||||||
|
}
|
19
quartz/components/ArticleTitle.tsx
Normal file
19
quartz/components/ArticleTitle.tsx
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||||
|
import { classNames } from "../util/lang"
|
||||||
|
|
||||||
|
const ArticleTitle: QuartzComponent = ({ fileData, displayClass }: QuartzComponentProps) => {
|
||||||
|
const title = fileData.frontmatter?.title
|
||||||
|
if (title) {
|
||||||
|
return <h1 class={classNames(displayClass, "article-title")}>{title}</h1>
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ArticleTitle.css = `
|
||||||
|
.article-title {
|
||||||
|
margin: 2rem 0 0 0;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export default (() => ArticleTitle) satisfies QuartzComponentConstructor
|
52
quartz/components/Backlinks.tsx
Normal file
52
quartz/components/Backlinks.tsx
Normal 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
|
13
quartz/components/Body.tsx
Normal file
13
quartz/components/Body.tsx
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
// @ts-ignore
|
||||||
|
import clipboardScript from "./scripts/clipboard.inline"
|
||||||
|
import clipboardStyle from "./styles/clipboard.scss"
|
||||||
|
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||||
|
|
||||||
|
const Body: QuartzComponent = ({ children }: QuartzComponentProps) => {
|
||||||
|
return <div id="quartz-body">{children}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
Body.afterDOMLoaded = clipboardScript
|
||||||
|
Body.css = clipboardStyle
|
||||||
|
|
||||||
|
export default (() => Body) satisfies QuartzComponentConstructor
|
139
quartz/components/Breadcrumbs.tsx
Normal file
139
quartz/components/Breadcrumbs.tsx
Normal 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
|
60
quartz/components/Comments.tsx
Normal file
60
quartz/components/Comments.tsx
Normal 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>
|
58
quartz/components/ContentMeta.tsx
Normal file
58
quartz/components/ContentMeta.tsx
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
import { Date, getDate } from "./Date"
|
||||||
|
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||||
|
import readingTime from "reading-time"
|
||||||
|
import { classNames } from "../util/lang"
|
||||||
|
import { i18n } from "../i18n"
|
||||||
|
import { JSX } from "preact"
|
||||||
|
import style from "./styles/contentMeta.scss"
|
||||||
|
|
||||||
|
interface ContentMetaOptions {
|
||||||
|
/**
|
||||||
|
* Whether to display reading time
|
||||||
|
*/
|
||||||
|
showReadingTime: boolean
|
||||||
|
showComma: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultOptions: ContentMetaOptions = {
|
||||||
|
showReadingTime: true,
|
||||||
|
showComma: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ((opts?: Partial<ContentMetaOptions>) => {
|
||||||
|
// Merge options with defaults
|
||||||
|
const options: ContentMetaOptions = { ...defaultOptions, ...opts }
|
||||||
|
|
||||||
|
function ContentMetadata({ cfg, fileData, displayClass }: QuartzComponentProps) {
|
||||||
|
const text = fileData.text
|
||||||
|
|
||||||
|
if (text) {
|
||||||
|
const segments: (string | JSX.Element)[] = []
|
||||||
|
|
||||||
|
if (fileData.dates) {
|
||||||
|
segments.push(<Date date={getDate(cfg, fileData)!} locale={cfg.locale} />)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display reading time if enabled
|
||||||
|
if (options.showReadingTime) {
|
||||||
|
const { minutes, words: _words } = readingTime(text)
|
||||||
|
const displayedTime = i18n(cfg.locale).components.contentMeta.readingTime({
|
||||||
|
minutes: Math.ceil(minutes),
|
||||||
|
})
|
||||||
|
segments.push(<span>{displayedTime}</span>)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p show-comma={options.showComma} class={classNames(displayClass, "content-meta")}>
|
||||||
|
{segments}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ContentMetadata.css = style
|
||||||
|
|
||||||
|
return ContentMetadata
|
||||||
|
}) satisfies QuartzComponentConstructor
|
50
quartz/components/Darkmode.tsx
Normal file
50
quartz/components/Darkmode.tsx
Normal 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
|
31
quartz/components/Date.tsx
Normal file
31
quartz/components/Date.tsx
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
import { GlobalConfiguration } from "../cfg"
|
||||||
|
import { ValidLocale } from "../i18n"
|
||||||
|
import { QuartzPluginData } from "../plugins/vfile"
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
date: Date
|
||||||
|
locale?: ValidLocale
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ValidDateType = keyof Required<QuartzPluginData>["dates"]
|
||||||
|
|
||||||
|
export function getDate(cfg: GlobalConfiguration, data: QuartzPluginData): Date | undefined {
|
||||||
|
if (!cfg.defaultDateType) {
|
||||||
|
throw new Error(
|
||||||
|
`Field 'defaultDateType' was not set in the configuration object of quartz.config.ts. See https://quartz.jzhao.xyz/configuration#general-configuration for more details.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return data.dates?.[cfg.defaultDateType]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(d: Date, locale: ValidLocale = "en-US"): string {
|
||||||
|
return d.toLocaleDateString(locale, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "2-digit",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Date({ date, locale }: Props) {
|
||||||
|
return <time datetime={date.toISOString()}>{formatDate(date, locale)}</time>
|
||||||
|
}
|
18
quartz/components/DesktopOnly.tsx
Normal file
18
quartz/components/DesktopOnly.tsx
Normal 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
|
128
quartz/components/Explorer.tsx
Normal file
128
quartz/components/Explorer.tsx
Normal 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
|
242
quartz/components/ExplorerNode.tsx
Normal file
242
quartz/components/ExplorerNode.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
33
quartz/components/Footer.tsx
Normal file
33
quartz/components/Footer.tsx
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||||
|
import style from "./styles/footer.scss"
|
||||||
|
import { version } from "../../package.json"
|
||||||
|
import { i18n } from "../i18n"
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
links: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ((opts?: Options) => {
|
||||||
|
const Footer: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps) => {
|
||||||
|
const year = new Date().getFullYear()
|
||||||
|
const links = opts?.links ?? []
|
||||||
|
return (
|
||||||
|
<footer class={`${displayClass ?? ""}`}>
|
||||||
|
<p>
|
||||||
|
{i18n(cfg.locale).components.footer.createdWith}{" "}
|
||||||
|
<a href="https://quartz.jzhao.xyz/">Quartz v{version}</a> © {year}
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
{Object.entries(links).map(([text, link]) => (
|
||||||
|
<li>
|
||||||
|
<a href={link}>{text}</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</footer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Footer.css = style
|
||||||
|
return Footer
|
||||||
|
}) satisfies QuartzComponentConstructor
|
106
quartz/components/Graph.tsx
Normal file
106
quartz/components/Graph.tsx
Normal 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
208
quartz/components/Head.tsx
Normal 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
|
22
quartz/components/Header.tsx
Normal file
22
quartz/components/Header.tsx
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||||
|
|
||||||
|
const Header: QuartzComponent = ({ children }: QuartzComponentProps) => {
|
||||||
|
return children.length > 0 ? <header>{children}</header> : null
|
||||||
|
}
|
||||||
|
|
||||||
|
Header.css = `
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
margin: 2rem 0;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
margin: 0;
|
||||||
|
flex: auto;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export default (() => Header) satisfies QuartzComponentConstructor
|
18
quartz/components/MobileOnly.tsx
Normal file
18
quartz/components/MobileOnly.tsx
Normal 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
|
87
quartz/components/PageList.tsx
Normal file
87
quartz/components/PageList.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
`
|
23
quartz/components/PageTitle.tsx
Normal file
23
quartz/components/PageTitle.tsx
Normal 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
|
93
quartz/components/RecentNotes.tsx
Normal file
93
quartz/components/RecentNotes.tsx
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||||
|
import { FullSlug, SimpleSlug, resolveRelative } from "../util/path"
|
||||||
|
import { QuartzPluginData } from "../plugins/vfile"
|
||||||
|
import { byDateAndAlphabetical } from "./PageList"
|
||||||
|
import style from "./styles/recentNotes.scss"
|
||||||
|
import { Date, getDate } from "./Date"
|
||||||
|
import { GlobalConfiguration } from "../cfg"
|
||||||
|
import { i18n } from "../i18n"
|
||||||
|
import { classNames } from "../util/lang"
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
title?: string
|
||||||
|
limit: number
|
||||||
|
linkToMore: SimpleSlug | false
|
||||||
|
showTags: boolean
|
||||||
|
filter: (f: QuartzPluginData) => boolean
|
||||||
|
sort: (f1: QuartzPluginData, f2: QuartzPluginData) => number
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultOptions = (cfg: GlobalConfiguration): Options => ({
|
||||||
|
limit: 3,
|
||||||
|
linkToMore: false,
|
||||||
|
showTags: true,
|
||||||
|
filter: () => true,
|
||||||
|
sort: byDateAndAlphabetical(cfg),
|
||||||
|
})
|
||||||
|
|
||||||
|
export default ((userOpts?: Partial<Options>) => {
|
||||||
|
const RecentNotes: QuartzComponent = ({
|
||||||
|
allFiles,
|
||||||
|
fileData,
|
||||||
|
displayClass,
|
||||||
|
cfg,
|
||||||
|
}: QuartzComponentProps) => {
|
||||||
|
const opts = { ...defaultOptions(cfg), ...userOpts }
|
||||||
|
const pages = allFiles.filter(opts.filter).sort(opts.sort)
|
||||||
|
const remaining = Math.max(0, pages.length - opts.limit)
|
||||||
|
return (
|
||||||
|
<div class={classNames(displayClass, "recent-notes")}>
|
||||||
|
<h3>{opts.title ?? i18n(cfg.locale).components.recentNotes.title}</h3>
|
||||||
|
<ul class="recent-ul">
|
||||||
|
{pages.slice(0, opts.limit).map((page) => {
|
||||||
|
const title = page.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title
|
||||||
|
const tags = page.frontmatter?.tags ?? []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li class="recent-li">
|
||||||
|
<div class="section">
|
||||||
|
<div class="desc">
|
||||||
|
<h3>
|
||||||
|
<a href={resolveRelative(fileData.slug!, page.slug!)} class="internal">
|
||||||
|
{title}
|
||||||
|
</a>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
{page.dates && (
|
||||||
|
<p class="meta">
|
||||||
|
<Date date={getDate(cfg, page)!} locale={cfg.locale} />
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{opts.showTags && (
|
||||||
|
<ul class="tags">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class="internal tag-link"
|
||||||
|
href={resolveRelative(fileData.slug!, `tags/${tag}` as FullSlug)}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
{opts.linkToMore && remaining > 0 && (
|
||||||
|
<p>
|
||||||
|
<a href={resolveRelative(fileData.slug!, opts.linkToMore)}>
|
||||||
|
{i18n(cfg.locale).components.recentNotes.seeRemainingMore({ remaining })}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
RecentNotes.css = style
|
||||||
|
return RecentNotes
|
||||||
|
}) satisfies QuartzComponentConstructor
|
53
quartz/components/Search.tsx
Normal file
53
quartz/components/Search.tsx
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||||
|
import style from "./styles/search.scss"
|
||||||
|
// @ts-ignore
|
||||||
|
import script from "./scripts/search.inline"
|
||||||
|
import { classNames } from "../util/lang"
|
||||||
|
import { i18n } from "../i18n"
|
||||||
|
|
||||||
|
export interface SearchOptions {
|
||||||
|
enablePreview: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultOptions: SearchOptions = {
|
||||||
|
enablePreview: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ((userOpts?: Partial<SearchOptions>) => {
|
||||||
|
const Search: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps) => {
|
||||||
|
const opts = { ...defaultOptions, ...userOpts }
|
||||||
|
const searchPlaceholder = i18n(cfg.locale).components.search.searchBarPlaceholder
|
||||||
|
return (
|
||||||
|
<div class={classNames(displayClass, "search")}>
|
||||||
|
<button class="search-button" 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
|
8
quartz/components/Spacer.tsx
Normal file
8
quartz/components/Spacer.tsx
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||||
|
import { classNames } from "../util/lang"
|
||||||
|
|
||||||
|
function Spacer({ displayClass }: QuartzComponentProps) {
|
||||||
|
return <div class={classNames(displayClass, "spacer")}></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default (() => Spacer) satisfies QuartzComponentConstructor
|
95
quartz/components/TableOfContents.tsx
Normal file
95
quartz/components/TableOfContents.tsx
Normal 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
|
57
quartz/components/TagList.tsx
Normal file
57
quartz/components/TagList.tsx
Normal 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
|
47
quartz/components/index.ts
Normal file
47
quartz/components/index.ts
Normal 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,
|
||||||
|
}
|
18
quartz/components/pages/404.tsx
Normal file
18
quartz/components/pages/404.tsx
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
import { i18n } from "../../i18n"
|
||||||
|
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types"
|
||||||
|
|
||||||
|
const NotFound: QuartzComponent = ({ cfg }: QuartzComponentProps) => {
|
||||||
|
// If baseUrl contains a pathname after the domain, use this as the home link
|
||||||
|
const url = new URL(`https://${cfg.baseUrl ?? "example.com"}`)
|
||||||
|
const baseDir = url.pathname
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article class="popover-hint">
|
||||||
|
<h1>404</h1>
|
||||||
|
<p>{i18n(cfg.locale).pages.error.notFound}</p>
|
||||||
|
<a href={baseDir}>{i18n(cfg.locale).pages.error.home}</a>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default (() => NotFound) satisfies QuartzComponentConstructor
|
11
quartz/components/pages/Content.tsx
Normal file
11
quartz/components/pages/Content.tsx
Normal 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
|
107
quartz/components/pages/FolderContent.tsx
Normal file
107
quartz/components/pages/FolderContent.tsx
Normal 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
|
127
quartz/components/pages/TagContent.tsx
Normal file
127
quartz/components/pages/TagContent.tsx
Normal 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
|
261
quartz/components/renderPage.tsx
Normal file
261
quartz/components/renderPage.tsx
Normal 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)
|
||||||
|
}
|
44
quartz/components/scripts/callout.inline.ts
Normal file
44
quartz/components/scripts/callout.inline.ts
Normal 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)
|
23
quartz/components/scripts/checkbox.inline.ts
Normal file
23
quartz/components/scripts/checkbox.inline.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import { getFullSlug } from "../../util/path"
|
||||||
|
|
||||||
|
const checkboxId = (index: number) => `${getFullSlug(window)}-checkbox-${index}`
|
||||||
|
|
||||||
|
document.addEventListener("nav", () => {
|
||||||
|
const checkboxes = document.querySelectorAll(
|
||||||
|
"input.checkbox-toggle",
|
||||||
|
) as NodeListOf<HTMLInputElement>
|
||||||
|
checkboxes.forEach((el, index) => {
|
||||||
|
const elId = checkboxId(index)
|
||||||
|
|
||||||
|
const switchState = (e: Event) => {
|
||||||
|
const newCheckboxState = (e.target as HTMLInputElement)?.checked ? "true" : "false"
|
||||||
|
localStorage.setItem(elId, newCheckboxState)
|
||||||
|
}
|
||||||
|
|
||||||
|
el.addEventListener("change", switchState)
|
||||||
|
window.addCleanup(() => el.removeEventListener("change", switchState))
|
||||||
|
if (localStorage.getItem(elId) === "true") {
|
||||||
|
el.checked = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
37
quartz/components/scripts/clipboard.inline.ts
Normal file
37
quartz/components/scripts/clipboard.inline.ts
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
const svgCopy =
|
||||||
|
'<svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true"><path fill-rule="evenodd" d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 010 1.5h-1.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 019.25 16h-7.5A1.75 1.75 0 010 14.25v-7.5z"></path><path fill-rule="evenodd" d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0114.25 11h-7.5A1.75 1.75 0 015 9.25v-7.5zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25h-7.5z"></path></svg>'
|
||||||
|
const svgCheck =
|
||||||
|
'<svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true"><path fill-rule="evenodd" fill="rgb(63, 185, 80)" d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"></path></svg>'
|
||||||
|
|
||||||
|
document.addEventListener("nav", () => {
|
||||||
|
const els = document.getElementsByTagName("pre")
|
||||||
|
for (let i = 0; i < els.length; i++) {
|
||||||
|
const codeBlock = els[i].getElementsByTagName("code")[0]
|
||||||
|
if (codeBlock) {
|
||||||
|
const source = (
|
||||||
|
codeBlock.dataset.clipboard ? JSON.parse(codeBlock.dataset.clipboard) : codeBlock.innerText
|
||||||
|
).replace(/\n\n/g, "\n")
|
||||||
|
const button = document.createElement("button")
|
||||||
|
button.className = "clipboard-button"
|
||||||
|
button.type = "button"
|
||||||
|
button.innerHTML = svgCopy
|
||||||
|
button.ariaLabel = "Copy source"
|
||||||
|
function onClick() {
|
||||||
|
navigator.clipboard.writeText(source).then(
|
||||||
|
() => {
|
||||||
|
button.blur()
|
||||||
|
button.innerHTML = svgCheck
|
||||||
|
setTimeout(() => {
|
||||||
|
button.innerHTML = svgCopy
|
||||||
|
button.style.borderColor = ""
|
||||||
|
}, 2000)
|
||||||
|
},
|
||||||
|
(error) => console.error(error),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
button.addEventListener("click", onClick)
|
||||||
|
window.addCleanup(() => button.removeEventListener("click", onClick))
|
||||||
|
els[i].prepend(button)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
91
quartz/components/scripts/comments.inline.ts
Normal file
91
quartz/components/scripts/comments.inline.ts
Normal 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))
|
||||||
|
})
|
38
quartz/components/scripts/darkmode.inline.ts
Normal file
38
quartz/components/scripts/darkmode.inline.ts
Normal 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))
|
||||||
|
})
|
135
quartz/components/scripts/explorer.inline.ts
Normal file
135
quartz/components/scripts/explorer.inline.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
601
quartz/components/scripts/graph.inline.ts
Normal file
601
quartz/components/scripts/graph.inline.ts
Normal 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))
|
||||||
|
})
|
242
quartz/components/scripts/mermaid.inline.ts
Normal file
242
quartz/components/scripts/mermaid.inline.ts
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
109
quartz/components/scripts/popover.inline.ts
Normal file
109
quartz/components/scripts/popover.inline.ts
Normal 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))
|
||||||
|
}
|
||||||
|
})
|
493
quartz/components/scripts/search.inline.ts
Normal file
493
quartz/components/scripts/search.inline.ts
Normal 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)
|
||||||
|
}
|
203
quartz/components/scripts/spa.inline.ts
Normal file
203
quartz/components/scripts/spa.inline.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
47
quartz/components/scripts/toc.inline.ts
Normal file
47
quartz/components/scripts/toc.inline.ts
Normal 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))
|
||||||
|
})
|
45
quartz/components/scripts/util.ts
Normal file
45
quartz/components/scripts/util.ts
Normal 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
|
||||||
|
}
|
44
quartz/components/styles/backlinks.scss
Normal file
44
quartz/components/styles/backlinks.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
22
quartz/components/styles/breadcrumbs.scss
Normal file
22
quartz/components/styles/breadcrumbs.scss
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
.breadcrumb-container {
|
||||||
|
margin: 0;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-element {
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
padding: 0;
|
||||||
|
line-height: normal;
|
||||||
|
}
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
36
quartz/components/styles/clipboard.scss
Normal file
36
quartz/components/styles/clipboard.scss
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
.clipboard-button {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
float: right;
|
||||||
|
right: 0;
|
||||||
|
padding: 0.4rem;
|
||||||
|
margin: 0.3rem;
|
||||||
|
color: var(--gray);
|
||||||
|
border-color: var(--dark);
|
||||||
|
background-color: var(--light);
|
||||||
|
border: 1px solid;
|
||||||
|
border-radius: 5px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: 0.2s;
|
||||||
|
|
||||||
|
& > svg {
|
||||||
|
fill: var(--light);
|
||||||
|
filter: contrast(0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
border-color: var(--secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
&:hover > .clipboard-button {
|
||||||
|
opacity: 1;
|
||||||
|
transition: 0.2s;
|
||||||
|
}
|
||||||
|
}
|
14
quartz/components/styles/contentMeta.scss
Normal file
14
quartz/components/styles/contentMeta.scss
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
.content-meta {
|
||||||
|
margin-top: 0;
|
||||||
|
color: var(--gray);
|
||||||
|
|
||||||
|
&[show-comma="true"] {
|
||||||
|
> *:not(:last-child) {
|
||||||
|
margin-right: 8px;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: ",";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
46
quartz/components/styles/darkmode.scss
Normal file
46
quartz/components/styles/darkmode.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
181
quartz/components/styles/explorer.scss
Normal file
181
quartz/components/styles/explorer.scss
Normal 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;
|
||||||
|
}
|
15
quartz/components/styles/footer.scss
Normal file
15
quartz/components/styles/footer.scss
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
footer {
|
||||||
|
text-align: left;
|
||||||
|
margin-bottom: 4rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
|
||||||
|
& ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: -1rem;
|
||||||
|
}
|
||||||
|
}
|
73
quartz/components/styles/graph.scss
Normal file
73
quartz/components/styles/graph.scss
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
@use "../../styles/variables.scss" as *;
|
||||||
|
|
||||||
|
.graph {
|
||||||
|
& > h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > .graph-outer {
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 1px solid var(--lightgray);
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 250px;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
& > #global-graph-icon {
|
||||||
|
cursor: pointer;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--dark);
|
||||||
|
opacity: 0.5;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
position: absolute;
|
||||||
|
padding: 0.2rem;
|
||||||
|
margin: 0.3rem;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: transparent;
|
||||||
|
transition: background-color 0.5s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--lightgray);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& > #global-graph-outer {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 9999;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100%;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: none;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > #global-graph-container {
|
||||||
|
border: 1px solid var(--lightgray);
|
||||||
|
background-color: var(--light);
|
||||||
|
border-radius: 5px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
height: 80vh;
|
||||||
|
width: 80vw;
|
||||||
|
|
||||||
|
@media all and not ($desktop) {
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
27
quartz/components/styles/legacyToc.scss
Normal file
27
quartz/components/styles/legacyToc.scss
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
details#toc {
|
||||||
|
& summary {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&::marker {
|
||||||
|
color: var(--dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
& > * {
|
||||||
|
padding-left: 0.25rem;
|
||||||
|
display: inline-block;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0.5rem 1.25rem;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@for $i from 1 through 6 {
|
||||||
|
& .depth-#{$i} {
|
||||||
|
padding-left: calc(1rem * #{$i});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
40
quartz/components/styles/listPage.scss
Normal file
40
quartz/components/styles/listPage.scss
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
@use "../../styles/variables.scss" as *;
|
||||||
|
|
||||||
|
ul.section-ul {
|
||||||
|
list-style: none;
|
||||||
|
margin-top: 2em;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
li.section-li {
|
||||||
|
margin-bottom: 1em;
|
||||||
|
|
||||||
|
& > .section {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: fit-content(8em) 3fr 1fr;
|
||||||
|
|
||||||
|
@media all and ($mobile) {
|
||||||
|
& > .tags {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& > .desc > h3 > a {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .meta {
|
||||||
|
margin: 0 1em 0 0;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// modifications in popover context
|
||||||
|
.popover .section {
|
||||||
|
grid-template-columns: fit-content(8em) 1fr !important;
|
||||||
|
|
||||||
|
& > .tags {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
163
quartz/components/styles/mermaid.inline.scss
Normal file
163
quartz/components/styles/mermaid.inline.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
83
quartz/components/styles/popover.scss
Normal file
83
quartz/components/styles/popover.scss
Normal 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;
|
||||||
|
}
|
24
quartz/components/styles/recentNotes.scss
Normal file
24
quartz/components/styles/recentNotes.scss
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
.recent-notes {
|
||||||
|
& > h3 {
|
||||||
|
margin: 0.5rem 0 0 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > ul.recent-ul {
|
||||||
|
list-style: none;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-left: 0;
|
||||||
|
|
||||||
|
& > li {
|
||||||
|
margin: 1rem 0;
|
||||||
|
.section > .desc > h3 > a {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section > .meta {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
233
quartz/components/styles/search.scss
Normal file
233
quartz/components/styles/search.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
93
quartz/components/styles/toc.scss
Normal file
93
quartz/components/styles/toc.scss
Normal 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});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
29
quartz/components/types.ts
Normal file
29
quartz/components/types.ts
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import { ComponentType, JSX } from "preact"
|
||||||
|
import { StaticResources } 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
118
quartz/depgraph.test.ts
Normal 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
228
quartz/depgraph.ts
Normal 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
76
quartz/i18n/index.ts
Normal 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
|
89
quartz/i18n/locales/ar-SA.ts
Normal file
89
quartz/i18n/locales/ar-SA.ts
Normal 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
|
84
quartz/i18n/locales/ca-ES.ts
Normal file
84
quartz/i18n/locales/ca-ES.ts
Normal 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
|
84
quartz/i18n/locales/cs-CZ.ts
Normal file
84
quartz/i18n/locales/cs-CZ.ts
Normal 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
|
84
quartz/i18n/locales/de-DE.ts
Normal file
84
quartz/i18n/locales/de-DE.ts
Normal 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
|
84
quartz/i18n/locales/definition.ts
Normal file
84
quartz/i18n/locales/definition.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
84
quartz/i18n/locales/en-GB.ts
Normal file
84
quartz/i18n/locales/en-GB.ts
Normal 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
|
84
quartz/i18n/locales/en-US.ts
Normal file
84
quartz/i18n/locales/en-US.ts
Normal 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
|
84
quartz/i18n/locales/es-ES.ts
Normal file
84
quartz/i18n/locales/es-ES.ts
Normal 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
|
84
quartz/i18n/locales/fa-IR.ts
Normal file
84
quartz/i18n/locales/fa-IR.ts
Normal 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
|
84
quartz/i18n/locales/fr-FR.ts
Normal file
84
quartz/i18n/locales/fr-FR.ts
Normal 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
|
82
quartz/i18n/locales/hu-HU.ts
Normal file
82
quartz/i18n/locales/hu-HU.ts
Normal 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
|
84
quartz/i18n/locales/it-IT.ts
Normal file
84
quartz/i18n/locales/it-IT.ts
Normal 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
|
82
quartz/i18n/locales/ja-JP.ts
Normal file
82
quartz/i18n/locales/ja-JP.ts
Normal 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
|
82
quartz/i18n/locales/ko-KR.ts
Normal file
82
quartz/i18n/locales/ko-KR.ts
Normal 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
|
86
quartz/i18n/locales/nl-NL.ts
Normal file
86
quartz/i18n/locales/nl-NL.ts
Normal 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
|
84
quartz/i18n/locales/pl-PL.ts
Normal file
84
quartz/i18n/locales/pl-PL.ts
Normal 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
|
84
quartz/i18n/locales/pt-BR.ts
Normal file
84
quartz/i18n/locales/pt-BR.ts
Normal 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
|
85
quartz/i18n/locales/ro-RO.ts
Normal file
85
quartz/i18n/locales/ro-RO.ts
Normal 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
|
96
quartz/i18n/locales/ru-RU.ts
Normal file
96
quartz/i18n/locales/ru-RU.ts
Normal 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
Loading…
Reference in a new issue