From c402f0c3857a75cc101c3459866c94e646fd2957 Mon Sep 17 00:00:00 2001
From: Jacky Zhao <j.zhao2k19@gmail.com>
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 <j.zhao2k19@gmail.com>",
   "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<FilePath, ProcessedContent>()
@@ -78,6 +77,12 @@ async function startServing(ctx: BuildCtx, initialContent: ProcessedContent[]) {
   let toRebuild: Set<FilePath> = new Set()
   let toRemove: Set<FilePath> = 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<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
 type SlugLike<T> = 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<Partial<Options>
 
               // 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<Partial<Options>
                     type: "html",
                     value: `<iframe src="${url}"></iframe>`,
                   }
-                } 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)
 }