From c402f0c3857a75cc101c3459866c94e646fd2957 Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Sat, 5 Aug 2023 11:28:09 -0700 Subject: [PATCH] more robust error handling, config hotreload --- content/features/upcoming features.md | 4 - package-lock.json | 4 +- package.json | 2 +- quartz/bootstrap-cli.mjs | 194 ++++++++++++++++---------- quartz/build.ts | 55 +++----- quartz/path.ts | 27 ---- quartz/plugins/transformers/ofm.ts | 6 +- quartz/processors/emit.ts | 1 - quartz/processors/parse.ts | 1 - quartz/trace.ts | 2 + 10 files changed, 151 insertions(+), 145 deletions(-) diff --git a/content/features/upcoming features.md b/content/features/upcoming features.md index 370e2cd..b5676fd 100644 --- a/content/features/upcoming features.md +++ b/content/features/upcoming features.md @@ -4,8 +4,6 @@ draft: true ## 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 - 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 - audio/video embed styling - 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? - CV mode? with print stylesheet diff --git a/package-lock.json b/package-lock.json index 3399f64..e1122dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@jackyzha0/quartz", - "version": "4.0.6", + "version": "4.0.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@jackyzha0/quartz", - "version": "4.0.6", + "version": "4.0.7", "license": "MIT", "dependencies": { "@clack/prompts": "^0.6.3", diff --git a/package.json b/package.json index 73a080a..f5e3ab8 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@jackyzha0/quartz", "description": "🌱 publish your digital garden and notes as a website", "private": true, - "version": "4.0.6", + "version": "4.0.7", "type": "module", "author": "jackyzha0 ", "license": "MIT", diff --git a/quartz/bootstrap-cli.mjs b/quartz/bootstrap-cli.mjs index 0094a4f..40ef34e 100755 --- a/quartz/bootstrap-cli.mjs +++ b/quartz/bootstrap-cli.mjs @@ -9,9 +9,13 @@ import { sassPlugin } from "esbuild-sass-plugin" import fs from "fs" import { intro, isCancel, outro, select, text } from "@clack/prompts" import { rimraf } from "rimraf" +import chokidar from "chokidar" import prettyBytes from "pretty-bytes" import { execSync, spawnSync } from "child_process" import { transform as cssTransform } from "lightningcss" +import http from "http" +import serveHandler from "serve-handler" +import { WebSocketServer } from "ws" const ORIGIN_NAME = "origin" 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!")) }) .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, - minify: true, - platform: "node", - format: "esm", - jsx: "automatic", - jsxImportSource: "preact", - packages: "external", - metafile: true, - sourcemap: true, - plugins: [ - sassPlugin({ - type: "css-text", - cssImports: true, - async transform(css) { - const { code } = cssTransform({ - filename: "style.css", - code: Buffer.from(css), - minify: true, - }) - 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", - } - }) - }, + console.log(chalk.bgGreen.black(`\n Quartz v${version} \n`)) + const ctx = await esbuild.context({ + entryPoints: [fp], + outfile: path.join("quartz", cacheFile), + bundle: true, + keepNames: true, + minify: true, + platform: "node", + format: "esm", + jsx: "automatic", + jsxImportSource: "preact", + packages: "external", + metafile: true, + sourcemap: true, + plugins: [ + sassPlugin({ + type: "css-text", + cssImports: true, + async transform(css) { + const { code } = cssTransform({ + filename: "style.css", + code: Buffer.from(css), + minify: true, + }) + return code.toString() }, - ], - }) - .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.log(`Reason: ${chalk.grey(err)}`) process.exit(1) }) - if (argv.bundleInfo) { - const outputFileName = "quartz/.quartz-cache/transpiled-build.mjs" - const meta = result.metafile.outputs[outputFileName] - console.log( - `Successfully transpiled ${Object.keys(meta.inputs).length} files (${prettyBytes( - meta.bytes, - )})`, - ) - console.log(await esbuild.analyzeMetafile(result.metafile, { color: true })) + if (argv.bundleInfo) { + const outputFileName = "quartz/.quartz-cache/transpiled-build.mjs" + const meta = result.metafile.outputs[outputFileName] + console.log( + `Successfully transpiled ${Object.keys(meta.inputs).length} files (${prettyBytes( + meta.bytes, + )})`, + ) + console.log(await esbuild.analyzeMetafile(result.metafile, { color: true })) + } + + // bypass module cache + 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) - buildQuartz(argv, version) + await build() + 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) .help() diff --git a/quartz/build.ts b/quartz/build.ts index a293277..b395f73 100644 --- a/quartz/build.ts +++ b/quartz/build.ts @@ -4,8 +4,6 @@ import { PerfTimer } from "./perf" import { rimraf } from "rimraf" import { isGitIgnored } from "globby" import chalk from "chalk" -import http from "http" -import serveHandler from "serve-handler" import { parseMarkdown } from "./processors/parse" import { filterContent } from "./processors/filter" import { emitContent } from "./processors/emit" @@ -13,18 +11,17 @@ import cfg from "../quartz.config" import { FilePath, joinSegments, slugifyFilePath } from "./path" import chokidar from "chokidar" import { ProcessedContent } from "./plugins/vfile" -import WebSocket, { WebSocketServer } from "ws" import { Argv, BuildCtx } from "./ctx" 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 = { argv, cfg, allSlugs: [], } - console.log(chalk.bgGreen.black(`\n Quartz v${version} \n`)) const perf = new PerfTimer() 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()}`)) 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 wss = new WebSocketServer({ port: 3001 }) - const connections: WebSocket[] = [] - wss.on("connection", (ws) => connections.push(ws)) const ignored = await isGitIgnored() const contentMap = new Map() @@ -78,6 +77,12 @@ async function startServing(ctx: BuildCtx, initialContent: ProcessedContent[]) { let toRebuild: Set = new Set() let toRemove: Set = new Set() 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) if (!ignored(fp)) { const filePath = joinSegments(argv.directory, fp) as FilePath @@ -120,7 +125,8 @@ async function startServing(ctx: BuildCtx, initialContent: ProcessedContent[]) { } catch { console.log(chalk.yellow(`Rebuild failed. Waiting on a change to fix the error...`)) } - connections.forEach((conn) => conn.send("rebuild")) + + clientRefresh() toRebuild.clear() toRemove.clear() }, 250) @@ -137,31 +143,12 @@ async function startServing(ctx: BuildCtx, initialContent: ProcessedContent[]) { .on("add", (fp) => rebuild(fp, "add")) .on("change", (fp) => rebuild(fp, "change")) .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 { - await buildQuartz(argv, version) - } catch { - console.log(chalk.red("\nExiting Quartz due to a fatal error")) - process.exit(1) + return await buildQuartz(argv, clientRefresh) + } catch (err) { + trace("\nExiting Quartz due to a fatal error", err as Error) } } diff --git a/quartz/path.ts b/quartz/path.ts index fca2c05..494d3c5 100644 --- a/quartz/path.ts +++ b/quartz/path.ts @@ -1,5 +1,4 @@ import { slug } 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 @@ -43,18 +42,6 @@ import { trace } from "./trace" // └────────────┤ MD File ├─────┴─────────────────┘ // └─────────┘ -const STRICT_TYPE_CHECKS = false -const HARD_EXIT_ON_FAIL = false - -function conditionCheck(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 type SlugLike = string & { __brand: T } @@ -102,36 +89,29 @@ 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) return res } export function getCanonicalSlug(window: Window): CanonicalSlug { const res = window.document.body.dataset.slug! as CanonicalSlug - conditionCheck(getCanonicalSlug.name, "post", res, isCanonicalSlug) return res } export function canonicalizeClient(slug: ClientSlug): CanonicalSlug { - conditionCheck(canonicalizeClient.name, "pre", slug, isClientSlug) const { pathname } = new URL(slug) let fp = pathname.slice(1) fp = fp.replace(new RegExp(_getFileExtension(fp) + "$"), "") const res = _canonicalize(fp) as CanonicalSlug - conditionCheck(canonicalizeClient.name, "post", res, isCanonicalSlug) return res } export function canonicalizeServer(slug: ServerSlug): CanonicalSlug { - conditionCheck(canonicalizeServer.name, "pre", slug, isServerSlug) let fp = slug as string const res = _canonicalize(fp) as CanonicalSlug - conditionCheck(canonicalizeServer.name, "post", res, isCanonicalSlug) return res } export function slugifyFilePath(fp: FilePath): ServerSlug { - conditionCheck(slugifyFilePath.name, "pre", fp, isFilePath) fp = _stripSlashes(fp) as FilePath const withoutFileExt = fp.replace(new RegExp(_getFileExtension(fp) + "$"), "") let slug = withoutFileExt @@ -145,7 +125,6 @@ export function slugifyFilePath(fp: FilePath): ServerSlug { slug = slug.replace(/_index$/, "index") } - conditionCheck(slugifyFilePath.name, "post", slug, isServerSlug) return slug as ServerSlug } @@ -165,13 +144,11 @@ export function transformInternalLink(link: string): RelativeURL { let joined = joinSegments(_stripSlashes(prefix), _stripSlashes(fp)) 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) let rootPath = slug .split("/") .filter((x) => x !== "") @@ -179,15 +156,11 @@ export function pathToRoot(slug: CanonicalSlug): RelativeURL { .join("/") const res = _addRelativeToStart(rootPath) as RelativeURL - 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) const res = joinSegments(pathToRoot(current), target) as RelativeURL - conditionCheck(resolveRelative.name, "post", res, isRelativeURL) return res } diff --git a/quartz/plugins/transformers/ofm.ts b/quartz/plugins/transformers/ofm.ts index a9beda4..13a32ca 100644 --- a/quartz/plugins/transformers/ofm.ts +++ b/quartz/plugins/transformers/ofm.ts @@ -184,7 +184,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin // embed cases 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 if ([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg"].includes(ext)) { const dims = alias ?? "" @@ -218,8 +218,8 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin type: "html", value: ``, } - } else { - // TODO: this is the node embed case + } else if (ext === "") { + // TODO: note embed } // otherwise, fall through to regular link } diff --git a/quartz/processors/emit.ts b/quartz/processors/emit.ts index 960f1e4..fd32685 100644 --- a/quartz/processors/emit.ts +++ b/quartz/processors/emit.ts @@ -37,7 +37,6 @@ export async function emitContent(ctx: BuildCtx, content: ProcessedContent[]) { } } catch (err) { trace(`Failed to emit from plugin \`${emitter.name}\``, err as Error) - throw err } } diff --git a/quartz/processors/parse.ts b/quartz/processors/parse.ts index 289ca94..52dc519 100644 --- a/quartz/processors/parse.ts +++ b/quartz/processors/parse.ts @@ -103,7 +103,6 @@ export function createFileParser(ctx: BuildCtx, fps: FilePath[]) { } } catch (err) { trace(`\nFailed to process \`${fp}\``, err as Error) - throw err } } diff --git a/quartz/trace.ts b/quartz/trace.ts index 803fd2f..337ffe0 100644 --- a/quartz/trace.ts +++ b/quartz/trace.ts @@ -1,4 +1,5 @@ import chalk from "chalk" +import process from "process" const rootFile = /.*at file:/ export function trace(msg: string, err: Error) { @@ -28,4 +29,5 @@ export function trace(msg: string, err: Error) { } } } + process.exit(1) }