more robust error handling, config hotreload

This commit is contained in:
Jacky Zhao 2023-08-05 11:28:09 -07:00
parent 6a7ee31c7c
commit 27baf57dd1
10 changed files with 151 additions and 145 deletions

View File

@ -4,8 +4,6 @@ draft: true
## high priority ## high priority
- images in same folder are broken on shortest path mode
- watch mode for config/source code
- block links: https://help.obsidian.md/Linking+notes+and+files/Internal+links#Link+to+a+block+in+a+note - block links: https://help.obsidian.md/Linking+notes+and+files/Internal+links#Link+to+a+block+in+a+note
- note/header/block transcludes: https://help.obsidian.md/Linking+notes+and+files/Embedding+files - note/header/block transcludes: https://help.obsidian.md/Linking+notes+and+files/Embedding+files
@ -22,7 +20,5 @@ draft: true
- https://help.obsidian.md/Advanced+topics/Using+Obsidian+URI - https://help.obsidian.md/Advanced+topics/Using+Obsidian+URI
- audio/video embed styling - audio/video embed styling
- Canvas - Canvas
- mermaid styling: https://mermaid.js.org/config/theming.html#theme-variables-reference-table
- https://github.com/jackyzha0/quartz/issues/331
- parse all images in page: use this for page lists if applicable? - parse all images in page: use this for page lists if applicable?
- CV mode? with print stylesheet - CV mode? with print stylesheet

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "@jackyzha0/quartz", "name": "@jackyzha0/quartz",
"version": "4.0.6", "version": "4.0.7",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@jackyzha0/quartz", "name": "@jackyzha0/quartz",
"version": "4.0.6", "version": "4.0.7",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@clack/prompts": "^0.6.3", "@clack/prompts": "^0.6.3",

View File

@ -2,7 +2,7 @@
"name": "@jackyzha0/quartz", "name": "@jackyzha0/quartz",
"description": "🌱 publish your digital garden and notes as a website", "description": "🌱 publish your digital garden and notes as a website",
"private": true, "private": true,
"version": "4.0.6", "version": "4.0.7",
"type": "module", "type": "module",
"author": "jackyzha0 <j.zhao2k19@gmail.com>", "author": "jackyzha0 <j.zhao2k19@gmail.com>",
"license": "MIT", "license": "MIT",

View File

