run prettier

This commit is contained in:
Jacky Zhao
2023-07-22 17:27:41 -07:00
parent 2034b970b6
commit 7db2eda76c
101 changed files with 1810 additions and 1405 deletions

View File

@ -1,19 +1,19 @@
#!/usr/bin/env node
import { promises, readFileSync } from 'fs'
import yargs from 'yargs'
import path from 'path'
import { hideBin } from 'yargs/helpers'
import esbuild from 'esbuild'
import chalk from 'chalk'
import { sassPlugin } from 'esbuild-sass-plugin'
import fs from 'fs'
import { intro, isCancel, outro, select, text } from '@clack/prompts'
import { rimraf } from 'rimraf'
import prettyBytes from 'pretty-bytes'
import { spawnSync } from 'child_process'
import { promises, readFileSync } from "fs"
import yargs from "yargs"
import path from "path"
import { hideBin } from "yargs/helpers"
import esbuild from "esbuild"
import chalk from "chalk"
import { sassPlugin } from "esbuild-sass-plugin"
import fs from "fs"
import { intro, isCancel, outro, select, text } from "@clack/prompts"
import { rimraf } from "rimraf"
import prettyBytes from "pretty-bytes"
import { spawnSync } from "child_process"
const UPSTREAM_NAME = 'upstream'
const QUARTZ_SOURCE_BRANCH = 'v4-alpha'
const UPSTREAM_NAME = "upstream"
const QUARTZ_SOURCE_BRANCH = "v4-alpha"
const cwd = process.cwd()
const cacheDir = path.join(cwd, ".quartz-cache")
const cacheFile = "./.quartz-cache/transpiled-build.mjs"
@ -24,16 +24,16 @@ const contentCacheFolder = path.join(cacheDir, "content-cache")
const CommonArgv = {
directory: {
string: true,
alias: ['d'],
default: 'content',
describe: 'directory to look for content files'
alias: ["d"],
default: "content",
describe: "directory to look for content files",
},
verbose: {
boolean: true,
alias: ['v'],
alias: ["v"],
default: false,
describe: 'print out extra logging information'
}
describe: "print out extra logging information",
},
}
const SyncArgv = {
@ -41,47 +41,46 @@ const SyncArgv = {
commit: {
boolean: true,
default: true,
describe: 'create a git commit for your unsaved changes'
describe: "create a git commit for your unsaved changes",
},
push: {
boolean: true,
default: true,
describe: 'push updates to your Quartz fork'
describe: "push updates to your Quartz fork",
},
force: {
boolean: true,
alias: ['f'],
alias: ["f"],
default: true,
describe: 'whether to apply the --force flag to git commands'
describe: "whether to apply the --force flag to git commands",
},
pull: {
boolean: true,
default: true,
describe: 'pull updates from your Quartz fork'
}
describe: "pull updates from your Quartz fork",
},
}
const BuildArgv = {
...CommonArgv,
output: {
string: true,
alias: ['o'],
default: 'public',
describe: 'output folder for files'
alias: ["o"],
default: "public",
describe: "output folder for files",
},
serve: {
boolean: true,
default: false,
describe: 'run a local server to live-preview your Quartz'
describe: "run a local server to live-preview your Quartz",
},
port: {
number: true,
default: 8080,
describe: 'port to serve Quartz on'
describe: "port to serve Quartz on",
},
}
function escapePath(fp) {
return fp
.replace(/\\ /g, " ") // unescape spaces
@ -91,7 +90,6 @@ function escapePath(fp) {
}
function exitIfCancel(val) {
if (isCancel(val)) {
outro(chalk.red("Exiting"))
process.exit(0)
@ -101,32 +99,48 @@ function exitIfCancel(val) {
}
async function stashContentFolder(contentFolder) {
await fs.promises.cp(contentFolder, contentCacheFolder, { force: true, recursive: true, verbatimSymlinks: true, preserveTimestamps: true })
await fs.promises.cp(contentFolder, contentCacheFolder, {
force: true,
recursive: true,
verbatimSymlinks: true,
preserveTimestamps: true,
})
await fs.promises.rm(contentFolder, { force: true, recursive: true })
}
async function popContentFolder(contentFolder) {
await fs.promises.cp(contentCacheFolder, contentFolder, { force: true, recursive: true, verbatimSymlinks: true, preserveTimestamps: true })
await fs.promises.cp(contentCacheFolder, contentFolder, {
force: true,
recursive: true,
verbatimSymlinks: true,
preserveTimestamps: true,
})
await fs.promises.rm(contentCacheFolder, { force: true, recursive: true })
}
yargs(hideBin(process.argv))
.scriptName("quartz")
.version(version)
.usage('$0 <cmd> [args]')
.command('create', 'Initialize Quartz', CommonArgv, async argv => {
.usage("$0 <cmd> [args]")
.command("create", "Initialize Quartz", CommonArgv, async (argv) => {
console.log()
intro(chalk.bgGreen.black(` Quartz v${version} `))
const contentFolder = path.join(cwd, argv.directory)
const setupStrategy = exitIfCancel(await select({
message: `Choose how to initialize the content in \`${contentFolder}\``,
options: [
{ value: 'new', label: "Empty Quartz" },
{ value: 'copy', label: "Replace with 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!" },
{ value: 'keep', label: "Keep the existing files" },
]
}))
const setupStrategy = exitIfCancel(
await select({
message: `Choose how to initialize the content in \`${contentFolder}\``,
options: [
{ value: "new", label: "Empty Quartz" },
{ value: "copy", label: "Replace with 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!",
},
{ value: "keep", label: "Keep the existing files" },
],
}),
)
async function rmContentFolder() {
const contentStat = await fs.promises.lstat(contentFolder)
@ -139,54 +153,77 @@ yargs(hideBin(process.argv))
}
}
if (setupStrategy === 'copy' || setupStrategy === 'symlink') {
const 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"
}
}
})))
if (setupStrategy === "copy" || setupStrategy === "symlink") {
const 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') {
if (setupStrategy === "copy") {
await fs.promises.cp(originalFolder, contentFolder, { recursive: true })
} else if (setupStrategy === 'symlink') {
await fs.promises.symlink(originalFolder, contentFolder, 'dir')
} else if (setupStrategy === "symlink") {
await fs.promises.symlink(originalFolder, contentFolder, "dir")
}
} else if (setupStrategy === 'new') {
} else if (setupStrategy === "new") {
await rmContentFolder()
await fs.promises.mkdir(contentFolder)
await fs.promises.writeFile(path.join(contentFolder, "index.md"),
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.
`
`,
)
}
// get a prefered link resolution strategy
const 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" },
]
}))
const 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}'`)
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:
@ -195,105 +232,120 @@ See the [documentation](https://quartz.jzhao.xyz) for how to get started.
• Hosting your Quartz online (see: https://quartz.jzhao.xyz/setup/hosting)
`)
})
.command('update', 'Get the latest Quartz updates', CommonArgv, async argv => {
.command("update", "Get the latest Quartz updates", CommonArgv, async (argv) => {
const contentFolder = path.join(cwd, argv.directory)
console.log(chalk.bgGreen.black(`\n Quartz v${version} \n`))
console.log('Backing up your content')
console.log("Backing up your content")
await stashContentFolder(contentFolder)
console.log("Pulling updates... you may need to resolve some `git` conflicts if you've made changes to components or plugins.")
spawnSync('git', ['pull', UPSTREAM_NAME, QUARTZ_SOURCE_BRANCH], { stdio: 'inherit' })
console.log(
"Pulling updates... you may need to resolve some `git` conflicts if you've made changes to components or plugins.",
)
spawnSync("git", ["pull", UPSTREAM_NAME, QUARTZ_SOURCE_BRANCH], { stdio: "inherit" })
await popContentFolder(contentFolder)
console.log(chalk.green('Done!'))
console.log(chalk.green("Done!"))
})
.command('sync', 'Sync your Quartz to and from GitHub.', SyncArgv, async argv => {
.command("sync", "Sync your Quartz to and from GitHub.", SyncArgv, async (argv) => {
const contentFolder = path.join(cwd, argv.directory)
console.log(chalk.bgGreen.black(`\n Quartz v${version} \n`))
console.log('Backing up your content')
console.log("Backing up your content")
if (argv.commit) {
const currentTimestamp = new Date().toLocaleString('en-US', { dateStyle: "medium", timeStyle: "short" })
spawnSync('git', ['commit', '-am', `Quartz sync: ${currentTimestamp}`], { stdio: 'inherit' })
const currentTimestamp = new Date().toLocaleString("en-US", {
dateStyle: "medium",
timeStyle: "short",
})
spawnSync("git", ["commit", "-am", `Quartz sync: ${currentTimestamp}`], { stdio: "inherit" })
}
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.")
spawnSync('git', ['pull', 'origin', QUARTZ_SOURCE_BRANCH], { stdio: 'inherit' })
console.log(
"Pulling updates from your repository. You may need to resolve some `git` conflicts if you've made changes to components or plugins.",
)
spawnSync("git", ["pull", "origin", QUARTZ_SOURCE_BRANCH], { stdio: "inherit" })
}
await popContentFolder(contentFolder)
if (argv.push) {
console.log("Pushing your changes")
const args = argv.force ?
['push', '-f', 'origin', QUARTZ_SOURCE_BRANCH] :
['push', 'origin', QUARTZ_SOURCE_BRANCH]
spawnSync('git', args, { stdio: 'inherit' })
const args = argv.force
? ["push", "-f", "origin", QUARTZ_SOURCE_BRANCH]
: ["push", "origin", QUARTZ_SOURCE_BRANCH]
spawnSync("git", args, { stdio: "inherit" })
}
console.log(chalk.green('Done!'))
console.log(chalk.green("Done!"))
})
.command('build', 'Build Quartz into a bundle of static HTML files', BuildArgv, async argv => {
const result = await esbuild.build({
entryPoints: [fp],
outfile: path.join("quartz", cacheFile),
bundle: true,
keepNames: true,
platform: "node",
format: "esm",
jsx: "automatic",
jsxImportSource: "preact",
packages: "external",
metafile: true,
sourcemap: true,
plugins: [
sassPlugin({
type: 'css-text',
}),
{
name: 'inline-script-loader',
setup(build) {
build.onLoad({ filter: /\.inline\.(ts|js)$/ }, async (args) => {
let text = await promises.readFile(args.path, 'utf8')
.command("build", "Build Quartz into a bundle of static HTML files", BuildArgv, async (argv) => {
const result = await esbuild
.build({
entryPoints: [fp],
outfile: path.join("quartz", cacheFile),
bundle: true,
keepNames: true,
platform: "node",
format: "esm",
jsx: "automatic",
jsxImportSource: "preact",
packages: "external",
metafile: true,
sourcemap: true,
plugins: [
sassPlugin({
type: "css-text",
}),
{
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', '')
// 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 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 rawMod = transpiled.outputFiles[0].text
return {
contents: rawMod,
loader: 'text',
}
})
}
}
]
}).catch(err => {
console.error(`${chalk.red("Couldn't parse Quartz configuration:")} ${fp}`)
console.log(`Reason: ${chalk.grey(err)}`)
console.log("hint: make sure all the required dependencies are installed (run `npm install`)")
process.exit(1)
})
},
},
],
})
.catch((err) => {
console.error(`${chalk.red("Couldn't parse Quartz configuration:")} ${fp}`)
console.log(`Reason: ${chalk.grey(err)}`)
console.log(
"hint: make sure all the required dependencies are installed (run `npm install`)",
)
process.exit(1)
})
if (argv.verbose) {
const outputFileName = 'quartz/.quartz-cache/transpiled-build.mjs'
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(
`Successfully transpiled ${Object.keys(meta.inputs).length} files (${prettyBytes(
meta.bytes,
)})`,
)
}
const { default: buildQuartz } = await import(cacheFile)
@ -302,5 +354,4 @@ See the [documentation](https://quartz.jzhao.xyz) for how to get started.
.showHelpOnFail(false)
.help()
.strict()
.demandCommand()
.argv
.demandCommand().argv

View File

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

View File

@ -1,4 +1,4 @@
import 'source-map-support/register.js'
import "source-map-support/register.js"
import path from "path"
import { PerfTimer } from "./perf"
import { rimraf } from "rimraf"
@ -12,8 +12,8 @@ import { emitContent } from "./processors/emit"
import cfg from "../quartz.config"
import { FilePath } from "./path"
import chokidar from "chokidar"
import { ProcessedContent } from './plugins/vfile'
import WebSocket, { WebSocketServer } from 'ws'
import { ProcessedContent } from "./plugins/vfile"
import WebSocket, { WebSocketServer } from "ws"
interface Argv {
directory: string
@ -29,30 +29,38 @@ export default async function buildQuartz(argv: Argv, version: string) {
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)
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(", ")}`)
console.log(` Transformers: ${pluginNames("transformers").join(", ")}`)
console.log(` Filters: ${pluginNames("filters").join(", ")}`)
console.log(` Emitters: ${pluginNames("emitters").join(", ")}`)
}
// clean
perf.addEvent('clean')
perf.addEvent("clean")
await rimraf(output)
console.log(`Cleaned output directory \`${output}\` in ${perf.timeSince('clean')}`)
console.log(`Cleaned output directory \`${output}\` in ${perf.timeSince("clean")}`)
// glob
perf.addEvent('glob')
const fps = await globby('**/*.md', {
perf.addEvent("glob")
const fps = await globby("**/*.md", {
cwd: argv.directory,
ignore: cfg.configuration.ignorePatterns,
gitignore: true,
})
console.log(`Found ${fps.length} input files from \`${argv.directory}\` in ${perf.timeSince('glob')}`)
console.log(
`Found ${fps.length} input files from \`${argv.directory}\` in ${perf.timeSince("glob")}`,
)
const filePaths = fps.map(fp => `${argv.directory}${path.sep}${fp}` as FilePath)
const parsedFiles = await parseMarkdown(cfg.plugins.transformers, argv.directory, filePaths, argv.verbose)
const filePaths = fps.map((fp) => `${argv.directory}${path.sep}${fp}` as FilePath)
const parsedFiles = await parseMarkdown(
cfg.plugins.transformers,
argv.directory,
filePaths,
argv.verbose,
)
const filteredContent = filterContent(cfg.plugins.filters, parsedFiles, argv.verbose)
await emitContent(argv.directory, output, cfg, filteredContent, argv.serve, argv.verbose)
console.log(chalk.green(`Done processing ${fps.length} files in ${perf.timeSince()}`))
@ -60,7 +68,7 @@ export default async function buildQuartz(argv: Argv, version: string) {
if (argv.serve) {
const wss = new WebSocketServer({ port: 3001 })
const connections: WebSocket[] = []
wss.on('connection', ws => connections.push(ws))
wss.on("connection", (ws) => connections.push(ws))
const ignored = await isGitIgnored()
const contentMap = new Map<FilePath, ProcessedContent>()
@ -69,15 +77,20 @@ export default async function buildQuartz(argv: Argv, version: string) {
contentMap.set(vfile.data.filePath!, content)
}
async function rebuild(fp: string, action: 'add' | 'change' | 'unlink') {
perf.addEvent('rebuild')
async function rebuild(fp: string, action: "add" | "change" | "unlink") {
perf.addEvent("rebuild")
if (!ignored(fp)) {
console.log(chalk.yellow(`Detected change in ${fp}, rebuilding...`))
const fullPath = `${argv.directory}${path.sep}${fp}` as FilePath
if (action === 'add' || action === 'change') {
const [parsedContent] = await parseMarkdown(cfg.plugins.transformers, argv.directory, [fullPath], argv.verbose)
if (action === "add" || action === "change") {
const [parsedContent] = await parseMarkdown(
cfg.plugins.transformers,
argv.directory,
[fullPath],
argv.verbose,
)
contentMap.set(fullPath, parsedContent)
} else if (action === 'unlink') {
} else if (action === "unlink") {
contentMap.delete(fullPath)
}
@ -85,21 +98,21 @@ export default async function buildQuartz(argv: Argv, version: string) {
const parsedFiles = [...contentMap.values()]
const filteredContent = filterContent(cfg.plugins.filters, parsedFiles, argv.verbose)
await emitContent(argv.directory, output, cfg, filteredContent, argv.serve, argv.verbose)
console.log(chalk.green(`Done rebuilding in ${perf.timeSince('rebuild')}`))
connections.forEach(conn => conn.send('rebuild'))
console.log(chalk.green(`Done rebuilding in ${perf.timeSince("rebuild")}`))
connections.forEach((conn) => conn.send("rebuild"))
}
}
const watcher = chokidar.watch('.', {
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, 'unlink'))
.on("add", (fp) => rebuild(fp, "add"))
.on("change", (fp) => rebuild(fp, "change"))
.on("unlink", (fp) => rebuild(fp, "unlink"))
const server = http.createServer(async (req, res) => {
await serveHandler(req, res, {
@ -107,15 +120,16 @@ export default async function buildQuartz(argv: Argv, version: string) {
directoryListing: false,
})
const status = res.statusCode
const statusString = (status >= 200 && status < 300) ?
chalk.green(`[${status}]`) :
(status >= 300 && status < 400) ?
chalk.yellow(`[${status}]`) :
chalk.red(`[${status}]`)
const statusString =
status >= 200 && status < 300
? chalk.green(`[${status}]`)
: status >= 300 && status < 400
? chalk.yellow(`[${status}]`)
: chalk.red(`[${status}]`)
console.log(statusString + chalk.grey(` ${req.url}`))
})
server.listen(argv.port)
console.log(chalk.cyan(`Started a Quartz server listening at http://localhost:${argv.port}`))
console.log('hint: exit with ctrl+c')
console.log("hint: exit with ctrl+c")
}
}

View File

@ -5,43 +5,43 @@ import { Theme } from "./theme"
export type Analytics =
| null
| {
provider: 'plausible'
}
provider: "plausible"
}
| {
provider: 'google',
tagId: string
}
provider: "google"
tagId: string
}
export interface GlobalConfiguration {
pageTitle: string,
pageTitle: string
/** Whether to enable single-page-app style rendering. this prevents flashes of unstyled content and improves smoothness of Quartz */
enableSPA: boolean,
enableSPA: boolean
/** Whether to display Wikipedia-style popovers when hovering over links */
enablePopovers: boolean,
enablePopovers: boolean
/** Analytics mode */
analytics: Analytics
/** Glob patterns to not search */
ignorePatterns: string[],
ignorePatterns: string[]
/** Base URL to use for CNAME files, sitemaps, and RSS feeds that require an absolute URL.
* Quartz will avoid using this as much as possible and use relative URLs most of the time
*/
baseUrl?: string,
* 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,
configuration: GlobalConfiguration
plugins: PluginTypes
}
export interface FullPageLayout {
head: QuartzComponent
header: QuartzComponent[],
beforeBody: QuartzComponent[],
pageBody: QuartzComponent,
left: QuartzComponent[],
right: QuartzComponent[],
footer: QuartzComponent,
header: QuartzComponent[]
beforeBody: QuartzComponent[]
pageBody: QuartzComponent
left: QuartzComponent[]
right: QuartzComponent[]
footer: QuartzComponent
}
export type PageLayout = Pick<FullPageLayout, "beforeBody" | "left" | "right">

View File

@ -4,15 +4,25 @@ import { canonicalizeServer, resolveRelative } from "../path"
function Backlinks({ fileData, allFiles }: QuartzComponentProps) {
const slug = canonicalizeServer(fileData.slug!)
const backlinkFiles = allFiles.filter(file => file.links?.includes(slug))
return <div class="backlinks">
<h3>Backlinks</h3>
<ul class="overflow">
{backlinkFiles.length > 0 ?
backlinkFiles.map(f => <li><a href={resolveRelative(slug, canonicalizeServer(f.slug!))} class="internal">{f.frontmatter?.title}</a></li>)
: <li>No backlinks found</li>}
</ul>
</div>
const backlinkFiles = allFiles.filter((file) => file.links?.includes(slug))
return (
<div class="backlinks">
<h3>Backlinks</h3>
<ul class="overflow">
{backlinkFiles.length > 0 ? (
backlinkFiles.map((f) => (
<li>
<a href={resolveRelative(slug, canonicalizeServer(f.slug!))} class="internal">
{f.frontmatter?.title}
</a>
</li>
))
) : (
<li>No backlinks found</li>
)}
</ul>
</div>
)
}
Backlinks.css = style

View File

@ -1,16 +1,13 @@
// @ts-ignore
import clipboardScript from './scripts/clipboard.inline'
import clipboardStyle from './styles/clipboard.scss'
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>
return <div id="quartz-body">{children}</div>
}
Body.afterDOMLoaded = clipboardScript
Body.css = clipboardStyle
export default (() => Body) satisfies QuartzComponentConstructor

View File

@ -1,50 +1,48 @@
// @ts-ignore: this is safe, we don't want to actually make darkmode.inline.ts a module as
// @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 styles from "./styles/darkmode.scss"
import { QuartzComponentConstructor } from "./types"
function Darkmode() {
return <div class="darkmode">
<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>
return (
<div class="darkmode">
<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

View File

@ -3,10 +3,10 @@ interface Props {
}
export function Date({ date }: Props) {
const formattedDate = date.toLocaleDateString('en-US', {
const formattedDate = date.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: '2-digit'
day: "2-digit",
})
return <>{formattedDate}</>
}

View File

@ -1,6 +1,6 @@
import { QuartzComponentConstructor } from "./types"
import style from "./styles/footer.scss"
import {version} from "../../package.json"
import { version } from "../../package.json"
interface Options {
links: Record<string, string>
@ -10,13 +10,21 @@ export default ((opts?: Options) => {
function Footer() {
const year = new Date().getFullYear()
const links = opts?.links ?? []
return <footer>
<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>
return (
<footer>
<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

View File

@ -4,19 +4,19 @@ 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,
drag: boolean
zoom: boolean
depth: number
scale: number
repelForce: number
centerForce: number
linkDistance: number
fontSize: number
opacityScale: number
}
interface GraphOptions {
localGraph: Partial<D3Config> | undefined,
localGraph: Partial<D3Config> | undefined
globalGraph: Partial<D3Config> | undefined
}
@ -30,7 +30,7 @@ const defaultOptions: GraphOptions = {
centerForce: 0.3,
linkDistance: 30,
fontSize: 0.6,
opacityScale: 1
opacityScale: 1,
},
globalGraph: {
drag: true,
@ -41,21 +41,32 @@ const defaultOptions: GraphOptions = {
centerForce: 0.3,
linkDistance: 30,
fontSize: 0.6,
opacityScale: 1
}
opacityScale: 1,
},
}
export default ((opts?: GraphOptions) => {
function Graph() {
const localGraph = { ...opts?.localGraph, ...defaultOptions.localGraph }
const globalGraph = { ...opts?.globalGraph, ...defaultOptions.globalGraph }
return <div class="graph">
<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
return (
<div class="graph">
<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
@ -65,13 +76,15 @@ export default ((opts?: GraphOptions) => {
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>
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>
<div id="global-graph-outer">
<div id="global-graph-container" data-cfg={JSON.stringify(globalGraph)}></div>
</div>
</div>
)
}
Graph.css = style

View File

@ -12,23 +12,29 @@ export default (() => {
const iconPath = baseDir + "/static/icon.png"
const ogImagePath = baseDir + "/static/og-image.png"
return <head>
<title>{title}</title>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta property="og:title" content={title} />
<meta property="og:description" content={title} />
<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>
<title>{title}</title>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta property="og:title" content={title} />
<meta property="og:description" content={title} />
<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

View File

@ -1,9 +1,7 @@
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
function Header({ children }: QuartzComponentProps) {
return (children.length > 0) ? <header>
{children}
</header> : null
return children.length > 0 ? <header>{children}</header> : null
}
Header.css = `

View File

@ -17,32 +17,51 @@ function byDateAndAlphabetical(f1: QuartzPluginData, f2: QuartzPluginData): numb
// otherwise, sort lexographically by title
const f1Title = f1.frontmatter?.title.toLowerCase() ?? ""
const f2Title = f2.frontmatter?.title.toLowerCase() ?? ""
return f1Title.localeCompare(f2Title)
return f1Title.localeCompare(f2Title)
}
export function PageList({ fileData, allFiles }: QuartzComponentProps) {
const slug = canonicalizeServer(fileData.slug!)
return <ul class="section-ul">
{allFiles.sort(byDateAndAlphabetical).map(page => {
const title = page.frontmatter?.title
const pageSlug = canonicalizeServer(page.slug!)
const tags = page.frontmatter?.tags ?? []
return (
<ul class="section-ul">
{allFiles.sort(byDateAndAlphabetical).map((page) => {
const title = page.frontmatter?.title
const pageSlug = canonicalizeServer(page.slug!)
const tags = page.frontmatter?.tags ?? []
return <li class="section-li">
<div class="section">
{page.dates && <p class="meta">
<Date date={page.dates.modified} />
</p>}
<div class="desc">
<h3><a href={resolveRelative(slug, pageSlug)} class="internal">{title}</a></h3>
</div>
<ul class="tags">
{tags.map(tag => <li><a class="internal" href={resolveRelative(slug, `tags/${tag}` as CanonicalSlug)}>#{tag}</a></li>)}
</ul>
</div>
</li>
})}
</ul>
return (
<li class="section-li">
<div class="section">
{page.dates && (
<p class="meta">
<Date date={page.dates.modified} />
</p>
)}
<div class="desc">
<h3>
<a href={resolveRelative(slug, pageSlug)} class="internal">
{title}
</a>
</h3>
</div>
<ul class="tags">
{tags.map((tag) => (
<li>
<a
class="internal"
href={resolveRelative(slug, `tags/${tag}` as CanonicalSlug)}
>
#{tag}
</a>
</li>
))}
</ul>
</div>
</li>
)
})}
</ul>
)
}
PageList.css = `

View File

@ -5,7 +5,11 @@ function PageTitle({ fileData, cfg }: QuartzComponentProps) {
const title = cfg?.pageTitle ?? "Untitled Quartz"
const slug = canonicalizeServer(fileData.slug!)
const baseDir = pathToRoot(slug)
return <h1 class="page-title"><a href={baseDir}>{title}</a></h1>
return (
<h1 class="page-title">
<a href={baseDir}>{title}</a>
</h1>
)
}
PageTitle.css = `

View File

@ -5,7 +5,11 @@ function ReadingTime({ fileData }: QuartzComponentProps) {
const text = fileData.text
if (text) {
const { text: timeTaken, words } = readingTime(text)
return <p class="reading-time">{words} words, {timeTaken}</p>
return (
<p class="reading-time">
{words} words, {timeTaken}
</p>
)
} else {
return null
}

View File

@ -5,27 +5,41 @@ import script from "./scripts/search.inline"
export default (() => {
function Search() {
return <div class="search">
<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">
return (
<div class="search">
<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>
</div>
)
}
Search.afterDOMLoaded = script

View File

@ -6,11 +6,11 @@ import modernStyle from "./styles/toc.scss"
import script from "./scripts/toc.inline"
interface Options {
layout: 'modern' | 'legacy'
layout: "modern" | "legacy"
}
const defaultOptions: Options = {
layout: 'modern'
layout: "modern",
}
function TableOfContents({ fileData }: QuartzComponentProps) {
@ -18,21 +18,38 @@ function TableOfContents({ fileData }: QuartzComponentProps) {
return null
}
return <div class="desktop-only">
<button type="button" id="toc">
<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>
return (
<div class="desktop-only">
<button type="button" id="toc">
<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>
</div>
)
}
TableOfContents.css = modernStyle
TableOfContents.afterDOMLoaded = script
@ -42,16 +59,22 @@ function LegacyTableOfContents({ fileData }: QuartzComponentProps) {
return null
}
return <details id="toc" open>
<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>
return (
<details id="toc" open>
<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

View File

@ -1,19 +1,27 @@
import { canonicalizeServer, pathToRoot } from "../path"
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
import { slug as slugAnchor } from 'github-slugger'
import { slug as slugAnchor } from "github-slugger"
function TagList({ fileData }: QuartzComponentProps) {
const tags = fileData.frontmatter?.tags
const slug = canonicalizeServer(fileData.slug!)
const baseDir = pathToRoot(slug)
if (tags && tags.length > 0) {
return <ul class="tags">{tags.map(tag => {
const display = `#${tag}`
const linkDest = baseDir + `/tags/${slugAnchor(tag)}`
return <li>
<a href={linkDest} class="internal">{display}</a>
</li>
})}</ul>
return (
<ul class="tags">
{tags.map((tag) => {
const display = `#${tag}`
const linkDest = baseDir + `/tags/${slugAnchor(tag)}`
return (
<li>
<a href={linkDest} class="internal">
{display}
</a>
</li>
)
})}
</ul>
)
} else {
return null
}

View File

@ -9,7 +9,7 @@ import ReadingTime from "./ReadingTime"
import Spacer from "./Spacer"
import TableOfContents from "./TableOfContents"
import TagList from "./TagList"
import Graph from "./Graph"
import Graph from "./Graph"
import Backlinks from "./Backlinks"
import Search from "./Search"
import Footer from "./Footer"
@ -33,5 +33,5 @@ export {
Search,
Footer,
DesktopOnly,
MobileOnly
}
MobileOnly,
}

View File

@ -1,10 +1,10 @@
import { QuartzComponentConstructor, QuartzComponentProps } from "../types"
import { Fragment, jsx, jsxs } from 'preact/jsx-runtime'
import { Fragment, jsx, jsxs } from "preact/jsx-runtime"
import { toJsxRuntime } from "hast-util-to-jsx-runtime"
function Content({ tree }: QuartzComponentProps) {
// @ts-ignore (preact makes it angry)
const content = toJsxRuntime(tree, { Fragment, jsx, jsxs, elementAttributeNameCase: 'html' })
const content = toJsxRuntime(tree, { Fragment, jsx, jsxs, elementAttributeNameCase: "html" })
return <article class="popover-hint">{content}</article>
}

View File

@ -1,16 +1,16 @@
import { QuartzComponentConstructor, QuartzComponentProps } from "../types"
import { Fragment, jsx, jsxs } from 'preact/jsx-runtime'
import { Fragment, jsx, jsxs } from "preact/jsx-runtime"
import { toJsxRuntime } from "hast-util-to-jsx-runtime"
import path from "path"
import style from '../styles/listPage.scss'
import style from "../styles/listPage.scss"
import { PageList } from "../PageList"
import { canonicalizeServer } from "../../path"
function FolderContent(props: QuartzComponentProps) {
const { tree, fileData, allFiles } = props
const folderSlug = canonicalizeServer(fileData.slug!)
const allPagesInFolder = allFiles.filter(file => {
const allPagesInFolder = allFiles.filter((file) => {
const fileSlug = file.slug ?? ""
const prefixed = fileSlug.startsWith(folderSlug)
const folderParts = folderSlug.split(path.posix.sep)
@ -21,18 +21,20 @@ function FolderContent(props: QuartzComponentProps) {
const listProps = {
...props,
allFiles: allPagesInFolder
allFiles: allPagesInFolder,
}
// @ts-ignore
const content = toJsxRuntime(tree, { Fragment, jsx, jsxs, elementAttributeNameCase: 'html' })
return <div class="popover-hint">
<article>{content}</article>
<p>{allPagesInFolder.length} items under this folder.</p>
<div>
<PageList {...listProps} />
const content = toJsxRuntime(tree, { Fragment, jsx, jsxs, elementAttributeNameCase: "html" })
return (
<div class="popover-hint">
<article>{content}</article>
<p>{allPagesInFolder.length} items under this folder.</p>
<div>
<PageList {...listProps} />
</div>
</div>
</div>
)
}
FolderContent.css = style + PageList.css

View File

@ -1,7 +1,7 @@
import { QuartzComponentConstructor, QuartzComponentProps } from "../types"
import { Fragment, jsx, jsxs } from 'preact/jsx-runtime'
import { Fragment, jsx, jsxs } from "preact/jsx-runtime"
import { toJsxRuntime } from "hast-util-to-jsx-runtime"
import style from '../styles/listPage.scss'
import style from "../styles/listPage.scss"
import { PageList } from "../PageList"
import { ServerSlug, canonicalizeServer } from "../../path"
@ -11,21 +11,23 @@ function TagContent(props: QuartzComponentProps) {
if (slug?.startsWith("tags/")) {
const tag = canonicalizeServer(slug.slice("tags/".length) as ServerSlug)
const allPagesWithTag = allFiles.filter(file => (file.frontmatter?.tags ?? []).includes(tag))
const allPagesWithTag = allFiles.filter((file) => (file.frontmatter?.tags ?? []).includes(tag))
const listProps = {
...props,
allFiles: allPagesWithTag
allFiles: allPagesWithTag,
}
// @ts-ignore
const content = toJsxRuntime(tree, { Fragment, jsx, jsxs, elementAttributeNameCase: 'html' })
return <div class="popover-hint">
<article>{content}</article>
<p>{allPagesWithTag.length} items with this tag.</p>
<div>
<PageList {...listProps} />
const content = toJsxRuntime(tree, { Fragment, jsx, jsxs, elementAttributeNameCase: "html" })
return (
<div class="popover-hint">
<article>{content}</article>
<p>{allPagesWithTag.length} items with this tag.</p>
<div>
<PageList {...listProps} />
</div>
</div>
</div>
)
} else {
throw new Error(`Component "TagContent" tried to render a non-tag page: ${slug}`)
}

View File

@ -1,21 +1,24 @@
import { render } from "preact-render-to-string";
import { QuartzComponent, QuartzComponentProps } from "./types";
import { render } from "preact-render-to-string"
import { QuartzComponent, QuartzComponentProps } from "./types"
import HeaderConstructor from "./Header"
import BodyConstructor from "./Body"
import { JSResourceToScriptElement, StaticResources } from "../resources";
import { CanonicalSlug, pathToRoot } from "../path";
import { JSResourceToScriptElement, StaticResources } from "../resources"
import { CanonicalSlug, pathToRoot } from "../path"
interface RenderComponents {
head: QuartzComponent
header: QuartzComponent[],
beforeBody: QuartzComponent[],
pageBody: QuartzComponent,
left: QuartzComponent[],
right: QuartzComponent[],
footer: QuartzComponent,
header: QuartzComponent[]
beforeBody: QuartzComponent[]
pageBody: QuartzComponent
left: QuartzComponent[]
right: QuartzComponent[]
footer: QuartzComponent
}
export function pageResources(slug: CanonicalSlug, staticResources: StaticResources): StaticResources {
export function pageResources(
slug: CanonicalSlug,
staticResources: StaticResources,
): StaticResources {
const baseDir = pathToRoot(slug)
const contentIndexPath = baseDir + "/static/contentIndex.json"
@ -25,52 +28,89 @@ export function pageResources(slug: CanonicalSlug, staticResources: StaticResour
css: [baseDir + "/index.css", ...staticResources.css],
js: [
{ src: baseDir + "/prescript.js", loadTime: "beforeDOMReady", contentType: "external" },
{ loadTime: "beforeDOMReady", contentType: "inline", spaPreserve: true, script: contentIndexScript },
{
loadTime: "beforeDOMReady",
contentType: "inline",
spaPreserve: true,
script: contentIndexScript,
},
...staticResources.js,
{ src: baseDir + "/postscript.js", loadTime: "afterDOMReady", moduleType: 'module', contentType: "external" }
]
{
src: baseDir + "/postscript.js",
loadTime: "afterDOMReady",
moduleType: "module",
contentType: "external",
},
],
}
}
export function renderPage(slug: CanonicalSlug, componentData: QuartzComponentProps, components: RenderComponents, pageResources: StaticResources): string {
const { head: Head, header, beforeBody, pageBody: Content, left, right, footer: Footer } = components
export function renderPage(
slug: CanonicalSlug,
componentData: QuartzComponentProps,
components: RenderComponents,
pageResources: StaticResources,
): string {
const {
head: Head,
header,
beforeBody,
pageBody: Content,
left,
right,
footer: Footer,
} = components
const Header = HeaderConstructor()
const Body = BodyConstructor()
const LeftComponent =
const LeftComponent = (
<div class="left sidebar">
{left.map(BodyComponent => <BodyComponent {...componentData} />)}
{left.map((BodyComponent) => (
<BodyComponent {...componentData} />
))}
</div>
)
const RightComponent =
const RightComponent = (
<div class="right sidebar">
{right.map(BodyComponent => <BodyComponent {...componentData} />)}
{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} />)}
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>
<Content {...componentData} />
</div>
{RightComponent}
</Body>
<Footer {...componentData} />
</div>
</body>
{pageResources.js.filter(resource => resource.loadTime === "afterDOMReady").map(res => JSResourceToScriptElement(res))}
</html>
{RightComponent}
</Body>
<Footer {...componentData} />
</div>
</body>
{pageResources.js
.filter((resource) => resource.loadTime === "afterDOMReady")
.map((res) => JSResourceToScriptElement(res))}
</html>
)
return "<!DOCTYPE html>\n" + render(doc)
}

View File

@ -7,7 +7,9 @@ function toggleCallout(this: HTMLElement) {
}
function setupCallout() {
const collapsible = document.getElementsByClassName(`callout is-collapsible`) as HTMLCollectionOf<HTMLElement>
const collapsible = document.getElementsByClassName(
`callout is-collapsible`,
) as HTMLCollectionOf<HTMLElement>
for (const div of collapsible) {
const title = div.firstElementChild

View File

@ -1,24 +1,23 @@
const userPref = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'
const currentTheme = localStorage.getItem('theme') ?? userPref
document.documentElement.setAttribute('saved-theme', currentTheme)
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')
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') {
const toggleSwitch = document.querySelector("#darkmode-toggle") as HTMLInputElement
toggleSwitch.removeEventListener("change", switchTheme)
toggleSwitch.addEventListener("change", switchTheme)
if (currentTheme === "dark") {
toggleSwitch.checked = true
}
})

View File

@ -1,16 +1,16 @@
import { ContentDetails } from "../../plugins/emitters/contentIndex"
import * as d3 from 'd3'
import * as d3 from "d3"
import { registerEscapeHandler, removeAllChildren } from "./util"
import { CanonicalSlug, getCanonicalSlug, getClientSlug, resolveRelative } from "../../path"
type NodeData = {
id: CanonicalSlug,
text: string,
id: CanonicalSlug
text: string
tags: string[]
} & d3.SimulationNodeDatum
type LinkData = {
source: CanonicalSlug,
source: CanonicalSlug
target: CanonicalSlug
}
@ -40,7 +40,7 @@ async function renderGraph(container: string, slug: CanonicalSlug) {
centerForce,
linkDistance,
fontSize,
opacityScale
opacityScale,
} = JSON.parse(graph.dataset["cfg"]!)
const data = await fetchData
@ -66,18 +66,22 @@ async function renderGraph(container: string, slug: CanonicalSlug) {
wl.push("__SENTINEL")
} else {
neighbourhood.add(cur)
const outgoing = links.filter(l => l.source === cur)
const incoming = links.filter(l => l.target === 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(id as CanonicalSlug))
Object.keys(data).forEach((id) => neighbourhood.add(id as CanonicalSlug))
}
const graphData: { nodes: NodeData[], links: LinkData[] } = {
nodes: [...neighbourhood].map(url => ({ id: url, text: data[url]?.title ?? url, tags: data[url]?.tags ?? [] })),
links: links.filter((l) => neighbourhood.has(l.source) && neighbourhood.has(l.target))
const graphData: { nodes: NodeData[]; links: LinkData[] } = {
nodes: [...neighbourhood].map((url) => ({
id: url,
text: data[url]?.title ?? url,
tags: data[url]?.tags ?? [],
})),
links: links.filter((l) => neighbourhood.has(l.source) && neighbourhood.has(l.target)),
}
const simulation: d3.Simulation<NodeData, LinkData> = d3
@ -96,11 +100,11 @@ async function renderGraph(container: string, slug: CanonicalSlug) {
const width = graph.offsetWidth
const svg = d3
.select<HTMLElement, NodeData>('#' + container)
.select<HTMLElement, NodeData>("#" + container)
.append("svg")
.attr("width", width)
.attr("height", height)
.attr('viewBox', [-width / 2 / scale, -height / 2 / scale, width / scale, height / scale])
.attr("viewBox", [-width / 2 / scale, -height / 2 / scale, width / scale, height / scale])
// draw links between nodes
const link = svg
@ -145,7 +149,7 @@ async function renderGraph(container: string, slug: CanonicalSlug) {
d.fy = null
}
const noop = () => { }
const noop = () => {}
return d3
.drag<Element, NodeData>()
.on("start", enableDrag ? dragstarted : noop)
@ -170,9 +174,11 @@ async function renderGraph(container: string, slug: CanonicalSlug) {
const targ = resolveRelative(slug, d.id)
window.spaNavigate(new URL(targ, getClientSlug(window)))
})
.on("mouseover", function(_, d) {
.on("mouseover", function (_, d) {
const neighbours: CanonicalSlug[] = data[slug].links ?? []
const neighbourNodes = d3.selectAll<HTMLElement, NodeData>(".node").filter((d) => neighbours.includes(d.id))
const neighbourNodes = d3
.selectAll<HTMLElement, NodeData>(".node")
.filter((d) => neighbours.includes(d.id))
console.log(neighbourNodes)
const currentId = d.id
const linkNodes = d3
@ -183,12 +189,7 @@ async function renderGraph(container: string, slug: CanonicalSlug) {
neighbourNodes.transition().duration(200).attr("fill", color)
// highlight links
linkNodes
.transition()
.duration(200)
.attr("stroke", "var(--gray)")
.attr("stroke-width", 1)
linkNodes.transition().duration(200).attr("stroke", "var(--gray)").attr("stroke-width", 1)
const bigFont = fontSize * 1.5
@ -199,11 +200,11 @@ async function renderGraph(container: string, slug: CanonicalSlug) {
.select("text")
.transition()
.duration(200)
.attr('opacityOld', d3.select(parent).select('text').style("opacity"))
.style('opacity', 1)
.style('font-size', bigFont + 'em')
.attr("opacityOld", d3.select(parent).select("text").style("opacity"))
.style("opacity", 1)
.style("font-size", bigFont + "em")
})
.on("mouseleave", function(_, d) {
.on("mouseleave", function (_, d) {
const currentId = d.id
const linkNodes = d3
.selectAll(".link")
@ -216,8 +217,8 @@ async function renderGraph(container: string, slug: CanonicalSlug) {
.select("text")
.transition()
.duration(200)
.style('opacity', d3.select(parent).select('text').attr("opacityOld"))
.style('font-size', fontSize + 'em')
.style("opacity", d3.select(parent).select("text").attr("opacityOld"))
.style("font-size", fontSize + "em")
})
// @ts-ignore
.call(drag(simulation))
@ -228,10 +229,12 @@ async function renderGraph(container: string, slug: CanonicalSlug) {
.attr("dx", 0)
.attr("dy", (d) => -nodeRadius(d) + "px")
.attr("text-anchor", "middle")
.text((d) => data[d.id]?.title || (d.id.charAt(1).toUpperCase() + d.id.slice(2)).replace("-", " "))
.style('opacity', (opacityScale - 1) / 3.75)
.text(
(d) => data[d.id]?.title || (d.id.charAt(1).toUpperCase() + d.id.slice(2)).replace("-", " "),
)
.style("opacity", (opacityScale - 1) / 3.75)
.style("pointer-events", "none")
.style('font-size', fontSize + 'em')
.style("font-size", fontSize + "em")
.raise()
// @ts-ignore
.call(drag(simulation))
@ -249,7 +252,7 @@ async function renderGraph(container: string, slug: CanonicalSlug) {
.on("zoom", ({ transform }) => {
link.attr("transform", transform)
node.attr("transform", transform)
const scale = transform.k * opacityScale;
const scale = transform.k * opacityScale
const scaledOpacity = Math.max((scale - 1) / 3.75, 0)
labels.attr("transform", transform).style("opacity", scaledOpacity)
}),
@ -263,17 +266,13 @@ async function renderGraph(container: string, slug: CanonicalSlug) {
.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)
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 = getCanonicalSlug(window)
const slug = getCanonicalSlug(window)
const container = document.getElementById("global-graph-outer")
const sidebar = container?.closest(".sidebar") as HTMLElement
container?.classList.add("active")
@ -305,4 +304,3 @@ document.addEventListener("nav", async (e: unknown) => {
containerIcon?.removeEventListener("click", renderGlobalGraph)
containerIcon?.addEventListener("click", renderGlobalGraph)
})

View File

@ -1,3 +1,3 @@
import Plausible from 'plausible-tracker'
import Plausible from "plausible-tracker"
const { trackPageview } = Plausible()
document.addEventListener("nav", () => trackPageview())

View File

@ -2,33 +2,25 @@ 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
) {
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('[href^="./"], [href^="../"]').forEach((item) => update(item, "href", base))
el.querySelectorAll('[src^="./"], [src^="../"]').forEach((item) =>
update(item, 'src', 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 }) {
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()
]
middleware: [inline({ x: clientX, y: clientY }), shift(), flip()],
})
Object.assign(popoverElement.style, {
left: `${x}px`,
@ -37,7 +29,7 @@ async function mouseEnterHandler(this: HTMLLinkElement, { clientX, clientY }: {
}
// dont refetch if there's already a popover
if ([...link.children].some(child => child.classList.contains("popover"))) {
if ([...link.children].some((child) => child.classList.contains("popover"))) {
return setPosition(link.lastChild as HTMLElement)
}
@ -68,7 +60,7 @@ async function mouseEnterHandler(this: HTMLLinkElement, { clientX, clientY }: {
const popoverInner = document.createElement("div")
popoverInner.classList.add("popover-inner")
popoverElement.appendChild(popoverInner)
elts.forEach(elt => popoverInner.appendChild(elt))
elts.forEach((elt) => popoverInner.appendChild(elt))
setPosition(popoverElement)
link.appendChild(popoverElement)
@ -77,7 +69,7 @@ async function mouseEnterHandler(this: HTMLLinkElement, { clientX, clientY }: {
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' })
popoverInner.scroll({ top: heading.offsetTop - 12, behavior: "instant" })
}
}
}

View File

@ -4,9 +4,9 @@ import { registerEscapeHandler, removeAllChildren } from "./util"
import { CanonicalSlug, getClientSlug, resolveRelative } from "../../path"
interface Item {
slug: CanonicalSlug,
title: string,
content: string,
slug: CanonicalSlug
title: string
content: string
}
let index: Document<Item> | undefined = undefined
@ -15,15 +15,17 @@ const contextWindowWords = 30
const numSearchResults = 5
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
const tokenizedTerms = searchTerm
.split(/\s+/)
.filter(t => t !== "")
.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 includesCheck = (tok: string) =>
tokenizedTerms.some((term) => tok.toLowerCase().startsWith(term.toLowerCase()))
const occurencesIndices = tokenizedText.map(includesCheck)
let bestSum = 0
@ -42,19 +44,22 @@ function highlight(searchTerm: string, text: string, trim?: boolean) {
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>`)
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
})
return tok
})
.join(" ")
return `${startIndex === 0 ? "" : "..."}${slice}${endIndex === tokenizedText.length - 1 ? "" : "..."}`
return `${startIndex === 0 ? "" : "..."}${slice}${
endIndex === tokenizedText.length - 1 ? "" : "..."
}`
}
const encoder = (str: string) => str.toLowerCase().split(/([^a-z]|[^\x00-\x7F])/)
@ -113,7 +118,7 @@ document.addEventListener("nav", async (e: unknown) => {
button.classList.add("result-card")
button.id = slug
button.innerHTML = `<h3>${title}</h3><p>${content}</p>`
button.addEventListener('click', () => {
button.addEventListener("click", () => {
const targ = resolveRelative(currentSlug, slug)
window.spaNavigate(new URL(targ, getClientSlug(window)))
})
@ -132,7 +137,6 @@ document.addEventListener("nav", async (e: unknown) => {
} else {
results.append(...finalResults.map(resultToHTML))
}
}
function onType(e: HTMLElementEventMap["input"]) {
@ -140,12 +144,12 @@ document.addEventListener("nav", async (e: unknown) => {
const searchResults = index?.search(term, numSearchResults) ?? []
const getByField = (field: string): CanonicalSlug[] => {
const results = searchResults.filter((x) => x.field === field)
return results.length === 0 ? [] : [...results[0].result] as CanonicalSlug[]
return results.length === 0 ? [] : ([...results[0].result] as CanonicalSlug[])
}
// order titles ahead of content
const allIds: Set<CanonicalSlug> = new Set([...getByField("title"), ...getByField("content")])
const finalResults = [...allIds].map(id => formatForDisplay(term, id))
const finalResults = [...allIds].map((id) => formatForDisplay(term, id))
displayResults(finalResults)
}
@ -160,7 +164,7 @@ document.addEventListener("nav", async (e: unknown) => {
if (!index) {
index = new Document({
cache: true,
charset: 'latin:extra',
charset: "latin:extra",
optimize: true,
encode: encoder,
document: {
@ -174,7 +178,7 @@ document.addEventListener("nav", async (e: unknown) => {
field: "content",
tokenize: "reverse",
},
]
],
},
})
@ -182,7 +186,7 @@ document.addEventListener("nav", async (e: unknown) => {
await index.addAsync(slug, {
slug: slug as CanonicalSlug,
title: fileData.title,
content: fileData.content
content: fileData.content,
})
}
}

View File

@ -5,8 +5,9 @@ import { CanonicalSlug, RelativeURL, getCanonicalSlug } from "../../path"
// 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
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)
@ -16,18 +17,18 @@ const isLocalUrl = (href: string) => {
}
return true
}
} catch (e) { }
} catch (e) {}
return false
}
const getOpts = ({ target }: Event): { url: URL, scroll?: boolean } | undefined => {
const getOpts = ({ target }: Event): { url: URL; scroll?: boolean } | undefined => {
if (!isElement(target)) return
const a = target.closest("a")
if (!a) return
if ('routerIgnore' in a.dataset) 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 }
return { url: new URL(href), scroll: "routerNoscroll" in a.dataset ? false : undefined }
}
function notifyNav(url: CanonicalSlug) {
@ -44,7 +45,7 @@ async function navigate(url: URL, isBack: boolean = false) {
window.location.assign(url)
})
if (!contents) return;
if (!contents) return
if (!isBack) {
history.pushState({}, "", url)
window.scrollTo({ top: 0 })
@ -54,22 +55,22 @@ async function navigate(url: URL, isBack: boolean = false) {
if (title) {
document.title = title
} else {
const h1 = document.querySelector('h1')
const h1 = document.querySelector("h1")
title = h1?.innerText ?? h1?.textContent ?? url.pathname
}
if (announcer.textContent !== title) {
announcer.textContent = title
}
announcer.dataset.persist = ''
announcer.dataset.persist = ""
html.body.appendChild(announcer)
micromorph(document.body, html.body)
// 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))
// 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))
notifyNav(getCanonicalSlug(window))
delete announcer.dataset.persist
@ -101,7 +102,7 @@ function createRouter() {
})
}
return new class Router {
return new (class Router {
go(pathname: RelativeURL) {
const url = new URL(pathname, window.location.toString())
return navigate(url, false)
@ -114,26 +115,30 @@ function createRouter() {
forward() {
return window.history.forward()
}
}
})()
}
createRouter()
notifyNav(getCanonicalSlug(window))
if (!customElements.get('route-announcer')) {
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'
"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)
customElements.define(
"route-announcer",
class RouteAnnouncer extends HTMLElement {
constructor() {
super()
}
}
})
connectedCallback() {
for (const [key, value] of Object.entries(attrs)) {
this.setAttribute(key, value)
}
}
},
)
}

View File

@ -1,5 +1,5 @@
const bufferPx = 150
const observer = new IntersectionObserver(entries => {
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
const slug = entry.target.id
const tocEntryElement = document.querySelector(`a[data-for="${slug}"]`)
@ -38,5 +38,5 @@ document.addEventListener("nav", () => {
// 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))
headers.forEach((header) => observer.observe(header))
})

View File

@ -15,7 +15,7 @@ export function registerEscapeHandler(outsideContainer: HTMLElement | null, cb:
outsideContainer?.removeEventListener("click", click)
outsideContainer?.addEventListener("click", click)
document.removeEventListener("keydown", esc)
document.addEventListener('keydown', esc)
document.addEventListener("keydown", esc)
}
export function removeAllChildren(node: HTMLElement) {

View File

@ -3,7 +3,7 @@
.graph {
& > h3 {
font-size: 1rem;
margin: 0
margin: 0;
}
& > .graph-outer {
@ -26,7 +26,7 @@
top: 0;
right: 0;
border-radius: 4px;
background-color: transparent;
background-color: transparent;
transition: background-color 0.5s ease;
cursor: pointer;
&:hover {
@ -52,7 +52,7 @@
& > #global-graph-container {
border: 1px solid var(--lightgray);
background-color: var(--light);
background-color: var(--light);
border-radius: 5px;
box-sizing: border-box;
position: fixed;

View File

@ -12,7 +12,7 @@ details#toc {
margin: 0;
}
}
& ul {
list-style: none;
margin: 0.5rem 1.25rem;

View File

@ -25,7 +25,7 @@ li.section-li {
}
& > .desc > h3 > a {
background-color: transparent;
background-color: transparent;
}
& > .meta {

View File

@ -32,7 +32,7 @@
border: 1px solid var(--lightgray);
background-color: var(--light);
border-radius: 5px;
box-shadow: 6px 6px 36px 0 rgba(0,0,0,0.25);
box-shadow: 6px 6px 36px 0 rgba(0, 0, 0, 0.25);
overflow: auto;
}
@ -42,14 +42,17 @@
visibility: hidden;
opacity: 0;
transition: opacity 0.3s ease, visibility 0.3s ease;
transition:
opacity 0.3s ease,
visibility 0.3s ease;
@media all and (max-width: $mobileBreakpoint) {
display: none !important;
}
}
a:hover .popover, .popover:hover {
a:hover .popover,
.popover:hover {
animation: dropin 0.3s ease;
animation-fill-mode: forwards;
animation-delay: 0.2s;

View File

@ -67,7 +67,9 @@
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);
box-shadow:
0 14px 50px rgba(27, 33, 48, 0.12),
0 10px 30px rgba(27, 33, 48, 0.16);
margin-bottom: 2em;
}
@ -108,7 +110,8 @@
font-weight: 700;
}
&:hover, &:focus {
&:hover,
&:focus {
background: var(--lightgray);
}
@ -127,12 +130,11 @@
margin: 0;
}
& > p {
& > p {
margin-bottom: 0;
}
}
}
}
}
}

View File

@ -15,16 +15,16 @@ button#toc {
}
& .fold {
margin-left: 0.5rem;
margin-left: 0.5rem;
transition: transform 0.3s ease;
opacity: 0.8;
}
&.collapsed .fold {
transform: rotateZ(-90deg)
transform: rotateZ(-90deg);
}
}
#toc-content {
list-style: none;
overflow: hidden;
@ -42,7 +42,9 @@ button#toc {
& > li > a {
color: var(--dark);
opacity: 0.35;
transition: 0.5s ease opacity, 0.3s ease color;
transition:
0.5s ease opacity,
0.3s ease color;
&.in-view {
opacity: 0.75;
}
@ -55,4 +57,3 @@ button#toc {
}
}
}

View File

@ -11,15 +11,17 @@ export type QuartzComponentProps = {
children: (QuartzComponent | JSX.Element)[]
tree: Node<QuartzPluginData>
allFiles: QuartzPluginData[]
displayClass?: 'mobile-only' | 'desktop-only'
displayClass?: "mobile-only" | "desktop-only"
} & JSX.IntrinsicAttributes & {
[key: string]: any
}
[key: string]: any
}
export type QuartzComponent = ComponentType<QuartzComponentProps> & {
css?: string,
beforeDOMLoaded?: string,
afterDOMLoaded?: string,
css?: string
beforeDOMLoaded?: string
afterDOMLoaded?: string
}
export type QuartzComponentConstructor<Options extends object | undefined = undefined> = (opts: Options) => QuartzComponent
export type QuartzComponentConstructor<Options extends object | undefined = undefined> = (
opts: Options,
) => QuartzComponent

View File

@ -1,4 +1,4 @@
import { Spinner } from 'cli-spinner'
import { Spinner } from "cli-spinner"
export class QuartzLogger {
verbose: boolean

View File

@ -1,9 +1,9 @@
import test, { describe } from 'node:test'
import * as path from './path'
import assert from 'node:assert'
import test, { describe } from "node:test"
import * as path from "./path"
import assert from "node:assert"
describe('typeguards', () => {
test('isClientSlug', () => {
describe("typeguards", () => {
test("isClientSlug", () => {
assert(path.isClientSlug("http://example.com"))
assert(path.isClientSlug("http://example.com/index"))
assert(path.isClientSlug("http://example.com/index.html"))
@ -23,7 +23,7 @@ describe('typeguards', () => {
assert(!path.isClientSlug("https"))
})
test('isCanonicalSlug', () => {
test("isCanonicalSlug", () => {
assert(path.isCanonicalSlug(""))
assert(path.isCanonicalSlug("abc"))
assert(path.isCanonicalSlug("notindex"))
@ -41,7 +41,7 @@ describe('typeguards', () => {
assert(!path.isCanonicalSlug("index.html"))
})
test('isRelativeURL', () => {
test("isRelativeURL", () => {
assert(path.isRelativeURL("."))
assert(path.isRelativeURL(".."))
assert(path.isRelativeURL("./abc/def"))
@ -58,7 +58,7 @@ describe('typeguards', () => {
assert(!path.isRelativeURL("./abc/def.md"))
})
test('isServerSlug', () => {
test("isServerSlug", () => {
assert(path.isServerSlug("index"))
assert(path.isServerSlug("abc/def"))
@ -72,7 +72,7 @@ describe('typeguards', () => {
assert(!path.isServerSlug("note with spaces"))
})
test('isFilePath', () => {
test("isFilePath", () => {
assert(path.isFilePath("content/index.md"))
assert(path.isFilePath("content/test.png"))
assert(!path.isFilePath("../test.pdf"))
@ -81,80 +81,112 @@ describe('typeguards', () => {
})
})
describe('transforms', () => {
function asserts<Inp, Out>(pairs: [string, string][], transform: (inp: Inp) => Out, checkPre: (x: any) => x is Inp, checkPost: (x: any) => x is Out) {
describe("transforms", () => {
function asserts<Inp, Out>(
pairs: [string, string][],
transform: (inp: Inp) => Out,
checkPre: (x: any) => x is Inp,
checkPost: (x: any) => x is Out,
) {
for (const [inp, expected] of pairs) {
assert(checkPre(inp), `${inp} wasn't the expected input type`)
const actual = transform(inp)
assert.strictEqual(actual, expected, `after transforming ${inp}, '${actual}' was not '${expected}'`)
assert.strictEqual(
actual,
expected,
`after transforming ${inp}, '${actual}' was not '${expected}'`,
)
assert(checkPost(actual), `${actual} wasn't the expected output type`)
}
}
test('canonicalizeServer', () => {
asserts([
["index", ""],
["abc/index", "abc"],
["abc/def", "abc/def"],
], path.canonicalizeServer, path.isServerSlug, path.isCanonicalSlug)
test("canonicalizeServer", () => {
asserts(
[
["index", ""],
["abc/index", "abc"],
["abc/def", "abc/def"],
],
path.canonicalizeServer,
path.isServerSlug,
path.isCanonicalSlug,
)
})
test('canonicalizeClient', () => {
asserts([
["http://localhost:3000", ""],
["http://localhost:3000/index", ""],
["http://localhost:3000/test", "test"],
["http://example.com", ""],
["http://example.com/index", ""],
["http://example.com/index.html", ""],
["http://example.com/", ""],
["https://example.com", ""],
["https://example.com/abc/def", "abc/def"],
["https://example.com/abc/def/", "abc/def"],
["https://example.com/abc/def#cool", "abc/def"],
["https://example.com/abc/def?field=1&another=2", "abc/def"],
["https://example.com/abc/def?field=1&another=2#cool", "abc/def"],
["https://example.com/abc/def.html?field=1&another=2#cool", "abc/def"],
], path.canonicalizeClient, path.isClientSlug, path.isCanonicalSlug)
test("canonicalizeClient", () => {
asserts(
[
["http://localhost:3000", ""],
["http://localhost:3000/index", ""],
["http://localhost:3000/test", "test"],
["http://example.com", ""],
["http://example.com/index", ""],
["http://example.com/index.html", ""],
["http://example.com/", ""],
["https://example.com", ""],
["https://example.com/abc/def", "abc/def"],
["https://example.com/abc/def/", "abc/def"],
["https://example.com/abc/def#cool", "abc/def"],
["https://example.com/abc/def?field=1&another=2", "abc/def"],
["https://example.com/abc/def?field=1&another=2#cool", "abc/def"],
["https://example.com/abc/def.html?field=1&another=2#cool", "abc/def"],
],
path.canonicalizeClient,
path.isClientSlug,
path.isCanonicalSlug,
)
})
describe('slugifyFilePath', () => {
asserts([
["content/index.md", "content/index"],
["content/_index.md", "content/index"],
["/content/index.md", "content/index"],
["content/cool.png", "content/cool"],
["index.md", "index"],
["note with spaces.md", "note-with-spaces"],
], path.slugifyFilePath, path.isFilePath, path.isServerSlug)
describe("slugifyFilePath", () => {
asserts(
[
["content/index.md", "content/index"],
["content/_index.md", "content/index"],
["/content/index.md", "content/index"],
["content/cool.png", "content/cool"],
["index.md", "index"],
["note with spaces.md", "note-with-spaces"],
],
path.slugifyFilePath,
path.isFilePath,
path.isServerSlug,
)
})
describe('transformInternalLink', () => {
asserts([
["", "."],
[".", "."],
["./", "."],
["./index", "."],
["./index.html", "."],
["./index.md", "."],
["content", "./content"],
["content/test.md", "./content/test"],
["./content/test.md", "./content/test"],
["../content/test.md", "../content/test"],
["tags/", "./tags"],
["/tags/", "./tags"],
["content/with spaces", "./content/with-spaces"],
["content/with spaces#and Anchor!", "./content/with-spaces#and-anchor"],
], path.transformInternalLink, (_x: string): _x is string => true, path.isRelativeURL)
describe("transformInternalLink", () => {
asserts(
[
["", "."],
[".", "."],
["./", "."],
["./index", "."],
["./index.html", "."],
["./index.md", "."],
["content", "./content"],
["content/test.md", "./content/test"],
["./content/test.md", "./content/test"],
["../content/test.md", "../content/test"],
["tags/", "./tags"],
["/tags/", "./tags"],
["content/with spaces", "./content/with-spaces"],
["content/with spaces#and Anchor!", "./content/with-spaces#and-anchor"],
],
path.transformInternalLink,
(_x: string): _x is string => true,
path.isRelativeURL,
)
})
describe('pathToRoot', () => {
asserts([
["", "."],
["abc", ".."],
["abc/def", "../.."],
], path.pathToRoot, path.isCanonicalSlug, path.isRelativeURL)
describe("pathToRoot", () => {
asserts(
[
["", "."],
["abc", ".."],
["abc/def", "../.."],
],
path.pathToRoot,
path.isCanonicalSlug,
path.isRelativeURL,
)
})
})

View File

@ -1,5 +1,5 @@
import { slug as slugAnchor } from 'github-slugger'
import { trace } from './trace'
import { slug as slugAnchor } from "github-slugger"
import { trace } from "./trace"
// Quartz Paths
// Things in boxes are not actual types but rather sources which these types can be acquired from
@ -46,7 +46,7 @@ import { trace } from './trace'
const STRICT_TYPE_CHECKS = false
const HARD_EXIT_ON_FAIL = false
function conditionCheck<T>(name: string, label: 'pre' | 'post', s: T, chk: (x: any) => x is T) {
function conditionCheck<T>(name: string, label: "pre" | "post", s: T, chk: (x: any) => x is T) {
if (STRICT_TYPE_CHECKS && !chk(s)) {
trace(`${name} failed ${label}-condition check: ${s} does not pass ${chk.name}`, new Error())
if (HARD_EXIT_ON_FAIL) {
@ -66,8 +66,8 @@ export function isClientSlug(s: string): s is ClientSlug {
}
/** Canonical slug, should be used whenever you need to refer to the location of a file/note.
* On the client, this is normally stored in `document.body.dataset.slug`
*/
* On the client, this is normally stored in `document.body.dataset.slug`
*/
export type CanonicalSlug = SlugLike<"canonical">
export function isCanonicalSlug(s: string): s is CanonicalSlug {
const validStart = !(s.startsWith(".") || s.startsWith("/"))
@ -76,8 +76,8 @@ export function isCanonicalSlug(s: string): s is CanonicalSlug {
}
/** A relative link, can be found on `href`s but can also be constructed for
* client-side navigation (e.g. search and graph)
*/
* client-side navigation (e.g. search and graph)
*/
export type RelativeURL = SlugLike<"relative">
export function isRelativeURL(s: string): s is RelativeURL {
const validStart = /^\.{1,2}/.test(s)
@ -102,58 +102,58 @@ export function isFilePath(s: string): s is FilePath {
export function getClientSlug(window: Window): ClientSlug {
const res = window.location.href as ClientSlug
conditionCheck(getClientSlug.name, 'post', res, isClientSlug)
conditionCheck(getClientSlug.name, "post", res, isClientSlug)
return res
}
export function getCanonicalSlug(window: Window): CanonicalSlug {
const res = window.document.body.dataset.slug! as CanonicalSlug
conditionCheck(getCanonicalSlug.name, 'post', res, isCanonicalSlug)
conditionCheck(getCanonicalSlug.name, "post", res, isCanonicalSlug)
return res
}
export function canonicalizeClient(slug: ClientSlug): CanonicalSlug {
conditionCheck(canonicalizeClient.name, 'pre', slug, isClientSlug)
conditionCheck(canonicalizeClient.name, "pre", slug, isClientSlug)
const { pathname } = new URL(slug)
let fp = pathname.slice(1)
fp = fp.replace(new RegExp(_getFileExtension(fp) + '$'), '')
fp = fp.replace(new RegExp(_getFileExtension(fp) + "$"), "")
const res = _canonicalize(fp) as CanonicalSlug
conditionCheck(canonicalizeClient.name, 'post', res, isCanonicalSlug)
conditionCheck(canonicalizeClient.name, "post", res, isCanonicalSlug)
return res
}
export function canonicalizeServer(slug: ServerSlug): CanonicalSlug {
conditionCheck(canonicalizeServer.name, 'pre', slug, isServerSlug)
conditionCheck(canonicalizeServer.name, "pre", slug, isServerSlug)
let fp = slug as string
const res = _canonicalize(fp) as CanonicalSlug
conditionCheck(canonicalizeServer.name, 'post', res, isCanonicalSlug)
conditionCheck(canonicalizeServer.name, "post", res, isCanonicalSlug)
return res
}
export function slugifyFilePath(fp: FilePath): ServerSlug {
conditionCheck(slugifyFilePath.name, 'pre', fp, isFilePath)
conditionCheck(slugifyFilePath.name, "pre", fp, isFilePath)
fp = _stripSlashes(fp) as FilePath
const withoutFileExt = fp.replace(new RegExp(_getFileExtension(fp) + '$'), '')
const withoutFileExt = fp.replace(new RegExp(_getFileExtension(fp) + "$"), "")
let slug = withoutFileExt
.split('/')
.map((segment) => segment.replace(/\s/g, '-')) // slugify all segments
.join('/') // always use / as sep
.replace(/\/$/, '') // remove trailing slash
.split("/")
.map((segment) => segment.replace(/\s/g, "-")) // slugify all segments
.join("/") // always use / as sep
.replace(/\/$/, "") // remove trailing slash
// treat _index as index
if (_endsWith(slug, "_index")) {
slug = slug.replace(/_index$/, "index")
}
conditionCheck(slugifyFilePath.name, 'post', slug, isServerSlug)
conditionCheck(slugifyFilePath.name, "post", slug, isServerSlug)
return slug as ServerSlug
}
export function transformInternalLink(link: string): RelativeURL {
let [fplike, anchor] = splitAnchor(decodeURI(link))
let segments = fplike.split("/").filter(x => x.length > 0)
let segments = fplike.split("/").filter((x) => x.length > 0)
let prefix = segments.filter(_isRelativeSegment).join("/")
let fp = segments.filter(seg => !_isRelativeSegment(seg)).join("/")
let fp = segments.filter((seg) => !_isRelativeSegment(seg)).join("/")
// implicit markdown
if (!_hasFileExtension(fp)) {
@ -164,57 +164,57 @@ export function transformInternalLink(link: string): RelativeURL {
fp = _trimSuffix(fp, "index")
let joined = joinSegments(_stripSlashes(prefix), _stripSlashes(fp))
const res = _addRelativeToStart(joined) + anchor as RelativeURL
conditionCheck(transformInternalLink.name, 'post', res, isRelativeURL)
const res = (_addRelativeToStart(joined) + anchor) as RelativeURL
conditionCheck(transformInternalLink.name, "post", res, isRelativeURL)
return res
}
// resolve /a/b/c to ../../
export function pathToRoot(slug: CanonicalSlug): RelativeURL {
conditionCheck(pathToRoot.name, 'pre', slug, isCanonicalSlug)
conditionCheck(pathToRoot.name, "pre", slug, isCanonicalSlug)
let rootPath = slug
.split('/')
.filter(x => x !== '')
.map(_ => '..')
.join('/')
.split("/")
.filter((x) => x !== "")
.map((_) => "..")
.join("/")
const res = _addRelativeToStart(rootPath) as RelativeURL
conditionCheck(pathToRoot.name, 'post', res, isRelativeURL)
conditionCheck(pathToRoot.name, "post", res, isRelativeURL)
return res
}
export function resolveRelative(current: CanonicalSlug, target: CanonicalSlug): RelativeURL {
conditionCheck(resolveRelative.name, 'pre', current, isCanonicalSlug)
conditionCheck(resolveRelative.name, 'pre', target, isCanonicalSlug)
conditionCheck(resolveRelative.name, "pre", current, isCanonicalSlug)
conditionCheck(resolveRelative.name, "pre", target, isCanonicalSlug)
const res = joinSegments(pathToRoot(current), target) as RelativeURL
conditionCheck(resolveRelative.name, 'post', res, isRelativeURL)
conditionCheck(resolveRelative.name, "post", res, isRelativeURL)
return res
}
export function splitAnchor(link: string): [string, string] {
let [fp, anchor] = link.split("#", 2)
anchor = anchor === undefined ? "" : '#' + slugAnchor(anchor)
anchor = anchor === undefined ? "" : "#" + slugAnchor(anchor)
return [fp, anchor]
}
export function joinSegments(...args: string[]): string {
return args.filter(segment => segment !== "").join('/')
return args.filter((segment) => segment !== "").join("/")
}
export const QUARTZ = "quartz"
function _canonicalize(fp: string): string {
fp = _trimSuffix(fp, "index")
return _stripSlashes(fp)
return _stripSlashes(fp)
}
function _endsWith(s: string, suffix: string): boolean {
return s === suffix || s.endsWith("/" + suffix)
return s === suffix || s.endsWith("/" + suffix)
}
function _trimSuffix(s: string, suffix: string): string {
if (_endsWith(s, suffix)) {
s = s.slice(0, -(suffix.length))
s = s.slice(0, -suffix.length)
}
return s
}

View File

@ -1,12 +1,12 @@
import chalk from 'chalk'
import pretty from 'pretty-time'
import chalk from "chalk"
import pretty from "pretty-time"
export class PerfTimer {
evts: { [key: string]: [number, number] }
constructor() {
this.evts = {}
this.addEvent('start')
this.addEvent("start")
}
addEvent(evtName: string) {
@ -14,6 +14,6 @@ export class PerfTimer {
}
timeSince(evtName?: string): string {
return chalk.yellow(pretty(process.hrtime(this.evts[evtName ?? 'start'])))
return chalk.yellow(pretty(process.hrtime(this.evts[evtName ?? "start"])))
}
}

View File

@ -1,6 +1,12 @@
import { CanonicalSlug, FilePath, ServerSlug, canonicalizeServer, resolveRelative } from "../../path"
import {
CanonicalSlug,
FilePath,
ServerSlug,
canonicalizeServer,
resolveRelative,
} from "../../path"
import { QuartzEmitterPlugin } from "../types"
import path from 'path'
import path from "path"
export const AliasRedirects: QuartzEmitterPlugin = () => ({
name: "AliasRedirects",
@ -24,7 +30,7 @@ export const AliasRedirects: QuartzEmitterPlugin = () => ({
for (const alias of aliases) {
const slug = path.posix.join(dir, alias) as ServerSlug
const fp = slug + ".html" as FilePath
const fp = (slug + ".html") as FilePath
const redirUrl = resolveRelative(canonicalizeServer(slug), ogSlug)
await emit({
content: `
@ -47,5 +53,5 @@ export const AliasRedirects: QuartzEmitterPlugin = () => ({
}
}
return fps
}
},
})

View File

@ -5,12 +5,12 @@ import path from "path"
export type ContentIndex = Map<CanonicalSlug, ContentDetails>
export type ContentDetails = {
title: string,
links: CanonicalSlug[],
tags: string[],
content: string,
date?: Date,
description?: string,
title: string
links: CanonicalSlug[]
tags: string[]
content: string
date?: Date
description?: string
}
interface Options {
@ -31,7 +31,9 @@ function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string {
<loc>https://${base}/${slug}</loc>
<lastmod>${content.date?.toISOString()}</lastmod>
</url>`
const urls = Array.from(idx).map(([slug, content]) => createURLEntry(slug, content)).join("")
const urls = Array.from(idx)
.map(([slug, content]) => createURLEntry(slug, content))
.join("")
return `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">${urls}</urlset>`
}
@ -47,7 +49,9 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex): string {
<pubDate>${content.date?.toUTCString()}</pubDate>
</items>`
const items = Array.from(idx).map(([slug, content]) => createURLEntry(slug, content)).join("")
const items = Array.from(idx)
.map(([slug, content]) => createURLEntry(slug, content))
.join("")
return `<rss xmlns:atom="http://www.w3.org/2005/atom" version="2.0">
<channel>
<title>${cfg.pageTitle}</title>
@ -71,14 +75,14 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
const slug = canonicalizeServer(file.data.slug!)
const date = file.data.dates?.modified ?? 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 ?? "",
date: date,
description: file.data.description ?? ""
})
linkIndex.set(slug, {
title: file.data.frontmatter?.title!,
links: file.data.links ?? [],
tags: file.data.frontmatter?.tags ?? [],
content: file.data.text ?? "",
date: date,
description: file.data.description ?? "",
})
}
}
@ -86,7 +90,7 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
await emit({
content: generateSiteMap(cfg, linkIndex),
slug: "sitemap" as ServerSlug,
ext: ".xml"
ext: ".xml",
})
emitted.push("sitemap.xml" as FilePath)
}
@ -95,7 +99,7 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
await emit({
content: generateRSSFeed(cfg, linkIndex),
slug: "index" as ServerSlug,
ext: ".xml"
ext: ".xml",
})
emitted.push("index.xml" as FilePath)
}
@ -109,7 +113,7 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
delete content.description
delete content.date
return [slug, content]
})
}),
)
await emit({

View File

@ -8,7 +8,9 @@ import { FilePath, canonicalizeServer } from "../../path"
export const ContentPage: QuartzEmitterPlugin<FullPageLayout> = (opts) => {
if (!opts) {
throw new Error("ContentPage must be initialized with options specifiying the components to use")
throw new Error(
"ContentPage must be initialized with options specifiying the components to use",
)
}
const { head: Head, header, beforeBody, pageBody: Content, left, right, footer: Footer } = opts
@ -22,7 +24,7 @@ export const ContentPage: QuartzEmitterPlugin<FullPageLayout> = (opts) => {
},
async emit(_contentDir, cfg, content, resources, emit): Promise<FilePath[]> {
const fps: FilePath[] = []
const allFiles = content.map(c => c[1].data)
const allFiles = content.map((c) => c[1].data)
for (const [tree, file] of content) {
const slug = canonicalizeServer(file.data.slug!)
const externalResources = pageResources(slug, resources)
@ -32,17 +34,12 @@ export const ContentPage: QuartzEmitterPlugin<FullPageLayout> = (opts) => {
cfg,
children: [],
tree,
allFiles
allFiles,
}
const content = renderPage(
slug,
componentData,
opts,
externalResources
)
const content = renderPage(slug, componentData, opts, externalResources)
const fp = file.data.slug + ".html" as FilePath
const fp = (file.data.slug + ".html") as FilePath
await emit({
content,
slug: file.data.slug!,
@ -52,6 +49,6 @@ export const ContentPage: QuartzEmitterPlugin<FullPageLayout> = (opts) => {
fps.push(fp)
}
return fps
}
},
}
}

View File

@ -24,20 +24,28 @@ export const FolderPage: QuartzEmitterPlugin<FullPageLayout> = (opts) => {
},
async emit(_contentDir, cfg, content, resources, emit): Promise<FilePath[]> {
const fps: FilePath[] = []
const allFiles = content.map(c => c[1].data)
const allFiles = content.map((c) => c[1].data)
const folders: Set<CanonicalSlug> = new Set(allFiles.flatMap(data => {
const slug = data.slug
const folderName = path.dirname(slug ?? "") as CanonicalSlug
if (slug && folderName !== "." && folderName !== "tags") {
return [folderName]
}
return []
}))
const folders: Set<CanonicalSlug> = new Set(
allFiles.flatMap((data) => {
const slug = data.slug
const folderName = path.dirname(slug ?? "") as CanonicalSlug
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 ServerSlug, frontmatter: { title: `Folder: ${folder}`, tags: [] } })
])))
const folderDescriptions: Record<string, ProcessedContent> = Object.fromEntries(
[...folders].map((folder) => [
folder,
defaultProcessedContent({
slug: joinSegments(folder, "index") as ServerSlug,
frontmatter: { title: `Folder: ${folder}`, tags: [] },
}),
]),
)
for (const [tree, file] of content) {
const slug = canonicalizeServer(file.data.slug!)
@ -56,17 +64,12 @@ export const FolderPage: QuartzEmitterPlugin<FullPageLayout> = (opts) => {
cfg,
children: [],
tree,
allFiles
allFiles,
}
const content = renderPage(
slug,
componentData,
opts,
externalResources
)
const content = renderPage(slug, componentData, opts, externalResources)
const fp = file.data.slug! + ".html" as FilePath
const fp = (file.data.slug! + ".html") as FilePath
await emit({
content,
slug: file.data.slug!,
@ -76,6 +79,6 @@ export const FolderPage: QuartzEmitterPlugin<FullPageLayout> = (opts) => {
fps.push(fp)
}
return fps
}
},
}
}

View File

@ -1,5 +1,5 @@
export { ContentPage } from './contentPage'
export { TagPage } from './tagPage'
export { FolderPage } from './folderPage'
export { ContentIndex } from './contentIndex'
export { AliasRedirects } from './aliases'
export { ContentPage } from "./contentPage"
export { TagPage } from "./tagPage"
export { FolderPage } from "./folderPage"
export { ContentIndex } from "./contentIndex"
export { AliasRedirects } from "./aliases"

View File

@ -23,12 +23,18 @@ export const TagPage: QuartzEmitterPlugin<FullPageLayout> = (opts) => {
},
async emit(_contentDir, cfg, content, resources, emit): Promise<FilePath[]> {
const fps: FilePath[] = []
const allFiles = content.map(c => c[1].data)
const allFiles = content.map((c) => c[1].data)
const tags: Set<string> = new Set(allFiles.flatMap(data => data.frontmatter?.tags ?? []))
const tagDescriptions: Record<string, ProcessedContent> = Object.fromEntries([...tags].map(tag => ([
tag, defaultProcessedContent({ slug: `tags/${tag}/index` as ServerSlug, frontmatter: { title: `Tag: ${tag}`, tags: [] } })
])))
const tags: Set<string> = new Set(allFiles.flatMap((data) => data.frontmatter?.tags ?? []))
const tagDescriptions: Record<string, ProcessedContent> = Object.fromEntries(
[...tags].map((tag) => [
tag,
defaultProcessedContent({
slug: `tags/${tag}/index` as ServerSlug,
frontmatter: { title: `Tag: ${tag}`, tags: [] },
}),
]),
)
for (const [tree, file] of content) {
const slug = file.data.slug!
@ -50,17 +56,12 @@ export const TagPage: QuartzEmitterPlugin<FullPageLayout> = (opts) => {
cfg,
children: [],
tree,
allFiles
allFiles,
}
const content = renderPage(
slug,
componentData,
opts,
externalResources
)
const content = renderPage(slug, componentData, opts, externalResources)
const fp = file.data.slug + ".html" as FilePath
const fp = (file.data.slug + ".html") as FilePath
await emit({
content,
slug: file.data.slug!,
@ -70,6 +71,6 @@ export const TagPage: QuartzEmitterPlugin<FullPageLayout> = (opts) => {
fps.push(fp)
}
return fps
}
},
}
}

View File

@ -5,5 +5,5 @@ export const RemoveDrafts: QuartzFilterPlugin<{}> = () => ({
shouldPublish([_tree, vfile]) {
const draftFlag: boolean = vfile.data?.frontmatter?.draft ?? false
return !draftFlag
}
},
})

View File

@ -5,5 +5,5 @@ export const ExplicitPublish: QuartzFilterPlugin = () => ({
shouldPublish([_tree, vfile]) {
const publishFlag: boolean = vfile.data?.frontmatter?.publish ?? false
return publishFlag
}
},
})

View File

@ -1,2 +1,2 @@
export { RemoveDrafts } from './draft'
export { ExplicitPublish } from './explicit'
export { RemoveDrafts } from "./draft"
export { ExplicitPublish } from "./explicit"

View File

@ -1,14 +1,14 @@
import { GlobalConfiguration } from '../cfg'
import { QuartzComponent } from '../components/types'
import { StaticResources } from '../resources'
import { joinStyles } from '../theme'
import { EmitCallback, PluginTypes } from './types'
import styles from '../styles/base.scss'
import { FilePath, ServerSlug } from '../path'
import { GlobalConfiguration } from "../cfg"
import { QuartzComponent } from "../components/types"
import { StaticResources } from "../resources"
import { joinStyles } from "../theme"
import { EmitCallback, PluginTypes } from "./types"
import styles from "../styles/base.scss"
import { FilePath, ServerSlug } from "../path"
export type ComponentResources = {
css: string[],
beforeDOMLoaded: string[],
css: string[]
beforeDOMLoaded: string[]
afterDOMLoaded: string[]
}
@ -24,7 +24,7 @@ export function getComponentResources(plugins: PluginTypes): ComponentResources
const componentResources = {
css: new Set<string>(),
beforeDOMLoaded: new Set<string>(),
afterDOMLoaded: new Set<string>()
afterDOMLoaded: new Set<string>(),
}
for (const component of allComponents) {
@ -39,39 +39,42 @@ export function getComponentResources(plugins: PluginTypes): ComponentResources
componentResources.afterDOMLoaded.add(afterDOMLoaded)
}
}
return {
css: [...componentResources.css],
beforeDOMLoaded: [...componentResources.beforeDOMLoaded],
afterDOMLoaded: [...componentResources.afterDOMLoaded]
afterDOMLoaded: [...componentResources.afterDOMLoaded],
}
}
function joinScripts(scripts: string[]): string {
// wrap with iife to prevent scope collision
return scripts.map(script => `(function () {${script}})();`).join("\n")
return scripts.map((script) => `(function () {${script}})();`).join("\n")
}
export async function emitComponentResources(cfg: GlobalConfiguration, res: ComponentResources, emit: EmitCallback): Promise<FilePath[]> {
export async function emitComponentResources(
cfg: GlobalConfiguration,
res: ComponentResources,
emit: EmitCallback,
): Promise<FilePath[]> {
const fps = await Promise.all([
emit({
slug: "index" as ServerSlug,
ext: ".css",
content: joinStyles(cfg.theme, styles, ...res.css)
content: joinStyles(cfg.theme, styles, ...res.css),
}),
emit({
slug: "prescript" as ServerSlug,
ext: ".js",
content: joinScripts(res.beforeDOMLoaded)
content: joinScripts(res.beforeDOMLoaded),
}),
emit({
slug: "postscript" as ServerSlug,
ext: ".js",
content: joinScripts(res.afterDOMLoaded)
})
content: joinScripts(res.afterDOMLoaded),
}),
])
return fps
}
export function getStaticResourcesFromPlugins(plugins: PluginTypes) {
@ -93,11 +96,11 @@ export function getStaticResourcesFromPlugins(plugins: PluginTypes) {
return staticResources
}
export * from './transformers'
export * from './filters'
export * from './emitters'
export * from "./transformers"
export * from "./filters"
export * from "./emitters"
declare module 'vfile' {
declare module "vfile" {
// inserted in processors.ts
interface DataMap {
slug: ServerSlug

View File

@ -1,4 +1,4 @@
import { Root as HTMLRoot } from 'hast'
import { Root as HTMLRoot } from "hast"
import { toString } from "hast-util-to-string"
import { QuartzTransformerPlugin } from "../types"
@ -7,11 +7,16 @@ export interface Options {
}
const defaultOptions: Options = {
descriptionLength: 150
descriptionLength: 150,
}
const escapeHTML = (unsafe: string) => {
return unsafe.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#039;');
return unsafe
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;")
}
export const Description: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
@ -26,30 +31,29 @@ export const Description: QuartzTransformerPlugin<Partial<Options> | undefined>
const text = escapeHTML(toString(tree))
const desc = frontMatterDescription ?? text
const sentences = desc.replace(/\s+/g, ' ').split('.')
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 + '.'
finalDesc += sentence + "."
sentenceIdx++
}
file.data.description = finalDesc
file.data.text = text
}
}
},
]
}
},
}
}
declare module 'vfile' {
declare module "vfile" {
interface DataMap {
description: string
text: string
}
}

View File

@ -1,17 +1,17 @@
import matter from "gray-matter"
import remarkFrontmatter from 'remark-frontmatter'
import remarkFrontmatter from "remark-frontmatter"
import { QuartzTransformerPlugin } from "../types"
import yaml from 'js-yaml'
import { slug as slugAnchor } from 'github-slugger'
import yaml from "js-yaml"
import { slug as slugAnchor } from "github-slugger"
export interface Options {
language: 'yaml' | 'toml',
language: "yaml" | "toml"
delims: string | string[]
}
const defaultOptions: Options = {
language: 'yaml',
delims: '---'
language: "yaml",
delims: "---",
}
export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
@ -26,8 +26,8 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined>
const { data } = matter(file.value, {
...opts,
engines: {
yaml: s => yaml.load(s, { schema: yaml.JSON_SCHEMA }) as object
}
yaml: (s) => yaml.load(s, { schema: yaml.JSON_SCHEMA }) as object,
},
})
// tag is an alias for tags
@ -36,7 +36,10 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined>
}
if (data.tags && !Array.isArray(data.tags)) {
data.tags = data.tags.toString().split(",").map((tag: string) => tag.trim())
data.tags = data.tags
.toString()
.split(",")
.map((tag: string) => tag.trim())
}
// slug them all!!
@ -46,16 +49,16 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined>
file.data.frontmatter = {
title: file.stem ?? "Untitled",
tags: [],
...data
...data,
}
}
}
},
]
},
}
}
declare module 'vfile' {
declare module "vfile" {
interface DataMap {
frontmatter: { [key: string]: any } & {
title: string

View File

@ -1,5 +1,5 @@
import remarkGfm from "remark-gfm"
import smartypants from 'remark-smartypants'
import smartypants from "remark-smartypants"
import { QuartzTransformerPlugin } from "../types"
import rehypeSlug from "rehype-slug"
import rehypeAutolinkHeadings from "rehype-autolink-headings"
@ -11,10 +11,12 @@ export interface Options {
const defaultOptions: Options = {
enableSmartyPants: true,
linkHeadings: true
linkHeadings: true,
}
export const GitHubFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
export const GitHubFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> | undefined> = (
userOpts,
) => {
const opts = { ...defaultOptions, ...userOpts }
return {
name: "GitHubFlavoredMarkdown",
@ -23,15 +25,22 @@ export const GitHubFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> |
},
htmlPlugins() {
if (opts.linkHeadings) {
return [rehypeSlug, [rehypeAutolinkHeadings, {
behavior: 'append', content: {
type: 'text',
value: ' §',
}
}]]
return [
rehypeSlug,
[
rehypeAutolinkHeadings,
{
behavior: "append",
content: {
type: "text",
value: " §",
},
},
],
]
} else {
return []
}
}
},
}
}

View File

@ -1,9 +1,9 @@
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 { SyntaxHighlighting } from './syntax'
export { TableOfContents } from './toc'
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 { SyntaxHighlighting } from "./syntax"
export { TableOfContents } from "./toc"

View File

@ -1,18 +1,20 @@
import fs from "fs"
import path from 'path'
import path from "path"
import { Repository } from "@napi-rs/simple-git"
import { QuartzTransformerPlugin } from "../types"
export interface Options {
priority: ('frontmatter' | 'git' | 'filesystem')[],
priority: ("frontmatter" | "git" | "filesystem")[]
}
const defaultOptions: Options = {
priority: ['frontmatter', 'git', 'filesystem']
priority: ["frontmatter", "git", "filesystem"],
}
type MaybeDate = undefined | string | number
export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options> | undefined> = (
userOpts,
) => {
const opts = { ...defaultOptions, ...userOpts }
return {
name: "CreatedModifiedDate",
@ -51,13 +53,13 @@ export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options> | und
published: published ? new Date(published) : new Date(),
}
}
}
},
]
},
}
}
declare module 'vfile' {
declare module "vfile" {
interface DataMap {
dates: {
created: Date

View File

@ -1,43 +1,39 @@
import remarkMath from "remark-math"
import rehypeKatex from 'rehype-katex'
import rehypeMathjax from 'rehype-mathjax/svg.js'
import rehypeKatex from "rehype-katex"
import rehypeMathjax from "rehype-mathjax/svg.js"
import { QuartzTransformerPlugin } from "../types"
interface Options {
renderEngine: 'katex' | 'mathjax'
renderEngine: "katex" | "mathjax"
}
export const Latex: QuartzTransformerPlugin<Options> = (opts?: Options) => {
const engine = opts?.renderEngine ?? 'katex'
const engine = opts?.renderEngine ?? "katex"
return {
name: "Latex",
markdownPlugins() {
return [remarkMath]
},
htmlPlugins() {
return [
engine === 'katex'
? [rehypeKatex, { output: 'html' }]
: [rehypeMathjax]
]
return [engine === "katex" ? [rehypeKatex, { output: "html" }] : [rehypeMathjax]]
},
externalResources() {
return engine === 'katex'
return engine === "katex"
? {
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'
}
]
}
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",
},
],
}
: {}
}
},
}
}

View File

@ -1,18 +1,27 @@
import { QuartzTransformerPlugin } from "../types"
import { CanonicalSlug, RelativeURL, canonicalizeServer, joinSegments, pathToRoot, resolveRelative, splitAnchor, transformInternalLink } from "../../path"
import {
CanonicalSlug,
RelativeURL,
canonicalizeServer,
joinSegments,
pathToRoot,
resolveRelative,
splitAnchor,
transformInternalLink,
} from "../../path"
import path from "path"
import { visit } from 'unist-util-visit'
import { visit } from "unist-util-visit"
import isAbsoluteUrl from "is-absolute-url"
interface Options {
/** How to resolve Markdown paths */
markdownLinkResolution: 'absolute' | 'relative' | 'shortest'
markdownLinkResolution: "absolute" | "relative" | "shortest"
/** Strips folders from a link so that it looks nice */
prettyLinks: boolean
}
const defaultOptions: Options = {
markdownLinkResolution: 'absolute',
markdownLinkResolution: "absolute",
prettyLinks: true,
}
@ -21,84 +30,91 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> =
return {
name: "LinkProcessing",
htmlPlugins() {
return [() => {
return (tree, file) => {
const curSlug = canonicalizeServer(file.data.slug!)
const transformLink = (target: string): RelativeURL => {
const targetSlug = transformInternalLink(target).slice("./".length)
let [targetCanonical, targetAnchor] = splitAnchor(targetSlug)
if (opts.markdownLinkResolution === 'relative') {
return targetSlug as RelativeURL
} else if (opts.markdownLinkResolution === 'shortest') {
// https://forum.obsidian.md/t/settings-new-link-format-what-is-shortest-path-when-possible/6748/5
const allSlugs = file.data.allSlugs!
return [
() => {
return (tree, file) => {
const curSlug = canonicalizeServer(file.data.slug!)
const transformLink = (target: string): RelativeURL => {
const targetSlug = transformInternalLink(target).slice("./".length)
let [targetCanonical, targetAnchor] = splitAnchor(targetSlug)
if (opts.markdownLinkResolution === "relative") {
return targetSlug as RelativeURL
} else if (opts.markdownLinkResolution === "shortest") {
// https://forum.obsidian.md/t/settings-new-link-format-what-is-shortest-path-when-possible/6748/5
const allSlugs = file.data.allSlugs!
// if the file name is unique, then it's just the filename
const matchingFileNames = allSlugs.filter(slug => {
const parts = slug.split(path.posix.sep)
const fileName = parts.at(-1)
return targetCanonical === fileName
})
// if the file name is unique, then it's just the filename
const matchingFileNames = allSlugs.filter((slug) => {
const parts = slug.split(path.posix.sep)
const fileName = parts.at(-1)
return targetCanonical === fileName
})
if (matchingFileNames.length === 1) {
const targetSlug = canonicalizeServer(matchingFileNames[0])
return resolveRelative(curSlug, targetSlug) + targetAnchor as RelativeURL
if (matchingFileNames.length === 1) {
const targetSlug = canonicalizeServer(matchingFileNames[0])
return (resolveRelative(curSlug, targetSlug) + targetAnchor) as RelativeURL
}
// if it's not unique, then it's the absolute path from the vault root
// (fall-through case)
}
// if it's not unique, then it's the absolute path from the vault root
// (fall-through case)
// treat as absolute
return joinSegments(pathToRoot(curSlug), targetSlug) as RelativeURL
}
// treat as absolute
return joinSegments(pathToRoot(curSlug), targetSlug) as RelativeURL
const outgoing: Set<CanonicalSlug> = new Set()
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 = isAbsoluteUrl(dest) ? "external" : "internal"
// don't process external links or intra-document anchors
if (!(isAbsoluteUrl(dest) || dest.startsWith("#"))) {
dest = node.properties.href = transformLink(dest)
const canonicalDest = path.normalize(joinSegments(curSlug, dest))
const [destCanonical, _destAnchor] = splitAnchor(canonicalDest)
outgoing.add(destCanonical as CanonicalSlug)
}
// rewrite link internals if prettylinks is on
if (
opts.prettyLinks &&
node.children.length === 1 &&
node.children[0].type === "text"
) {
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)) {
const ext = path.extname(node.properties.src)
node.properties.src =
transformLink(path.join("assets", node.properties.src)) + ext
}
}
})
file.data.links = [...outgoing]
}
const outgoing: Set<CanonicalSlug> = new Set()
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 = isAbsoluteUrl(dest) ? "external" : "internal"
// don't process external links or intra-document anchors
if (!(isAbsoluteUrl(dest) || dest.startsWith("#"))) {
dest = node.properties.href = transformLink(dest)
const canonicalDest = path.normalize(joinSegments(curSlug, dest))
const [destCanonical, _destAnchor] = splitAnchor(canonicalDest)
outgoing.add(destCanonical as CanonicalSlug)
}
// rewrite link internals if prettylinks is on
if (opts.prettyLinks && node.children.length === 1 && node.children[0].type === 'text') {
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)) {
const ext = path.extname(node.properties.src)
node.properties.src = transformLink(path.join("assets", node.properties.src)) + ext
}
}
})
file.data.links = [...outgoing]
}
}]
}
},
]
},
}
}
declare module 'vfile' {
declare module "vfile" {
interface DataMap {
links: CanonicalSlug[]
}

View File

@ -1,8 +1,8 @@
import { PluggableList } from "unified"
import { QuartzTransformerPlugin } from "../types"
import { Root, HTML, BlockContent, DefinitionContent, Code } from 'mdast'
import { Root, HTML, BlockContent, DefinitionContent, Code } from "mdast"
import { findAndReplace } from "mdast-util-find-and-replace"
import { slug as slugAnchor } from 'github-slugger'
import { slug as slugAnchor } from "github-slugger"
import rehypeRaw from "rehype-raw"
import { visit } from "unist-util-visit"
import path from "path"
@ -71,7 +71,7 @@ function canonicalizeCallout(calloutName: string): keyof typeof callouts {
bug: "bug",
example: "example",
quote: "quote",
cite: "quote"
cite: "quote",
}
return calloutMapping[callout]
@ -94,10 +94,10 @@ const callouts = {
}
const capitalize = (s: string): string => {
return s.substring(0, 1).toUpperCase() + s.substring(1);
return s.substring(0, 1).toUpperCase() + s.substring(1)
}
// Match wikilinks
// Match wikilinks
// !? -> optional embedding
// \[\[ -> open brace
// ([^\[\]\|\#]+) -> one or more non-special characters ([,],|, or #) (name)
@ -105,16 +105,18 @@ const capitalize = (s: string): string => {
// (|[^\[\]\|\#]+)? -> | then one or more non-special characters (alias)
const wikilinkRegex = new RegExp(/!?\[\[([^\[\]\|\#]+)(#[^\[\]\|\#]+)?(\|[^\[\]\|\#]+)?\]\]/, "g")
// Match highlights
// Match highlights
const highlightRegex = new RegExp(/==(.+)==/, "g")
// Match comments
// Match comments
const commentRegex = new RegExp(/%%(.+)%%/, "g")
// from https://github.com/escwxyz/remark-obsidian-callout/blob/main/src/index.ts
const calloutRegex = new RegExp(/^\[\!(\w+)\]([+-]?)/)
export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> | undefined> = (
userOpts,
) => {
const opts = { ...defaultOptions, ...userOpts }
return {
name: "ObsidianFlavoredMarkdown",
@ -154,28 +156,31 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
width ||= "auto"
height ||= "auto"
return {
type: 'image',
type: "image",
url,
data: {
hProperties: {
width, height
}
}
width,
height,
},
},
}
} else if ([".mp4", ".webm", ".ogv", ".mov", ".mkv"].includes(ext)) {
return {
type: 'html',
value: `<video src="${url}" controls></video>`
type: "html",
value: `<video src="${url}" controls></video>`,
}
} else if ([".mp3", ".webm", ".wav", ".m4a", ".ogg", ".3gp", ".flac"].includes(ext)) {
} else if (
[".mp3", ".webm", ".wav", ".m4a", ".ogg", ".3gp", ".flac"].includes(ext)
) {
return {
type: 'html',
value: `<audio src="${url}" controls></audio>`
type: "html",
value: `<audio src="${url}" controls></audio>`,
}
} else if ([".pdf"].includes(ext)) {
return {
type: 'html',
value: `<iframe src="${url}"></iframe>`
type: "html",
value: `<iframe src="${url}"></iframe>`,
}
} else {
// TODO: this is the node embed case
@ -187,17 +192,18 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
// const url = transformInternalLink(fp + anchor)
const url = fp + anchor
return {
type: 'link',
type: "link",
url,
children: [{
type: 'text',
value: alias ?? fp
}]
children: [
{
type: "text",
value: alias ?? fp,
},
],
}
})
}
}
)
})
}
if (opts.highlight) {
@ -206,21 +212,21 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
findAndReplace(tree, highlightRegex, (_value: string, ...capture: string[]) => {
const [inner] = capture
return {
type: 'html',
value: `<span class="text-highlight">${inner}</span>`
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: ''
type: "text",
value: "",
}
})
}
@ -252,7 +258,8 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
const calloutType = typeString.toLowerCase() as keyof typeof callouts
const collapse = collapseChar === "+" || collapseChar === "-"
const defaultState = collapseChar === "-" ? "collapsed" : "expanded"
const title = match.input.slice(calloutDirective.length).trim() || capitalize(calloutType)
const title =
match.input.slice(calloutDirective.length).trim() || capitalize(calloutType)
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>
@ -266,17 +273,20 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
<div class="callout-icon">${callouts[canonicalizeCallout(calloutType)]}</div>
<div class="callout-title-inner">${title}</div>
${collapse ? toggleIcon : ""}
</div>`
</div>`,
}
const blockquoteContent: (BlockContent | DefinitionContent)[] = [titleNode]
if (remainingText.length > 0) {
blockquoteContent.push({
type: 'paragraph',
children: [{
type: 'text',
value: remainingText,
}, ...restChildren]
type: "paragraph",
children: [
{
type: "text",
value: remainingText,
},
...restChildren,
],
})
}
@ -287,10 +297,12 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
node.data = {
hProperties: {
...(node.data?.hProperties ?? {}),
className: `callout ${collapse ? "is-collapsible" : ""} ${defaultState === "collapsed" ? "is-collapsed" : ""}`,
className: `callout ${collapse ? "is-collapsible" : ""} ${
defaultState === "collapsed" ? "is-collapsed" : ""
}`,
"data-callout": calloutType,
"data-callout-fold": collapse,
}
},
}
}
})
@ -301,12 +313,12 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
if (opts.mermaid) {
plugins.push(() => {
return (tree: Root, _file) => {
visit(tree, 'code', (node: Code) => {
if (node.lang === 'mermaid') {
visit(tree, "code", (node: Code) => {
if (node.lang === "mermaid") {
node.data = {
hProperties: {
className: 'mermaid'
}
className: "mermaid",
},
}
}
})
@ -325,8 +337,8 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
if (opts.callouts) {
js.push({
script: calloutScript,
loadTime: 'afterDOMReady',
contentType: 'inline'
loadTime: "afterDOMReady",
contentType: "inline",
})
}
@ -336,13 +348,13 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.esm.min.mjs';
mermaid.initialize({ startOnLoad: true });
`,
loadTime: 'afterDOMReady',
moduleType: 'module',
contentType: 'inline'
loadTime: "afterDOMReady",
moduleType: "module",
contentType: "inline",
})
}
return { js }
}
},
}
}

View File

@ -4,8 +4,13 @@ import rehypePrettyCode, { Options as CodeOptions } from "rehype-pretty-code"
export const SyntaxHighlighting: QuartzTransformerPlugin = () => ({
name: "SyntaxHighlighting",
htmlPlugins() {
return [[rehypePrettyCode, {
theme: 'css-variables',
} satisfies Partial<CodeOptions>]]
}
return [
[
rehypePrettyCode,
{
theme: "css-variables",
} satisfies Partial<CodeOptions>,
],
]
},
})

View File

@ -2,11 +2,11 @@ import { QuartzTransformerPlugin } from "../types"
import { Root } from "mdast"
import { visit } from "unist-util-visit"
import { toString } from "mdast-util-to-string"
import { slug as slugAnchor } from 'github-slugger'
import { slug as slugAnchor } from "github-slugger"
export interface Options {
maxDepth: 1 | 2 | 3 | 4 | 5 | 6,
minEntries: 1,
maxDepth: 1 | 2 | 3 | 4 | 5 | 6
minEntries: 1
showByDefault: boolean
}
@ -17,47 +17,53 @@ const defaultOptions: Options = {
}
interface TocEntry {
depth: number,
text: string,
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) => {
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 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(text)
})
}
})
return [
() => {
return async (tree: Root, file) => {
const display = file.data.frontmatter?.enableToc ?? opts.showByDefault
if (display) {
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(text),
})
}
})
if (toc.length > opts.minEntries) {
file.data.toc = toc.map(entry => ({ ...entry, depth: entry.depth - highestDepth }))
if (toc.length > opts.minEntries) {
file.data.toc = toc.map((entry) => ({
...entry,
depth: entry.depth - highestDepth,
}))
}
}
}
}
}]
},
]
},
}
}
declare module 'vfile' {
declare module "vfile" {
interface DataMap {
toc: TocEntry[]
}
}

View File

@ -6,13 +6,15 @@ import { QuartzComponent } from "../components/types"
import { FilePath, ServerSlug } from "../path"
export interface PluginTypes {
transformers: QuartzTransformerPluginInstance[],
filters: QuartzFilterPluginInstance[],
emitters: QuartzEmitterPluginInstance[],
transformers: QuartzTransformerPluginInstance[]
filters: QuartzFilterPluginInstance[]
emitters: QuartzEmitterPluginInstance[]
}
type OptionType = object | undefined
export type QuartzTransformerPlugin<Options extends OptionType = undefined> = (opts?: Options) => QuartzTransformerPluginInstance
export type QuartzTransformerPlugin<Options extends OptionType = undefined> = (
opts?: Options,
) => QuartzTransformerPluginInstance
export type QuartzTransformerPluginInstance = {
name: string
textTransform?: (src: string | Buffer) => string | Buffer
@ -21,16 +23,26 @@ export type QuartzTransformerPluginInstance = {
externalResources?: () => Partial<StaticResources>
}
export type QuartzFilterPlugin<Options extends OptionType = undefined> = (opts?: Options) => QuartzFilterPluginInstance
export type QuartzFilterPlugin<Options extends OptionType = undefined> = (
opts?: Options,
) => QuartzFilterPluginInstance
export type QuartzFilterPluginInstance = {
name: string
shouldPublish(content: ProcessedContent): boolean
}
export type QuartzEmitterPlugin<Options extends OptionType = undefined> = (opts?: Options) => QuartzEmitterPluginInstance
export type QuartzEmitterPlugin<Options extends OptionType = undefined> = (
opts?: Options,
) => QuartzEmitterPluginInstance
export type QuartzEmitterPluginInstance = {
name: string
emit(contentDir: string, cfg: GlobalConfiguration, content: ProcessedContent[], resources: StaticResources, emitCallback: EmitCallback): Promise<FilePath[]>
emit(
contentDir: string,
cfg: GlobalConfiguration,
content: ProcessedContent[],
resources: StaticResources,
emitCallback: EmitCallback,
): Promise<FilePath[]>
getQuartzComponents(): QuartzComponent[]
}

View File

@ -1,11 +1,11 @@
import { Node, Parent } from 'hast'
import { Data, VFile } from 'vfile'
import { Node, Parent } from "hast"
import { Data, VFile } from "vfile"
export type QuartzPluginData = Data
export type ProcessedContent = [Node<QuartzPluginData>, VFile]
export function defaultProcessedContent(vfileData: Partial<QuartzPluginData>): ProcessedContent {
const root: Parent = { type: 'root', children: [] }
const root: Parent = { type: "root", children: [] }
const vfile = new VFile("")
vfile.data = vfileData
return [root, vfile]

View File

@ -2,25 +2,35 @@ import path from "path"
import fs from "fs"
import { GlobalConfiguration, QuartzConfig } from "../cfg"
import { PerfTimer } from "../perf"
import { ComponentResources, emitComponentResources, getComponentResources, getStaticResourcesFromPlugins } from "../plugins"
import {
ComponentResources,
emitComponentResources,
getComponentResources,
getStaticResourcesFromPlugins,
} from "../plugins"
import { EmitCallback } from "../plugins/types"
import { ProcessedContent } from "../plugins/vfile"
import { FilePath, QUARTZ, slugifyFilePath } from "../path"
import { globbyStream } from "globby"
// @ts-ignore
import spaRouterScript from '../components/scripts/spa.inline'
import spaRouterScript from "../components/scripts/spa.inline"
// @ts-ignore
import plausibleScript from '../components/scripts/plausible.inline'
import plausibleScript from "../components/scripts/plausible.inline"
// @ts-ignore
import popoverScript from '../components/scripts/popover.inline'
import popoverStyle from '../components/styles/popover.scss'
import popoverScript from "../components/scripts/popover.inline"
import popoverStyle from "../components/styles/popover.scss"
import { StaticResources } from "../resources"
import { QuartzLogger } from "../log"
import { googleFontHref } from "../theme"
import { trace } from "../trace"
function addGlobalPageResources(cfg: GlobalConfiguration, reloadScript: boolean, staticResources: StaticResources, componentResources: ComponentResources) {
function addGlobalPageResources(
cfg: GlobalConfiguration,
reloadScript: boolean,
staticResources: StaticResources,
componentResources: ComponentResources,
) {
staticResources.css.push(googleFontHref(cfg.theme))
// popovers
@ -33,8 +43,8 @@ function addGlobalPageResources(cfg: GlobalConfiguration, reloadScript: boolean,
const tagId = cfg.analytics.tagId
staticResources.js.push({
src: `https://www.googletagmanager.com/gtag/js?id=${tagId}`,
contentType: 'external',
loadTime: 'afterDOMReady',
contentType: "external",
loadTime: "afterDOMReady",
})
componentResources.afterDOMLoaded.push(`
window.dataLayer = window.dataLayer || [];
@ -47,8 +57,7 @@ function addGlobalPageResources(cfg: GlobalConfiguration, reloadScript: boolean,
page_title: document.title,
page_location: location.href,
});
});`
)
});`)
} else if (cfg.analytics?.provider === "plausible") {
componentResources.afterDOMLoaded.push(plausibleScript)
}
@ -60,8 +69,7 @@ function addGlobalPageResources(cfg: GlobalConfiguration, reloadScript: boolean,
componentResources.afterDOMLoaded.push(`
window.spaNavigate = (url, _) => window.location.assign(url)
const event = new CustomEvent("nav", { detail: { slug: document.body.dataset.slug } })
document.dispatchEvent(event)`
)
document.dispatchEvent(event)`)
}
if (reloadScript) {
@ -71,12 +79,19 @@ function addGlobalPageResources(cfg: GlobalConfiguration, reloadScript: boolean,
script: `
const socket = new WebSocket('ws://localhost:3001')
socket.addEventListener('message', () => document.location.reload())
`
`,
})
}
}
export async function emitContent(contentFolder: string, output: string, cfg: QuartzConfig, content: ProcessedContent[], reloadScript: boolean, verbose: boolean) {
export async function emitContent(
contentFolder: string,
output: string,
cfg: QuartzConfig,
content: ProcessedContent[],
reloadScript: boolean,
verbose: boolean,
) {
const perf = new PerfTimer()
const log = new QuartzLogger(verbose)
@ -95,8 +110,8 @@ export async function emitContent(contentFolder: string, output: string, cfg: Qu
// component specific scripts and styles
const componentResources = getComponentResources(cfg.plugins)
// important that this goes *after* component scripts
// as the "nav" event gets triggered here and we should make sure
// important that this goes *after* component scripts
// as the "nav" event gets triggered here and we should make sure
// that everyone else had the chance to register a listener for it
addGlobalPageResources(cfg.configuration, reloadScript, staticResources, componentResources)
@ -112,7 +127,13 @@ export async function emitContent(contentFolder: string, output: string, cfg: Qu
// emitter plugins
for (const emitter of cfg.plugins.emitters) {
try {
const emitted = await emitter.emit(contentFolder, cfg.configuration, content, staticResources, emit)
const emitted = await emitter.emit(
contentFolder,
cfg.configuration,
content,
staticResources,
emit,
)
emittedFiles += emitted.length
if (verbose) {
@ -141,7 +162,7 @@ export async function emitContent(contentFolder: string, output: string, cfg: Qu
const fp = rawFp as FilePath
const ext = path.extname(fp)
const src = path.join(contentFolder, fp) as FilePath
const name = slugifyFilePath(fp as FilePath) + ext as FilePath
const name = (slugifyFilePath(fp as FilePath) + ext) as FilePath
const dest = path.join(assetsPath, name) as FilePath
const dir = path.dirname(dest) as FilePath
await fs.promises.mkdir(dir, { recursive: true }) // ensure dir exists

View File

@ -2,14 +2,18 @@ import { PerfTimer } from "../perf"
import { QuartzFilterPluginInstance } from "../plugins/types"
import { ProcessedContent } from "../plugins/vfile"
export function filterContent(plugins: QuartzFilterPluginInstance[], content: ProcessedContent[], verbose: boolean): ProcessedContent[] {
export function filterContent(
plugins: QuartzFilterPluginInstance[],
content: ProcessedContent[],
verbose: boolean,
): ProcessedContent[] {
const perf = new PerfTimer()
const initialLength = content.length
for (const plugin of plugins) {
const updatedContent = content.filter(plugin.shouldPublish)
if (verbose) {
const diff = content.filter(x => !updatedContent.includes(x))
const diff = content.filter((x) => !updatedContent.includes(x))
for (const file of diff) {
console.log(`[filter:${plugin.name}] ${file[1].data.slug}`)
}

View File

@ -1,19 +1,19 @@
import esbuild from 'esbuild'
import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype'
import esbuild from "esbuild"
import remarkParse from "remark-parse"
import remarkRehype from "remark-rehype"
import { Processor, unified } from "unified"
import { Root as MDRoot } from 'remark-parse/lib'
import { Root as HTMLRoot } from 'hast'
import { ProcessedContent } from '../plugins/vfile'
import { PerfTimer } from '../perf'
import { read } from 'to-vfile'
import { FilePath, QUARTZ, ServerSlug, slugifyFilePath } from '../path'
import path from 'path'
import os from 'os'
import workerpool, { Promise as WorkerPromise } from 'workerpool'
import { QuartzTransformerPluginInstance } from '../plugins/types'
import { QuartzLogger } from '../log'
import { trace } from '../trace'
import { Root as MDRoot } from "remark-parse/lib"
import { Root as HTMLRoot } from "hast"
import { ProcessedContent } from "../plugins/vfile"
import { PerfTimer } from "../perf"
import { read } from "to-vfile"
import { FilePath, QUARTZ, ServerSlug, slugifyFilePath } from "../path"
import path from "path"
import os from "os"
import workerpool, { Promise as WorkerPromise } from "workerpool"
import { QuartzTransformerPluginInstance } from "../plugins/types"
import { QuartzLogger } from "../log"
import { trace } from "../trace"
export type QuartzProcessor = Processor<MDRoot, HTMLRoot, void>
export function createProcessor(transformers: QuartzTransformerPluginInstance[]): QuartzProcessor {
@ -21,16 +21,15 @@ export function createProcessor(transformers: QuartzTransformerPluginInstance[])
let processor = unified().use(remarkParse)
// MD AST -> MD AST transforms
for (const plugin of transformers.filter(p => p.markdownPlugins)) {
for (const plugin of transformers.filter((p) => p.markdownPlugins)) {
processor = processor.use(plugin.markdownPlugins!())
}
// MD AST -> HTML AST
processor = processor.use(remarkRehype, { allowDangerousHtml: true })
// HTML AST -> HTML AST transforms
for (const plugin of transformers.filter(p => p.htmlPlugins)) {
for (const plugin of transformers.filter((p) => p.htmlPlugins)) {
processor = processor.use(plugin.htmlPlugins!())
}
@ -57,23 +56,29 @@ async function transpileWorkerScript() {
packages: "external",
plugins: [
{
name: 'css-and-scripts-as-text',
name: "css-and-scripts-as-text",
setup(build) {
build.onLoad({ filter: /\.scss$/ }, (_) => ({
contents: '',
loader: 'text'
contents: "",
loader: "text",
}))
build.onLoad({ filter: /\.inline\.(ts|js)$/ }, (_) => ({
contents: '',
loader: 'text'
contents: "",
loader: "text",
}))
}
}
]
},
},
],
})
}
export function createFileParser(transformers: QuartzTransformerPluginInstance[], baseDir: string, fps: FilePath[], allSlugs: ServerSlug[], verbose: boolean) {
export function createFileParser(
transformers: QuartzTransformerPluginInstance[],
baseDir: string,
fps: FilePath[],
allSlugs: ServerSlug[],
verbose: boolean,
) {
return async (processor: QuartzProcessor) => {
const res: ProcessedContent[] = []
for (const fp of fps) {
@ -84,7 +89,7 @@ export function createFileParser(transformers: QuartzTransformerPluginInstance[]
file.value = file.value.toString().trim()
// Text -> Text transforms
for (const plugin of transformers.filter(p => p.textTransform)) {
for (const plugin of transformers.filter((p) => p.textTransform)) {
file.value = plugin.textTransform!(file.value)
}
@ -110,7 +115,12 @@ export function createFileParser(transformers: QuartzTransformerPluginInstance[]
}
}
export async function parseMarkdown(transformers: QuartzTransformerPluginInstance[], baseDir: string, fps: FilePath[], verbose: boolean): Promise<ProcessedContent[]> {
export async function parseMarkdown(
transformers: QuartzTransformerPluginInstance[],
baseDir: string,
fps: FilePath[],
verbose: boolean,
): Promise<ProcessedContent[]> {
const perf = new PerfTimer()
const log = new QuartzLogger(verbose)
@ -118,7 +128,9 @@ export async function parseMarkdown(transformers: QuartzTransformerPluginInstanc
let concurrency = fps.length < CHUNK_SIZE ? 1 : os.availableParallelism()
// get all slugs ahead of time as each thread needs a copy
const allSlugs = fps.map(fp => slugifyFilePath(path.relative(baseDir, path.resolve(fp)) as FilePath))
const allSlugs = fps.map((fp) =>
slugifyFilePath(path.relative(baseDir, path.resolve(fp)) as FilePath),
)
let res: ProcessedContent[] = []
log.start(`Parsing input files using ${concurrency} threads`)
@ -128,18 +140,15 @@ export async function parseMarkdown(transformers: QuartzTransformerPluginInstanc
res = await parse(processor)
} else {
await transpileWorkerScript()
const pool = workerpool.pool(
'./quartz/bootstrap-worker.mjs',
{
minWorkers: 'max',
maxWorkers: concurrency,
workerType: 'thread'
}
)
const pool = workerpool.pool("./quartz/bootstrap-worker.mjs", {
minWorkers: "max",
maxWorkers: concurrency,
workerType: "thread",
})
const childPromises: WorkerPromise<ProcessedContent[]>[] = []
for (const chunk of chunks(fps, CHUNK_SIZE)) {
childPromises.push(pool.exec('parseFiles', [baseDir, chunk, allSlugs, verbose]))
childPromises.push(pool.exec("parseFiles", [baseDir, chunk, allSlugs, verbose]))
}
const results: ProcessedContent[][] = await WorkerPromise.all(childPromises)

View File

@ -2,29 +2,38 @@ import { randomUUID } from "crypto"
import { JSX } from "preact/jsx-runtime"
export type JSResource = {
loadTime: 'beforeDOMReady' | 'afterDOMReady'
moduleType?: 'module',
loadTime: "beforeDOMReady" | "afterDOMReady"
moduleType?: "module"
spaPreserve?: boolean
} & ({
src: string
contentType: 'external'
} | {
script: string
contentType: 'inline'
})
} & (
| {
src: string
contentType: "external"
}
| {
script: string
contentType: "inline"
}
)
export function JSResourceToScriptElement(resource: JSResource, preserve?: boolean): JSX.Element {
const scriptType = resource.moduleType ?? 'application/javascript'
const scriptType = resource.moduleType ?? "application/javascript"
const spaPreserve = preserve ?? resource.spaPreserve
if (resource.contentType === 'external') {
return <script key={resource.src} src={resource.src} type={scriptType} spa-preserve={spaPreserve}/>
if (resource.contentType === "external") {
return (
<script key={resource.src} src={resource.src} type={scriptType} spa-preserve={spaPreserve} />
)
} else {
const content = resource.script
return <script key={randomUUID()} type={scriptType} spa-preserve={spaPreserve}>{content}</script>
return (
<script key={randomUUID()} type={scriptType} spa-preserve={spaPreserve}>
{content}
</script>
)
}
}
export interface StaticResources {
css: string[],
css: string[]
js: JSResource[]
}

View File

@ -21,7 +21,17 @@ body {
border-radius: 5px;
}
p, ul, text, a, tr, td, li, ol, ul, .katex, .math {
p,
ul,
text,
a,
tr,
td,
li,
ol,
ul,
.katex,
.math {
color: var(--darkgray);
fill: var(--darkgray);
}
@ -79,7 +89,7 @@ a {
font-size: 2rem;
}
& li:has(> input[type='checkbox']) {
& li:has(> input[type="checkbox"]) {
list-style-type: none;
padding-left: 0;
margin-left: -1.4rem;
@ -144,7 +154,8 @@ a {
}
}
& .center, & footer {
& .center,
& footer {
width: $pageWidth;
margin-left: auto;
margin-right: auto;
@ -195,9 +206,12 @@ thead {
}
}
h1, h2, h3, h4, h5, h6 {
h1,
h2,
h3,
h4,
h5,
h6 {
&[id] > a[href^="#"] {
margin: 0 0.5rem;
opacity: 0;
@ -277,11 +291,11 @@ pre {
}
}
&[data-line-numbers-max-digits='2'] > [data-line]::before {
&[data-line-numbers-max-digits="2"] > [data-line]::before {
width: 2rem;
}
&[data-line-numbers-max-digits='3'] > [data-line]::before {
&[data-line-numbers-max-digits="3"] > [data-line]::before {
width: 3rem;
}
}
@ -296,7 +310,9 @@ code {
background: var(--lightgray);
}
tbody, li, p {
tbody,
li,
p {
line-height: 1.5rem;
}
@ -307,7 +323,8 @@ table {
border-collapse: collapse;
}
td, th {
td,
th {
padding: 0.2rem 1rem;
border: 1px solid var(--gray);
}
@ -331,7 +348,8 @@ hr {
background-color: var(--lightgray);
}
audio, video {
audio,
video {
width: 100%;
border-radius: 5px;
}
@ -340,7 +358,8 @@ audio, video {
flex: 1 1 auto;
}
ul.overflow, ol.overflow {
ul.overflow,
ol.overflow {
height: 400px;
overflow-y: scroll;
@ -354,9 +373,9 @@ ul.overflow, ol.overflow {
&:after {
pointer-events: none;
content: '';
content: "";
width: 100%;
height: 50px;
height: 50px;
position: absolute;
left: 0;
bottom: 0;

View File

@ -1,104 +1,104 @@
@use "sass:color";
.callout {
border: 1px solid var(--border);
background-color: var(--bg);
border-radius: 5px;
padding: 0 1rem;
overflow-y: hidden;
border: 1px solid var(--border);
background-color: var(--bg);
border-radius: 5px;
padding: 0 1rem;
overflow-y: hidden;
transition: max-height 0.3s ease;
& > *:nth-child(2) {
margin-top: 0;
}
&[data-callout="note"] {
--color: #448aff;
--border: #448aff22;
--bg: #448aff09;
}
&[data-callout="note"] {
--color: #448aff;
--border: #448aff22;
--bg: #448aff09;
}
&[data-callout="abstract"] {
--color: #00b0ff;
--border: #00b0ff22;
--bg: #00b0ff09;
}
&[data-callout="abstract"] {
--color: #00b0ff;
--border: #00b0ff22;
--bg: #00b0ff09;
}
&[data-callout="info"], &[data-callout="todo"] {
--color: #00b8d4;
--border: #00b8d422;
--bg: #00b8d409;
}
&[data-callout="info"],
&[data-callout="todo"] {
--color: #00b8d4;
--border: #00b8d422;
--bg: #00b8d409;
}
&[data-callout="tip"] {
--color: #00bfa5;
--border: #00bfa522;
--bg: #00bfa509;
}
&[data-callout="tip"] {
--color: #00bfa5;
--border: #00bfa522;
--bg: #00bfa509;
}
&[data-callout="success"] {
--color: #09ad7a;
--border: #09ad7122;
--bg: #09ad7109;
}
&[data-callout="success"] {
--color: #09ad7a;
--border: #09ad7122;
--bg: #09ad7109;
}
&[data-callout="question"] {
--color: #dba642;
--border: #dba64222;
--bg: #dba64209;
}
&[data-callout="question"] {
--color: #dba642;
--border: #dba64222;
--bg: #dba64209;
}
&[data-callout="warning"] {
--color: #db8942;
--border: #db894222;
--bg: #db894209;
}
&[data-callout="warning"] {
--color: #db8942;
--border: #db894222;
--bg: #db894209;
}
&[data-callout="failure"], &[data-callout="danger"], &[data-callout="bug"] {
--color: #db4242;
--border: #db424222;
--bg: #db424209;
}
&[data-callout="failure"],
&[data-callout="danger"],
&[data-callout="bug"] {
--color: #db4242;
--border: #db424222;
--bg: #db424209;
}
&[data-callout="example"] {
--color: #7a43b5;
--border: #7a43b522;
--bg: #7a43b509;
}
&[data-callout="example"] {
--color: #7a43b5;
--border: #7a43b522;
--bg: #7a43b509;
}
&[data-callout="quote"] {
--color: var(--secondary);
--border: var(--lightgray);
}
&[data-callout="quote"] {
--color: var(--secondary);
--border: var(--lightgray);
}
&.is-collapsed > .callout-title > .fold {
transform: rotateZ(-90deg)
transform: rotateZ(-90deg);
}
}
.callout-title {
display: flex;
align-items: center;
gap: 5px;
padding: 1rem 0;
color: var(--color);
display: flex;
align-items: center;
gap: 5px;
padding: 1rem 0;
color: var(--color);
& .fold {
margin-left: 0.5rem;
& .fold {
margin-left: 0.5rem;
transition: transform 0.3s ease;
opacity: 0.8;
cursor: pointer;
}
}
.callout-icon {
width: 18px;
height: 18px;
width: 18px;
height: 18px;
}
.callout-title-inner {
font-weight: 700;
font-weight: 700;
}

View File

@ -3,4 +3,4 @@ $mobileBreakpoint: 600px;
$tabletBreakpoint: 1200px;
$sidePanelWidth: 400px;
$topSpacing: 6rem;
$fullPageWidth: $pageWidth + 2 * $sidePanelWidth
$fullPageWidth: $pageWidth + 2 * $sidePanelWidth;

View File

@ -1,27 +1,28 @@
export interface ColorScheme {
light: string,
lightgray: string,
gray: string,
darkgray: string,
dark: string,
secondary: string,
tertiary: string,
light: string
lightgray: string
gray: string
darkgray: string
dark: string
secondary: string
tertiary: string
highlight: string
}
export interface Theme {
typography: {
header: string,
body: string,
header: string
body: string
code: string
},
}
colors: {
lightMode: ColorScheme,
lightMode: ColorScheme
darkMode: ColorScheme
}
}
const DEFAULT_SANS_SERIF = "-apple-system, BlinkMacSystemFont, \"Segoe UI\", Helvetica, Arial, sans-serif"
const DEFAULT_SANS_SERIF =
'-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif'
const DEFAULT_MONO = "ui-monospace, SFMono-Regular, SF Mono, Menlo, monospace"
export function googleFontHref(theme: Theme) {
const { code, header, body } = theme.typography

View File

@ -4,13 +4,17 @@ const rootFile = /.*at file:/
export function trace(msg: string, err: Error) {
const stack = err.stack
console.log()
console.log(chalk.bgRed.white.bold(" ERROR ") + chalk.red(` ${msg}`) + (err.message.length > 0 ? `: ${err.message}` : ""))
console.log(
chalk.bgRed.white.bold(" ERROR ") +
chalk.red(` ${msg}`) +
(err.message.length > 0 ? `: ${err.message}` : ""),
)
if (!stack) {
return
}
let reachedEndOfLegibleTrace = false
for (const line of stack.split('\n').slice(1)) {
for (const line of stack.split("\n").slice(1)) {
if (reachedEndOfLegibleTrace) {
break
}

View File

@ -6,7 +6,12 @@ const transformers = config.plugins.transformers
const processor = createProcessor(transformers)
// only called from worker thread
export async function parseFiles(baseDir: string, fps: FilePath[], allSlugs: ServerSlug[], verbose: boolean) {
export async function parseFiles(
baseDir: string,
fps: FilePath[],
allSlugs: ServerSlug[],
verbose: boolean,
) {
const parse = createFileParser(transformers, baseDir, fps, allSlugs, verbose)
return parse(processor)
}