project: init
This commit is contained in:
commit
0f4d9fcfb5
11
Dockerfile
Normal file
11
Dockerfile
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
FROM node:20-slim as builder
|
||||||
|
WORKDIR /usr/src/app
|
||||||
|
COPY package.json .
|
||||||
|
COPY package-lock.json* .
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
FROM node:20-slim
|
||||||
|
WORKDIR /usr/src/app
|
||||||
|
COPY --from=builder /usr/src/app/ /usr/src/app/
|
||||||
|
COPY . .
|
||||||
|
CMD ["npx", "quartz", "build", "--serve"]
|
21
LICENSE.txt
Normal file
21
LICENSE.txt
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.
|
20
README.md
Normal file
20
README.md
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
# Quartz v4
|
||||||
|
|
||||||
|
> “[One] who works with the door open gets all kinds of interruptions, but [they] also occasionally gets clues as to what the world is and what might be important.” — Richard Hamming
|
||||||
|
|
||||||
|
Quartz is a set of tools that helps you publish your [digital garden](https://jzhao.xyz/posts/networked-thought) and notes as a website for free.
|
||||||
|
Quartz v4 features a from-the-ground rewrite focusing on end-user extensibility and ease-of-use.
|
||||||
|
|
||||||
|
**If you are looking for Quartz v3, you can find it on the [`hugo` branch](https://github.com/jackyzha0/quartz/tree/hugo).**
|
||||||
|
|
||||||
|
🔗 Read the documentation and get started: https://quartz.jzhao.xyz/
|
||||||
|
|
||||||
|
[Join the Discord Community](https://discord.gg/cRFFHYye7t)
|
||||||
|
|
||||||
|
## Sponsors
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://github.com/sponsors/jackyzha0">
|
||||||
|
<img src="https://cdn.jsdelivr.net/gh/jackyzha0/jackyzha0/sponsorkit/sponsors.svg" />
|
||||||
|
</a>
|
||||||
|
</p>
|
12
globals.d.ts
vendored
Normal file
12
globals.d.ts
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
export declare global {
|
||||||
|
interface Document {
|
||||||
|
addEventListener<K extends keyof CustomEventMap>(
|
||||||
|
type: K,
|
||||||
|
listener: (this: Document, ev: CustomEventMap[K]) => void,
|
||||||
|
): void
|
||||||
|
dispatchEvent<K extends keyof CustomEventMap>(ev: CustomEventMap[K]): void
|
||||||
|
}
|
||||||
|
interface Window {
|
||||||
|
spaNavigate(url: URL, isBack: boolean = false)
|
||||||
|
}
|
||||||
|
}
|
11
index.d.ts
vendored
Normal file
11
index.d.ts
vendored
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
declare module "*.scss" {
|
||||||
|
const content: string
|
||||||
|
export = content
|
||||||
|
}
|
||||||
|
|
||||||
|
// dom custom event
|
||||||
|
interface CustomEventMap {
|
||||||
|
nav: CustomEvent<{ url: FullSlug }>
|
||||||
|
}
|
||||||
|
|
||||||
|
declare const fetchData: Promise<ContentIndex>
|
6213
package-lock.json
generated
Normal file
6213
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
109
package.json
Normal file
109
package.json
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
{
|
||||||
|
"name": "@jackyzha0/quartz",
|
||||||
|
"description": "🌱 publish your digital garden and notes as a website",
|
||||||
|
"private": true,
|
||||||
|
"version": "4.1.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": {
|
||||||
|
"docs": "npx quartz build --serve -d docs",
|
||||||
|
"check": "tsc --noEmit && npx prettier . --check",
|
||||||
|
"format": "npx prettier . --write",
|
||||||
|
"test": "tsx ./quartz/util/path.test.ts",
|
||||||
|
"profile": "0x -D prof ./quartz/bootstrap-cli.mjs build --concurrency=1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"npm": ">=9.3.1",
|
||||||
|
"node": ">=18.14"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"site generator",
|
||||||
|
"ssg",
|
||||||
|
"digital-garden",
|
||||||
|
"markdown",
|
||||||
|
"blog",
|
||||||
|
"quartz"
|
||||||
|
],
|
||||||
|
"bin": {
|
||||||
|
"quartz": "./quartz/bootstrap-cli.mjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@clack/prompts": "^0.6.3",
|
||||||
|
"@floating-ui/dom": "^1.4.0",
|
||||||
|
"@napi-rs/simple-git": "0.1.9",
|
||||||
|
"async-mutex": "^0.4.0",
|
||||||
|
"chalk": "^4.1.2",
|
||||||
|
"chokidar": "^3.5.3",
|
||||||
|
"cli-spinner": "^0.2.10",
|
||||||
|
"d3": "^7.8.5",
|
||||||
|
"esbuild-sass-plugin": "^2.12.0",
|
||||||
|
"flexsearch": "0.7.21",
|
||||||
|
"github-slugger": "^2.0.0",
|
||||||
|
"globby": "^13.1.4",
|
||||||
|
"gray-matter": "^4.0.3",
|
||||||
|
"hast-util-to-html": "^8.0.4",
|
||||||
|
"hast-util-to-jsx-runtime": "^1.2.0",
|
||||||
|
"hast-util-to-string": "^2.0.0",
|
||||||
|
"is-absolute-url": "^4.0.1",
|
||||||
|
"js-yaml": "^4.1.0",
|
||||||
|
"lightningcss": "1.21.7",
|
||||||
|
"mdast-util-find-and-replace": "^2.2.2",
|
||||||
|
"mdast-util-to-hast": "^12.3.0",
|
||||||
|
"mdast-util-to-string": "^3.2.0",
|
||||||
|
"micromorph": "^0.4.5",
|
||||||
|
"plausible-tracker": "^0.3.8",
|
||||||
|
"preact": "^10.14.1",
|
||||||
|
"preact-render-to-string": "^6.0.3",
|
||||||
|
"pretty-bytes": "^6.1.0",
|
||||||
|
"pretty-time": "^1.1.0",
|
||||||
|
"reading-time": "^1.5.0",
|
||||||
|
"rehype-autolink-headings": "^6.1.1",
|
||||||
|
"rehype-katex": "^6.0.3",
|
||||||
|
"rehype-mathjax": "^4.0.3",
|
||||||
|
"rehype-pretty-code": "^0.10.0",
|
||||||
|
"rehype-raw": "^6.1.1",
|
||||||
|
"rehype-slug": "^5.1.0",
|
||||||
|
"remark": "^14.0.2",
|
||||||
|
"remark-breaks": "^3.0.3",
|
||||||
|
"remark-frontmatter": "^4.0.1",
|
||||||
|
"remark-gfm": "^3.0.1",
|
||||||
|
"remark-math": "^5.1.1",
|
||||||
|
"remark-parse": "^10.0.1",
|
||||||
|
"remark-rehype": "^10.1.0",
|
||||||
|
"remark-smartypants": "^2.0.0",
|
||||||
|
"rimraf": "^5.0.1",
|
||||||
|
"serve-handler": "^6.1.5",
|
||||||
|
"source-map-support": "^0.5.21",
|
||||||
|
"to-vfile": "^7.2.4",
|
||||||
|
"toml": "^3.0.0",
|
||||||
|
"unified": "^10.1.2",
|
||||||
|
"unist-util-visit": "^4.1.2",
|
||||||
|
"vfile": "^5.3.7",
|
||||||
|
"workerpool": "^6.4.0",
|
||||||
|
"ws": "^8.13.0",
|
||||||
|
"yargs": "^17.7.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/cli-spinner": "^0.2.1",
|
||||||
|
"@types/d3": "^7.4.0",
|
||||||
|
"@types/flexsearch": "^0.7.3",
|
||||||
|
"@types/hast": "^2.3.4",
|
||||||
|
"@types/js-yaml": "^4.0.5",
|
||||||
|
"@types/node": "^20.1.2",
|
||||||
|
"@types/pretty-time": "^1.1.2",
|
||||||
|
"@types/source-map-support": "^0.5.6",
|
||||||
|
"@types/workerpool": "^6.4.0",
|
||||||
|
"@types/ws": "^8.5.5",
|
||||||
|
"@types/yargs": "^17.0.24",
|
||||||
|
"esbuild": "0.19.2",
|
||||||
|
"prettier": "^3.0.0",
|
||||||
|
"tsx": "^3.12.7",
|
||||||
|
"typescript": "^5.0.4"
|
||||||
|
}
|
||||||
|
}
|
75
quartz.config.ts
Normal file
75
quartz.config.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { QuartzConfig } from "./quartz/cfg"
|
||||||
|
import * as Plugin from "./quartz/plugins"
|
||||||
|
|
||||||
|
const config: QuartzConfig = {
|
||||||
|
configuration: {
|
||||||
|
pageTitle: "📚 X·Eden",
|
||||||
|
enableSPA: true,
|
||||||
|
enablePopovers: true,
|
||||||
|
analytics: null,
|
||||||
|
baseUrl: "wiki.7wate.com",
|
||||||
|
ignorePatterns: ["private", "Templates", ".obsidian", "Canvas", "Static"],
|
||||||
|
defaultDateType: "created",
|
||||||
|
theme: {
|
||||||
|
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)",
|
||||||
|
},
|
||||||
|
darkMode: {
|
||||||
|
light: "#161618",
|
||||||
|
lightgray: "#393639",
|
||||||
|
gray: "#646464",
|
||||||
|
darkgray: "#d4d4d4",
|
||||||
|
dark: "#ebebec",
|
||||||
|
secondary: "#7b97aa",
|
||||||
|
tertiary: "#84a59d",
|
||||||
|
highlight: "rgba(143, 159, 169, 0.15)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
transformers: [
|
||||||
|
Plugin.FrontMatter(),
|
||||||
|
Plugin.TableOfContents(),
|
||||||
|
Plugin.CreatedModifiedDate({
|
||||||
|
priority: ["frontmatter", "filesystem"], // you can add 'git' here for last modified from Git but this makes the build slower
|
||||||
|
}),
|
||||||
|
Plugin.SyntaxHighlighting(),
|
||||||
|
Plugin.ObsidianFlavoredMarkdown({ enableInHtmlEmbed: false }),
|
||||||
|
Plugin.GitHubFlavoredMarkdown(),
|
||||||
|
Plugin.CrawlLinks({ markdownLinkResolution: "shortest" }),
|
||||||
|
Plugin.Latex({ renderEngine: "katex" }),
|
||||||
|
Plugin.Description(),
|
||||||
|
],
|
||||||
|
filters: [Plugin.RemoveDrafts()],
|
||||||
|
emitters: [
|
||||||
|
Plugin.AliasRedirects(),
|
||||||
|
Plugin.ComponentResources({ fontOrigin: "googleFonts" }),
|
||||||
|
Plugin.ContentPage(),
|
||||||
|
Plugin.FolderPage(),
|
||||||
|
Plugin.TagPage(),
|
||||||
|
Plugin.ContentIndex({
|
||||||
|
enableSiteMap: true,
|
||||||
|
enableRSS: true,
|
||||||
|
}),
|
||||||
|
Plugin.Assets(),
|
||||||
|
Plugin.Static(),
|
||||||
|
Plugin.NotFoundPage(),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
57
quartz.layout.ts
Normal file
57
quartz.layout.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { PageLayout, SharedLayout, } from "./quartz/cfg"
|
||||||
|
import * as Component from "./quartz/components"
|
||||||
|
import { QuartzPluginData } from "./quartz/plugins/vfile";
|
||||||
|
|
||||||
|
// components shared across all pages
|
||||||
|
export const sharedPageComponents: SharedLayout = {
|
||||||
|
head: Component.Head(),
|
||||||
|
header: [],
|
||||||
|
footer: Component.Footer({
|
||||||
|
links: {
|
||||||
|
"Blog": "https://blog.7wate.com",
|
||||||
|
GitHub: "https://github.com/7wate",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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()),
|
||||||
|
Component.DesktopOnly(Component.RecentNotes({
|
||||||
|
filter:(data: QuartzPluginData) => {
|
||||||
|
// 是否以 'Blog/' 开头
|
||||||
|
// console.log('Current file path:', data.filePath);
|
||||||
|
return data.filePath ? data.filePath.startsWith('content/Blog') : false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
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.ArticleTitle()],
|
||||||
|
left: [
|
||||||
|
Component.PageTitle(),
|
||||||
|
Component.MobileOnly(Component.Spacer()),
|
||||||
|
Component.Search(),
|
||||||
|
Component.Darkmode(),
|
||||||
|
],
|
||||||
|
right: [],
|
||||||
|
}
|
8026
quartz/.quartz-cache/transpiled-build.mjs
Normal file
8026
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
3181
quartz/.quartz-cache/transpiled-worker.mjs
Normal file
3181
quartz/.quartz-cache/transpiled-worker.mjs
Normal file
File diff suppressed because it is too large
Load Diff
6
quartz/.quartz-cache/transpiled-worker.mjs.map
Normal file
6
quartz/.quartz-cache/transpiled-worker.mjs.map
Normal file
File diff suppressed because one or more lines are too long
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,
|
||||||
|
})
|
183
quartz/build.ts
Normal file
183
quartz/build.ts
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
import sourceMapSupport from "source-map-support"
|
||||||
|
sourceMapSupport.install(options)
|
||||||
|
import path from "path"
|
||||||
|
import { PerfTimer } from "./util/perf"
|
||||||
|
import { rimraf } from "rimraf"
|
||||||
|
import { 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, 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"
|
||||||
|
|
||||||
|
async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) {
|
||||||
|
const ctx: BuildCtx = {
|
||||||
|
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(output)
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setup watcher for rebuilds
|
||||||
|
async function startServing(
|
||||||
|
ctx: BuildCtx,
|
||||||
|
mut: Mutex,
|
||||||
|
initialContent: ProcessedContent[],
|
||||||
|
clientRefresh: () => void,
|
||||||
|
) {
|
||||||
|
const { argv } = ctx
|
||||||
|
|
||||||
|
const ignored = await isGitIgnored()
|
||||||
|
const contentMap = new Map<FilePath, ProcessedContent>()
|
||||||
|
for (const content of initialContent) {
|
||||||
|
const [_tree, vfile] = content
|
||||||
|
contentMap.set(vfile.data.filePath!, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialSlugs = ctx.allSlugs
|
||||||
|
let lastBuildMs = 0
|
||||||
|
const toRebuild: Set<FilePath> = new Set()
|
||||||
|
const toRemove: Set<FilePath> = new Set()
|
||||||
|
const trackedAssets: Set<FilePath> = new Set()
|
||||||
|
async function rebuild(fp: string, action: "add" | "change" | "delete") {
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// debounce rebuilds every 250ms
|
||||||
|
|
||||||
|
const buildStart = new Date().getTime()
|
||||||
|
lastBuildMs = buildStart
|
||||||
|
const release = await mut.acquire()
|
||||||
|
if (lastBuildMs > buildStart) {
|
||||||
|
release()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const perf = new PerfTimer()
|
||||||
|
console.log(chalk.yellow("Detected change, rebuilding..."))
|
||||||
|
try {
|
||||||
|
const filesToRebuild = [...toRebuild].filter((fp) => !toRemove.has(fp))
|
||||||
|
|
||||||
|
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])]
|
||||||
|
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)
|
||||||
|
|
||||||
|
// TODO: we can probably traverse the link graph to figure out what's safe to delete here
|
||||||
|
// instead of just deleting everything
|
||||||
|
await rimraf(argv.output)
|
||||||
|
await emitContent(ctx, filteredContent)
|
||||||
|
console.log(chalk.green(`Done rebuilding in ${perf.timeSince()}`))
|
||||||
|
} catch {
|
||||||
|
console.log(chalk.yellow(`Rebuild failed. Waiting on a change to fix the error...`))
|
||||||
|
}
|
||||||
|
|
||||||
|
clientRefresh()
|
||||||
|
toRebuild.clear()
|
||||||
|
toRemove.clear()
|
||||||
|
release()
|
||||||
|
}
|
||||||
|
|
||||||
|
const watcher = chokidar.watch(".", {
|
||||||
|
persistent: true,
|
||||||
|
cwd: argv.directory,
|
||||||
|
ignoreInitial: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
watcher
|
||||||
|
.on("add", (fp) => rebuild(fp, "add"))
|
||||||
|
.on("change", (fp) => rebuild(fp, "change"))
|
||||||
|
.on("unlink", (fp) => rebuild(fp, "delete"))
|
||||||
|
|
||||||
|
return async () => {
|
||||||
|
await watcher.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
55
quartz/cfg.ts
Normal file
55
quartz/cfg.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { ValidDateType } from "./components/Date"
|
||||||
|
import { QuartzComponent } from "./components/types"
|
||||||
|
import { PluginTypes } from "./plugins/types"
|
||||||
|
import { Theme } from "./util/theme"
|
||||||
|
|
||||||
|
export type Analytics =
|
||||||
|
| null
|
||||||
|
| {
|
||||||
|
provider: "plausible"
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
provider: "google"
|
||||||
|
tagId: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
provider: "umami"
|
||||||
|
websiteId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GlobalConfiguration {
|
||||||
|
pageTitle: string
|
||||||
|
/** Whether to enable single-page-app style rendering. this prevents flashes of unstyled content and improves smoothness of Quartz */
|
||||||
|
enableSPA: boolean
|
||||||
|
/** Whether to display Wikipedia-style popovers when hovering over links */
|
||||||
|
enablePopovers: boolean
|
||||||
|
/** Analytics mode */
|
||||||
|
analytics: Analytics
|
||||||
|
/** Glob patterns to not search */
|
||||||
|
ignorePatterns: string[]
|
||||||
|
/** Whether to use created, modified, or published as the default type of date */
|
||||||
|
defaultDateType: ValidDateType
|
||||||
|
/** Base URL to use for CNAME files, sitemaps, and RSS feeds that require an absolute URL.
|
||||||
|
* Quartz will avoid using this as much as possible and use relative URLs most of the time
|
||||||
|
*/
|
||||||
|
baseUrl?: string
|
||||||
|
theme: Theme
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuartzConfig {
|
||||||
|
configuration: GlobalConfiguration
|
||||||
|
plugins: PluginTypes
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FullPageLayout {
|
||||||
|
head: QuartzComponent
|
||||||
|
header: QuartzComponent[]
|
||||||
|
beforeBody: QuartzComponent[]
|
||||||
|
pageBody: QuartzComponent
|
||||||
|
left: QuartzComponent[]
|
||||||
|
right: QuartzComponent[]
|
||||||
|
footer: QuartzComponent
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PageLayout = Pick<FullPageLayout, "beforeBody" | "left" | "right">
|
||||||
|
export type SharedLayout = Pick<FullPageLayout, "head" | "header" | "footer">
|
98
quartz/cli/args.js
Normal file
98
quartz/cli/args.js
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
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",
|
||||||
|
},
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
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")
|
511
quartz/cli/handlers.js
Normal file
511
quartz/cli/handlers.js
Normal file
@ -0,0 +1,511 @@
|
|||||||
|
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 {
|
||||||
|
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 theyre 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.promises.unlink(path.join(contentFolder, ".gitkeep"))
|
||||||
|
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. You can change this later in \`quartz.config.ts\`.`,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
value: "absolute",
|
||||||
|
label: "Treat links as absolute path",
|
||||||
|
hint: "for content made for Quartz 3 and Hugo",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "shortest",
|
||||||
|
label: "Treat links as shortest path",
|
||||||
|
hint: "for most Obsidian vaults",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "relative",
|
||||||
|
label: "Treat links as relative paths",
|
||||||
|
hint: "for just normal Markdown files",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
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) {
|
||||||
|
await cleanupBuild()
|
||||||
|
console.log(chalk.yellow("Detected a source code change, doing a hard rebuild..."))
|
||||||
|
}
|
||||||
|
|
||||||
|
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: "**/*.html",
|
||||||
|
headers: [{ key: "Content-Disposition", value: "inline" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
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")
|
||||||
|
chokidar
|
||||||
|
.watch(["**/*.ts", "**/*.tsx", "**/*.scss", "package.json"], {
|
||||||
|
ignoreInitial: true,
|
||||||
|
})
|
||||||
|
.on("all", async () => {
|
||||||
|
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.",
|
||||||
|
)
|
||||||
|
gitPull(UPSTREAM_NAME, QUARTZ_SOURCE_BRANCH)
|
||||||
|
await popContentFolder(contentFolder)
|
||||||
|
console.log("Ensuring dependencies are up to date")
|
||||||
|
spawnSync("npm", ["i"], { stdio: "inherit" })
|
||||||
|
console.log(chalk.green("Done!"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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",
|
||||||
|
})
|
||||||
|
spawnSync("git", ["add", "."], { stdio: "inherit" })
|
||||||
|
spawnSync("git", ["commit", "-m", `Quartz sync: ${currentTimestamp}`], { 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.",
|
||||||
|
)
|
||||||
|
gitPull(ORIGIN_NAME, QUARTZ_SOURCE_BRANCH)
|
||||||
|
}
|
||||||
|
|
||||||
|
await popContentFolder(contentFolder)
|
||||||
|
if (argv.push) {
|
||||||
|
console.log("Pushing your changes")
|
||||||
|
spawnSync("git", ["push", "-f", ORIGIN_NAME, QUARTZ_SOURCE_BRANCH], { stdio: "inherit" })
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(chalk.green("Done!"))
|
||||||
|
}
|
52
quartz/cli/helpers.js
Normal file
52
quartz/cli/helpers.js
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
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(`Error while pulling updates: ${out.stderr}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 })
|
||||||
|
}
|
17
quartz/components/ArticleTitle.tsx
Normal file
17
quartz/components/ArticleTitle.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||||
|
|
||||||
|
function ArticleTitle({ fileData, displayClass }: QuartzComponentProps) {
|
||||||
|
const title = fileData.frontmatter?.title
|
||||||
|
if (title) {
|
||||||
|
return <h1 class={`article-title ${displayClass ?? ""}`}>{title}</h1>
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ArticleTitle.css = `
|
||||||
|
.article-title {
|
||||||
|
margin: 2rem 0 0 0;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export default (() => ArticleTitle) satisfies QuartzComponentConstructor
|
29
quartz/components/Backlinks.tsx
Normal file
29
quartz/components/Backlinks.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||||
|
import style from "./styles/backlinks.scss"
|
||||||
|
import { resolveRelative, simplifySlug } from "../util/path"
|
||||||
|
|
||||||
|
function Backlinks({ fileData, allFiles, displayClass }: QuartzComponentProps) {
|
||||||
|
const slug = simplifySlug(fileData.slug!)
|
||||||
|
const backlinkFiles = allFiles.filter((file) => file.links?.includes(slug))
|
||||||
|
return (
|
||||||
|
<div class={`backlinks ${displayClass ?? ""}`}>
|
||||||
|
<h3>Backlinks</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>No backlinks found</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Backlinks.css = style
|
||||||
|
export default (() => 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 { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||||
|
|
||||||
|
function Body({ children }: QuartzComponentProps) {
|
||||||
|
return <div id="quartz-body">{children}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
Body.afterDOMLoaded = clipboardScript
|
||||||
|
Body.css = clipboardStyle
|
||||||
|
|
||||||
|
export default (() => Body) satisfies QuartzComponentConstructor
|
117
quartz/components/Breadcrumbs.tsx
Normal file
117
quartz/components/Breadcrumbs.tsx
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||||
|
import breadcrumbsStyle from "./styles/breadcrumbs.scss"
|
||||||
|
import { FullSlug, SimpleSlug, resolveRelative } from "../util/path"
|
||||||
|
import { QuartzPluginData } from "../plugins/vfile"
|
||||||
|
|
||||||
|
type CrumbData = {
|
||||||
|
displayName: string
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BreadcrumbOptions {
|
||||||
|
/**
|
||||||
|
* Symbol between crumbs
|
||||||
|
*/
|
||||||
|
spacerSymbol: string
|
||||||
|
/**
|
||||||
|
* Name of first crumb
|
||||||
|
*/
|
||||||
|
rootName: string
|
||||||
|
/**
|
||||||
|
* wether to look up frontmatter title for folders (could cause performance problems with big vaults)
|
||||||
|
*/
|
||||||
|
resolveFrontmatterTitle: boolean
|
||||||
|
/**
|
||||||
|
* Wether to display breadcrumbs on root `index.md`
|
||||||
|
*/
|
||||||
|
hideOnRoot: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultOptions: BreadcrumbOptions = {
|
||||||
|
spacerSymbol: ">",
|
||||||
|
rootName: "Home",
|
||||||
|
resolveFrontmatterTitle: false,
|
||||||
|
hideOnRoot: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCrumb(displayName: string, baseSlug: FullSlug, currentSlug: SimpleSlug): CrumbData {
|
||||||
|
return {
|
||||||
|
displayName: displayName.replaceAll("-", " "),
|
||||||
|
path: resolveRelative(baseSlug, currentSlug),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// given a folderName (e.g. "features"), search for the corresponding `index.md` file
|
||||||
|
function findCurrentFile(allFiles: QuartzPluginData[], folderName: string) {
|
||||||
|
return allFiles.find((file) => {
|
||||||
|
if (file.slug?.endsWith("index")) {
|
||||||
|
const folderParts = file.filePath?.split("/")
|
||||||
|
if (folderParts) {
|
||||||
|
const name = folderParts[folderParts?.length - 2]
|
||||||
|
if (name === folderName) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ((opts?: Partial<BreadcrumbOptions>) => {
|
||||||
|
// Merge options with defaults
|
||||||
|
const options: BreadcrumbOptions = { ...defaultOptions, ...opts }
|
||||||
|
|
||||||
|
function Breadcrumbs({ 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]
|
||||||
|
|
||||||
|
// Split slug into hierarchy/parts
|
||||||
|
const slugParts = fileData.slug?.split("/")
|
||||||
|
if (slugParts) {
|
||||||
|
// full path until current part
|
||||||
|
let currentPath = ""
|
||||||
|
for (let i = 0; i < slugParts.length - 1; i++) {
|
||||||
|
let currentTitle = slugParts[i]
|
||||||
|
|
||||||
|
// TODO: performance optimizations/memoizing
|
||||||
|
// Try to resolve frontmatter folder title
|
||||||
|
if (options?.resolveFrontmatterTitle) {
|
||||||
|
// try to find file for current path
|
||||||
|
const currentFile = findCurrentFile(allFiles, currentTitle)
|
||||||
|
if (currentFile) {
|
||||||
|
currentTitle = currentFile.frontmatter!.title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Add current slug to full path
|
||||||
|
currentPath += slugParts[i] + "/"
|
||||||
|
|
||||||
|
// Format and add current crumb
|
||||||
|
const crumb = formatCrumb(currentTitle, fileData.slug!, currentPath as SimpleSlug)
|
||||||
|
crumbs.push(crumb)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add current file to crumb (can directly use frontmatter title)
|
||||||
|
crumbs.push({
|
||||||
|
displayName: fileData.frontmatter!.title,
|
||||||
|
path: "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<nav class={`breadcrumb-container ${displayClass ?? ""}`} 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
|
30
quartz/components/ContentMeta.tsx
Normal file
30
quartz/components/ContentMeta.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { formatDate, getDate } from "./Date"
|
||||||
|
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||||
|
import readingTime from "reading-time"
|
||||||
|
|
||||||
|
export default (() => {
|
||||||
|
function ContentMetadata({ cfg, fileData, displayClass }: QuartzComponentProps) {
|
||||||
|
const text = fileData.text
|
||||||
|
if (text) {
|
||||||
|
const segments: string[] = []
|
||||||
|
const { text: timeTaken, words: _words } = readingTime(text)
|
||||||
|
|
||||||
|
if (fileData.dates) {
|
||||||
|
segments.push(formatDate(getDate(cfg, fileData)!))
|
||||||
|
}
|
||||||
|
|
||||||
|
segments.push(timeTaken)
|
||||||
|
return <p class={`content-meta ${displayClass ?? ""}`}>{segments.join(", ")}</p>
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ContentMetadata.css = `
|
||||||
|
.content-meta {
|
||||||
|
margin-top: 0;
|
||||||
|
color: var(--gray);
|
||||||
|
}
|
||||||
|
`
|
||||||
|
return ContentMetadata
|
||||||
|
}) satisfies QuartzComponentConstructor
|
51
quartz/components/Darkmode.tsx
Normal file
51
quartz/components/Darkmode.tsx
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
// @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 { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||||
|
|
||||||
|
function Darkmode({ displayClass }: QuartzComponentProps) {
|
||||||
|
return (
|
||||||
|
<div class={`darkmode ${displayClass ?? ""}`}>
|
||||||
|
<input class="toggle" id="darkmode-toggle" type="checkbox" tabIndex={-1} />
|
||||||
|
<label id="toggle-label-light" for="darkmode-toggle" tabIndex={-1}>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<title>Light mode</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>
|
||||||
|
</label>
|
||||||
|
<label id="toggle-label-dark" for="darkmode-toggle" tabIndex={-1}>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<title>Dark mode</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>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Darkmode.beforeDOMLoaded = darkmodeScript
|
||||||
|
Darkmode.css = styles
|
||||||
|
|
||||||
|
export default (() => Darkmode) satisfies QuartzComponentConstructor
|
29
quartz/components/Date.tsx
Normal file
29
quartz/components/Date.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { GlobalConfiguration } from "../cfg"
|
||||||
|
import { QuartzPluginData } from "../plugins/vfile"
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
date: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
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): string {
|
||||||
|
return d.toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "2-digit",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Date({ date }: Props) {
|
||||||
|
return <>{formatDate(date)}</>
|
||||||
|
}
|
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
|
||||||
|
function DesktopOnly(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
|
126
quartz/components/Explorer.tsx
Normal file
126
quartz/components/Explorer.tsx
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import { 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"
|
||||||
|
|
||||||
|
// Options interface defined in `ExplorerNode` to avoid circular dependency
|
||||||
|
const defaultOptions = {
|
||||||
|
title: "Explorer",
|
||||||
|
folderClickBehavior: "collapse",
|
||||||
|
folderDefaultState: "collapsed",
|
||||||
|
useSavedState: true,
|
||||||
|
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
|
||||||
|
|
||||||
|
function constructFileTree(allFiles: QuartzPluginData[]) {
|
||||||
|
if (!fileTree) {
|
||||||
|
// Construct tree from allFiles
|
||||||
|
fileTree = new FileNode("")
|
||||||
|
allFiles.forEach((file) => fileTree.add(file, 1))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keys of this object must match corresponding function name of `FileNode`,
|
||||||
|
* while values must be the argument that will be passed to the function.
|
||||||
|
*
|
||||||
|
* e.g. entry for FileNode.sort: `sort: opts.sortFn` (value is sort function from options)
|
||||||
|
*/
|
||||||
|
const functions = {
|
||||||
|
map: opts.mapFn,
|
||||||
|
sort: opts.sortFn,
|
||||||
|
filter: opts.filterFn,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (functions[functionName]) {
|
||||||
|
// for every entry in order, call matching function in FileNode and pass matching argument
|
||||||
|
// e.g. i = 0; functionName = "filter"
|
||||||
|
// converted to: (if opts.filterFn) => fileTree.filter(opts.filterFn)
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
// typescript cant statically check these dynamic references, so manually make sure reference is valid and ignore warning
|
||||||
|
fileTree[functionName].call(fileTree, functions[functionName])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all folders of tree. Initialize with collapsed state
|
||||||
|
const folders = fileTree.getFolderPaths(opts.folderDefaultState === "collapsed")
|
||||||
|
|
||||||
|
// Stringify to pass json tree as data attribute ([data-tree])
|
||||||
|
jsonTree = JSON.stringify(folders)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Explorer({ allFiles, displayClass, fileData }: QuartzComponentProps) {
|
||||||
|
constructFileTree(allFiles)
|
||||||
|
return (
|
||||||
|
<div class={`explorer ${displayClass ?? ""}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="explorer"
|
||||||
|
data-behavior={opts.folderClickBehavior}
|
||||||
|
data-collapsed={opts.folderDefaultState}
|
||||||
|
data-savestate={opts.useSavedState}
|
||||||
|
data-tree={jsonTree}
|
||||||
|
>
|
||||||
|
<h1>{opts.title}</h1>
|
||||||
|
<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
|
224
quartz/components/ExplorerNode.tsx
Normal file
224
quartz/components/ExplorerNode.tsx
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
// @ts-ignore
|
||||||
|
import { QuartzPluginData } from "../plugins/vfile"
|
||||||
|
import { resolveRelative } 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Structure to add all files into a tree
|
||||||
|
export class FileNode {
|
||||||
|
children: FileNode[]
|
||||||
|
name: string
|
||||||
|
displayName: string
|
||||||
|
file: QuartzPluginData | null
|
||||||
|
depth: number
|
||||||
|
|
||||||
|
constructor(name: string, file?: QuartzPluginData, depth?: number) {
|
||||||
|
this.children = []
|
||||||
|
this.name = name
|
||||||
|
this.displayName = name
|
||||||
|
this.file = file ? structuredClone(file) : null
|
||||||
|
this.depth = depth ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private insert(file: DataWrapper) {
|
||||||
|
if (file.path.length === 1) {
|
||||||
|
if (file.path[0] !== "index.md") {
|
||||||
|
this.children.push(new FileNode(file.file.frontmatter!.title, file.file, this.depth + 1))
|
||||||
|
} else {
|
||||||
|
const title = file.file.frontmatter?.title
|
||||||
|
if (title && title !== "index" && file.path[0] === "index.md") {
|
||||||
|
this.displayName = title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const next = file.path[0]
|
||||||
|
file.path = file.path.splice(1)
|
||||||
|
for (const child of this.children) {
|
||||||
|
if (child.name === next) {
|
||||||
|
child.insert(file)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newChild = new FileNode(next, undefined, this.depth + 1)
|
||||||
|
newChild.insert(file)
|
||||||
|
this.children.push(newChild)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new file to tree
|
||||||
|
add(file: QuartzPluginData, splice: number = 0) {
|
||||||
|
this.insert({ file, path: file.filePath!.split("/").splice(splice) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print tree structure (for debugging)
|
||||||
|
print(depth: number = 0) {
|
||||||
|
let folderChar = ""
|
||||||
|
if (!this.file) folderChar = "|"
|
||||||
|
console.log("-".repeat(depth), folderChar, this.name, this.depth)
|
||||||
|
this.children.forEach((e) => e.print(depth + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 = currentPath + (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
|
||||||
|
let pathOld = fullPath ? fullPath : ""
|
||||||
|
let folderPath = ""
|
||||||
|
if (node.name !== "") {
|
||||||
|
folderPath = `${pathOld}/${node.name}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
{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>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{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={`${folderPath}`} data-for={node.name} class="folder-title">
|
||||||
|
{node.displayName}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<button class="folder-button">
|
||||||
|
<p class="folder-title">{node.displayName}</p>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
32
quartz/components/Footer.tsx
Normal file
32
quartz/components/Footer.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||||
|
import style from "./styles/footer.scss"
|
||||||
|
import { version } from "../../package.json"
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
links: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ((opts?: Options) => {
|
||||||
|
function Footer({ displayClass }: QuartzComponentProps) {
|
||||||
|
const year = new Date().getFullYear()
|
||||||
|
const links = opts?.links ?? []
|
||||||
|
return (
|
||||||
|
<footer class={`${displayClass ?? ""}`}>
|
||||||
|
<hr />
|
||||||
|
<p>
|
||||||
|
Created with <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
|
100
quartz/components/Graph.tsx
Normal file
100
quartz/components/Graph.tsx
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||||
|
// @ts-ignore
|
||||||
|
import script from "./scripts/graph.inline"
|
||||||
|
import style from "./styles/graph.scss"
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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: [],
|
||||||
|
},
|
||||||
|
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: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ((opts?: GraphOptions) => {
|
||||||
|
function Graph({ displayClass }: QuartzComponentProps) {
|
||||||
|
const localGraph = { ...defaultOptions.localGraph, ...opts?.localGraph }
|
||||||
|
const globalGraph = { ...defaultOptions.globalGraph, ...opts?.globalGraph }
|
||||||
|
return (
|
||||||
|
<div class={`graph ${displayClass ?? ""}`}>
|
||||||
|
<h3>Graph View</h3>
|
||||||
|
<div class="graph-outer">
|
||||||
|
<div id="graph-container" data-cfg={JSON.stringify(localGraph)}></div>
|
||||||
|
<svg
|
||||||
|
version="1.1"
|
||||||
|
id="global-graph-icon"
|
||||||
|
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>
|
||||||
|
</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
|
44
quartz/components/Head.tsx
Normal file
44
quartz/components/Head.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { FullSlug, _stripSlashes, joinSegments, pathToRoot } from "../util/path"
|
||||||
|
import { JSResourceToScriptElement } from "../util/resources"
|
||||||
|
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||||
|
|
||||||
|
export default (() => {
|
||||||
|
function Head({ cfg, fileData, externalResources }: QuartzComponentProps) {
|
||||||
|
const title = fileData.frontmatter?.title ?? "Untitled"
|
||||||
|
const description = fileData.description?.trim() ?? "No description provided"
|
||||||
|
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 ogImagePath = `https://${cfg.baseUrl}/static/og-image.png`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<head>
|
||||||
|
<title>{title}</title>
|
||||||
|
<meta charSet="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta property="og:title" content={title} />
|
||||||
|
<meta property="og:description" content={description} />
|
||||||
|
{cfg.baseUrl && <meta property="og:image" content={ogImagePath} />}
|
||||||
|
<meta property="og:width" content="1200" />
|
||||||
|
<meta property="og:height" content="675" />
|
||||||
|
<link rel="icon" href={iconPath} />
|
||||||
|
<meta name="description" content={description} />
|
||||||
|
<meta name="generator" content="Quartz" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" />
|
||||||
|
{css.map((href) => (
|
||||||
|
<link key={href} href={href} rel="stylesheet" type="text/css" spa-preserve />
|
||||||
|
))}
|
||||||
|
{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 { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||||
|
|
||||||
|
function Header({ 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
|
||||||
|
function MobileOnly(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 { QuartzComponentProps } from "./types"
|
||||||
|
import { GlobalConfiguration } from "../cfg"
|
||||||
|
|
||||||
|
export function byDateAndAlphabetical(
|
||||||
|
cfg: GlobalConfiguration,
|
||||||
|
): (f1: QuartzPluginData, f2: QuartzPluginData) => number {
|
||||||
|
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
|
||||||
|
} & QuartzComponentProps
|
||||||
|
|
||||||
|
export function PageList({ cfg, fileData, allFiles, limit }: Props) {
|
||||||
|
let list = allFiles.sort(byDateAndAlphabetical(cfg))
|
||||||
|
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">
|
||||||
|
{page.dates && (
|
||||||
|
<p class="meta">
|
||||||
|
<Date date={getDate(cfg, page)!} />
|
||||||
|
</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;
|
||||||
|
}
|
||||||
|
`
|
20
quartz/components/PageTitle.tsx
Normal file
20
quartz/components/PageTitle.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { pathToRoot } from "../util/path"
|
||||||
|
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||||
|
|
||||||
|
function PageTitle({ fileData, cfg, displayClass }: QuartzComponentProps) {
|
||||||
|
const title = cfg?.pageTitle ?? "Untitled Quartz"
|
||||||
|
const baseDir = pathToRoot(fileData.slug!)
|
||||||
|
return (
|
||||||
|
<h1 class={`page-title ${displayClass ?? ""}`}>
|
||||||
|
<a href={baseDir}>{title}</a>
|
||||||
|
</h1>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
PageTitle.css = `
|
||||||
|
.page-title {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export default (() => PageTitle) satisfies QuartzComponentConstructor
|
85
quartz/components/RecentNotes.tsx
Normal file
85
quartz/components/RecentNotes.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import { 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"
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
title: string
|
||||||
|
limit: number
|
||||||
|
linkToMore: SimpleSlug | false
|
||||||
|
filter: (f: QuartzPluginData) => boolean
|
||||||
|
sort: (f1: QuartzPluginData, f2: QuartzPluginData) => number
|
||||||
|
showTags: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultOptions = (cfg: GlobalConfiguration): Options => ({
|
||||||
|
title: "Recent Notes",
|
||||||
|
limit: 3,
|
||||||
|
linkToMore: false,
|
||||||
|
filter: () => true,
|
||||||
|
sort: byDateAndAlphabetical(cfg),
|
||||||
|
showTags: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
export default ((userOpts?: Partial<Options>) => {
|
||||||
|
function RecentNotes({ 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={`recent-notes ${displayClass ?? ""}`}>
|
||||||
|
<h3>{opts.title}</h3>
|
||||||
|
<ul class="recent-ul">
|
||||||
|
{pages.slice(0, opts.limit).map((page) => {
|
||||||
|
const title = page.frontmatter?.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)!} />
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{opts.showTags && tags.length > 0 && ( // 根据 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)}>See {remaining} more →</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
RecentNotes.css = style
|
||||||
|
return RecentNotes
|
||||||
|
}) satisfies QuartzComponentConstructor
|
49
quartz/components/Search.tsx
Normal file
49
quartz/components/Search.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||||
|
import style from "./styles/search.scss"
|
||||||
|
// @ts-ignore
|
||||||
|
import script from "./scripts/search.inline"
|
||||||
|
|
||||||
|
export default (() => {
|
||||||
|
function Search({ displayClass }: QuartzComponentProps) {
|
||||||
|
return (
|
||||||
|
<div class={`search ${displayClass ?? ""}`}>
|
||||||
|
<div id="search-icon">
|
||||||
|
<p>Search</p>
|
||||||
|
<div></div>
|
||||||
|
<svg
|
||||||
|
tabIndex={0}
|
||||||
|
aria-labelledby="title desc"
|
||||||
|
role="img"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 19.9 19.7"
|
||||||
|
>
|
||||||
|
<title id="title">Search</title>
|
||||||
|
<desc id="desc">Search</desc>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div id="search-container">
|
||||||
|
<div id="search-space">
|
||||||
|
<input
|
||||||
|
autocomplete="off"
|
||||||
|
id="search-bar"
|
||||||
|
name="search"
|
||||||
|
type="text"
|
||||||
|
aria-label="Search for something"
|
||||||
|
placeholder="Search for something"
|
||||||
|
/>
|
||||||
|
<div id="results-container"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Search.afterDOMLoaded = script
|
||||||
|
Search.css = style
|
||||||
|
|
||||||
|
return Search
|
||||||
|
}) satisfies QuartzComponentConstructor
|
7
quartz/components/Spacer.tsx
Normal file
7
quartz/components/Spacer.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||||
|
|
||||||
|
function Spacer({ displayClass }: QuartzComponentProps) {
|
||||||
|
return <div class={`spacer ${displayClass ?? ""}`}></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default (() => Spacer) satisfies QuartzComponentConstructor
|
84
quartz/components/TableOfContents.tsx
Normal file
84
quartz/components/TableOfContents.tsx
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||||
|
import legacyStyle from "./styles/legacyToc.scss"
|
||||||
|
import modernStyle from "./styles/toc.scss"
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
import script from "./scripts/toc.inline"
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
layout: "modern" | "legacy"
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultOptions: Options = {
|
||||||
|
layout: "modern",
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableOfContents({ fileData, displayClass }: QuartzComponentProps) {
|
||||||
|
if (!fileData.toc) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class={`toc ${displayClass ?? ""}`}>
|
||||||
|
<button type="button" id="toc" class={fileData.collapseToc ? "collapsed" : ""}>
|
||||||
|
<h3>Table of Contents</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">
|
||||||
|
<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
|
||||||
|
|
||||||
|
function LegacyTableOfContents({ fileData }: QuartzComponentProps) {
|
||||||
|
if (!fileData.toc) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<details id="toc" open={!fileData.collapseToc}>
|
||||||
|
<summary>
|
||||||
|
<h3>Table of Contents</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
|
58
quartz/components/TagList.tsx
Normal file
58
quartz/components/TagList.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { pathToRoot, slugTag } from "../util/path"
|
||||||
|
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||||
|
|
||||||
|
function TagList({ fileData, displayClass }: QuartzComponentProps) {
|
||||||
|
const tags = fileData.frontmatter?.tags
|
||||||
|
const baseDir = pathToRoot(fileData.slug!)
|
||||||
|
if (tags && tags.length > 0) {
|
||||||
|
return (
|
||||||
|
<ul class={`tags ${displayClass ?? ""}`}>
|
||||||
|
{tags.map((tag) => {
|
||||||
|
const display = `#${tag}`
|
||||||
|
const linkDest = baseDir + `/tags/${slugTag(tag)}`
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<a href={linkDest} class="internal tag-link">
|
||||||
|
{display}
|
||||||
|
</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;
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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
|
45
quartz/components/index.ts
Normal file
45
quartz/components/index.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
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"
|
||||||
|
|
||||||
|
export {
|
||||||
|
ArticleTitle,
|
||||||
|
Content,
|
||||||
|
TagContent,
|
||||||
|
FolderContent,
|
||||||
|
Darkmode,
|
||||||
|
Head,
|
||||||
|
PageTitle,
|
||||||
|
ContentMeta,
|
||||||
|
Spacer,
|
||||||
|
TableOfContents,
|
||||||
|
Explorer,
|
||||||
|
TagList,
|
||||||
|
Graph,
|
||||||
|
Backlinks,
|
||||||
|
Search,
|
||||||
|
Footer,
|
||||||
|
DesktopOnly,
|
||||||
|
MobileOnly,
|
||||||
|
RecentNotes,
|
||||||
|
NotFound,
|
||||||
|
Breadcrumbs,
|
||||||
|
}
|
12
quartz/components/pages/404.tsx
Normal file
12
quartz/components/pages/404.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { QuartzComponentConstructor } from "../types"
|
||||||
|
|
||||||
|
function NotFound() {
|
||||||
|
return (
|
||||||
|
<article class="popover-hint">
|
||||||
|
<h1>404</h1>
|
||||||
|
<p>Either this page is private or doesn't exist.</p>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default (() => NotFound) satisfies QuartzComponentConstructor
|
9
quartz/components/pages/Content.tsx
Normal file
9
quartz/components/pages/Content.tsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { htmlToJsx } from "../../util/jsx"
|
||||||
|
import { QuartzComponentConstructor, QuartzComponentProps } from "../types"
|
||||||
|
|
||||||
|
function Content({ fileData, tree }: QuartzComponentProps) {
|
||||||
|
const content = htmlToJsx(fileData.filePath!, tree)
|
||||||
|
return <article class="popover-hint">{content}</article>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default (() => Content) satisfies QuartzComponentConstructor
|
47
quartz/components/pages/FolderContent.tsx
Normal file
47
quartz/components/pages/FolderContent.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { QuartzComponentConstructor, QuartzComponentProps } from "../types"
|
||||||
|
import path from "path"
|
||||||
|
|
||||||
|
import style from "../styles/listPage.scss"
|
||||||
|
import { PageList } from "../PageList"
|
||||||
|
import { _stripSlashes, simplifySlug } from "../../util/path"
|
||||||
|
import { Root } from "hast"
|
||||||
|
import { pluralize } from "../../util/lang"
|
||||||
|
import { htmlToJsx } from "../../util/jsx"
|
||||||
|
|
||||||
|
function FolderContent(props: QuartzComponentProps) {
|
||||||
|
const { tree, fileData, allFiles } = props
|
||||||
|
const folderSlug = _stripSlashes(simplifySlug(fileData.slug!))
|
||||||
|
const allPagesInFolder = allFiles.filter((file) => {
|
||||||
|
const fileSlug = _stripSlashes(simplifySlug(file.slug!))
|
||||||
|
const prefixed = fileSlug.startsWith(folderSlug) && fileSlug !== folderSlug
|
||||||
|
const folderParts = folderSlug.split(path.posix.sep)
|
||||||
|
const fileParts = fileSlug.split(path.posix.sep)
|
||||||
|
const isDirectChild = fileParts.length === folderParts.length + 1
|
||||||
|
return prefixed && isDirectChild
|
||||||
|
})
|
||||||
|
|
||||||
|
const listProps = {
|
||||||
|
...props,
|
||||||
|
allFiles: allPagesInFolder,
|
||||||
|
}
|
||||||
|
|
||||||
|
const content =
|
||||||
|
(tree as Root).children.length === 0
|
||||||
|
? fileData.description
|
||||||
|
: htmlToJsx(fileData.filePath!, tree)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="popover-hint">
|
||||||
|
<article>
|
||||||
|
<p>{content}</p>
|
||||||
|
</article>
|
||||||
|
<p>{pluralize(allPagesInFolder.length, "item")} under this folder.</p>
|
||||||
|
<div>
|
||||||
|
<PageList {...listProps} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
FolderContent.css = style + PageList.css
|
||||||
|
export default (() => FolderContent) satisfies QuartzComponentConstructor
|
92
quartz/components/pages/TagContent.tsx
Normal file
92
quartz/components/pages/TagContent.tsx
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import { QuartzComponentConstructor, QuartzComponentProps } from "../types"
|
||||||
|
import style from "../styles/listPage.scss"
|
||||||
|
import { PageList } from "../PageList"
|
||||||
|
import { FullSlug, getAllSegmentPrefixes, simplifySlug } from "../../util/path"
|
||||||
|
import { QuartzPluginData } from "../../plugins/vfile"
|
||||||
|
import { Root } from "hast"
|
||||||
|
import { pluralize } from "../../util/lang"
|
||||||
|
import { htmlToJsx } from "../../util/jsx"
|
||||||
|
|
||||||
|
const numPages = 10
|
||||||
|
function TagContent(props: QuartzComponentProps) {
|
||||||
|
const { tree, fileData, allFiles } = 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)
|
||||||
|
|
||||||
|
if (tag === "") {
|
||||||
|
const tags = [...new Set(allFiles.flatMap((data) => data.frontmatter?.tags ?? []))]
|
||||||
|
const tagItemMap: Map<string, QuartzPluginData[]> = new Map()
|
||||||
|
for (const tag of tags) {
|
||||||
|
tagItemMap.set(tag, allPagesWithTag(tag))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="popover-hint">
|
||||||
|
<article>
|
||||||
|
<p>{content}</p>
|
||||||
|
</article>
|
||||||
|
<p>Found {tags.length} total tags.</p>
|
||||||
|
<div>
|
||||||
|
{tags.map((tag) => {
|
||||||
|
const pages = tagItemMap.get(tag)!
|
||||||
|
const listProps = {
|
||||||
|
...props,
|
||||||
|
allFiles: pages,
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentPage = allFiles.filter((file) => file.slug === `tags/${tag}`)[0]
|
||||||
|
const content = contentPage?.description
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>
|
||||||
|
<a class="internal tag-link" href={`./${tag}`}>
|
||||||
|
#{tag}
|
||||||
|
</a>
|
||||||
|
</h2>
|
||||||
|
{content && <p>{content}</p>}
|
||||||
|
<p>
|
||||||
|
{pluralize(pages.length, "item")} with this tag.{" "}
|
||||||
|
{pages.length > numPages && `Showing first ${numPages}.`}
|
||||||
|
</p>
|
||||||
|
<PageList limit={numPages} {...listProps} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
const pages = allPagesWithTag(tag)
|
||||||
|
const listProps = {
|
||||||
|
...props,
|
||||||
|
allFiles: pages,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="popover-hint">
|
||||||
|
<article>{content}</article>
|
||||||
|
<p>{pluralize(pages.length, "item")} with this tag.</p>
|
||||||
|
<div>
|
||||||
|
<PageList {...listProps} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TagContent.css = style + PageList.css
|
||||||
|
export default (() => TagContent) satisfies QuartzComponentConstructor
|
154
quartz/components/renderPage.tsx
Normal file
154
quartz/components/renderPage.tsx
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
import { render } from "preact-render-to-string"
|
||||||
|
import { QuartzComponent, QuartzComponentProps } from "./types"
|
||||||
|
import HeaderConstructor from "./Header"
|
||||||
|
import BodyConstructor from "./Body"
|
||||||
|
import { JSResourceToScriptElement, StaticResources } from "../util/resources"
|
||||||
|
import { FullSlug, RelativeURL, joinSegments } from "../util/path"
|
||||||
|
import { visit } from "unist-util-visit"
|
||||||
|
import { Root, Element } from "hast"
|
||||||
|
|
||||||
|
interface RenderComponents {
|
||||||
|
head: QuartzComponent
|
||||||
|
header: QuartzComponent[]
|
||||||
|
beforeBody: QuartzComponent[]
|
||||||
|
pageBody: QuartzComponent
|
||||||
|
left: QuartzComponent[]
|
||||||
|
right: QuartzComponent[]
|
||||||
|
footer: QuartzComponent
|
||||||
|
}
|
||||||
|
|
||||||
|
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: [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(
|
||||||
|
slug: FullSlug,
|
||||||
|
componentData: QuartzComponentProps,
|
||||||
|
components: RenderComponents,
|
||||||
|
pageResources: StaticResources,
|
||||||
|
): string {
|
||||||
|
// process transcludes in componentData
|
||||||
|
visit(componentData.tree as 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 blockSlug = inner.properties?.["data-slug"] as FullSlug
|
||||||
|
const blockRef = node.properties!.dataBlock as string
|
||||||
|
|
||||||
|
// TODO: avoid this expensive find operation and construct an index ahead of time
|
||||||
|
let blockNode = componentData.allFiles.find((f) => f.slug === blockSlug)?.blocks?.[blockRef]
|
||||||
|
if (blockNode) {
|
||||||
|
if (blockNode.tagName === "li") {
|
||||||
|
blockNode = {
|
||||||
|
type: "element",
|
||||||
|
tagName: "ul",
|
||||||
|
children: [blockNode],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
node.children = [
|
||||||
|
blockNode,
|
||||||
|
{
|
||||||
|
type: "element",
|
||||||
|
tagName: "a",
|
||||||
|
properties: { href: inner.properties?.href, class: ["internal"] },
|
||||||
|
children: [{ type: "text", value: `Link to original` }],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
head: Head,
|
||||||
|
header,
|
||||||
|
beforeBody,
|
||||||
|
pageBody: Content,
|
||||||
|
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 doc = (
|
||||||
|
<html>
|
||||||
|
<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} />
|
||||||
|
</div>
|
||||||
|
{RightComponent}
|
||||||
|
</Body>
|
||||||
|
<Footer {...componentData} />
|
||||||
|
</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.removeEventListener(`click`, toggleCallout)
|
||||||
|
title.addEventListener(`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)
|
33
quartz/components/scripts/clipboard.inline.ts
Normal file
33
quartz/components/scripts/clipboard.inline.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
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.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"
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
navigator.clipboard.writeText(source).then(
|
||||||
|
() => {
|
||||||
|
button.blur()
|
||||||
|
button.innerHTML = svgCheck
|
||||||
|
setTimeout(() => {
|
||||||
|
button.innerHTML = svgCopy
|
||||||
|
button.style.borderColor = ""
|
||||||
|
}, 2000)
|
||||||
|
},
|
||||||
|
(error) => console.error(error),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
els[i].prepend(button)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
32
quartz/components/scripts/darkmode.inline.ts
Normal file
32
quartz/components/scripts/darkmode.inline.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
const userPref = window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark"
|
||||||
|
const currentTheme = localStorage.getItem("theme") ?? userPref
|
||||||
|
document.documentElement.setAttribute("saved-theme", currentTheme)
|
||||||
|
|
||||||
|
document.addEventListener("nav", () => {
|
||||||
|
const switchTheme = (e: any) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
document.documentElement.setAttribute("saved-theme", "dark")
|
||||||
|
localStorage.setItem("theme", "dark")
|
||||||
|
} else {
|
||||||
|
document.documentElement.setAttribute("saved-theme", "light")
|
||||||
|
localStorage.setItem("theme", "light")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Darkmode toggle
|
||||||
|
const toggleSwitch = document.querySelector("#darkmode-toggle") as HTMLInputElement
|
||||||
|
toggleSwitch.removeEventListener("change", switchTheme)
|
||||||
|
toggleSwitch.addEventListener("change", switchTheme)
|
||||||
|
if (currentTheme === "dark") {
|
||||||
|
toggleSwitch.checked = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for changes in prefers-color-scheme
|
||||||
|
const colorSchemeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
|
||||||
|
colorSchemeMediaQuery.addEventListener("change", (e) => {
|
||||||
|
const newTheme = e.matches ? "dark" : "light"
|
||||||
|
document.documentElement.setAttribute("saved-theme", newTheme)
|
||||||
|
localStorage.setItem("theme", newTheme)
|
||||||
|
toggleSwitch.checked = e.matches
|
||||||
|
})
|
||||||
|
})
|
164
quartz/components/scripts/explorer.inline.ts
Normal file
164
quartz/components/scripts/explorer.inline.ts
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
import { FolderState } from "../ExplorerNode"
|
||||||
|
|
||||||
|
// Current state of folders
|
||||||
|
let explorerState: FolderState[]
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver((entries) => {
|
||||||
|
// If last element is observed, remove gradient of "overflow" class so element is visible
|
||||||
|
const explorer = document.getElementById("explorer-ul")
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
explorer?.classList.add("no-background")
|
||||||
|
} else {
|
||||||
|
explorer?.classList.remove("no-background")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleExplorer(this: HTMLElement) {
|
||||||
|
// Toggle collapsed state of entire explorer
|
||||||
|
this.classList.toggle("collapsed")
|
||||||
|
const content = this.nextElementSibling as HTMLElement
|
||||||
|
content.classList.toggle("collapsed")
|
||||||
|
content.style.maxHeight = content.style.maxHeight === "0px" ? content.scrollHeight + "px" : "0px"
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFolder(evt: MouseEvent) {
|
||||||
|
evt.stopPropagation()
|
||||||
|
|
||||||
|
// Element that was clicked
|
||||||
|
const target = evt.target as HTMLElement
|
||||||
|
|
||||||
|
// Check if target was svg icon or button
|
||||||
|
const isSvg = target.nodeName === "svg"
|
||||||
|
|
||||||
|
// corresponding <ul> element relative to clicked button/folder
|
||||||
|
let childFolderContainer: HTMLElement
|
||||||
|
|
||||||
|
// <li> element of folder (stores folder-path dataset)
|
||||||
|
let currentFolderParent: HTMLElement
|
||||||
|
|
||||||
|
// Get correct relative container and toggle collapsed class
|
||||||
|
if (isSvg) {
|
||||||
|
childFolderContainer = target.parentElement?.nextSibling as HTMLElement
|
||||||
|
currentFolderParent = target.nextElementSibling as HTMLElement
|
||||||
|
|
||||||
|
childFolderContainer.classList.toggle("open")
|
||||||
|
} else {
|
||||||
|
childFolderContainer = target.parentElement?.parentElement?.nextElementSibling as HTMLElement
|
||||||
|
currentFolderParent = target.parentElement as HTMLElement
|
||||||
|
|
||||||
|
childFolderContainer.classList.toggle("open")
|
||||||
|
}
|
||||||
|
if (!childFolderContainer) return
|
||||||
|
|
||||||
|
// Collapse folder container
|
||||||
|
const isCollapsed = childFolderContainer.classList.contains("open")
|
||||||
|
setFolderState(childFolderContainer, !isCollapsed)
|
||||||
|
|
||||||
|
// Save folder state to localStorage
|
||||||
|
const clickFolderPath = currentFolderParent.dataset.folderpath as string
|
||||||
|
|
||||||
|
// Remove leading "/"
|
||||||
|
const fullFolderPath = clickFolderPath.substring(1)
|
||||||
|
toggleCollapsedByPath(explorerState, fullFolderPath)
|
||||||
|
|
||||||
|
const stringifiedFileTree = JSON.stringify(explorerState)
|
||||||
|
localStorage.setItem("fileTree", stringifiedFileTree)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupExplorer() {
|
||||||
|
// Set click handler for collapsing entire explorer
|
||||||
|
const explorer = document.getElementById("explorer")
|
||||||
|
|
||||||
|
// Get folder state from local storage
|
||||||
|
const storageTree = localStorage.getItem("fileTree")
|
||||||
|
|
||||||
|
// Convert to bool
|
||||||
|
const useSavedFolderState = explorer?.dataset.savestate === "true"
|
||||||
|
|
||||||
|
if (explorer) {
|
||||||
|
// Get config
|
||||||
|
const collapseBehavior = explorer.dataset.behavior
|
||||||
|
|
||||||
|
// Add click handlers for all folders (click handler on folder "label")
|
||||||
|
if (collapseBehavior === "collapse") {
|
||||||
|
Array.prototype.forEach.call(
|
||||||
|
document.getElementsByClassName("folder-button"),
|
||||||
|
function (item) {
|
||||||
|
item.removeEventListener("click", toggleFolder)
|
||||||
|
item.addEventListener("click", toggleFolder)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add click handler to main explorer
|
||||||
|
explorer.removeEventListener("click", toggleExplorer)
|
||||||
|
explorer.addEventListener("click", toggleExplorer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up click handlers for each folder (click handler on folder "icon")
|
||||||
|
Array.prototype.forEach.call(document.getElementsByClassName("folder-icon"), function (item) {
|
||||||
|
item.removeEventListener("click", toggleFolder)
|
||||||
|
item.addEventListener("click", toggleFolder)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (storageTree && useSavedFolderState) {
|
||||||
|
// Get state from localStorage and set folder state
|
||||||
|
explorerState = JSON.parse(storageTree)
|
||||||
|
explorerState.map((folderUl) => {
|
||||||
|
// grab <li> element for matching folder path
|
||||||
|
const folderLi = document.querySelector(
|
||||||
|
`[data-folderpath='/${folderUl.path}']`,
|
||||||
|
) as HTMLElement
|
||||||
|
|
||||||
|
// Get corresponding content <ul> tag and set state
|
||||||
|
if (folderLi) {
|
||||||
|
const folderUL = folderLi.parentElement?.nextElementSibling
|
||||||
|
if (folderUL) {
|
||||||
|
setFolderState(folderUL as HTMLElement, folderUl.collapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// If tree is not in localStorage or config is disabled, use tree passed from Explorer as dataset
|
||||||
|
explorerState = JSON.parse(explorer?.dataset.tree as string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("resize", setupExplorer)
|
||||||
|
document.addEventListener("nav", () => {
|
||||||
|
setupExplorer()
|
||||||
|
|
||||||
|
const explorerContent = document.getElementById("explorer-ul")
|
||||||
|
// select pseudo element at end of list
|
||||||
|
const lastItem = document.getElementById("explorer-end")
|
||||||
|
|
||||||
|
observer.disconnect()
|
||||||
|
observer.observe(lastItem as Element)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
||||||
|
if (collapsed) {
|
||||||
|
folderElement?.classList.remove("open")
|
||||||
|
} else {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
328
quartz/components/scripts/graph.inline.ts
Normal file
328
quartz/components/scripts/graph.inline.ts
Normal file
@ -0,0 +1,328 @@
|
|||||||
|
import type { ContentDetails } from "../../plugins/emitters/contentIndex"
|
||||||
|
import * as d3 from "d3"
|
||||||
|
import { registerEscapeHandler, removeAllChildren } from "./util"
|
||||||
|
import { FullSlug, SimpleSlug, getFullSlug, resolveRelative, simplifySlug } from "../../util/path"
|
||||||
|
|
||||||
|
type NodeData = {
|
||||||
|
id: SimpleSlug
|
||||||
|
text: string
|
||||||
|
tags: string[]
|
||||||
|
} & d3.SimulationNodeDatum
|
||||||
|
|
||||||
|
type LinkData = {
|
||||||
|
source: SimpleSlug
|
||||||
|
target: SimpleSlug
|
||||||
|
}
|
||||||
|
|
||||||
|
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]))
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
} = JSON.parse(graph.dataset["cfg"]!)
|
||||||
|
|
||||||
|
const data = await fetchData
|
||||||
|
|
||||||
|
const links: LinkData[] = []
|
||||||
|
const tags: SimpleSlug[] = []
|
||||||
|
|
||||||
|
const validLinks = new Set(Object.keys(data).map((slug) => simplifySlug(slug as FullSlug)))
|
||||||
|
|
||||||
|
for (const [src, details] of Object.entries<ContentDetails>(data)) {
|
||||||
|
const source = simplifySlug(src as FullSlug)
|
||||||
|
const outgoing = details.links ?? []
|
||||||
|
|
||||||
|
for (const dest of outgoing) {
|
||||||
|
if (validLinks.has(dest)) {
|
||||||
|
links.push({ 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, 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 {
|
||||||
|
Object.keys(data).forEach((id) => neighbourhood.add(simplifySlug(id as FullSlug)))
|
||||||
|
if (showTags) tags.forEach((tag) => neighbourhood.add(tag))
|
||||||
|
}
|
||||||
|
|
||||||
|
const graphData: { nodes: NodeData[]; links: LinkData[] } = {
|
||||||
|
nodes: [...neighbourhood].map((url) => {
|
||||||
|
const text = url.startsWith("tags/") ? "#" + url.substring(5) : data[url]?.title ?? url
|
||||||
|
return {
|
||||||
|
id: url,
|
||||||
|
text: text,
|
||||||
|
tags: data[url]?.tags ?? [],
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
links: links.filter((l) => neighbourhood.has(l.source) && neighbourhood.has(l.target)),
|
||||||
|
}
|
||||||
|
|
||||||
|
const simulation: d3.Simulation<NodeData, LinkData> = d3
|
||||||
|
.forceSimulation(graphData.nodes)
|
||||||
|
.force("charge", d3.forceManyBody().strength(-100 * repelForce))
|
||||||
|
.force(
|
||||||
|
"link",
|
||||||
|
d3
|
||||||
|
.forceLink(graphData.links)
|
||||||
|
.id((d: any) => d.id)
|
||||||
|
.distance(linkDistance),
|
||||||
|
)
|
||||||
|
.force("center", d3.forceCenter().strength(centerForce))
|
||||||
|
|
||||||
|
const height = Math.max(graph.offsetHeight, 250)
|
||||||
|
const width = graph.offsetWidth
|
||||||
|
|
||||||
|
const svg = d3
|
||||||
|
.select<HTMLElement, NodeData>("#" + container)
|
||||||
|
.append("svg")
|
||||||
|
.attr("width", width)
|
||||||
|
.attr("height", height)
|
||||||
|
.attr("viewBox", [-width / 2 / scale, -height / 2 / scale, width / scale, height / scale])
|
||||||
|
|
||||||
|
// draw links between nodes
|
||||||
|
const link = svg
|
||||||
|
.append("g")
|
||||||
|
.selectAll("line")
|
||||||
|
.data(graphData.links)
|
||||||
|
.join("line")
|
||||||
|
.attr("class", "link")
|
||||||
|
.attr("stroke", "var(--lightgray)")
|
||||||
|
.attr("stroke-width", 1)
|
||||||
|
|
||||||
|
// svg groups
|
||||||
|
const graphNode = svg.append("g").selectAll("g").data(graphData.nodes).enter().append("g")
|
||||||
|
|
||||||
|
// calculate color
|
||||||
|
const color = (d: NodeData) => {
|
||||||
|
const isCurrent = d.id === slug
|
||||||
|
if (isCurrent) {
|
||||||
|
return "var(--secondary)"
|
||||||
|
} else if (visited.has(d.id) || d.id.startsWith("tags/")) {
|
||||||
|
return "var(--tertiary)"
|
||||||
|
} else {
|
||||||
|
return "var(--gray)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const drag = (simulation: d3.Simulation<NodeData, LinkData>) => {
|
||||||
|
function dragstarted(event: any, d: NodeData) {
|
||||||
|
if (!event.active) simulation.alphaTarget(1).restart()
|
||||||
|
d.fx = d.x
|
||||||
|
d.fy = d.y
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragged(event: any, d: NodeData) {
|
||||||
|
d.fx = event.x
|
||||||
|
d.fy = event.y
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragended(event: any, d: NodeData) {
|
||||||
|
if (!event.active) simulation.alphaTarget(0)
|
||||||
|
d.fx = null
|
||||||
|
d.fy = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const noop = () => {}
|
||||||
|
return d3
|
||||||
|
.drag<Element, NodeData>()
|
||||||
|
.on("start", enableDrag ? dragstarted : noop)
|
||||||
|
.on("drag", enableDrag ? dragged : noop)
|
||||||
|
.on("end", enableDrag ? dragended : noop)
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeRadius(d: NodeData) {
|
||||||
|
const numLinks = links.filter((l: any) => l.source.id === d.id || l.target.id === d.id).length
|
||||||
|
return 2 + Math.sqrt(numLinks)
|
||||||
|
}
|
||||||
|
|
||||||
|
// draw individual nodes
|
||||||
|
const node = graphNode
|
||||||
|
.append("circle")
|
||||||
|
.attr("class", "node")
|
||||||
|
.attr("id", (d) => d.id)
|
||||||
|
.attr("r", nodeRadius)
|
||||||
|
.attr("fill", color)
|
||||||
|
.style("cursor", "pointer")
|
||||||
|
.on("click", (_, d) => {
|
||||||
|
const targ = resolveRelative(fullSlug, d.id)
|
||||||
|
window.spaNavigate(new URL(targ, window.location.toString()))
|
||||||
|
})
|
||||||
|
.on("mouseover", function (_, d) {
|
||||||
|
const neighbours: SimpleSlug[] = data[fullSlug].links ?? []
|
||||||
|
const neighbourNodes = d3
|
||||||
|
.selectAll<HTMLElement, NodeData>(".node")
|
||||||
|
.filter((d) => neighbours.includes(d.id))
|
||||||
|
const currentId = d.id
|
||||||
|
const linkNodes = d3
|
||||||
|
.selectAll(".link")
|
||||||
|
.filter((d: any) => d.source.id === currentId || d.target.id === currentId)
|
||||||
|
|
||||||
|
// highlight neighbour nodes
|
||||||
|
neighbourNodes.transition().duration(200).attr("fill", color)
|
||||||
|
|
||||||
|
// highlight links
|
||||||
|
linkNodes.transition().duration(200).attr("stroke", "var(--gray)").attr("stroke-width", 1)
|
||||||
|
|
||||||
|
const bigFont = fontSize * 1.5
|
||||||
|
|
||||||
|
// show text for self
|
||||||
|
const parent = this.parentNode as HTMLElement
|
||||||
|
d3.select<HTMLElement, NodeData>(parent)
|
||||||
|
.raise()
|
||||||
|
.select("text")
|
||||||
|
.transition()
|
||||||
|
.duration(200)
|
||||||
|
.attr("opacityOld", d3.select(parent).select("text").style("opacity"))
|
||||||
|
.style("opacity", 1)
|
||||||
|
.style("font-size", bigFont + "em")
|
||||||
|
})
|
||||||
|
.on("mouseleave", function (_, d) {
|
||||||
|
const currentId = d.id
|
||||||
|
const linkNodes = d3
|
||||||
|
.selectAll(".link")
|
||||||
|
.filter((d: any) => d.source.id === currentId || d.target.id === currentId)
|
||||||
|
|
||||||
|
linkNodes.transition().duration(200).attr("stroke", "var(--lightgray)")
|
||||||
|
|
||||||
|
const parent = this.parentNode as HTMLElement
|
||||||
|
d3.select<HTMLElement, NodeData>(parent)
|
||||||
|
.select("text")
|
||||||
|
.transition()
|
||||||
|
.duration(200)
|
||||||
|
.style("opacity", d3.select(parent).select("text").attr("opacityOld"))
|
||||||
|
.style("font-size", fontSize + "em")
|
||||||
|
})
|
||||||
|
// @ts-ignore
|
||||||
|
.call(drag(simulation))
|
||||||
|
|
||||||
|
// draw labels
|
||||||
|
const labels = graphNode
|
||||||
|
.append("text")
|
||||||
|
.attr("dx", 0)
|
||||||
|
.attr("dy", (d) => -nodeRadius(d) + "px")
|
||||||
|
.attr("text-anchor", "middle")
|
||||||
|
.text((d) => d.text)
|
||||||
|
.style("opacity", (opacityScale - 1) / 3.75)
|
||||||
|
.style("pointer-events", "none")
|
||||||
|
.style("font-size", fontSize + "em")
|
||||||
|
.raise()
|
||||||
|
// @ts-ignore
|
||||||
|
.call(drag(simulation))
|
||||||
|
|
||||||
|
// set panning
|
||||||
|
if (enableZoom) {
|
||||||
|
svg.call(
|
||||||
|
d3
|
||||||
|
.zoom<SVGSVGElement, NodeData>()
|
||||||
|
.extent([
|
||||||
|
[0, 0],
|
||||||
|
[width, height],
|
||||||
|
])
|
||||||
|
.scaleExtent([0.25, 4])
|
||||||
|
.on("zoom", ({ transform }) => {
|
||||||
|
link.attr("transform", transform)
|
||||||
|
node.attr("transform", transform)
|
||||||
|
const scale = transform.k * opacityScale
|
||||||
|
const scaledOpacity = Math.max((scale - 1) / 3.75, 0)
|
||||||
|
labels.attr("transform", transform).style("opacity", scaledOpacity)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// progress the simulation
|
||||||
|
simulation.on("tick", () => {
|
||||||
|
link
|
||||||
|
.attr("x1", (d: any) => d.source.x)
|
||||||
|
.attr("y1", (d: any) => d.source.y)
|
||||||
|
.attr("x2", (d: any) => d.target.x)
|
||||||
|
.attr("y2", (d: any) => d.target.y)
|
||||||
|
node.attr("cx", (d: any) => d.x).attr("cy", (d: any) => d.y)
|
||||||
|
labels.attr("x", (d: any) => d.x).attr("y", (d: any) => d.y)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGlobalGraph() {
|
||||||
|
const slug = getFullSlug(window)
|
||||||
|
const container = document.getElementById("global-graph-outer")
|
||||||
|
const sidebar = container?.closest(".sidebar") as HTMLElement
|
||||||
|
container?.classList.add("active")
|
||||||
|
if (sidebar) {
|
||||||
|
sidebar.style.zIndex = "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
renderGraph("global-graph-container", slug)
|
||||||
|
|
||||||
|
function hideGlobalGraph() {
|
||||||
|
container?.classList.remove("active")
|
||||||
|
const graph = document.getElementById("global-graph-container")
|
||||||
|
if (sidebar) {
|
||||||
|
sidebar.style.zIndex = "unset"
|
||||||
|
}
|
||||||
|
if (!graph) return
|
||||||
|
removeAllChildren(graph)
|
||||||
|
}
|
||||||
|
|
||||||
|
registerEscapeHandler(container, hideGlobalGraph)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("nav", async (e: unknown) => {
|
||||||
|
const slug = (e as CustomEventMap["nav"]).detail.url
|
||||||
|
addToVisited(slug)
|
||||||
|
await renderGraph("graph-container", slug)
|
||||||
|
|
||||||
|
const containerIcon = document.getElementById("global-graph-icon")
|
||||||
|
containerIcon?.removeEventListener("click", renderGlobalGraph)
|
||||||
|
containerIcon?.addEventListener("click", renderGlobalGraph)
|
||||||
|
})
|
3
quartz/components/scripts/plausible.inline.ts
Normal file
3
quartz/components/scripts/plausible.inline.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import Plausible from "plausible-tracker"
|
||||||
|
const { trackPageview } = Plausible()
|
||||||
|
document.addEventListener("nav", () => trackPageview())
|
83
quartz/components/scripts/popover.inline.ts
Normal file
83
quartz/components/scripts/popover.inline.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import { computePosition, flip, inline, shift } from "@floating-ui/dom"
|
||||||
|
|
||||||
|
// from micromorph/src/utils.ts
|
||||||
|
// https://github.com/natemoo-re/micromorph/blob/main/src/utils.ts#L5
|
||||||
|
export function normalizeRelativeURLs(el: Element | Document, base: string | URL) {
|
||||||
|
const update = (el: Element, attr: string, base: string | URL) => {
|
||||||
|
el.setAttribute(attr, new URL(el.getAttribute(attr)!, base).pathname)
|
||||||
|
}
|
||||||
|
|
||||||
|
el.querySelectorAll('[href^="./"], [href^="../"]').forEach((item) => update(item, "href", base))
|
||||||
|
|
||||||
|
el.querySelectorAll('[src^="./"], [src^="../"]').forEach((item) => update(item, "src", base))
|
||||||
|
}
|
||||||
|
|
||||||
|
const p = new DOMParser()
|
||||||
|
async function mouseEnterHandler(
|
||||||
|
this: HTMLLinkElement,
|
||||||
|
{ clientX, clientY }: { clientX: number; clientY: number },
|
||||||
|
) {
|
||||||
|
const link = this
|
||||||
|
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`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// dont refetch if there's already a popover
|
||||||
|
if ([...link.children].some((child) => child.classList.contains("popover"))) {
|
||||||
|
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 = targetUrl.hash
|
||||||
|
targetUrl.hash = ""
|
||||||
|
targetUrl.search = ""
|
||||||
|
// prevent hover of the same page
|
||||||
|
if (thisUrl.toString() === targetUrl.toString()) return
|
||||||
|
|
||||||
|
const contents = await fetch(`${targetUrl}`)
|
||||||
|
.then((res) => res.text())
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!contents) return
|
||||||
|
const html = p.parseFromString(contents, "text/html")
|
||||||
|
normalizeRelativeURLs(html, targetUrl)
|
||||||
|
const elts = [...html.getElementsByClassName("popover-hint")]
|
||||||
|
if (elts.length === 0) return
|
||||||
|
|
||||||
|
const popoverElement = document.createElement("div")
|
||||||
|
popoverElement.classList.add("popover")
|
||||||
|
const popoverInner = document.createElement("div")
|
||||||
|
popoverInner.classList.add("popover-inner")
|
||||||
|
popoverElement.appendChild(popoverInner)
|
||||||
|
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 HTMLLinkElement[]
|
||||||
|
for (const link of links) {
|
||||||
|
link.removeEventListener("mouseenter", mouseEnterHandler)
|
||||||
|
link.addEventListener("mouseenter", mouseEnterHandler)
|
||||||
|
}
|
||||||
|
})
|
352
quartz/components/scripts/search.inline.ts
Normal file
352
quartz/components/scripts/search.inline.ts
Normal file
@ -0,0 +1,352 @@
|
|||||||
|
import { Document, SimpleDocumentSearchResultSetUnit } from "flexsearch"
|
||||||
|
import { ContentDetails } from "../../plugins/emitters/contentIndex"
|
||||||
|
import { registerEscapeHandler, removeAllChildren } from "./util"
|
||||||
|
import { FullSlug, resolveRelative } from "../../util/path"
|
||||||
|
|
||||||
|
interface Item {
|
||||||
|
id: number
|
||||||
|
slug: FullSlug
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
tags: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
let index: Document<Item> | undefined = undefined
|
||||||
|
|
||||||
|
// Can be expanded with things like "term" in the future
|
||||||
|
type SearchType = "basic" | "tags"
|
||||||
|
|
||||||
|
// Current searchType
|
||||||
|
let searchType: SearchType = "basic"
|
||||||
|
|
||||||
|
const contextWindowWords = 30
|
||||||
|
const numSearchResults = 5
|
||||||
|
const numTagResults = 3
|
||||||
|
function highlight(searchTerm: string, text: string, trim?: boolean) {
|
||||||
|
// try to highlight longest tokens first
|
||||||
|
const tokenizedTerms = searchTerm
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter((t) => t !== "")
|
||||||
|
.sort((a, b) => b.length - a.length)
|
||||||
|
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 occurencesIndices = tokenizedText.map(includesCheck)
|
||||||
|
|
||||||
|
let bestSum = 0
|
||||||
|
let bestIndex = 0
|
||||||
|
for (let i = 0; i < Math.max(tokenizedText.length - contextWindowWords, 0); i++) {
|
||||||
|
const window = occurencesIndices.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 ? "" : "..."
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoder = (str: string) => str.toLowerCase().split(/([^a-z]|[^\x00-\x7F])/)
|
||||||
|
let prevShortcutHandler: ((e: HTMLElementEventMap["keydown"]) => void) | undefined = undefined
|
||||||
|
document.addEventListener("nav", async (e: unknown) => {
|
||||||
|
const currentSlug = (e as CustomEventMap["nav"]).detail.url
|
||||||
|
|
||||||
|
const data = await fetchData
|
||||||
|
const container = document.getElementById("search-container")
|
||||||
|
const sidebar = container?.closest(".sidebar") as HTMLElement
|
||||||
|
const searchIcon = document.getElementById("search-icon")
|
||||||
|
const searchBar = document.getElementById("search-bar") as HTMLInputElement | null
|
||||||
|
const results = document.getElementById("results-container")
|
||||||
|
const resultCards = document.getElementsByClassName("result-card")
|
||||||
|
const idDataMap = Object.keys(data) as FullSlug[]
|
||||||
|
|
||||||
|
function hideSearch() {
|
||||||
|
container?.classList.remove("active")
|
||||||
|
if (searchBar) {
|
||||||
|
searchBar.value = "" // clear the input when we dismiss the search
|
||||||
|
}
|
||||||
|
if (sidebar) {
|
||||||
|
sidebar.style.zIndex = "unset"
|
||||||
|
}
|
||||||
|
if (results) {
|
||||||
|
removeAllChildren(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
searchType = "basic" // reset search type after closing
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSearch(searchTypeNew: SearchType) {
|
||||||
|
searchType = searchTypeNew
|
||||||
|
if (sidebar) {
|
||||||
|
sidebar.style.zIndex = "1"
|
||||||
|
}
|
||||||
|
container?.classList.add("active")
|
||||||
|
searchBar?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
} 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 = "#"
|
||||||
|
} else 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
|
||||||
|
active.click()
|
||||||
|
} else {
|
||||||
|
const anchor = document.getElementsByClassName("result-card")[0] as HTMLInputElement | null
|
||||||
|
anchor?.click()
|
||||||
|
}
|
||||||
|
} else if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault()
|
||||||
|
// When first pressing ArrowDown, results wont contain the active element, so focus first element
|
||||||
|
if (!results?.contains(document.activeElement)) {
|
||||||
|
const firstResult = resultCards[0] as HTMLInputElement | null
|
||||||
|
firstResult?.focus()
|
||||||
|
} else {
|
||||||
|
// If an element in results-container already has focus, focus next one
|
||||||
|
const nextResult = document.activeElement?.nextElementSibling as HTMLInputElement | null
|
||||||
|
nextResult?.focus()
|
||||||
|
}
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault()
|
||||||
|
if (results?.contains(document.activeElement)) {
|
||||||
|
// If an element in results-container already has focus, focus previous one
|
||||||
|
const prevResult = document.activeElement?.previousElementSibling as HTMLInputElement | null
|
||||||
|
prevResult?.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimContent(content: string) {
|
||||||
|
// works without escaping html like in `description.ts`
|
||||||
|
const sentences = content.replace(/\s+/g, " ").split(".")
|
||||||
|
let finalDesc = ""
|
||||||
|
let sentenceIdx = 0
|
||||||
|
|
||||||
|
// Roughly estimate characters by (words * 5). Matches description length in `description.ts`.
|
||||||
|
const len = contextWindowWords * 5
|
||||||
|
while (finalDesc.length < len) {
|
||||||
|
const sentence = sentences[sentenceIdx]
|
||||||
|
if (!sentence) break
|
||||||
|
finalDesc += sentence + "."
|
||||||
|
sentenceIdx++
|
||||||
|
}
|
||||||
|
|
||||||
|
// If more content would be available, indicate it by finishing with "..."
|
||||||
|
if (finalDesc.length < content.length) {
|
||||||
|
finalDesc += ".."
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalDesc
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatForDisplay = (term: string, id: number) => {
|
||||||
|
const slug = idDataMap[id]
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
slug,
|
||||||
|
title: searchType === "tags" ? data[slug].title : highlight(term, data[slug].title ?? ""),
|
||||||
|
// if searchType is tag, display context from start of file and trim, otherwise use regular highlight
|
||||||
|
content:
|
||||||
|
searchType === "tags"
|
||||||
|
? trimContent(data[slug].content)
|
||||||
|
: highlight(term, data[slug].content ?? "", true),
|
||||||
|
tags: highlightTags(term, data[slug].tags),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightTags(term: string, tags: string[]) {
|
||||||
|
if (tags && searchType === "tags") {
|
||||||
|
// Find matching tags
|
||||||
|
const termLower = term.toLowerCase()
|
||||||
|
let matching = tags.filter((str) => str.includes(termLower))
|
||||||
|
|
||||||
|
// Substract matching from original tags, then push difference
|
||||||
|
if (matching.length > 0) {
|
||||||
|
let difference = tags.filter((x) => !matching.includes(x))
|
||||||
|
|
||||||
|
// Convert to html (cant be done later as matches/term dont get passed to `resultToHTML`)
|
||||||
|
matching = matching.map((tag) => `<li><p class="match-tag">#${tag}</p></li>`)
|
||||||
|
difference = difference.map((tag) => `<li><p>#${tag}</p></li>`)
|
||||||
|
matching.push(...difference)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allow max of `numTagResults` in preview
|
||||||
|
if (tags.length > numTagResults) {
|
||||||
|
matching.splice(numTagResults)
|
||||||
|
}
|
||||||
|
|
||||||
|
return matching
|
||||||
|
} else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultToHTML = ({ slug, title, content, tags }: Item) => {
|
||||||
|
const htmlTags = tags.length > 0 ? `<ul>${tags.join("")}</ul>` : ``
|
||||||
|
const button = document.createElement("button")
|
||||||
|
button.classList.add("result-card")
|
||||||
|
button.id = slug
|
||||||
|
button.innerHTML = `<h3>${title}</h3>${htmlTags}<p>${content}</p>`
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
const targ = resolveRelative(currentSlug, slug)
|
||||||
|
window.spaNavigate(new URL(targ, window.location.toString()))
|
||||||
|
hideSearch()
|
||||||
|
})
|
||||||
|
return button
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayResults(finalResults: Item[]) {
|
||||||
|
if (!results) return
|
||||||
|
|
||||||
|
removeAllChildren(results)
|
||||||
|
if (finalResults.length === 0) {
|
||||||
|
results.innerHTML = `<button class="result-card">
|
||||||
|
<h3>No results.</h3>
|
||||||
|
<p>Try another search term?</p>
|
||||||
|
</button>`
|
||||||
|
} else {
|
||||||
|
results.append(...finalResults.map(resultToHTML))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onType(e: HTMLElementEventMap["input"]) {
|
||||||
|
let term = (e.target as HTMLInputElement).value
|
||||||
|
let searchResults: SimpleDocumentSearchResultSetUnit[]
|
||||||
|
|
||||||
|
if (term.toLowerCase().startsWith("#")) {
|
||||||
|
searchType = "tags"
|
||||||
|
} else {
|
||||||
|
searchType = "basic"
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (searchType) {
|
||||||
|
case "tags": {
|
||||||
|
term = term.substring(1)
|
||||||
|
searchResults =
|
||||||
|
(await index?.searchAsync({ query: term, limit: numSearchResults, index: ["tags"] })) ??
|
||||||
|
[]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "basic":
|
||||||
|
default: {
|
||||||
|
searchResults =
|
||||||
|
(await index?.searchAsync({
|
||||||
|
query: term,
|
||||||
|
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(term, id))
|
||||||
|
displayResults(finalResults)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prevShortcutHandler) {
|
||||||
|
document.removeEventListener("keydown", prevShortcutHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("keydown", shortcutHandler)
|
||||||
|
prevShortcutHandler = shortcutHandler
|
||||||
|
searchIcon?.removeEventListener("click", () => showSearch("basic"))
|
||||||
|
searchIcon?.addEventListener("click", () => showSearch("basic"))
|
||||||
|
searchBar?.removeEventListener("input", onType)
|
||||||
|
searchBar?.addEventListener("input", onType)
|
||||||
|
|
||||||
|
// setup index if it hasn't been already
|
||||||
|
if (!index) {
|
||||||
|
index = new Document({
|
||||||
|
charset: "latin:extra",
|
||||||
|
optimize: true,
|
||||||
|
encode: encoder,
|
||||||
|
document: {
|
||||||
|
id: "id",
|
||||||
|
index: [
|
||||||
|
{
|
||||||
|
field: "title",
|
||||||
|
tokenize: "reverse",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "content",
|
||||||
|
tokenize: "reverse",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "tags",
|
||||||
|
tokenize: "reverse",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
fillDocument(index, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// register handlers
|
||||||
|
registerEscapeHandler(container, hideSearch)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fills flexsearch document with data
|
||||||
|
* @param index index to fill
|
||||||
|
* @param data data to fill index with
|
||||||
|
*/
|
||||||
|
async function fillDocument(index: Document<Item, false>, data: any) {
|
||||||
|
let id = 0
|
||||||
|
for (const [slug, fileData] of Object.entries<ContentDetails>(data)) {
|
||||||
|
await index.addAsync(id, {
|
||||||
|
id,
|
||||||
|
slug: slug as FullSlug,
|
||||||
|
title: fileData.title,
|
||||||
|
content: fileData.content,
|
||||||
|
tags: fileData.tags,
|
||||||
|
})
|
||||||
|
id++
|
||||||
|
}
|
||||||
|
}
|
156
quartz/components/scripts/spa.inline.ts
Normal file
156
quartz/components/scripts/spa.inline.ts
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
import micromorph from "micromorph"
|
||||||
|
import { FullSlug, RelativeURL, getFullSlug } from "../../util/path"
|
||||||
|
|
||||||
|
// 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 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
let p: DOMParser
|
||||||
|
async function navigate(url: URL, isBack: boolean = false) {
|
||||||
|
p = p || new DOMParser()
|
||||||
|
const contents = await fetch(`${url}`)
|
||||||
|
.then((res) => res.text())
|
||||||
|
.catch(() => {
|
||||||
|
window.location.assign(url)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!contents) return
|
||||||
|
|
||||||
|
const html = p.parseFromString(contents, "text/html")
|
||||||
|
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) ?? {}
|
||||||
|
if (!url || event.ctrlKey || event.metaKey) return
|
||||||
|
event.preventDefault()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
43
quartz/components/scripts/toc.inline.ts
Normal file
43
quartz/components/scripts/toc.inline.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
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")
|
||||||
|
const content = this.nextElementSibling as HTMLElement
|
||||||
|
content.classList.toggle("collapsed")
|
||||||
|
content.style.maxHeight = content.style.maxHeight === "0px" ? content.scrollHeight + "px" : "0px"
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupToc() {
|
||||||
|
const toc = document.getElementById("toc")
|
||||||
|
if (toc) {
|
||||||
|
const collapsed = toc.classList.contains("collapsed")
|
||||||
|
const content = toc.nextElementSibling as HTMLElement
|
||||||
|
content.style.maxHeight = collapsed ? "0px" : content.scrollHeight + "px"
|
||||||
|
toc.removeEventListener("click", toggleToc)
|
||||||
|
toc.addEventListener("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))
|
||||||
|
})
|
25
quartz/components/scripts/util.ts
Normal file
25
quartz/components/scripts/util.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
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()
|
||||||
|
cb()
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(e: HTMLElementEventMap["keydown"]) {
|
||||||
|
if (!e.key.startsWith("Esc")) return
|
||||||
|
e.preventDefault()
|
||||||
|
cb()
|
||||||
|
}
|
||||||
|
|
||||||
|
outsideContainer?.removeEventListener("click", click)
|
||||||
|
outsideContainer?.addEventListener("click", click)
|
||||||
|
document.removeEventListener("keydown", esc)
|
||||||
|
document.addEventListener("keydown", esc)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeAllChildren(node: HTMLElement) {
|
||||||
|
while (node.firstChild) {
|
||||||
|
node.removeChild(node.firstChild)
|
||||||
|
}
|
||||||
|
}
|
20
quartz/components/styles/backlinks.scss
Normal file
20
quartz/components/styles/backlinks.scss
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
.backlinks {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
& > h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
|
||||||
|
& > li {
|
||||||
|
& > a {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
22
quartz/components/styles/breadcrumbs.scss
Normal file
22
quartz/components/styles/breadcrumbs.scss
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
.breadcrumb-container {
|
||||||
|
margin: 0;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-element {
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
padding: 0;
|
||||||
|
line-height: normal;
|
||||||
|
}
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
36
quartz/components/styles/clipboard.scss
Normal file
36
quartz/components/styles/clipboard.scss
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
.clipboard-button {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
float: right;
|
||||||
|
right: 0;
|
||||||
|
padding: 0.4rem;
|
||||||
|
margin: -0.2rem 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;
|
||||||
|
}
|
||||||
|
}
|
48
quartz/components/styles/darkmode.scss
Normal file
48
quartz/components/styles/darkmode.scss
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
.darkmode {
|
||||||
|
position: relative;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
margin: 0 10px;
|
||||||
|
|
||||||
|
& > .toggle {
|
||||||
|
display: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
& svg {
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
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"] .toggle ~ label {
|
||||||
|
& > #dayIcon {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
& > #nightIcon {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:root .toggle ~ label {
|
||||||
|
& > #dayIcon {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
& > #nightIcon {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
146
quartz/components/styles/explorer.scss
Normal file
146
quartz/components/styles/explorer.scss
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
button#explorer {
|
||||||
|
all: unset;
|
||||||
|
background-color: transparent;
|
||||||
|
border: none;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--dark);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
& h1 {
|
||||||
|
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;
|
||||||
|
max-height: none;
|
||||||
|
transition: max-height 0.35s ease;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
|
||||||
|
&.collapsed > .overflow::after {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
& 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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: 600;
|
||||||
|
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);
|
||||||
|
|
||||||
|
& p {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
display: inline-block;
|
||||||
|
color: var(--secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
div: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;
|
||||||
|
}
|
||||||
|
}
|
70
quartz/components/styles/graph.scss
Normal file
70
quartz/components/styles/graph.scss
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
@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 {
|
||||||
|
color: var(--dark);
|
||||||
|
opacity: 0.5;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
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: 60vh;
|
||||||
|
width: 50vw;
|
||||||
|
|
||||||
|
@media all and (max-width: $fullPageWidth) {
|
||||||
|
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: 6em 3fr 1fr;
|
||||||
|
|
||||||
|
@media all and (max-width: $mobileBreakpoint) {
|
||||||
|
& > .tags {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& > .desc > h3 > a {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > .meta {
|
||||||
|
margin: 0;
|
||||||
|
flex-basis: 6em;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// modifications in popover context
|
||||||
|
.popover .section {
|
||||||
|
grid-template-columns: 6em 1fr !important;
|
||||||
|
& > .tags {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
60
quartz/components/styles/popover.scss
Normal file
60
quartz/components/styles/popover.scss
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
@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;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
visibility: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
transition:
|
||||||
|
opacity 0.3s ease,
|
||||||
|
visibility 0.3s ease;
|
||||||
|
|
||||||
|
@media all and (max-width: $mobileBreakpoint) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
178
quartz/components/styles/search.scss
Normal file
178
quartz/components/styles/search.scss
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
@use "../../styles/variables.scss" as *;
|
||||||
|
|
||||||
|
.search {
|
||||||
|
min-width: fit-content;
|
||||||
|
max-width: 14rem;
|
||||||
|
flex-grow: 0.3;
|
||||||
|
|
||||||
|
& > #search-icon {
|
||||||
|
background-color: var(--lightgray);
|
||||||
|
border-radius: 4px;
|
||||||
|
height: 2rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
& > div {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > 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: 50%;
|
||||||
|
margin-top: 15vh;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
|
||||||
|
@media all and (max-width: $fullPageWidth) {
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > * {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 5px;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& > #results-container {
|
||||||
|
& .result-card {
|
||||||
|
padding: 1em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
border: 1px solid var(--lightgray);
|
||||||
|
border-bottom: none;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
// normalize button props
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 100%;
|
||||||
|
line-height: 1.15;
|
||||||
|
margin: 0;
|
||||||
|
text-transform: none;
|
||||||
|
text-align: left;
|
||||||
|
background: var(--light);
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
& .highlight {
|
||||||
|
color: var(--secondary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus {
|
||||||
|
background: var(--lightgray);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:first-of-type {
|
||||||
|
border-top-left-radius: 5px;
|
||||||
|
border-top-right-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-of-type {
|
||||||
|
border-bottom-left-radius: 5px;
|
||||||
|
border-bottom-right-radius: 5px;
|
||||||
|
border-bottom: 1px solid var(--lightgray);
|
||||||
|
}
|
||||||
|
|
||||||
|
& > h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > ul > li {
|
||||||
|
margin: 0;
|
||||||
|
display: inline-block;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > ul {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
padding-left: 0;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin: 0;
|
||||||
|
margin-top: 0.45rem;
|
||||||
|
// Offset border radius
|
||||||
|
margin-left: -2px;
|
||||||
|
overflow: hidden;
|
||||||
|
background-clip: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > ul > li > p {
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: var(--highlight);
|
||||||
|
overflow: hidden;
|
||||||
|
background-clip: border-box;
|
||||||
|
padding: 0.03rem 0.4rem;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--secondary);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > ul > li > .match-tag {
|
||||||
|
color: var(--tertiary);
|
||||||
|
font-weight: bold;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > p {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
59
quartz/components/styles/toc.scss
Normal file
59
quartz/components/styles/toc.scss
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
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;
|
||||||
|
max-height: none;
|
||||||
|
transition: max-height 0.5s ease;
|
||||||
|
|
||||||
|
&.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@for $i from 0 through 6 {
|
||||||
|
& .depth-#{$i} {
|
||||||
|
padding-left: calc(1rem * #{$i});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
27
quartz/components/types.ts
Normal file
27
quartz/components/types.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { ComponentType, JSX } from "preact"
|
||||||
|
import { StaticResources } from "../util/resources"
|
||||||
|
import { QuartzPluginData } from "../plugins/vfile"
|
||||||
|
import { GlobalConfiguration } from "../cfg"
|
||||||
|
import { Node } from "hast"
|
||||||
|
|
||||||
|
export type QuartzComponentProps = {
|
||||||
|
externalResources: StaticResources
|
||||||
|
fileData: QuartzPluginData
|
||||||
|
cfg: GlobalConfiguration
|
||||||
|
children: (QuartzComponent | JSX.Element)[]
|
||||||
|
tree: Node<QuartzPluginData>
|
||||||
|
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
|
59
quartz/plugins/emitters/404.tsx
Normal file
59
quartz/plugins/emitters/404.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { QuartzEmitterPlugin } from "../types"
|
||||||
|
import { QuartzComponentProps } from "../../components/types"
|
||||||
|
import BodyConstructor from "../../components/Body"
|
||||||
|
import { pageResources, renderPage } from "../../components/renderPage"
|
||||||
|
import { FullPageLayout } from "../../cfg"
|
||||||
|
import { FilePath, FullSlug } from "../../util/path"
|
||||||
|
import { sharedPageComponents } from "../../../quartz.layout"
|
||||||
|
import { NotFound } from "../../components"
|
||||||
|
import { defaultProcessedContent } from "../vfile"
|
||||||
|
|
||||||
|
export const NotFoundPage: QuartzEmitterPlugin = () => {
|
||||||
|
const opts: FullPageLayout = {
|
||||||
|
...sharedPageComponents,
|
||||||
|
pageBody: NotFound(),
|
||||||
|
beforeBody: [],
|
||||||
|
left: [],
|
||||||
|
right: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const { head: Head, pageBody, footer: Footer } = opts
|
||||||
|
const Body = BodyConstructor()
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: "404Page",
|
||||||
|
getQuartzComponents() {
|
||||||
|
return [Head, Body, pageBody, Footer]
|
||||||
|
},
|
||||||
|
async emit(ctx, _content, resources, emit): Promise<FilePath[]> {
|
||||||
|
const cfg = ctx.cfg.configuration
|
||||||
|
const slug = "404" as FullSlug
|
||||||
|
|
||||||
|
const url = new URL(`https://${cfg.baseUrl ?? "example.com"}`)
|
||||||
|
const path = url.pathname as FullSlug
|
||||||
|
const externalResources = pageResources(path, resources)
|
||||||
|
const [tree, vfile] = defaultProcessedContent({
|
||||||
|
slug,
|
||||||
|
text: "Not Found",
|
||||||
|
description: "Not Found",
|
||||||
|
frontmatter: { title: "Not Found", tags: [] },
|
||||||
|
})
|
||||||
|
const componentData: QuartzComponentProps = {
|
||||||
|
fileData: vfile.data,
|
||||||
|
externalResources,
|
||||||
|
cfg,
|
||||||
|
children: [],
|
||||||
|
tree,
|
||||||
|
allFiles: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
await emit({
|
||||||
|
content: renderPage(slug, componentData, opts, externalResources),
|
||||||
|
slug,
|
||||||
|
ext: ".html",
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
52
quartz/plugins/emitters/aliases.ts
Normal file
52
quartz/plugins/emitters/aliases.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { FilePath, FullSlug, resolveRelative, simplifySlug } from "../../util/path"
|
||||||
|
import { QuartzEmitterPlugin } from "../types"
|
||||||
|
import path from "path"
|
||||||
|
|
||||||
|
export const AliasRedirects: QuartzEmitterPlugin = () => ({
|
||||||
|
name: "AliasRedirects",
|
||||||
|
getQuartzComponents() {
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
async emit({ argv }, content, _resources, emit): Promise<FilePath[]> {
|
||||||
|
const fps: FilePath[] = []
|
||||||
|
|
||||||
|
for (const [_tree, file] of content) {
|
||||||
|
const ogSlug = simplifySlug(file.data.slug!)
|
||||||
|
const dir = path.posix.relative(argv.directory, path.dirname(file.data.filePath!))
|
||||||
|
|
||||||
|
let aliases: FullSlug[] = file.data.frontmatter?.aliases ?? file.data.frontmatter?.alias ?? []
|
||||||
|
if (typeof aliases === "string") {
|
||||||
|
aliases = [aliases]
|
||||||
|
}
|
||||||
|
|
||||||
|
const slugs: FullSlug[] = aliases.map((alias) => path.posix.join(dir, alias) as FullSlug)
|
||||||
|
const permalink = file.data.frontmatter?.permalink
|
||||||
|
if (typeof permalink === "string") {
|
||||||
|
slugs.push(permalink as FullSlug)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const slug of slugs) {
|
||||||
|
const redirUrl = resolveRelative(slug, file.data.slug!)
|
||||||
|
const fp = await emit({
|
||||||
|
content: `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en-us">
|
||||||
|
<head>
|
||||||
|
<title>${ogSlug}</title>
|
||||||
|
<link rel="canonical" href="${redirUrl}">
|
||||||
|
<meta name="robots" content="noindex">
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta http-equiv="refresh" content="0; url=${redirUrl}">
|
||||||
|
</head>
|
||||||
|
</html>
|
||||||
|
`,
|
||||||
|
slug,
|
||||||
|
ext: ".html",
|
||||||
|
})
|
||||||
|
|
||||||
|
fps.push(fp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fps
|
||||||
|
},
|
||||||
|
})
|
33
quartz/plugins/emitters/assets.ts
Normal file
33
quartz/plugins/emitters/assets.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { FilePath, joinSegments, slugifyFilePath } from "../../util/path"
|
||||||
|
import { QuartzEmitterPlugin } from "../types"
|
||||||
|
import path from "path"
|
||||||
|
import fs from "fs"
|
||||||
|
import { glob } from "../../util/glob"
|
||||||
|
|
||||||
|
export const Assets: QuartzEmitterPlugin = () => {
|
||||||
|
return {
|
||||||
|
name: "Assets",
|
||||||
|
getQuartzComponents() {
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
async emit({ argv, cfg }, _content, _resources, _emit): Promise<FilePath[]> {
|
||||||
|
// glob all non MD/MDX/HTML files in content folder and copy it over
|
||||||
|
const assetsPath = argv.output
|
||||||
|
const fps = await glob("**", argv.directory, ["**/*.md", ...cfg.configuration.ignorePatterns])
|
||||||
|
const res: FilePath[] = []
|
||||||
|
for (const fp of fps) {
|
||||||
|
const ext = path.extname(fp)
|
||||||
|
const src = joinSegments(argv.directory, fp) as FilePath
|
||||||
|
const name = (slugifyFilePath(fp as FilePath, true) + ext) as FilePath
|
||||||
|
|
||||||
|
const dest = joinSegments(assetsPath, name) as FilePath
|
||||||
|
const dir = path.dirname(dest) as FilePath
|
||||||
|
await fs.promises.mkdir(dir, { recursive: true }) // ensure dir exists
|
||||||
|
await fs.promises.copyFile(src, dest)
|
||||||
|
res.push(dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
return res
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
202
quartz/plugins/emitters/componentResources.ts
Normal file
202
quartz/plugins/emitters/componentResources.ts
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
import { FilePath, FullSlug } from "../../util/path"
|
||||||
|
import { QuartzEmitterPlugin } from "../types"
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
import spaRouterScript from "../../components/scripts/spa.inline"
|
||||||
|
// @ts-ignore
|
||||||
|
import plausibleScript from "../../components/scripts/plausible.inline"
|
||||||
|
// @ts-ignore
|
||||||
|
import popoverScript from "../../components/scripts/popover.inline"
|
||||||
|
import styles from "../../styles/custom.scss"
|
||||||
|
import popoverStyle from "../../components/styles/popover.scss"
|
||||||
|
import { BuildCtx } from "../../util/ctx"
|
||||||
|
import { StaticResources } from "../../util/resources"
|
||||||
|
import { QuartzComponent } from "../../components/types"
|
||||||
|
import { googleFontHref, joinStyles } from "../../util/theme"
|
||||||
|
import { Features, transform } from "lightningcss"
|
||||||
|
|
||||||
|
type ComponentResources = {
|
||||||
|
css: string[]
|
||||||
|
beforeDOMLoaded: string[]
|
||||||
|
afterDOMLoaded: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getComponentResources(ctx: BuildCtx): ComponentResources {
|
||||||
|
const allComponents: Set<QuartzComponent> = new Set()
|
||||||
|
for (const emitter of ctx.cfg.plugins.emitters) {
|
||||||
|
const components = emitter.getQuartzComponents(ctx)
|
||||||
|
for (const component of components) {
|
||||||
|
allComponents.add(component)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const componentResources = {
|
||||||
|
css: new Set<string>(),
|
||||||
|
beforeDOMLoaded: new Set<string>(),
|
||||||
|
afterDOMLoaded: new Set<string>(),
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const component of allComponents) {
|
||||||
|
const { css, beforeDOMLoaded, afterDOMLoaded } = component
|
||||||
|
if (css) {
|
||||||
|
componentResources.css.add(css)
|
||||||
|
}
|
||||||
|
if (beforeDOMLoaded) {
|
||||||
|
componentResources.beforeDOMLoaded.add(beforeDOMLoaded)
|
||||||
|
}
|
||||||
|
if (afterDOMLoaded) {
|
||||||
|
componentResources.afterDOMLoaded.add(afterDOMLoaded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
css: [...componentResources.css],
|
||||||
|
beforeDOMLoaded: [...componentResources.beforeDOMLoaded],
|
||||||
|
afterDOMLoaded: [...componentResources.afterDOMLoaded],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinScripts(scripts: string[]): string {
|
||||||
|
// wrap with iife to prevent scope collision
|
||||||
|
return scripts.map((script) => `(function () {${script}})();`).join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
function addGlobalPageResources(
|
||||||
|
ctx: BuildCtx,
|
||||||
|
staticResources: StaticResources,
|
||||||
|
componentResources: ComponentResources,
|
||||||
|
) {
|
||||||
|
const cfg = ctx.cfg.configuration
|
||||||
|
const reloadScript = ctx.argv.serve
|
||||||
|
|
||||||
|
// popovers
|
||||||
|
if (cfg.enablePopovers) {
|
||||||
|
componentResources.afterDOMLoaded.push(popoverScript)
|
||||||
|
componentResources.css.push(popoverStyle)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cfg.analytics?.provider === "google") {
|
||||||
|
const tagId = cfg.analytics.tagId
|
||||||
|
staticResources.js.push({
|
||||||
|
src: `https://www.googletagmanager.com/gtag/js?id=${tagId}`,
|
||||||
|
contentType: "external",
|
||||||
|
loadTime: "afterDOMReady",
|
||||||
|
})
|
||||||
|
componentResources.afterDOMLoaded.push(`
|
||||||
|
window.dataLayer = window.dataLayer || [];
|
||||||
|
function gtag() { dataLayer.push(arguments); }
|
||||||
|
gtag(\`js\`, new Date());
|
||||||
|
gtag(\`config\`, \`${tagId}\`, { send_page_view: false });
|
||||||
|
|
||||||
|
document.addEventListener(\`nav\`, () => {
|
||||||
|
gtag(\`event\`, \`page_view\`, {
|
||||||
|
page_title: document.title,
|
||||||
|
page_location: location.href,
|
||||||
|
});
|
||||||
|
});`)
|
||||||
|
} else if (cfg.analytics?.provider === "plausible") {
|
||||||
|
componentResources.afterDOMLoaded.push(plausibleScript)
|
||||||
|
} else if (cfg.analytics?.provider === "umami") {
|
||||||
|
componentResources.afterDOMLoaded.push(`
|
||||||
|
const umamiScript = document.createElement("script")
|
||||||
|
umamiScript.src = "https://analytics.umami.is/script.js"
|
||||||
|
umamiScript.setAttribute("data-website-id", "${cfg.analytics.websiteId}")
|
||||||
|
umamiScript.async = true
|
||||||
|
|
||||||
|
document.head.appendChild(umamiScript)
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cfg.enableSPA) {
|
||||||
|
componentResources.afterDOMLoaded.push(spaRouterScript)
|
||||||
|
} else {
|
||||||
|
componentResources.afterDOMLoaded.push(`
|
||||||
|
window.spaNavigate = (url, _) => window.location.assign(url)
|
||||||
|
const event = new CustomEvent("nav", { detail: { url: document.body.dataset.slug } })
|
||||||
|
document.dispatchEvent(event)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
let wsUrl = `ws://localhost:${ctx.argv.wsPort}`
|
||||||
|
|
||||||
|
if (ctx.argv.remoteDevHost) {
|
||||||
|
wsUrl = `wss://${ctx.argv.remoteDevHost}:${ctx.argv.wsPort}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reloadScript) {
|
||||||
|
staticResources.js.push({
|
||||||
|
loadTime: "afterDOMReady",
|
||||||
|
contentType: "inline",
|
||||||
|
script: `
|
||||||
|
const socket = new WebSocket('${wsUrl}')
|
||||||
|
socket.addEventListener('message', () => document.location.reload())
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
fontOrigin: "googleFonts" | "local"
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultOptions: Options = {
|
||||||
|
fontOrigin: "googleFonts",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ComponentResources: QuartzEmitterPlugin<Options> = (opts?: Partial<Options>) => {
|
||||||
|
const { fontOrigin } = { ...defaultOptions, ...opts }
|
||||||
|
return {
|
||||||
|
name: "ComponentResources",
|
||||||
|
getQuartzComponents() {
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
async emit(ctx, _content, resources, emit): Promise<FilePath[]> {
|
||||||
|
// component specific scripts and styles
|
||||||
|
const componentResources = getComponentResources(ctx)
|
||||||
|
// important that this goes *after* component scripts
|
||||||
|
// as the "nav" event gets triggered here and we should make sure
|
||||||
|
// that everyone else had the chance to register a listener for it
|
||||||
|
|
||||||
|
if (fontOrigin === "googleFonts") {
|
||||||
|
resources.css.push(googleFontHref(ctx.cfg.configuration.theme))
|
||||||
|
} else if (fontOrigin === "local") {
|
||||||
|
// let the user do it themselves in css
|
||||||
|
}
|
||||||
|
|
||||||
|
addGlobalPageResources(ctx, resources, componentResources)
|
||||||
|
|
||||||
|
const stylesheet = joinStyles(ctx.cfg.configuration.theme, ...componentResources.css, styles)
|
||||||
|
const prescript = joinScripts(componentResources.beforeDOMLoaded)
|
||||||
|
const postscript = joinScripts(componentResources.afterDOMLoaded)
|
||||||
|
const fps = await Promise.all([
|
||||||
|
emit({
|
||||||
|
slug: "index" as FullSlug,
|
||||||
|
ext: ".css",
|
||||||
|
content: transform({
|
||||||
|
filename: "index.css",
|
||||||
|
code: Buffer.from(stylesheet),
|
||||||
|
minify: true,
|
||||||
|
targets: {
|
||||||
|
safari: (15 << 16) | (6 << 8), // 15.6
|
||||||
|
ios_saf: (15 << 16) | (6 << 8), // 15.6
|
||||||
|
edge: 115 << 16,
|
||||||
|
firefox: 102 << 16,
|
||||||
|
chrome: 109 << 16,
|
||||||
|
},
|
||||||
|
include: Features.MediaQueries,
|
||||||
|
}).code.toString(),
|
||||||
|
}),
|
||||||
|
emit({
|
||||||
|
slug: "prescript" as FullSlug,
|
||||||
|
ext: ".js",
|
||||||
|
content: prescript,
|
||||||
|
}),
|
||||||
|
emit({
|
||||||
|
slug: "postscript" as FullSlug,
|
||||||
|
ext: ".js",
|
||||||
|
content: postscript,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
return fps
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
150
quartz/plugins/emitters/contentIndex.ts
Normal file
150
quartz/plugins/emitters/contentIndex.ts
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
import { Root } from "hast"
|
||||||
|
import { GlobalConfiguration } from "../../cfg"
|
||||||
|
import { getDate } from "../../components/Date"
|
||||||
|
import { escapeHTML } from "../../util/escape"
|
||||||
|
import { FilePath, FullSlug, SimpleSlug, simplifySlug } from "../../util/path"
|
||||||
|
import { QuartzEmitterPlugin } from "../types"
|
||||||
|
import { toHtml } from "hast-util-to-html"
|
||||||
|
import path from "path"
|
||||||
|
|
||||||
|
export type ContentIndex = Map<FullSlug, ContentDetails>
|
||||||
|
export type ContentDetails = {
|
||||||
|
title: string
|
||||||
|
links: SimpleSlug[]
|
||||||
|
tags: string[]
|
||||||
|
content: string
|
||||||
|
richContent?: string
|
||||||
|
date?: Date
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
enableSiteMap: boolean
|
||||||
|
enableRSS: boolean
|
||||||
|
rssLimit?: number
|
||||||
|
rssFullHtml: boolean
|
||||||
|
includeEmptyFiles: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultOptions: Options = {
|
||||||
|
enableSiteMap: true,
|
||||||
|
enableRSS: true,
|
||||||
|
rssLimit: 10,
|
||||||
|
rssFullHtml: false,
|
||||||
|
includeEmptyFiles: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string {
|
||||||
|
const base = cfg.baseUrl ?? ""
|
||||||
|
const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<url>
|
||||||
|
<loc>https://${base}/${encodeURI(slug)}</loc>
|
||||||
|
<lastmod>${content.date?.toISOString()}</lastmod>
|
||||||
|
</url>`
|
||||||
|
const urls = Array.from(idx)
|
||||||
|
.map(([slug, content]) => createURLEntry(simplifySlug(slug), content))
|
||||||
|
.join("")
|
||||||
|
return `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">${urls}</urlset>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, limit?: number): string {
|
||||||
|
const base = cfg.baseUrl ?? ""
|
||||||
|
const root = `https://${base}`
|
||||||
|
|
||||||
|
const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<item>
|
||||||
|
<title>${escapeHTML(content.title)}</title>
|
||||||
|
<link>${root}/${encodeURI(slug)}</link>
|
||||||
|
<guid>${root}/${encodeURI(slug)}</guid>
|
||||||
|
<description>${content.richContent ?? content.description}</description>
|
||||||
|
<pubDate>${content.date?.toUTCString()}</pubDate>
|
||||||
|
</item>`
|
||||||
|
|
||||||
|
const items = Array.from(idx)
|
||||||
|
.map(([slug, content]) => createURLEntry(simplifySlug(slug), content))
|
||||||
|
.slice(0, limit ?? idx.size)
|
||||||
|
.join("")
|
||||||
|
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<rss version="2.0">
|
||||||
|
<channel>
|
||||||
|
<title>${escapeHTML(cfg.pageTitle)}</title>
|
||||||
|
<link>${root}</link>
|
||||||
|
<description>${!!limit ? `Last ${limit} notes` : "Recent notes"} on ${escapeHTML(
|
||||||
|
cfg.pageTitle,
|
||||||
|
)}</description>
|
||||||
|
<generator>Quartz -- quartz.jzhao.xyz</generator>
|
||||||
|
${items}
|
||||||
|
</channel>
|
||||||
|
</rss>`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
|
||||||
|
opts = { ...defaultOptions, ...opts }
|
||||||
|
return {
|
||||||
|
name: "ContentIndex",
|
||||||
|
async emit(ctx, content, _resources, emit) {
|
||||||
|
const cfg = ctx.cfg.configuration
|
||||||
|
const emitted: FilePath[] = []
|
||||||
|
const linkIndex: ContentIndex = new Map()
|
||||||
|
for (const [tree, file] of content) {
|
||||||
|
const slug = file.data.slug!
|
||||||
|
const date = getDate(ctx.cfg.configuration, file.data) ?? new Date()
|
||||||
|
if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) {
|
||||||
|
linkIndex.set(slug, {
|
||||||
|
title: file.data.frontmatter?.title!,
|
||||||
|
links: file.data.links ?? [],
|
||||||
|
tags: file.data.frontmatter?.tags ?? [],
|
||||||
|
content: file.data.text ?? "",
|
||||||
|
richContent: opts?.rssFullHtml
|
||||||
|
? escapeHTML(toHtml(tree as Root, { allowDangerousHtml: true }))
|
||||||
|
: undefined,
|
||||||
|
date: date,
|
||||||
|
description: file.data.description ?? "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts?.enableSiteMap) {
|
||||||
|
emitted.push(
|
||||||
|
await emit({
|
||||||
|
content: generateSiteMap(cfg, linkIndex),
|
||||||
|
slug: "sitemap" as FullSlug,
|
||||||
|
ext: ".xml",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts?.enableRSS) {
|
||||||
|
emitted.push(
|
||||||
|
await emit({
|
||||||
|
content: generateRSSFeed(cfg, linkIndex, opts.rssLimit),
|
||||||
|
slug: "index" as FullSlug,
|
||||||
|
ext: ".xml",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fp = path.join("static", "contentIndex") as FullSlug
|
||||||
|
const simplifiedIndex = Object.fromEntries(
|
||||||
|
Array.from(linkIndex).map(([slug, content]) => {
|
||||||
|
// remove description and from content index as nothing downstream
|
||||||
|
// actually uses it. we only keep it in the index as we need it
|
||||||
|
// for the RSS feed
|
||||||
|
delete content.description
|
||||||
|
delete content.date
|
||||||
|
return [slug, content]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
emitted.push(
|
||||||
|
await emit({
|
||||||
|
content: JSON.stringify(simplifiedIndex),
|
||||||
|
slug: fp,
|
||||||
|
ext: ".json",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return emitted
|
||||||
|
},
|
||||||
|
getQuartzComponents: () => [],
|
||||||
|
}
|
||||||
|
}
|
72
quartz/plugins/emitters/contentPage.tsx
Normal file
72
quartz/plugins/emitters/contentPage.tsx
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import { QuartzEmitterPlugin } from "../types"
|
||||||
|
import { QuartzComponentProps } from "../../components/types"
|
||||||
|
import HeaderConstructor from "../../components/Header"
|
||||||
|
import BodyConstructor from "../../components/Body"
|
||||||
|
import { pageResources, renderPage } from "../../components/renderPage"
|
||||||
|
import { FullPageLayout } from "../../cfg"
|
||||||
|
import { FilePath, pathToRoot } from "../../util/path"
|
||||||
|
import { defaultContentPageLayout, sharedPageComponents } from "../../../quartz.layout"
|
||||||
|
import { Content } from "../../components"
|
||||||
|
import chalk from "chalk"
|
||||||
|
|
||||||
|
export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpts) => {
|
||||||
|
const opts: FullPageLayout = {
|
||||||
|
...sharedPageComponents,
|
||||||
|
...defaultContentPageLayout,
|
||||||
|
pageBody: Content(),
|
||||||
|
...userOpts,
|
||||||
|
}
|
||||||
|
|
||||||
|
const { head: Head, header, beforeBody, pageBody, left, right, footer: Footer } = opts
|
||||||
|
const Header = HeaderConstructor()
|
||||||
|
const Body = BodyConstructor()
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: "ContentPage",
|
||||||
|
getQuartzComponents() {
|
||||||
|
return [Head, Header, Body, ...header, ...beforeBody, pageBody, ...left, ...right, Footer]
|
||||||
|
},
|
||||||
|
async emit(ctx, content, resources, emit): Promise<FilePath[]> {
|
||||||
|
const cfg = ctx.cfg.configuration
|
||||||
|
const fps: FilePath[] = []
|
||||||
|
const allFiles = content.map((c) => c[1].data)
|
||||||
|
|
||||||
|
let containsIndex = false
|
||||||
|
for (const [tree, file] of content) {
|
||||||
|
const slug = file.data.slug!
|
||||||
|
if (slug === "index") {
|
||||||
|
containsIndex = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const externalResources = pageResources(pathToRoot(slug), resources)
|
||||||
|
const componentData: QuartzComponentProps = {
|
||||||
|
fileData: file.data,
|
||||||
|
externalResources,
|
||||||
|
cfg,
|
||||||
|
children: [],
|
||||||
|
tree,
|
||||||
|
allFiles,
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = renderPage(slug, componentData, opts, externalResources)
|
||||||
|
const fp = await emit({
|
||||||
|
content,
|
||||||
|
slug,
|
||||||
|
ext: ".html",
|
||||||
|
})
|
||||||
|
|
||||||
|
fps.push(fp)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!containsIndex) {
|
||||||
|
console.log(
|
||||||
|
chalk.yellow(
|
||||||
|
`\nWarning: you seem to be missing an \`index.md\` home page file at the root of your \`${ctx.argv.directory}\` folder. This may cause errors when deploying.`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fps
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
96
quartz/plugins/emitters/folderPage.tsx
Normal file
96
quartz/plugins/emitters/folderPage.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import { QuartzEmitterPlugin } from "../types"
|
||||||
|
import { QuartzComponentProps } from "../../components/types"
|
||||||
|
import HeaderConstructor from "../../components/Header"
|
||||||
|
import BodyConstructor from "../../components/Body"
|
||||||
|
import { pageResources, renderPage } from "../../components/renderPage"
|
||||||
|
import { ProcessedContent, defaultProcessedContent } from "../vfile"
|
||||||
|
import { FullPageLayout } from "../../cfg"
|
||||||
|
import path from "path"
|
||||||
|
import {
|
||||||
|
FilePath,
|
||||||
|
FullSlug,
|
||||||
|
SimpleSlug,
|
||||||
|
_stripSlashes,
|
||||||
|
joinSegments,
|
||||||
|
pathToRoot,
|
||||||
|
simplifySlug,
|
||||||
|
} from "../../util/path"
|
||||||
|
import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
|
||||||
|
import { FolderContent } from "../../components"
|
||||||
|
|
||||||
|
export const FolderPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
|
||||||
|
const opts: FullPageLayout = {
|
||||||
|
...sharedPageComponents,
|
||||||
|
...defaultListPageLayout,
|
||||||
|
pageBody: FolderContent(),
|
||||||
|
...userOpts,
|
||||||
|
}
|
||||||
|
|
||||||
|
const { head: Head, header, beforeBody, pageBody, left, right, footer: Footer } = opts
|
||||||
|
const Header = HeaderConstructor()
|
||||||
|
const Body = BodyConstructor()
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: "FolderPage",
|
||||||
|
getQuartzComponents() {
|
||||||
|
return [Head, Header, Body, ...header, ...beforeBody, pageBody, ...left, ...right, Footer]
|
||||||
|
},
|
||||||
|
async emit(ctx, content, resources, emit): Promise<FilePath[]> {
|
||||||
|
const fps: FilePath[] = []
|
||||||
|
const allFiles = content.map((c) => c[1].data)
|
||||||
|
const cfg = ctx.cfg.configuration
|
||||||
|
|
||||||
|
const folders: Set<SimpleSlug> = new Set(
|
||||||
|
allFiles.flatMap((data) => {
|
||||||
|
const slug = data.slug
|
||||||
|
const folderName = path.dirname(slug ?? "") as SimpleSlug
|
||||||
|
if (slug && folderName !== "." && folderName !== "tags") {
|
||||||
|
return [folderName]
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const folderDescriptions: Record<string, ProcessedContent> = Object.fromEntries(
|
||||||
|
[...folders].map((folder) => [
|
||||||
|
folder,
|
||||||
|
defaultProcessedContent({
|
||||||
|
slug: joinSegments(folder, "index") as FullSlug,
|
||||||
|
frontmatter: { title: `Folder: ${folder}`, tags: [] },
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const [tree, file] of content) {
|
||||||
|
const slug = _stripSlashes(simplifySlug(file.data.slug!)) as SimpleSlug
|
||||||
|
if (folders.has(slug)) {
|
||||||
|
folderDescriptions[slug] = [tree, file]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const folder of folders) {
|
||||||
|
const slug = joinSegments(folder, "index") as FullSlug
|
||||||
|
const externalResources = pageResources(pathToRoot(slug), resources)
|
||||||
|
const [tree, file] = folderDescriptions[folder]
|
||||||
|
const componentData: QuartzComponentProps = {
|
||||||
|
fileData: file.data,
|
||||||
|
externalResources,
|
||||||
|
cfg,
|
||||||
|
children: [],
|
||||||
|
tree,
|
||||||
|
allFiles,
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = renderPage(slug, componentData, opts, externalResources)
|
||||||
|
const fp = await emit({
|
||||||
|
content,
|
||||||
|
slug,
|
||||||
|
ext: ".html",
|
||||||
|
})
|
||||||
|
|
||||||
|
fps.push(fp)
|
||||||
|
}
|
||||||
|
return fps
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
9
quartz/plugins/emitters/index.ts
Normal file
9
quartz/plugins/emitters/index.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
export { ContentPage } from "./contentPage"
|
||||||
|
export { TagPage } from "./tagPage"
|
||||||
|
export { FolderPage } from "./folderPage"
|
||||||
|
export { ContentIndex } from "./contentIndex"
|
||||||
|
export { AliasRedirects } from "./aliases"
|
||||||
|
export { Assets } from "./assets"
|
||||||
|
export { Static } from "./static"
|
||||||
|
export { ComponentResources } from "./componentResources"
|
||||||
|
export { NotFoundPage } from "./404"
|
17
quartz/plugins/emitters/static.ts
Normal file
17
quartz/plugins/emitters/static.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { FilePath, QUARTZ, joinSegments } from "../../util/path"
|
||||||
|
import { QuartzEmitterPlugin } from "../types"
|
||||||
|
import fs from "fs"
|
||||||
|
import { glob } from "../../util/glob"
|
||||||
|
|
||||||
|
export const Static: QuartzEmitterPlugin = () => ({
|
||||||
|
name: "Static",
|
||||||
|
getQuartzComponents() {
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
async emit({ argv, cfg }, _content, _resources, _emit): Promise<FilePath[]> {
|
||||||
|
const staticPath = joinSegments(QUARTZ, "static")
|
||||||
|
const fps = await glob("**", staticPath, cfg.configuration.ignorePatterns)
|
||||||
|
await fs.promises.cp(staticPath, joinSegments(argv.output, "static"), { recursive: true })
|
||||||
|
return fps.map((fp) => joinSegments(argv.output, "static", fp)) as FilePath[]
|
||||||
|
},
|
||||||
|
})
|
94
quartz/plugins/emitters/tagPage.tsx
Normal file
94
quartz/plugins/emitters/tagPage.tsx
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
import { QuartzEmitterPlugin } from "../types"
|
||||||
|
import { QuartzComponentProps } from "../../components/types"
|
||||||
|
import HeaderConstructor from "../../components/Header"
|
||||||
|
import BodyConstructor from "../../components/Body"
|
||||||
|
import { pageResources, renderPage } from "../../components/renderPage"
|
||||||
|
import { ProcessedContent, defaultProcessedContent } from "../vfile"
|
||||||
|
import { FullPageLayout } from "../../cfg"
|
||||||
|
import {
|
||||||
|
FilePath,
|
||||||
|
FullSlug,
|
||||||
|
getAllSegmentPrefixes,
|
||||||
|
joinSegments,
|
||||||
|
pathToRoot,
|
||||||
|
} from "../../util/path"
|
||||||
|
import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
|
||||||
|
import { TagContent } from "../../components"
|
||||||
|
|
||||||
|
export const TagPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
|
||||||
|
const opts: FullPageLayout = {
|
||||||
|
...sharedPageComponents,
|
||||||
|
...defaultListPageLayout,
|
||||||
|
pageBody: TagContent(),
|
||||||
|
...userOpts,
|
||||||
|
}
|
||||||
|
|
||||||
|
const { head: Head, header, beforeBody, pageBody, left, right, footer: Footer } = opts
|
||||||
|
const Header = HeaderConstructor()
|
||||||
|
const Body = BodyConstructor()
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: "TagPage",
|
||||||
|
getQuartzComponents() {
|
||||||
|
return [Head, Header, Body, ...header, ...beforeBody, pageBody, ...left, ...right, Footer]
|
||||||
|
},
|
||||||
|
async emit(ctx, content, resources, emit): Promise<FilePath[]> {
|
||||||
|
const fps: FilePath[] = []
|
||||||
|
const allFiles = content.map((c) => c[1].data)
|
||||||
|
const cfg = ctx.cfg.configuration
|
||||||
|
|
||||||
|
const tags: Set<string> = new Set(
|
||||||
|
allFiles.flatMap((data) => data.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes),
|
||||||
|
)
|
||||||
|
// add base tag
|
||||||
|
tags.add("index")
|
||||||
|
|
||||||
|
const tagDescriptions: Record<string, ProcessedContent> = Object.fromEntries(
|
||||||
|
[...tags].map((tag) => {
|
||||||
|
const title = tag === "" ? "Tag Index" : `Tag: #${tag}`
|
||||||
|
return [
|
||||||
|
tag,
|
||||||
|
defaultProcessedContent({
|
||||||
|
slug: joinSegments("tags", tag) as FullSlug,
|
||||||
|
frontmatter: { title, tags: [] },
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const [tree, file] of content) {
|
||||||
|
const slug = file.data.slug!
|
||||||
|
if (slug.startsWith("tags/")) {
|
||||||
|
const tag = slug.slice("tags/".length)
|
||||||
|
if (tags.has(tag)) {
|
||||||
|
tagDescriptions[tag] = [tree, file]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const tag of tags) {
|
||||||
|
const slug = joinSegments("tags", tag) as FullSlug
|
||||||
|
const externalResources = pageResources(pathToRoot(slug), resources)
|
||||||
|
const [tree, file] = tagDescriptions[tag]
|
||||||
|
const componentData: QuartzComponentProps = {
|
||||||
|
fileData: file.data,
|
||||||
|
externalResources,
|
||||||
|
cfg,
|
||||||
|
children: [],
|
||||||
|
tree,
|
||||||
|
allFiles,
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = renderPage(slug, componentData, opts, externalResources)
|
||||||
|
const fp = await emit({
|
||||||
|
content,
|
||||||
|
slug: file.data.slug!,
|
||||||
|
ext: ".html",
|
||||||
|
})
|
||||||
|
|
||||||
|
fps.push(fp)
|
||||||
|
}
|
||||||
|
return fps
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
9
quartz/plugins/filters/draft.ts
Normal file
9
quartz/plugins/filters/draft.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { QuartzFilterPlugin } from "../types"
|
||||||
|
|
||||||
|
export const RemoveDrafts: QuartzFilterPlugin<{}> = () => ({
|
||||||
|
name: "RemoveDrafts",
|
||||||
|
shouldPublish(_ctx, [_tree, vfile]) {
|
||||||
|
const draftFlag: boolean = vfile.data?.frontmatter?.draft ?? false
|
||||||
|
return !draftFlag
|
||||||
|
},
|
||||||
|
})
|
9
quartz/plugins/filters/explicit.ts
Normal file
9
quartz/plugins/filters/explicit.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { QuartzFilterPlugin } from "../types"
|
||||||
|
|
||||||
|
export const ExplicitPublish: QuartzFilterPlugin = () => ({
|
||||||
|
name: "ExplicitPublish",
|
||||||
|
shouldPublish(_ctx, [_tree, vfile]) {
|
||||||
|
const publishFlag: boolean = vfile.data?.frontmatter?.publish ?? false
|
||||||
|
return publishFlag
|
||||||
|
},
|
||||||
|
})
|
2
quartz/plugins/filters/index.ts
Normal file
2
quartz/plugins/filters/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { RemoveDrafts } from "./draft"
|
||||||
|
export { ExplicitPublish } from "./explicit"
|
34
quartz/plugins/index.ts
Normal file
34
quartz/plugins/index.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { StaticResources } from "../util/resources"
|
||||||
|
import { FilePath, FullSlug } from "../util/path"
|
||||||
|
import { BuildCtx } from "../util/ctx"
|
||||||
|
|
||||||
|
export function getStaticResourcesFromPlugins(ctx: BuildCtx) {
|
||||||
|
const staticResources: StaticResources = {
|
||||||
|
css: [],
|
||||||
|
js: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const transformer of ctx.cfg.plugins.transformers) {
|
||||||
|
const res = transformer.externalResources ? transformer.externalResources(ctx) : {}
|
||||||
|
if (res?.js) {
|
||||||
|
staticResources.js.push(...res.js)
|
||||||
|
}
|
||||||
|
if (res?.css) {
|
||||||
|
staticResources.css.push(...res.css)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return staticResources
|
||||||
|
}
|
||||||
|
|
||||||
|
export * from "./transformers"
|
||||||
|
export * from "./filters"
|
||||||
|
export * from "./emitters"
|
||||||
|
|
||||||
|
declare module "vfile" {
|
||||||
|
// inserted in processors.ts
|
||||||
|
interface DataMap {
|
||||||
|
slug: FullSlug
|
||||||
|
filePath: FilePath
|
||||||
|
}
|
||||||
|
}
|
51
quartz/plugins/transformers/description.ts
Normal file
51
quartz/plugins/transformers/description.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { Root as HTMLRoot } from "hast"
|
||||||
|
import { toString } from "hast-util-to-string"
|
||||||
|
import { QuartzTransformerPlugin } from "../types"
|
||||||
|
import { escapeHTML } from "../../util/escape"
|
||||||
|
|
||||||
|
export interface Options {
|
||||||
|
descriptionLength: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultOptions: Options = {
|
||||||
|
descriptionLength: 150,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Description: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
|
||||||
|
const opts = { ...defaultOptions, ...userOpts }
|
||||||
|
return {
|
||||||
|
name: "Description",
|
||||||
|
htmlPlugins() {
|
||||||
|
return [
|
||||||
|
() => {
|
||||||
|
return async (tree: HTMLRoot, file) => {
|
||||||
|
const frontMatterDescription = file.data.frontmatter?.description
|
||||||
|
const text = escapeHTML(toString(tree))
|
||||||
|
|
||||||
|
const desc = frontMatterDescription ?? text
|
||||||
|
const sentences = desc.replace(/\s+/g, " ").split(".")
|
||||||
|
let finalDesc = ""
|
||||||
|
let sentenceIdx = 0
|
||||||
|
const len = opts.descriptionLength
|
||||||
|
while (finalDesc.length < len) {
|
||||||
|
const sentence = sentences[sentenceIdx]
|
||||||
|
if (!sentence) break
|
||||||
|
finalDesc += sentence + "."
|
||||||
|
sentenceIdx++
|
||||||
|
}
|
||||||
|
|
||||||
|
file.data.description = finalDesc
|
||||||
|
file.data.text = text
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "vfile" {
|
||||||
|
interface DataMap {
|
||||||
|
description: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
}
|
75
quartz/plugins/transformers/frontmatter.ts
Normal file
75
quartz/plugins/transformers/frontmatter.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import matter from "gray-matter"
|
||||||
|
import remarkFrontmatter from "remark-frontmatter"
|
||||||
|
import { QuartzTransformerPlugin } from "../types"
|
||||||
|
import yaml from "js-yaml"
|
||||||
|
import toml from "toml"
|
||||||
|
import { slugTag } from "../../util/path"
|
||||||
|
|
||||||
|
export interface Options {
|
||||||
|
delims: string | string[]
|
||||||
|
language: "yaml" | "toml"
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultOptions: Options = {
|
||||||
|
delims: "---",
|
||||||
|
language: "yaml",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
|
||||||
|
const opts = { ...defaultOptions, ...userOpts }
|
||||||
|
return {
|
||||||
|
name: "FrontMatter",
|
||||||
|
markdownPlugins() {
|
||||||
|
return [
|
||||||
|
[remarkFrontmatter, ["yaml", "toml"]],
|
||||||
|
() => {
|
||||||
|
return (_, file) => {
|
||||||
|
const { data } = matter(file.value, {
|
||||||
|
...opts,
|
||||||
|
engines: {
|
||||||
|
yaml: (s) => yaml.load(s, { schema: yaml.JSON_SCHEMA }) as object,
|
||||||
|
toml: (s) => toml.parse(s) as object,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// tag is an alias for tags
|
||||||
|
if (data.tag) {
|
||||||
|
data.tags = data.tag
|
||||||
|
}
|
||||||
|
|
||||||
|
// coerce title to string
|
||||||
|
if (data.title) {
|
||||||
|
data.title = data.title.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.tags && !Array.isArray(data.tags)) {
|
||||||
|
data.tags = data.tags
|
||||||
|
.toString()
|
||||||
|
.split(",")
|
||||||
|
.map((tag: string) => tag.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
// slug them all!!
|
||||||
|
data.tags = [...new Set(data.tags?.map((tag: string) => slugTag(tag)))] ?? []
|
||||||
|
|
||||||
|
// fill in frontmatter
|
||||||
|
file.data.frontmatter = {
|
||||||
|
title: file.stem ?? "Untitled",
|
||||||
|
tags: [],
|
||||||
|
...data,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "vfile" {
|
||||||
|
interface DataMap {
|
||||||
|
frontmatter: { [key: string]: any } & {
|
||||||
|
title: string
|
||||||
|
tags: string[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
46
quartz/plugins/transformers/gfm.ts
Normal file
46
quartz/plugins/transformers/gfm.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import remarkGfm from "remark-gfm"
|
||||||
|
import smartypants from "remark-smartypants"
|
||||||
|
import { QuartzTransformerPlugin } from "../types"
|
||||||
|
import rehypeSlug from "rehype-slug"
|
||||||
|
import rehypeAutolinkHeadings from "rehype-autolink-headings"
|
||||||
|
|
||||||
|
export interface Options {
|
||||||
|
enableSmartyPants: boolean
|
||||||
|
linkHeadings: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultOptions: Options = {
|
||||||
|
enableSmartyPants: true,
|
||||||
|
linkHeadings: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GitHubFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> | undefined> = (
|
||||||
|
userOpts,
|
||||||
|
) => {
|
||||||
|
const opts = { ...defaultOptions, ...userOpts }
|
||||||
|
return {
|
||||||
|
name: "GitHubFlavoredMarkdown",
|
||||||
|
markdownPlugins() {
|
||||||
|
return opts.enableSmartyPants ? [remarkGfm, smartypants] : [remarkGfm]
|
||||||
|
},
|
||||||
|
htmlPlugins() {
|
||||||
|
if (opts.linkHeadings) {
|
||||||
|
return [
|
||||||
|
rehypeSlug,
|
||||||
|
[
|
||||||
|
rehypeAutolinkHeadings,
|
||||||
|
{
|
||||||
|
behavior: "append",
|
||||||
|
content: {
|
||||||
|
type: "text",
|
||||||
|
value: " §",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
11
quartz/plugins/transformers/index.ts
Normal file
11
quartz/plugins/transformers/index.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
export { FrontMatter } from "./frontmatter"
|
||||||
|
export { GitHubFlavoredMarkdown } from "./gfm"
|
||||||
|
export { CreatedModifiedDate } from "./lastmod"
|
||||||
|
export { Latex } from "./latex"
|
||||||
|
export { Description } from "./description"
|
||||||
|
export { CrawlLinks } from "./links"
|
||||||
|
export { ObsidianFlavoredMarkdown } from "./ofm"
|
||||||
|
export { OxHugoFlavouredMarkdown } from "./oxhugofm"
|
||||||
|
export { SyntaxHighlighting } from "./syntax"
|
||||||
|
export { TableOfContents } from "./toc"
|
||||||
|
export { HardLineBreaks } from "./linebreaks"
|
87
quartz/plugins/transformers/lastmod.ts
Normal file
87
quartz/plugins/transformers/lastmod.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import fs from "fs"
|
||||||
|
import path from "path"
|
||||||
|
import { Repository } from "@napi-rs/simple-git"
|
||||||
|
import { QuartzTransformerPlugin } from "../types"
|
||||||
|
import chalk from "chalk"
|
||||||
|
|
||||||
|
export interface Options {
|
||||||
|
priority: ("frontmatter" | "git" | "filesystem")[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultOptions: Options = {
|
||||||
|
priority: ["frontmatter", "git", "filesystem"],
|
||||||
|
}
|
||||||
|
|
||||||
|
function coerceDate(fp: string, d: any): Date {
|
||||||
|
const dt = new Date(d)
|
||||||
|
const invalidDate = isNaN(dt.getTime()) || dt.getTime() === 0
|
||||||
|
if (invalidDate && d !== undefined) {
|
||||||
|
console.log(
|
||||||
|
chalk.yellow(
|
||||||
|
`\nWarning: found invalid date "${d}" in \`${fp}\`. Supported formats: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#date_time_string_format`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return invalidDate ? new Date() : dt
|
||||||
|
}
|
||||||
|
|
||||||
|
type MaybeDate = undefined | string | number
|
||||||
|
export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options> | undefined> = (
|
||||||
|
userOpts,
|
||||||
|
) => {
|
||||||
|
const opts = { ...defaultOptions, ...userOpts }
|
||||||
|
return {
|
||||||
|
name: "CreatedModifiedDate",
|
||||||
|
markdownPlugins() {
|
||||||
|
return [
|
||||||
|
() => {
|
||||||
|
let repo: Repository | undefined = undefined
|
||||||
|
return async (_tree, file) => {
|
||||||
|
let created: MaybeDate = undefined
|
||||||
|
let modified: MaybeDate = undefined
|
||||||
|
let published: MaybeDate = undefined
|
||||||
|
|
||||||
|
const fp = file.data.filePath!
|
||||||
|
const fullFp = path.posix.join(file.cwd, fp)
|
||||||
|
for (const source of opts.priority) {
|
||||||
|
if (source === "filesystem") {
|
||||||
|
const st = await fs.promises.stat(fullFp)
|
||||||
|
created ||= st.birthtimeMs
|
||||||
|
modified ||= st.mtimeMs
|
||||||
|
} else if (source === "frontmatter" && file.data.frontmatter) {
|
||||||
|
created ||= file.data.frontmatter.date
|
||||||
|
modified ||= file.data.frontmatter.lastmod
|
||||||
|
modified ||= file.data.frontmatter.updated
|
||||||
|
modified ||= file.data.frontmatter["last-modified"]
|
||||||
|
published ||= file.data.frontmatter.publishDate
|
||||||
|
} else if (source === "git") {
|
||||||
|
if (!repo) {
|
||||||
|
repo = new Repository(file.cwd)
|
||||||
|
}
|
||||||
|
|
||||||
|
modified ||= await repo.getFileLatestModifiedDateAsync(file.data.filePath!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
file.data.dates = {
|
||||||
|
created: coerceDate(fp, created),
|
||||||
|
modified: coerceDate(fp, modified),
|
||||||
|
published: coerceDate(fp, published),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "vfile" {
|
||||||
|
interface DataMap {
|
||||||
|
dates: {
|
||||||
|
created: Date
|
||||||
|
modified: Date
|
||||||
|
published: Date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
45
quartz/plugins/transformers/latex.ts
Normal file
45
quartz/plugins/transformers/latex.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import remarkMath from "remark-math"
|
||||||
|
import rehypeKatex from "rehype-katex"
|
||||||
|
import rehypeMathjax from "rehype-mathjax/svg.js"
|
||||||
|
import { QuartzTransformerPlugin } from "../types"
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
renderEngine: "katex" | "mathjax"
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Latex: QuartzTransformerPlugin<Options> = (opts?: Options) => {
|
||||||
|
const engine = opts?.renderEngine ?? "katex"
|
||||||
|
return {
|
||||||
|
name: "Latex",
|
||||||
|
markdownPlugins() {
|
||||||
|
return [remarkMath]
|
||||||
|
},
|
||||||
|
htmlPlugins() {
|
||||||
|
if (engine === "katex") {
|
||||||
|
return [[rehypeKatex, { output: "html" }]]
|
||||||
|
} else {
|
||||||
|
return [rehypeMathjax]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
externalResources() {
|
||||||
|
if (engine === "katex") {
|
||||||
|
return {
|
||||||
|
css: [
|
||||||
|
// base css
|
||||||
|
"https://cdn.jsdelivr.net/npm/katex@0.16.0/dist/katex.min.css",
|
||||||
|
],
|
||||||
|
js: [
|
||||||
|
{
|
||||||
|
// fix copy behaviour: https://github.com/KaTeX/KaTeX/blob/main/contrib/copy-tex/README.md
|
||||||
|
src: "https://cdn.jsdelivr.net/npm/katex@0.16.7/dist/contrib/copy-tex.min.js",
|
||||||
|
loadTime: "afterDOMReady",
|
||||||
|
contentType: "external",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
11
quartz/plugins/transformers/linebreaks.ts
Normal file
11
quartz/plugins/transformers/linebreaks.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { QuartzTransformerPlugin } from "../types"
|
||||||
|
import remarkBreaks from "remark-breaks"
|
||||||
|
|
||||||
|
export const HardLineBreaks: QuartzTransformerPlugin = () => {
|
||||||
|
return {
|
||||||
|
name: "HardLineBreaks",
|
||||||
|
markdownPlugins() {
|
||||||
|
return [remarkBreaks]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
126
quartz/plugins/transformers/links.ts
Normal file
126
quartz/plugins/transformers/links.ts
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import { QuartzTransformerPlugin } from "../types"
|
||||||
|
import {
|
||||||
|
FullSlug,
|
||||||
|
RelativeURL,
|
||||||
|
SimpleSlug,
|
||||||
|
TransformOptions,
|
||||||
|
_stripSlashes,
|
||||||
|
simplifySlug,
|
||||||
|
splitAnchor,
|
||||||
|
transformLink,
|
||||||
|
} from "../../util/path"
|
||||||
|
import path from "path"
|
||||||
|
import { visit } from "unist-util-visit"
|
||||||
|
import isAbsoluteUrl from "is-absolute-url"
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
/** How to resolve Markdown paths */
|
||||||
|
markdownLinkResolution: TransformOptions["strategy"]
|
||||||
|
/** Strips folders from a link so that it looks nice */
|
||||||
|
prettyLinks: boolean
|
||||||
|
openLinksInNewTab: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultOptions: Options = {
|
||||||
|
markdownLinkResolution: "absolute",
|
||||||
|
prettyLinks: true,
|
||||||
|
openLinksInNewTab: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
|
||||||
|
const opts = { ...defaultOptions, ...userOpts }
|
||||||
|
return {
|
||||||
|
name: "LinkProcessing",
|
||||||
|
htmlPlugins(ctx) {
|
||||||
|
return [
|
||||||
|
() => {
|
||||||
|
return (tree, file) => {
|
||||||
|
const curSlug = simplifySlug(file.data.slug!)
|
||||||
|
const outgoing: Set<SimpleSlug> = new Set()
|
||||||
|
|
||||||
|
const transformOptions: TransformOptions = {
|
||||||
|
strategy: opts.markdownLinkResolution,
|
||||||
|
allSlugs: ctx.allSlugs,
|
||||||
|
}
|
||||||
|
|
||||||
|
visit(tree, "element", (node, _index, _parent) => {
|
||||||
|
// rewrite all links
|
||||||
|
if (
|
||||||
|
node.tagName === "a" &&
|
||||||
|
node.properties &&
|
||||||
|
typeof node.properties.href === "string"
|
||||||
|
) {
|
||||||
|
let dest = node.properties.href as RelativeURL
|
||||||
|
node.properties.className ??= []
|
||||||
|
node.properties.className.push(isAbsoluteUrl(dest) ? "external" : "internal")
|
||||||
|
|
||||||
|
if (opts.openLinksInNewTab) {
|
||||||
|
node.properties.target = "_blank"
|
||||||
|
}
|
||||||
|
|
||||||
|
// don't process external links or intra-document anchors
|
||||||
|
const isInternal = !(isAbsoluteUrl(dest) || dest.startsWith("#"))
|
||||||
|
if (isInternal) {
|
||||||
|
dest = node.properties.href = transformLink(
|
||||||
|
file.data.slug!,
|
||||||
|
dest,
|
||||||
|
transformOptions,
|
||||||
|
)
|
||||||
|
|
||||||
|
// url.resolve is considered legacy
|
||||||
|
// WHATWG equivalent https://nodejs.dev/en/api/v18/url/#urlresolvefrom-to
|
||||||
|
const url = new URL(dest, `https://base.com/${curSlug}`)
|
||||||
|
const canonicalDest = url.pathname
|
||||||
|
const [destCanonical, _destAnchor] = splitAnchor(canonicalDest)
|
||||||
|
|
||||||
|
// need to decodeURIComponent here as WHATWG URL percent-encodes everything
|
||||||
|
const simple = decodeURIComponent(
|
||||||
|
simplifySlug(destCanonical as FullSlug),
|
||||||
|
) as SimpleSlug
|
||||||
|
outgoing.add(simple)
|
||||||
|
node.properties["data-slug"] = simple
|
||||||
|
}
|
||||||
|
|
||||||
|
// rewrite link internals if prettylinks is on
|
||||||
|
if (
|
||||||
|
opts.prettyLinks &&
|
||||||
|
isInternal &&
|
||||||
|
node.children.length === 1 &&
|
||||||
|
node.children[0].type === "text" &&
|
||||||
|
!node.children[0].value.startsWith("#")
|
||||||
|
) {
|
||||||
|
node.children[0].value = path.basename(node.children[0].value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// transform all other resources that may use links
|
||||||
|
if (
|
||||||
|
["img", "video", "audio", "iframe"].includes(node.tagName) &&
|
||||||
|
node.properties &&
|
||||||
|
typeof node.properties.src === "string"
|
||||||
|
) {
|
||||||
|
if (!isAbsoluteUrl(node.properties.src)) {
|
||||||
|
let dest = node.properties.src as RelativeURL
|
||||||
|
dest = node.properties.src = transformLink(
|
||||||
|
file.data.slug!,
|
||||||
|
dest,
|
||||||
|
transformOptions,
|
||||||
|
)
|
||||||
|
node.properties.src = dest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
file.data.links = [...outgoing]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "vfile" {
|
||||||
|
interface DataMap {
|
||||||
|
links: SimpleSlug[]
|
||||||
|
}
|
||||||
|
}
|
528
quartz/plugins/transformers/ofm.ts
Normal file
528
quartz/plugins/transformers/ofm.ts
Normal file
@ -0,0 +1,528 @@
|
|||||||
|
import { PluggableList } from "unified"
|
||||||
|
import { QuartzTransformerPlugin } from "../types"
|
||||||
|
import { Root, HTML, BlockContent, DefinitionContent, Code, Paragraph } from "mdast"
|
||||||
|
import { Element, Literal } from "hast"
|
||||||
|
import { Replace, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace"
|
||||||
|
import { slug as slugAnchor } from "github-slugger"
|
||||||
|
import rehypeRaw from "rehype-raw"
|
||||||
|
import { visit } from "unist-util-visit"
|
||||||
|
import path from "path"
|
||||||
|
import { JSResource } from "../../util/resources"
|
||||||
|
// @ts-ignore
|
||||||
|
import calloutScript from "../../components/scripts/callout.inline.ts"
|
||||||
|
import { FilePath, pathToRoot, slugTag, slugifyFilePath } from "../../util/path"
|
||||||
|
import { toHast } from "mdast-util-to-hast"
|
||||||
|
import { toHtml } from "hast-util-to-html"
|
||||||
|
import { PhrasingContent } from "mdast-util-find-and-replace/lib"
|
||||||
|
import { capitalize } from "../../util/lang"
|
||||||
|
|
||||||
|
export interface Options {
|
||||||
|
comments: boolean
|
||||||
|
highlight: boolean
|
||||||
|
wikilinks: boolean
|
||||||
|
callouts: boolean
|
||||||
|
mermaid: boolean
|
||||||
|
parseTags: boolean
|
||||||
|
parseBlockReferences: boolean
|
||||||
|
enableInHtmlEmbed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultOptions: Options = {
|
||||||
|
comments: true,
|
||||||
|
highlight: true,
|
||||||
|
wikilinks: true,
|
||||||
|
callouts: true,
|
||||||
|
mermaid: true,
|
||||||
|
parseTags: true,
|
||||||
|
parseBlockReferences: true,
|
||||||
|
enableInHtmlEmbed: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const icons = {
|
||||||
|
infoIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>`,
|
||||||
|
pencilIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="2" x2="22" y2="6"></line><path d="M7.5 20.5 19 9l-4-4L3.5 16.5 2 22z"></path></svg>`,
|
||||||
|
clipboardListIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path><path d="M12 11h4"></path><path d="M12 16h4"></path><path d="M8 11h.01"></path><path d="M8 16h.01"></path></svg>`,
|
||||||
|
checkCircleIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"></path><path d="m9 12 2 2 4-4"></path></svg>`,
|
||||||
|
flameIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"></path></svg>`,
|
||||||
|
checkIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>`,
|
||||||
|
helpCircleIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>`,
|
||||||
|
alertTriangleIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>`,
|
||||||
|
xIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>`,
|
||||||
|
zapIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>`,
|
||||||
|
bugIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="8" height="14" x="8" y="6" rx="4"></rect><path d="m19 7-3 2"></path><path d="m5 7 3 2"></path><path d="m19 19-3-2"></path><path d="m5 19 3-2"></path><path d="M20 13h-4"></path><path d="M4 13h4"></path><path d="m10 4 1 2"></path><path d="m14 4-1 2"></path></svg>`,
|
||||||
|
listIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></svg>`,
|
||||||
|
quoteIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"></path><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"></path></svg>`,
|
||||||
|
}
|
||||||
|
|
||||||
|
const callouts = {
|
||||||
|
note: icons.pencilIcon,
|
||||||
|
abstract: icons.clipboardListIcon,
|
||||||
|
info: icons.infoIcon,
|
||||||
|
todo: icons.checkCircleIcon,
|
||||||
|
tip: icons.flameIcon,
|
||||||
|
success: icons.checkIcon,
|
||||||
|
question: icons.helpCircleIcon,
|
||||||
|
warning: icons.alertTriangleIcon,
|
||||||
|
failure: icons.xIcon,
|
||||||
|
danger: icons.zapIcon,
|
||||||
|
bug: icons.bugIcon,
|
||||||
|
example: icons.listIcon,
|
||||||
|
quote: icons.quoteIcon,
|
||||||
|
}
|
||||||
|
|
||||||
|
const calloutMapping: Record<string, keyof typeof callouts> = {
|
||||||
|
note: "note",
|
||||||
|
abstract: "abstract",
|
||||||
|
summary: "abstract",
|
||||||
|
tldr: "abstract",
|
||||||
|
info: "info",
|
||||||
|
todo: "todo",
|
||||||
|
tip: "tip",
|
||||||
|
hint: "tip",
|
||||||
|
important: "tip",
|
||||||
|
success: "success",
|
||||||
|
check: "success",
|
||||||
|
done: "success",
|
||||||
|
question: "question",
|
||||||
|
help: "question",
|
||||||
|
faq: "question",
|
||||||
|
warning: "warning",
|
||||||
|
attention: "warning",
|
||||||
|
caution: "warning",
|
||||||
|
failure: "failure",
|
||||||
|
missing: "failure",
|
||||||
|
fail: "failure",
|
||||||
|
danger: "danger",
|
||||||
|
error: "danger",
|
||||||
|
bug: "bug",
|
||||||
|
example: "example",
|
||||||
|
quote: "quote",
|
||||||
|
cite: "quote",
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalizeCallout(calloutName: string): keyof typeof callouts {
|
||||||
|
let callout = calloutName.toLowerCase() as keyof typeof calloutMapping
|
||||||
|
return calloutMapping[callout] ?? "note"
|
||||||
|
}
|
||||||
|
|
||||||
|
// !? -> optional embedding
|
||||||
|
// \[\[ -> open brace
|
||||||
|
// ([^\[\]\|\#]+) -> one or more non-special characters ([,],|, or #) (name)
|
||||||
|
// (#[^\[\]\|\#]+)? -> # then one or more non-special characters (heading link)
|
||||||
|
// (|[^\[\]\|\#]+)? -> | then one or more non-special characters (alias)
|
||||||
|
const wikilinkRegex = new RegExp(/!?\[\[([^\[\]\|\#]+)?(#[^\[\]\|\#]+)?(\|[^\[\]\|\#]+)?\]\]/, "g")
|
||||||
|
const highlightRegex = new RegExp(/==([^=]+)==/, "g")
|
||||||
|
const commentRegex = new RegExp(/%%(.+)%%/, "g")
|
||||||
|
// from https://github.com/escwxyz/remark-obsidian-callout/blob/main/src/index.ts
|
||||||
|
const calloutRegex = new RegExp(/^\[\!(\w+)\]([+-]?)/)
|
||||||
|
const calloutLineRegex = new RegExp(/^> *\[\!\w+\][+-]?.*$/, "gm")
|
||||||
|
// (?:^| ) -> non-capturing group, tag should start be separated by a space or be the start of the line
|
||||||
|
// #(...) -> capturing group, tag itself must start with #
|
||||||
|
// (?:[-_\p{L}])+ -> non-capturing group, non-empty string of (Unicode-aware) alpha-numeric characters, hyphens and/or underscores
|
||||||
|
// (?:\/[-_\p{L}]+)*) -> non-capturing group, matches an arbitrary number of tag strings separated by "/"
|
||||||
|
const tagRegex = new RegExp(/(?:^| )#((?:[-_\p{L}\d])+(?:\/[-_\p{L}\d]+)*)/, "gu")
|
||||||
|
const blockReferenceRegex = new RegExp(/\^([A-Za-z0-9]+)$/, "g")
|
||||||
|
|
||||||
|
export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> | undefined> = (
|
||||||
|
userOpts,
|
||||||
|
) => {
|
||||||
|
const opts = { ...defaultOptions, ...userOpts }
|
||||||
|
|
||||||
|
const mdastToHtml = (ast: PhrasingContent | Paragraph) => {
|
||||||
|
const hast = toHast(ast, { allowDangerousHtml: true })!
|
||||||
|
return toHtml(hast, { allowDangerousHtml: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const findAndReplace = opts.enableInHtmlEmbed
|
||||||
|
? (tree: Root, regex: RegExp, replace?: Replace | null | undefined) => {
|
||||||
|
if (replace) {
|
||||||
|
visit(tree, "html", (node: HTML) => {
|
||||||
|
if (typeof replace === "string") {
|
||||||
|
node.value = node.value.replace(regex, replace)
|
||||||
|
} else {
|
||||||
|
node.value = node.value.replaceAll(regex, (substring: string, ...args) => {
|
||||||
|
const replaceValue = replace(substring, ...args)
|
||||||
|
if (typeof replaceValue === "string") {
|
||||||
|
return replaceValue
|
||||||
|
} else if (Array.isArray(replaceValue)) {
|
||||||
|
return replaceValue.map(mdastToHtml).join("")
|
||||||
|
} else if (typeof replaceValue === "object" && replaceValue !== null) {
|
||||||
|
return mdastToHtml(replaceValue)
|
||||||
|
} else {
|
||||||
|
return substring
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
mdastFindReplace(tree, regex, replace)
|
||||||
|
}
|
||||||
|
: mdastFindReplace
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: "ObsidianFlavoredMarkdown",
|
||||||
|
textTransform(_ctx, src) {
|
||||||
|
// pre-transform blockquotes
|
||||||
|
if (opts.callouts) {
|
||||||
|
src = src.toString()
|
||||||
|
src = src.replaceAll(calloutLineRegex, (value) => {
|
||||||
|
// force newline after title of callout
|
||||||
|
return value + "\n> "
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// pre-transform wikilinks (fix anchors to things that may contain illegal syntax e.g. codeblocks, latex)
|
||||||
|
if (opts.wikilinks) {
|
||||||
|
src = src.toString()
|
||||||
|
src = src.replaceAll(wikilinkRegex, (value, ...capture) => {
|
||||||
|
const [rawFp, rawHeader, rawAlias] = capture
|
||||||
|
const fp = rawFp ?? ""
|
||||||
|
const anchor = rawHeader?.trim().slice(1)
|
||||||
|
const displayAnchor = anchor ? `#${slugAnchor(anchor)}` : ""
|
||||||
|
const displayAlias = rawAlias ?? rawHeader?.replace("#", "|") ?? ""
|
||||||
|
const embedDisplay = value.startsWith("!") ? "!" : ""
|
||||||
|
return `${embedDisplay}[[${fp}${displayAnchor}${displayAlias}]]`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return src
|
||||||
|
},
|
||||||
|
markdownPlugins() {
|
||||||
|
const plugins: PluggableList = []
|
||||||
|
if (opts.wikilinks) {
|
||||||
|
plugins.push(() => {
|
||||||
|
return (tree: Root, _file) => {
|
||||||
|
findAndReplace(tree, wikilinkRegex, (value: string, ...capture: string[]) => {
|
||||||
|
let [rawFp, rawHeader, rawAlias] = capture
|
||||||
|
const fp = rawFp?.trim() ?? ""
|
||||||
|
const anchor = rawHeader?.trim() ?? ""
|
||||||
|
const alias = rawAlias?.slice(1).trim()
|
||||||
|
|
||||||
|
// embed cases
|
||||||
|
if (value.startsWith("!")) {
|
||||||
|
const ext: string = path.extname(fp).toLowerCase()
|
||||||
|
const url = slugifyFilePath(fp as FilePath)
|
||||||
|
if ([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg"].includes(ext)) {
|
||||||
|
const dims = alias ?? ""
|
||||||
|
let [width, height] = dims.split("x", 2)
|
||||||
|
width ||= "auto"
|
||||||
|
height ||= "auto"
|
||||||
|
return {
|
||||||
|
type: "image",
|
||||||
|
url,
|
||||||
|
data: {
|
||||||
|
hProperties: {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else if ([".mp4", ".webm", ".ogv", ".mov", ".mkv"].includes(ext)) {
|
||||||
|
return {
|
||||||
|
type: "html",
|
||||||
|
value: `<video src="${url}" controls></video>`,
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
[".mp3", ".webm", ".wav", ".m4a", ".ogg", ".3gp", ".flac"].includes(ext)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
type: "html",
|
||||||
|
value: `<audio src="${url}" controls></audio>`,
|
||||||
|
}
|
||||||
|
} else if ([".pdf"].includes(ext)) {
|
||||||
|
return {
|
||||||
|
type: "html",
|
||||||
|
value: `<iframe src="${url}"></iframe>`,
|
||||||
|
}
|
||||||
|
} else if (ext === "") {
|
||||||
|
const block = anchor.slice(1)
|
||||||
|
return {
|
||||||
|
type: "html",
|
||||||
|
data: { hProperties: { transclude: true } },
|
||||||
|
value: `<blockquote class="transclude" data-url="${url}" data-block="${block}"><a href="${
|
||||||
|
url + anchor
|
||||||
|
}" class="transclude-inner">Transclude of block ${block}</a></blockquote>`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// otherwise, fall through to regular link
|
||||||
|
}
|
||||||
|
|
||||||
|
// internal link
|
||||||
|
const url = fp + anchor
|
||||||
|
return {
|
||||||
|
type: "link",
|
||||||
|
url,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
value: alias ?? fp,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.highlight) {
|
||||||
|
plugins.push(() => {
|
||||||
|
return (tree: Root, _file) => {
|
||||||
|
findAndReplace(tree, highlightRegex, (_value: string, ...capture: string[]) => {
|
||||||
|
const [inner] = capture
|
||||||
|
return {
|
||||||
|
type: "html",
|
||||||
|
value: `<span class="text-highlight">${inner}</span>`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.comments) {
|
||||||
|
plugins.push(() => {
|
||||||
|
return (tree: Root, _file) => {
|
||||||
|
findAndReplace(tree, commentRegex, (_value: string, ..._capture: string[]) => {
|
||||||
|
return {
|
||||||
|
type: "text",
|
||||||
|
value: "",
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.callouts) {
|
||||||
|
plugins.push(() => {
|
||||||
|
return (tree: Root, _file) => {
|
||||||
|
visit(tree, "blockquote", (node) => {
|
||||||
|
if (node.children.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// find first line
|
||||||
|
const firstChild = node.children[0]
|
||||||
|
if (firstChild.type !== "paragraph" || firstChild.children[0]?.type !== "text") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = firstChild.children[0].value
|
||||||
|
const restChildren = firstChild.children.slice(1)
|
||||||
|
const [firstLine, ...remainingLines] = text.split("\n")
|
||||||
|
const remainingText = remainingLines.join("\n")
|
||||||
|
|
||||||
|
const match = firstLine.match(calloutRegex)
|
||||||
|
if (match && match.input) {
|
||||||
|
const [calloutDirective, typeString, collapseChar] = match
|
||||||
|
const calloutType = canonicalizeCallout(
|
||||||
|
typeString.toLowerCase() as keyof typeof calloutMapping,
|
||||||
|
)
|
||||||
|
const collapse = collapseChar === "+" || collapseChar === "-"
|
||||||
|
const defaultState = collapseChar === "-" ? "collapsed" : "expanded"
|
||||||
|
const titleContent =
|
||||||
|
match.input.slice(calloutDirective.length).trim() || capitalize(calloutType)
|
||||||
|
const titleNode: Paragraph = {
|
||||||
|
type: "paragraph",
|
||||||
|
children: [{ type: "text", value: titleContent + " " }, ...restChildren],
|
||||||
|
}
|
||||||
|
const title = mdastToHtml(titleNode)
|
||||||
|
|
||||||
|
const toggleIcon = `<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>`
|
||||||
|
|
||||||
|
const titleHtml: HTML = {
|
||||||
|
type: "html",
|
||||||
|
value: `<div
|
||||||
|
class="callout-title"
|
||||||
|
>
|
||||||
|
<div class="callout-icon">${callouts[calloutType]}</div>
|
||||||
|
<div class="callout-title-inner">${title}</div>
|
||||||
|
${collapse ? toggleIcon : ""}
|
||||||
|
</div>`,
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockquoteContent: (BlockContent | DefinitionContent)[] = [titleHtml]
|
||||||
|
if (remainingText.length > 0) {
|
||||||
|
blockquoteContent.push({
|
||||||
|
type: "paragraph",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
value: remainingText,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// replace first line of blockquote with title and rest of the paragraph text
|
||||||
|
node.children.splice(0, 1, ...blockquoteContent)
|
||||||
|
|
||||||
|
// add properties to base blockquote
|
||||||
|
node.data = {
|
||||||
|
hProperties: {
|
||||||
|
...(node.data?.hProperties ?? {}),
|
||||||
|
className: `callout ${collapse ? "is-collapsible" : ""} ${
|
||||||
|
defaultState === "collapsed" ? "is-collapsed" : ""
|
||||||
|
}`,
|
||||||
|
"data-callout": calloutType,
|
||||||
|
"data-callout-fold": collapse,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.mermaid) {
|
||||||
|
plugins.push(() => {
|
||||||
|
return (tree: Root, _file) => {
|
||||||
|
visit(tree, "code", (node: Code) => {
|
||||||
|
if (node.lang === "mermaid") {
|
||||||
|
node.data = {
|
||||||
|
hProperties: {
|
||||||
|
className: ["mermaid"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.parseTags) {
|
||||||
|
plugins.push(() => {
|
||||||
|
return (tree: Root, file) => {
|
||||||
|
const base = pathToRoot(file.data.slug!)
|
||||||
|
findAndReplace(tree, tagRegex, (_value: string, tag: string) => {
|
||||||
|
// Check if the tag only includes numbers
|
||||||
|
if (/^\d+$/.test(tag)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
tag = slugTag(tag)
|
||||||
|
if (file.data.frontmatter && !file.data.frontmatter.tags.includes(tag)) {
|
||||||
|
file.data.frontmatter.tags.push(tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "link",
|
||||||
|
url: base + `/tags/${tag}`,
|
||||||
|
data: {
|
||||||
|
hProperties: {
|
||||||
|
className: ["tag-link"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
value: `#${tag}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return plugins
|
||||||
|
},
|
||||||
|
htmlPlugins() {
|
||||||
|
const plugins = [rehypeRaw]
|
||||||
|
|
||||||
|
if (opts.parseBlockReferences) {
|
||||||
|
plugins.push(() => {
|
||||||
|
const inlineTagTypes = new Set(["p", "li"])
|
||||||
|
const blockTagTypes = new Set(["blockquote"])
|
||||||
|
return (tree, file) => {
|
||||||
|
file.data.blocks = {}
|
||||||
|
|
||||||
|
visit(tree, "element", (node, index, parent) => {
|
||||||
|
if (blockTagTypes.has(node.tagName)) {
|
||||||
|
const nextChild = parent?.children.at(index! + 2) as Element
|
||||||
|
if (nextChild && nextChild.tagName === "p") {
|
||||||
|
const text = nextChild.children.at(0) as Literal
|
||||||
|
if (text && text.value && text.type === "text") {
|
||||||
|
const matches = text.value.match(blockReferenceRegex)
|
||||||
|
if (matches && matches.length >= 1) {
|
||||||
|
parent!.children.splice(index! + 2, 1)
|
||||||
|
const block = matches[0].slice(1)
|
||||||
|
|
||||||
|
if (!Object.keys(file.data.blocks!).includes(block)) {
|
||||||
|
node.properties = {
|
||||||
|
...node.properties,
|
||||||
|
id: block,
|
||||||
|
}
|
||||||
|
file.data.blocks![block] = node
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (inlineTagTypes.has(node.tagName)) {
|
||||||
|
const last = node.children.at(-1) as Literal
|
||||||
|
if (last && last.value && typeof last.value === "string") {
|
||||||
|
const matches = last.value.match(blockReferenceRegex)
|
||||||
|
if (matches && matches.length >= 1) {
|
||||||
|
last.value = last.value.slice(0, -matches[0].length)
|
||||||
|
const block = matches[0].slice(1)
|
||||||
|
|
||||||
|
if (!Object.keys(file.data.blocks!).includes(block)) {
|
||||||
|
node.properties = {
|
||||||
|
...node.properties,
|
||||||
|
id: block,
|
||||||
|
}
|
||||||
|
file.data.blocks![block] = node
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return plugins
|
||||||
|
},
|
||||||
|
externalResources() {
|
||||||
|
const js: JSResource[] = []
|
||||||
|
|
||||||
|
if (opts.callouts) {
|
||||||
|
js.push({
|
||||||
|
script: calloutScript,
|
||||||
|
loadTime: "afterDOMReady",
|
||||||
|
contentType: "inline",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.mermaid) {
|
||||||
|
js.push({
|
||||||
|
script: `
|
||||||
|
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.esm.min.mjs';
|
||||||
|
const darkMode = document.documentElement.getAttribute('saved-theme') === 'dark'
|
||||||
|
mermaid.initialize({
|
||||||
|
startOnLoad: false,
|
||||||
|
securityLevel: 'loose',
|
||||||
|
theme: darkMode ? 'dark' : 'default'
|
||||||
|
});
|
||||||
|
document.addEventListener('nav', async () => {
|
||||||
|
await mermaid.run({
|
||||||
|
querySelector: '.mermaid'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
`,
|
||||||
|
loadTime: "afterDOMReady",
|
||||||
|
moduleType: "module",
|
||||||
|
contentType: "inline",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { js }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "vfile" {
|
||||||
|
interface DataMap {
|
||||||
|
blocks: Record<string, Element>
|
||||||
|
}
|
||||||
|
}
|
108
quartz/plugins/transformers/oxhugofm.ts
Normal file
108
quartz/plugins/transformers/oxhugofm.ts
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import { QuartzTransformerPlugin } from "../types"
|
||||||
|
|
||||||
|
export interface Options {
|
||||||
|
/** Replace {{ relref }} with quartz wikilinks []() */
|
||||||
|
wikilinks: boolean
|
||||||
|
/** Remove pre-defined anchor (see https://ox-hugo.scripter.co/doc/anchors/) */
|
||||||
|
removePredefinedAnchor: boolean
|
||||||
|
/** Remove hugo shortcode syntax */
|
||||||
|
removeHugoShortcode: boolean
|
||||||
|
/** Replace <figure/> with ![]() */
|
||||||
|
replaceFigureWithMdImg: boolean
|
||||||
|
|
||||||
|
/** Replace org latex fragments with $ and $$ */
|
||||||
|
replaceOrgLatex: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultOptions: Options = {
|
||||||
|
wikilinks: true,
|
||||||
|
removePredefinedAnchor: true,
|
||||||
|
removeHugoShortcode: true,
|
||||||
|
replaceFigureWithMdImg: true,
|
||||||
|
replaceOrgLatex: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const relrefRegex = new RegExp(/\[([^\]]+)\]\(\{\{< relref "([^"]+)" >\}\}\)/, "g")
|
||||||
|
const predefinedHeadingIdRegex = new RegExp(/(.*) {#(?:.*)}/, "g")
|
||||||
|
const hugoShortcodeRegex = new RegExp(/{{(.*)}}/, "g")
|
||||||
|
const figureTagRegex = new RegExp(/< ?figure src="(.*)" ?>/, "g")
|
||||||
|
// \\\\\( -> matches \\(
|
||||||
|
// (.+?) -> Lazy match for capturing the equation
|
||||||
|
// \\\\\) -> matches \\)
|
||||||
|
const inlineLatexRegex = new RegExp(/\\\\\((.+?)\\\\\)/, "g")
|
||||||
|
// (?:\\begin{equation}|\\\\\(|\\\\\[) -> start of equation
|
||||||
|
// ([\s\S]*?) -> Matches the block equation
|
||||||
|
// (?:\\\\\]|\\\\\)|\\end{equation}) -> end of equation
|
||||||
|
const blockLatexRegex = new RegExp(
|
||||||
|
/(?:\\begin{equation}|\\\\\(|\\\\\[)([\s\S]*?)(?:\\\\\]|\\\\\)|\\end{equation})/,
|
||||||
|
"g",
|
||||||
|
)
|
||||||
|
// \$\$[\s\S]*?\$\$ -> Matches block equations
|
||||||
|
// \$.*?\$ -> Matches inline equations
|
||||||
|
const quartzLatexRegex = new RegExp(/\$\$[\s\S]*?\$\$|\$.*?\$/, "g")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ox-hugo is an org exporter backend that exports org files to hugo-compatible
|
||||||
|
* markdown in an opinionated way. This plugin adds some tweaks to the generated
|
||||||
|
* markdown to make it compatible with quartz but the list of changes applied it
|
||||||
|
* is not exhaustive.
|
||||||
|
* */
|
||||||
|
export const OxHugoFlavouredMarkdown: QuartzTransformerPlugin<Partial<Options> | undefined> = (
|
||||||
|
userOpts,
|
||||||
|
) => {
|
||||||
|
const opts = { ...defaultOptions, ...userOpts }
|
||||||
|
return {
|
||||||
|
name: "OxHugoFlavouredMarkdown",
|
||||||
|
textTransform(_ctx, src) {
|
||||||
|
if (opts.wikilinks) {
|
||||||
|
src = src.toString()
|
||||||
|
src = src.replaceAll(relrefRegex, (value, ...capture) => {
|
||||||
|
const [text, link] = capture
|
||||||
|
return `[${text}](${link})`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.removePredefinedAnchor) {
|
||||||
|
src = src.toString()
|
||||||
|
src = src.replaceAll(predefinedHeadingIdRegex, (value, ...capture) => {
|
||||||
|
const [headingText] = capture
|
||||||
|
return headingText
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.removeHugoShortcode) {
|
||||||
|
src = src.toString()
|
||||||
|
src = src.replaceAll(hugoShortcodeRegex, (value, ...capture) => {
|
||||||
|
const [scContent] = capture
|
||||||
|
return scContent
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.replaceFigureWithMdImg) {
|
||||||
|
src = src.toString()
|
||||||
|
src = src.replaceAll(figureTagRegex, (value, ...capture) => {
|
||||||
|
const [src] = capture
|
||||||
|
return `![](${src})`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.replaceOrgLatex) {
|
||||||
|
src = src.toString()
|
||||||
|
src = src.replaceAll(inlineLatexRegex, (value, ...capture) => {
|
||||||
|
const [eqn] = capture
|
||||||
|
return `$${eqn}$`
|
||||||
|
})
|
||||||
|
src = src.replaceAll(blockLatexRegex, (value, ...capture) => {
|
||||||
|
const [eqn] = capture
|
||||||
|
return `$$${eqn}$$`
|
||||||
|
})
|
||||||
|
|
||||||
|
// ox-hugo escapes _ as \_
|
||||||
|
src = src.replaceAll(quartzLatexRegex, (value) => {
|
||||||
|
return value.replaceAll("\\_", "_")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return src
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
16
quartz/plugins/transformers/syntax.ts
Normal file
16
quartz/plugins/transformers/syntax.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { QuartzTransformerPlugin } from "../types"
|
||||||
|
import rehypePrettyCode, { Options as CodeOptions } from "rehype-pretty-code"
|
||||||
|
|
||||||
|
export const SyntaxHighlighting: QuartzTransformerPlugin = () => ({
|
||||||
|
name: "SyntaxHighlighting",
|
||||||
|
htmlPlugins() {
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
rehypePrettyCode,
|
||||||
|
{
|
||||||
|
theme: "css-variables",
|
||||||
|
} satisfies Partial<CodeOptions>,
|
||||||
|
],
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
74
quartz/plugins/transformers/toc.ts
Normal file
74
quartz/plugins/transformers/toc.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { QuartzTransformerPlugin } from "../types"
|
||||||
|
import { Root } from "mdast"
|
||||||
|
import { visit } from "unist-util-visit"
|
||||||
|
import { toString } from "mdast-util-to-string"
|
||||||
|
import Slugger from "github-slugger"
|
||||||
|
|
||||||
|
export interface Options {
|
||||||
|
maxDepth: 1 | 2 | 3 | 4 | 5 | 6
|
||||||
|
minEntries: 1
|
||||||
|
showByDefault: boolean
|
||||||
|
collapseByDefault: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultOptions: Options = {
|
||||||
|
maxDepth: 3,
|
||||||
|
minEntries: 1,
|
||||||
|
showByDefault: true,
|
||||||
|
collapseByDefault: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TocEntry {
|
||||||
|
depth: number
|
||||||
|
text: string
|
||||||
|
slug: string // this is just the anchor (#some-slug), not the canonical slug
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TableOfContents: QuartzTransformerPlugin<Partial<Options> | undefined> = (
|
||||||
|
userOpts,
|
||||||
|
) => {
|
||||||
|
const opts = { ...defaultOptions, ...userOpts }
|
||||||
|
return {
|
||||||
|
name: "TableOfContents",
|
||||||
|
markdownPlugins() {
|
||||||
|
return [
|
||||||
|
() => {
|
||||||
|
return async (tree: Root, file) => {
|
||||||
|
const display = file.data.frontmatter?.enableToc ?? opts.showByDefault
|
||||||
|
if (display) {
|
||||||
|
const slugAnchor = new Slugger()
|
||||||
|
const toc: TocEntry[] = []
|
||||||
|
let highestDepth: number = opts.maxDepth
|
||||||
|
visit(tree, "heading", (node) => {
|
||||||
|
if (node.depth <= opts.maxDepth) {
|
||||||
|
const text = toString(node)
|
||||||
|
highestDepth = Math.min(highestDepth, node.depth)
|
||||||
|
toc.push({
|
||||||
|
depth: node.depth,
|
||||||
|
text,
|
||||||
|
slug: slugAnchor.slug(text),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (toc.length > opts.minEntries) {
|
||||||
|
file.data.toc = toc.map((entry) => ({
|
||||||
|
...entry,
|
||||||
|
depth: entry.depth - highestDepth,
|
||||||
|
}))
|
||||||
|
file.data.collapseToc = opts.collapseByDefault
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "vfile" {
|
||||||
|
interface DataMap {
|
||||||
|
toc: TocEntry[]
|
||||||
|
collapseToc: boolean
|
||||||
|
}
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user