@ -9,9 +9,13 @@ import { sassPlugin } from "esbuild-sass-plugin"
import fs from "fs" import fs from "fs"
import { intro, isCancel, outro, select, text } from "@clack/prompts" import { intro, isCancel, outro, select, text } from "@clack/prompts"
import { rimraf } from "rimraf" import { rimraf } from "rimraf"
import chokidar from "chokidar"
import prettyBytes from "pretty-bytes" import prettyBytes from "pretty-bytes"
import { execSync, spawnSync } from "child_process" import { execSync, spawnSync } from "child_process"
import { transform as cssTransform } from "lightningcss" import { transform as cssTransform } from "lightningcss"
import http from "http"
import serveHandler from "serve-handler"
import { WebSocketServer } from "ws"
const ORIGIN_NAME = "origin" const ORIGIN_NAME = "origin"
const UPSTREAM_NAME = "upstream" const UPSTREAM_NAME = "upstream"
@ -287,86 +291,132 @@ See the [documentation](https://quartz.jzhao.xyz) for how to get started.
console.log(chalk.green("Done!")) console.log(chalk.green("Done!"))
}) })
.command("build", "Build Quartz into a bundle of static HTML files", BuildArgv, async (argv) => { .command("build", "Build Quartz into a bundle of static HTML files", BuildArgv, async (argv) => {
const result = await esbuild console.log(chalk.bgGreen.black(`\n Quartz v${version} \n`))
.build({ const ctx = await esbuild.context({
entryPoints: [fp], entryPoints: [fp],
outfile: path.join("quartz", cacheFile), outfile: path.join("quartz", cacheFile),
bundle: true, bundle: true,
keepNames: true, keepNames: true,
minify: true, minify: true,
platform: "node", platform: "node",
format: "esm", format: "esm",
jsx: "automatic", jsx: "automatic",
jsxImportSource: "preact", jsxImportSource: "preact",
packages: "external", packages: "external",
metafile: true, metafile: true,
sourcemap: true, sourcemap: true,
plugins: [ plugins: [
sassPlugin({ sassPlugin({
type: "css-text", type: "css-text",
cssImports: true, cssImports: true,
async transform(css) { async transform(css) {
const { code } = cssTransform({ const { code } = cssTransform({
filename: "style.css", filename: "style.css",
code: Buffer.from(css), code: Buffer.from(css),
minify: true, minify: true,
}) })
return code.toString() return code.toString()
},
}),
{
name: "inline-script-loader",
setup(build) {
build.onLoad({ filter: /\.inline\.(ts|js)$/ }, async (args) => {
let text = await promises.readFile(args.path, "utf8")
// remove default exports that we manually inserted
text = text.replace("export default", "")
text = text.replace("export", "")
const sourcefile = path.relative(path.resolve("."), args.path)
const resolveDir = path.dirname(sourcefile)
const transpiled = await esbuild.build({
stdin: {
contents: text,
loader: "ts",
resolveDir,
sourcefile,
},
write: false,
bundle: true,
platform: "browser",
format: "esm",
})
const rawMod = transpiled.outputFiles[0].text
return {
contents: rawMod,
loader: "text",
}
})
},
}, },
], }),
}) {
.catch((err) => { name: "inline-script-loader",
setup(build) {
build.onLoad({ filter: /\.inline\.(ts|js)$/ }, async (args) => {
let text = await promises.readFile(args.path, "utf8")
// remove default exports that we manually inserted
text = text.replace("export default", "")
text = text.replace("export", "")
const sourcefile = path.relative(path.resolve("."), args.path)
const resolveDir = path.dirname(sourcefile)
const transpiled = await esbuild.build({
stdin: {
contents: text,
loader: "ts",
resolveDir,
sourcefile,
},
write: false,
bundle: true,
platform: "browser",
format: "esm",
})
const rawMod = transpiled.outputFiles[0].text
return {
contents: rawMod,
loader: "text",
}
})
},
},
],
})
let clientRefresh = () => {}
let closeHandler = null
const build = async () => {
const result = await ctx.rebuild().catch((err) => {
console.error(`${chalk.red("Couldn't parse Quartz configuration:")} ${fp}`) console.error(`${chalk.red("Couldn't parse Quartz configuration:")} ${fp}`)
console.log(`Reason: ${chalk.grey(err)}`) console.log(`Reason: ${chalk.grey(err)}`)
process.exit(1) process.exit(1)
}) })
if (argv.bundleInfo) { if (argv.bundleInfo) {
const outputFileName = "quartz/.quartz-cache/transpiled-build.mjs" const outputFileName = "quartz/.quartz-cache/transpiled-build.mjs"
const meta = result.metafile.outputs[outputFileName] const meta = result.metafile.outputs[outputFileName]
console.log( console.log(
`Successfully transpiled ${Object.keys(meta.inputs).length} files (${prettyBytes( `Successfully transpiled ${Object.keys(meta.inputs).length} files (${prettyBytes(
meta.bytes, meta.bytes,
)})`, )})`,
) )
console.log(await esbuild.analyzeMetafile(result.metafile, { color: true })) console.log(await esbuild.analyzeMetafile(result.metafile, { color: true }))
}
// bypass module cache
const { default: buildQuartz } = await import(cacheFile + `?update=${new Date()}`)
if (closeHandler) {
await closeHandler()
}
closeHandler = await buildQuartz(argv, clientRefresh)
clientRefresh()
} }
const { default: buildQuartz } = await import(cacheFile) await build()
buildQuartz(argv, version) if (argv.serve) {
const wss = new WebSocketServer({ port: 3001 })
const connections = []
wss.on("connection", (ws) => connections.push(ws))
clientRefresh = () => connections.forEach((conn) => conn.send("rebuild"))
const server = http.createServer(async (req, res) => {
await serveHandler(req, res, {
public: argv.output,
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}]`)
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")
chokidar
.watch(["**/*.ts", "**/*.tsx", "**/*.scss", "package.json"], {
ignoreInitial: true,
})
.on("all", async () => {
console.log(chalk.yellow("Detected a source code change, doing a hard rebuild..."))
await build()
})
} else {
ctx.dispose()
}
}) })
.showHelpOnFail(false) .showHelpOnFail(false)
.help() .help()

View File

@ -4,8 +4,6 @@ import { PerfTimer } from "./perf"
import { rimraf } from "rimraf" import { rimraf } from "rimraf"
import { isGitIgnored } from "globby" import { isGitIgnored } from "globby"
import chalk from "chalk" import chalk from "chalk"
import http from "http"
import serveHandler from "serve-handler"
import { parseMarkdown } from "./processors/parse" import { parseMarkdown } from "./processors/parse"
import { filterContent } from "./processors/filter" import { filterContent } from "./processors/filter"
import { emitContent } from "./processors/emit" import { emitContent } from "./processors/emit"
@ -13,18 +11,17 @@ import cfg from "../quartz.config"
import { FilePath, joinSegments, slugifyFilePath } from "./path" import { FilePath, joinSegments, slugifyFilePath } from "./path"
import chokidar from "chokidar" import chokidar from "chokidar"
import { ProcessedContent } from "./plugins/vfile" import { ProcessedContent } from "./plugins/vfile"
import WebSocket, { WebSocketServer } from "ws"
import { Argv, BuildCtx } from "./ctx" import { Argv, BuildCtx } from "./ctx"
import { glob, toPosixPath } from "./glob" import { glob, toPosixPath } from "./glob"
import { trace } from "./trace"
async function buildQuartz(argv: Argv, version: string) { async function buildQuartz(argv: Argv, clientRefresh: () => void) {
const ctx: BuildCtx = { const ctx: BuildCtx = {
argv, argv,
cfg, cfg,
allSlugs: [], allSlugs: [],
} }
console.log(chalk.bgGreen.black(`\n Quartz v${version} \n`))
const perf = new PerfTimer() const perf = new PerfTimer()
const output = argv.output const output = argv.output
@ -57,15 +54,17 @@ async function buildQuartz(argv: Argv, version: string) {
console.log(chalk.green(`Done processing ${fps.length} files in ${perf.timeSince()}`)) console.log(chalk.green(`Done processing ${fps.length} files in ${perf.timeSince()}`))
if (argv.serve) { if (argv.serve) {
await startServing(ctx, parsedFiles) return startServing(ctx, parsedFiles, clientRefresh)
} }
} }
async function startServing(ctx: BuildCtx, initialContent: ProcessedContent[]) { // setup watcher for rebuilds
async function startServing(
ctx: BuildCtx,
initialContent: ProcessedContent[],
clientRefresh: () => void,
) {
const { argv } = ctx const { argv } = ctx
const wss = new WebSocketServer({ port: 3001 })
const connections: WebSocket[] = []
wss.on("connection", (ws) => connections.push(ws))
const ignored = await isGitIgnored() const ignored = await isGitIgnored()
const contentMap = new Map<FilePath, ProcessedContent>() const contentMap = new Map<FilePath, ProcessedContent>()
@ -78,6 +77,12 @@ async function startServing(ctx: BuildCtx, initialContent: ProcessedContent[]) {
let toRebuild: Set<FilePath> = new Set() let toRebuild: Set<FilePath> = new Set()
let toRemove: Set<FilePath> = new Set() let toRemove: Set<FilePath> = new Set()
async function rebuild(fp: string, action: "add" | "change" | "delete") { async function rebuild(fp: string, action: "add" | "change" | "delete") {
if (path.extname(fp) !== ".md") {
// dont bother rebuilding for non-content files, just refresh
clientRefresh()
return
}
fp = toPosixPath(fp) fp = toPosixPath(fp)
if (!ignored(fp)) { if (!ignored(fp)) {
const filePath = joinSegments(argv.directory, fp) as FilePath const filePath = joinSegments(argv.directory, fp) as FilePath
@ -120,7 +125,8 @@ async function startServing(ctx: BuildCtx, initialContent: ProcessedContent[]) {
} catch { } catch {
console.log(chalk.yellow(`Rebuild failed. Waiting on a change to fix the error...`)) console.log(chalk.yellow(`Rebuild failed. Waiting on a change to fix the error...`))
} }
connections.forEach((conn) => conn.send("rebuild"))
clientRefresh()
toRebuild.clear() toRebuild.clear()
toRemove.clear() toRemove.clear()
}, 250) }, 250)
@ -137,31 +143,12 @@ async function startServing(ctx: BuildCtx, initialContent: ProcessedContent[]) {
.on("add", (fp) => rebuild(fp, "add")) .on("add", (fp) => rebuild(fp, "add"))
.on("change", (fp) => rebuild(fp, "change")) .on("change", (fp) => rebuild(fp, "change"))
.on("unlink", (fp) => rebuild(fp, "delete")) .on("unlink", (fp) => rebuild(fp, "delete"))
const server = http.createServer(async (req, res) => {
await serveHandler(req, res, {
public: argv.output,
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}]`)
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")
} }
export default async (argv: Argv, version: string) => { export default async (argv: Argv, clientRefresh: () => void) => {
try { try {
await buildQuartz(argv, version) return await buildQuartz(argv, clientRefresh)
} catch { } catch (err) {
console.log(chalk.red("\nExiting Quartz due to a fatal error")) trace("\nExiting Quartz due to a fatal error", err as Error)
process.exit(1)
} }
} }

View File

@ -1,5 +1,4 @@
import { slug } from "github-slugger" import { slug } from "github-slugger"
import { trace } from "./trace"
// Quartz Paths // Quartz Paths
// Things in boxes are not actual types but rather sources which these types can be acquired from // Things in boxes are not actual types but rather sources which these types can be acquired from
@ -43,18 +42,6 @@ import { trace } from "./trace"
// └────────────┤ MD File ├─────┴─────────────────┘ // └────────────┤ MD File ├─────┴─────────────────┘
// └─────────┘ // └─────────┘
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) {
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) {
process.exit(1)
}
}
}
/// Utility type to simulate nominal types in TypeScript /// Utility type to simulate nominal types in TypeScript
type SlugLike<T> = string & { __brand: T } type SlugLike<T> = string & { __brand: T }
@ -102,36 +89,29 @@ export function isFilePath(s: string): s is FilePath {
export function getClientSlug(window: Window): ClientSlug { export function getClientSlug(window: Window): ClientSlug {
const res = window.location.href as ClientSlug const res = window.location.href as ClientSlug
conditionCheck(getClientSlug.name, "post", res, isClientSlug)
return res return res
} }
export function getCanonicalSlug(window: Window): CanonicalSlug { export function getCanonicalSlug(window: Window): CanonicalSlug {
const res = window.document.body.dataset.slug! as CanonicalSlug const res = window.document.body.dataset.slug! as CanonicalSlug
conditionCheck(getCanonicalSlug.name, "post", res, isCanonicalSlug)
return res return res
} }
export function canonicalizeClient(slug: ClientSlug): CanonicalSlug { export function canonicalizeClient(slug: ClientSlug): CanonicalSlug {
conditionCheck(canonicalizeClient.name, "pre", slug, isClientSlug)
const { pathname } = new URL(slug) const { pathname } = new URL(slug)
let fp = pathname.slice(1) 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 const res = _canonicalize(fp) as CanonicalSlug
conditionCheck(canonicalizeClient.name, "post", res, isCanonicalSlug)
return res return res
} }
export function canonicalizeServer(slug: ServerSlug): CanonicalSlug { export function canonicalizeServer(slug: ServerSlug): CanonicalSlug {
conditionCheck(canonicalizeServer.name, "pre", slug, isServerSlug)
let fp = slug as string let fp = slug as string
const res = _canonicalize(fp) as CanonicalSlug const res = _canonicalize(fp) as CanonicalSlug
conditionCheck(canonicalizeServer.name, "post", res, isCanonicalSlug)
return res return res
} }
export function slugifyFilePath(fp: FilePath): ServerSlug { export function slugifyFilePath(fp: FilePath): ServerSlug {
conditionCheck(slugifyFilePath.name, "pre", fp, isFilePath)
fp = _stripSlashes(fp) as FilePath fp = _stripSlashes(fp) as FilePath
const withoutFileExt = fp.replace(new RegExp(_getFileExtension(fp) + "$"), "") const withoutFileExt = fp.replace(new RegExp(_getFileExtension(fp) + "$"), "")
let slug = withoutFileExt let slug = withoutFileExt
@ -145,7 +125,6 @@ export function slugifyFilePath(fp: FilePath): ServerSlug {
slug = slug.replace(/_index$/, "index") slug = slug.replace(/_index$/, "index")
} }
conditionCheck(slugifyFilePath.name, "post", slug, isServerSlug)
return slug as ServerSlug return slug as ServerSlug
} }
@ -165,13 +144,11 @@ export function transformInternalLink(link: string): RelativeURL {
let joined = joinSegments(_stripSlashes(prefix), _stripSlashes(fp)) let joined = joinSegments(_stripSlashes(prefix), _stripSlashes(fp))
const res = (_addRelativeToStart(joined) + anchor) as RelativeURL const res = (_addRelativeToStart(joined) + anchor) as RelativeURL
conditionCheck(transformInternalLink.name, "post", res, isRelativeURL)
return res return res
} }
// resolve /a/b/c to ../../ // resolve /a/b/c to ../../
export function pathToRoot(slug: CanonicalSlug): RelativeURL { export function pathToRoot(slug: CanonicalSlug): RelativeURL {
conditionCheck(pathToRoot.name, "pre", slug, isCanonicalSlug)
let rootPath = slug let rootPath = slug
.split("/") .split("/")
.filter((x) => x !== "") .filter((x) => x !== "")
@ -179,15 +156,11 @@ export function pathToRoot(slug: CanonicalSlug): RelativeURL {
.join("/") .join("/")
const res = _addRelativeToStart(rootPath) as RelativeURL const res = _addRelativeToStart(rootPath) as RelativeURL
conditionCheck(pathToRoot.name, "post", res, isRelativeURL)
return res return res
} }
export function resolveRelative(current: CanonicalSlug, target: CanonicalSlug): RelativeURL { export function resolveRelative(current: CanonicalSlug, target: CanonicalSlug): RelativeURL {
conditionCheck(resolveRelative.name, "pre", current, isCanonicalSlug)
conditionCheck(resolveRelative.name, "pre", target, isCanonicalSlug)
const res = joinSegments(pathToRoot(current), target) as RelativeURL const res = joinSegments(pathToRoot(current), target) as RelativeURL
conditionCheck(resolveRelative.name, "post", res, isRelativeURL)
return res return res
} }

View File

@ -184,7 +184,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
// embed cases // embed cases
if (value.startsWith("!")) { if (value.startsWith("!")) {
const ext: string | undefined = path.extname(fp).toLowerCase() const ext: string = path.extname(fp).toLowerCase()
const url = slugifyFilePath(fp as FilePath) + ext const url = slugifyFilePath(fp as FilePath) + ext
if ([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg"].includes(ext)) { if ([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg"].includes(ext)) {
const dims = alias ?? "" const dims = alias ?? ""
@ -218,8 +218,8 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
type: "html", type: "html",
value: `<iframe src="${url}"></iframe>`, value: `<iframe src="${url}"></iframe>`,
} }
} else { } else if (ext === "") {
// TODO: this is the node embed case // TODO: note embed
} }
// otherwise, fall through to regular link // otherwise, fall through to regular link
} }

View File

@ -37,7 +37,6 @@ export async function emitContent(ctx: BuildCtx, content: ProcessedContent[]) {
} }
} catch (err) { } catch (err) {
trace(`Failed to emit from plugin \`${emitter.name}\``, err as Error) trace(`Failed to emit from plugin \`${emitter.name}\``, err as Error)
throw err
} }
} }

View File

@ -103,7 +103,6 @@ export function createFileParser(ctx: BuildCtx, fps: FilePath[]) {
} }
} catch (err) { } catch (err) {
trace(`\nFailed to process \`${fp}\``, err as Error) trace(`\nFailed to process \`${fp}\``, err as Error)
throw err
} }
} }

View File

@ -1,4 +1,5 @@
import chalk from "chalk" import chalk from "chalk"
import process from "process"
const rootFile = /.*at file:/ const rootFile = /.*at file:/
export function trace(msg: string, err: Error) { export function trace(msg: string, err: Error) {
@ -28,4 +29,5 @@ export function trace(msg: string, err: Error) {
} }
} }
} }
process.exit(1)
} }