From a7372079817fb1a1e69b2632405d759f9c5e913d Mon Sep 17 00:00:00 2001
From: Jacky Zhao <j.zhao2k19@gmail.com>
Date: Sun, 16 Mar 2025 21:17:31 +0000
Subject: [PATCH] perf: incremental rebuild (--fastRebuild v2 but default) (#1841)

---
 quartz/plugins/types.ts                       |   23 
 package-lock.json                             |   22 
 quartz/cli/handlers.js                        |   29 
 quartz/plugins/transformers/oxhugofm.ts       |   12 
 quartz/cfg.ts                                 |    1 
 quartz/plugins/transformers/frontmatter.ts    |   42 
 quartz/build.ts                               |  442 +++++---------
 quartz/plugins/emitters/componentResources.ts |   35 
 quartz/plugins/emitters/contentIndex.tsx      |   25 
 quartz/components/scripts/darkmode.inline.ts  |    2 
 quartz/util/path.ts                           |    2 
 quartz/plugins/emitters/tagPage.tsx           |  200 +++--
 quartz/components/renderPage.tsx              |   26 
 quartz/processors/emit.ts                     |    6 
 quartz/plugins/emitters/assets.ts             |   54 
 quartz/cli/args.js                            |    4 
 quartz/plugins/emitters/aliases.ts            |   71 +-
 quartz/plugins/emitters/cname.ts              |    7 
 quartz.config.ts                              |    2 
 quartz/util/ctx.ts                            |    8 
 quartz/plugins/emitters/static.ts             |   18 
 quartz/plugins/emitters/404.tsx               |    9 
 quartz/util/log.ts                            |   19 
 quartz/worker.ts                              |   27 
 quartz/processors/parse.ts                    |   46 +
 docs/index.md                                 |    2 
 /dev/null                                     |  228 -------
 tsconfig.json                                 |    2 
 package.json                                  |    4 
 quartz/plugins/emitters/folderPage.tsx        |  167 +++--
 quartz/plugins/transformers/lastmod.ts        |   10 
 quartz/plugins/emitters/contentPage.tsx       |  133 +--
 quartz/plugins/transformers/roam.ts           |   17 
 docs/advanced/making plugins.md               |   18 
 quartz/plugins/emitters/ogImage.tsx           |   87 +-
 35 files changed, 767 insertions(+), 1,033 deletions(-)

diff --git a/docs/advanced/making plugins.md b/docs/advanced/making plugins.md
index b65bd37..f5cb199 100644
--- a/docs/advanced/making plugins.md
+++ b/docs/advanced/making plugins.md
@@ -221,12 +221,26 @@
 
 export type QuartzEmitterPluginInstance = {
   name: string
-  emit(ctx: BuildCtx, content: ProcessedContent[], resources: StaticResources): Promise<FilePath[]>
+  emit(
+    ctx: BuildCtx,
+    content: ProcessedContent[],
+    resources: StaticResources,
+  ): Promise<FilePath[]> | AsyncGenerator<FilePath>
+  partialEmit?(
+    ctx: BuildCtx,
+    content: ProcessedContent[],
+    resources: StaticResources,
+    changeEvents: ChangeEvent[],
+  ): Promise<FilePath[]> | AsyncGenerator<FilePath> | null
   getQuartzComponents(ctx: BuildCtx): QuartzComponent[]
 }
 ```
 
-An emitter plugin must define a `name` field, an `emit` function, and a `getQuartzComponents` function. `emit` is responsible for looking at all the parsed and filtered content and then appropriately creating files and returning a list of paths to files the plugin created.
+An emitter plugin must define a `name` field, an `emit` function, and a `getQuartzComponents` function. It can optionally implement a `partialEmit` function for incremental builds.
+
+- `emit` is responsible for looking at all the parsed and filtered content and then appropriately creating files and returning a list of paths to files the plugin created.
+- `partialEmit` is an optional function that enables incremental builds. It receives information about which files have changed (`changeEvents`) and can selectively rebuild only the necessary files. This is useful for optimizing build times in development mode. If `partialEmit` is undefined, it will default to the `emit` function.
+- `getQuartzComponents` declares which Quartz components the emitter uses to construct its pages.
 
 Creating new files can be done via regular Node [fs module](https://nodejs.org/api/fs.html) (i.e. `fs.cp` or `fs.writeFile`) or via the `write` function in `quartz/plugins/emitters/helpers.ts` if you are creating files that contain text. `write` has the following signature:
 
diff --git a/docs/index.md b/docs/index.md
index d4a751a..bd8c896 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -32,7 +32,7 @@
 ## 🔧 Features
 
 - [[Obsidian compatibility]], [[full-text search]], [[graph view]], note transclusion, [[wikilinks]], [[backlinks]], [[features/Latex|Latex]], [[syntax highlighting]], [[popover previews]], [[Docker Support]], [[i18n|internationalization]], [[comments]] and [many more](./features/) right out of the box
-- Hot-reload for both configuration and content
+- Hot-reload on configuration edits and incremental rebuilds for content edits
 - Simple JSX layouts and [[creating components|page components]]
 - [[SPA Routing|Ridiculously fast page loads]] and tiny bundle sizes
 - Fully-customizable parsing, filtering, and page generation through [[making plugins|plugins]]
diff --git a/package-lock.json b/package-lock.json
index 5888205..db2e373 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "@jackyzha0/quartz",
-  "version": "4.4.1",
+  "version": "4.5.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "@jackyzha0/quartz",
-      "version": "4.4.1",
+      "version": "4.5.0",
       "license": "MIT",
       "dependencies": {
         "@clack/prompts": "^0.10.0",
@@ -14,6 +14,7 @@
         "@myriaddreamin/rehype-typst": "^0.5.4",
         "@napi-rs/simple-git": "0.1.19",
         "@tweenjs/tween.js": "^25.0.0",
+        "ansi-truncate": "^1.2.0",
         "async-mutex": "^0.5.0",
         "chalk": "^5.4.1",
         "chokidar": "^4.0.3",
@@ -34,6 +35,7 @@
         "mdast-util-to-hast": "^13.2.0",
         "mdast-util-to-string": "^4.0.0",
         "micromorph": "^0.4.5",
+        "minimatch": "^10.0.1",
         "pixi.js": "^8.8.1",
         "preact": "^10.26.4",
         "preact-render-to-string": "^6.5.13",
@@ -2032,6 +2034,15 @@
         "url": "https://github.com/chalk/ansi-styles?sponsor=1"
       }
     },
+    "node_modules/ansi-truncate": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/ansi-truncate/-/ansi-truncate-1.2.0.tgz",
+      "integrity": "sha512-/SLVrxNIP8o8iRHjdK3K9s2hDqdvb86NEjZOAB6ecWFsOo+9obaby97prnvAPn6j7ExXCpbvtlJFYPkkspg4BQ==",
+      "license": "MIT",
+      "dependencies": {
+        "fast-string-truncated-width": "^1.2.0"
+      }
+    },
     "node_modules/argparse": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@@ -3058,6 +3069,12 @@
         "node": ">=8.6.0"
       }
     },
+    "node_modules/fast-string-truncated-width": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-1.2.1.tgz",
+      "integrity": "sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow==",
+      "license": "MIT"
+    },
     "node_modules/fastq": {
       "version": "1.19.0",
       "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.0.tgz",
@@ -5238,6 +5255,7 @@
       "version": "10.0.1",
       "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz",
       "integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==",
+      "license": "ISC",
       "dependencies": {
         "brace-expansion": "^2.0.1"
       },
diff --git a/package.json b/package.json
index 1fb31ee..0b0b9d9 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.4.1",
+  "version": "4.5.0",
   "type": "module",
   "author": "jackyzha0 <j.zhao2k19@gmail.com>",
   "license": "MIT",
@@ -40,6 +40,7 @@
     "@myriaddreamin/rehype-typst": "^0.5.4",
     "@napi-rs/simple-git": "0.1.19",
     "@tweenjs/tween.js": "^25.0.0",
+    "ansi-truncate": "^1.2.0",
     "async-mutex": "^0.5.0",
     "chalk": "^5.4.1",
     "chokidar": "^4.0.3",
@@ -60,6 +61,7 @@
     "mdast-util-to-hast": "^13.2.0",
     "mdast-util-to-string": "^4.0.0",
     "micromorph": "^0.4.5",
+    "minimatch": "^10.0.1",
     "pixi.js": "^8.8.1",
     "preact": "^10.26.4",
     "preact-render-to-string": "^6.5.13",
diff --git a/quartz.config.ts b/quartz.config.ts
index f540609..03ef0d7 100644
--- a/quartz.config.ts
+++ b/quartz.config.ts
@@ -57,7 +57,7 @@
     transformers: [
       Plugin.FrontMatter(),
       Plugin.CreatedModifiedDate({
-        priority: ["frontmatter", "filesystem"],
+        priority: ["git", "frontmatter", "filesystem"],
       }),
       Plugin.SyntaxHighlighting({
         theme: {
diff --git a/quartz/build.ts b/quartz/build.ts
index 91a5a5a..7cf4405 100644
--- a/quartz/build.ts
+++ b/quartz/build.ts
@@ -9,7 +9,7 @@
 import { filterContent } from "./processors/filter"
 import { emitContent } from "./processors/emit"
 import cfg from "../quartz.config"
-import { FilePath, FullSlug, joinSegments, slugifyFilePath } from "./util/path"
+import { FilePath, joinSegments, slugifyFilePath } from "./util/path"
 import chokidar from "chokidar"
 import { ProcessedContent } from "./plugins/vfile"
 import { Argv, BuildCtx } from "./util/ctx"
@@ -17,34 +17,39 @@
 import { trace } from "./util/trace"
 import { options } from "./util/sourcemap"
 import { Mutex } from "async-mutex"
-import DepGraph from "./depgraph"
 import { getStaticResourcesFromPlugins } from "./plugins"
 import { randomIdNonSecure } from "./util/random"
+import { ChangeEvent } from "./plugins/types"
+import { minimatch } from "minimatch"
 
-type Dependencies = Record<string, DepGraph<FilePath> | null>
+type ContentMap = Map<
+  FilePath,
+  | {
+      type: "markdown"
+      content: ProcessedContent
+    }
+  | {
+      type: "other"
+    }
+>
 
 type BuildData = {
   ctx: BuildCtx
   ignored: GlobbyFilterFunction
   mut: Mutex
-  initialSlugs: FullSlug[]
-  // TODO merge contentMap and trackedAssets
-  contentMap: Map<FilePath, ProcessedContent>
-  trackedAssets: Set<FilePath>
-  toRebuild: Set<FilePath>
-  toRemove: Set<FilePath>
+  contentMap: ContentMap
+  changesSinceLastBuild: Record<FilePath, ChangeEvent["type"]>
   lastBuildMs: number
-  dependencies: Dependencies
 }
 
-type FileEvent = "add" | "change" | "delete"
-
 async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) {
   const ctx: BuildCtx = {
     buildId: randomIdNonSecure(),
     argv,
     cfg,
     allSlugs: [],
+    allFiles: [],
+    incremental: false,
   }
 
   const perf = new PerfTimer()
@@ -67,64 +72,70 @@
 
   perf.addEvent("glob")
   const allFiles = await glob("**/*.*", argv.directory, cfg.configuration.ignorePatterns)
-  const fps = allFiles.filter((fp) => fp.endsWith(".md")).sort()
+  const markdownPaths = allFiles.filter((fp) => fp.endsWith(".md")).sort()
   console.log(
-    `Found ${fps.length} input files from \`${argv.directory}\` in ${perf.timeSince("glob")}`,
+    `Found ${markdownPaths.length} input files from \`${argv.directory}\` in ${perf.timeSince("glob")}`,
   )
 
-  const filePaths = fps.map((fp) => joinSegments(argv.directory, fp) as FilePath)
+  const filePaths = markdownPaths.map((fp) => joinSegments(argv.directory, fp) as FilePath)
+  ctx.allFiles = allFiles
   ctx.allSlugs = allFiles.map((fp) => slugifyFilePath(fp as FilePath))
 
   const parsedFiles = await parseMarkdown(ctx, filePaths)
   const filteredContent = filterContent(ctx, parsedFiles)
 
-  const dependencies: Record<string, DepGraph<FilePath> | null> = {}
-
-  // Only build dependency graphs if we're doing a fast rebuild
-  if (argv.fastRebuild) {
-    const staticResources = getStaticResourcesFromPlugins(ctx)
-    for (const emitter of cfg.plugins.emitters) {
-      dependencies[emitter.name] =
-        (await emitter.getDependencyGraph?.(ctx, filteredContent, staticResources)) ?? null
-    }
-  }
-
   await emitContent(ctx, filteredContent)
-  console.log(chalk.green(`Done processing ${fps.length} files in ${perf.timeSince()}`))
+  console.log(chalk.green(`Done processing ${markdownPaths.length} files in ${perf.timeSince()}`))
   release()
 
-  if (argv.serve) {
-    return startServing(ctx, mut, parsedFiles, clientRefresh, dependencies)
+  if (argv.watch) {
+    ctx.incremental = true
+    return startWatching(ctx, mut, parsedFiles, clientRefresh)
   }
 }
 
 // setup watcher for rebuilds
-async function startServing(
+async function startWatching(
   ctx: BuildCtx,
   mut: Mutex,
   initialContent: ProcessedContent[],
   clientRefresh: () => void,
-  dependencies: Dependencies, // emitter name: dep graph
 ) {
-  const { argv } = ctx
+  const { argv, allFiles } = ctx
 
-  // cache file parse results
-  const contentMap = new Map<FilePath, ProcessedContent>()
-  for (const content of initialContent) {
-    const [_tree, vfile] = content
-    contentMap.set(vfile.data.filePath!, content)
+  const contentMap: ContentMap = new Map()
+  for (const filePath of allFiles) {
+    contentMap.set(filePath, {
+      type: "other",
+    })
   }
 
+  for (const content of initialContent) {
+    const [_tree, vfile] = content
+    contentMap.set(vfile.data.relativePath!, {
+      type: "markdown",
+      content,
+    })
+  }
+
+  const gitIgnoredMatcher = await isGitIgnored()
   const buildData: BuildData = {
     ctx,
     mut,
-    dependencies,
     contentMap,
-    ignored: await isGitIgnored(),
-    initialSlugs: ctx.allSlugs,
-    toRebuild: new Set<FilePath>(),
-    toRemove: new Set<FilePath>(),
-    trackedAssets: new Set<FilePath>(),
+    ignored: (path) => {
+      if (gitIgnoredMatcher(path)) return true
+      const pathStr = path.toString()
+      for (const pattern of cfg.configuration.ignorePatterns) {
+        if (minimatch(pathStr, pattern)) {
+          return true
+        }
+      }
+
+      return false
+    },
+
+    changesSinceLastBuild: {},
     lastBuildMs: 0,
   }
 
@@ -134,34 +145,37 @@
     ignoreInitial: true,
   })
 
-  const buildFromEntry = argv.fastRebuild ? partialRebuildFromEntrypoint : rebuildFromEntrypoint
+  const changes: ChangeEvent[] = []
   watcher
-    .on("add", (fp) => buildFromEntry(fp as string, "add", clientRefresh, buildData))
-    .on("change", (fp) => buildFromEntry(fp as string, "change", clientRefresh, buildData))
-    .on("unlink", (fp) => buildFromEntry(fp as string, "delete", clientRefresh, buildData))
+    .on("add", (fp) => {
+      if (buildData.ignored(fp)) return
+      changes.push({ path: fp as FilePath, type: "add" })
+      void rebuild(changes, clientRefresh, buildData)
+    })
+    .on("change", (fp) => {
+      if (buildData.ignored(fp)) return
+      changes.push({ path: fp as FilePath, type: "change" })
+      void rebuild(changes, clientRefresh, buildData)
+    })
+    .on("unlink", (fp) => {
+      if (buildData.ignored(fp)) return
+      changes.push({ path: fp as FilePath, type: "delete" })
+      void rebuild(changes, clientRefresh, buildData)
+    })
 
   return async () => {
     await watcher.close()
   }
 }
 
-async function partialRebuildFromEntrypoint(
-  filepath: string,
-  action: FileEvent,
-  clientRefresh: () => void,
-  buildData: BuildData, // note: this function mutates buildData
-) {
-  const { ctx, ignored, dependencies, contentMap, mut, toRemove } = buildData
+async function rebuild(changes: ChangeEvent[], clientRefresh: () => void, buildData: BuildData) {
+  const { ctx, contentMap, mut, changesSinceLastBuild } = buildData
   const { argv, cfg } = ctx
 
-  // don't do anything for gitignored files
-  if (ignored(filepath)) {
-    return
-  }
-
   const buildId = randomIdNonSecure()
   ctx.buildId = buildId
   buildData.lastBuildMs = new Date().getTime()
+  const numChangesInBuild = changes.length
   const release = await mut.acquire()
 
   // if there's another build after us, release and let them do it
@@ -171,261 +185,105 @@
   }
 
   const perf = new PerfTimer()
+  perf.addEvent("rebuild")
   console.log(chalk.yellow("Detected change, rebuilding..."))
 
-  // UPDATE DEP GRAPH
-  const fp = joinSegments(argv.directory, toPosixPath(filepath)) as FilePath
+  // update changesSinceLastBuild
+  for (const change of changes) {
+    changesSinceLastBuild[change.path] = change.type
+  }
 
   const staticResources = getStaticResourcesFromPlugins(ctx)
-  let processedFiles: ProcessedContent[] = []
-
-  switch (action) {
-    case "add":
-      // add to cache when new file is added
-      processedFiles = await parseMarkdown(ctx, [fp])
-      processedFiles.forEach(([tree, vfile]) => contentMap.set(vfile.data.filePath!, [tree, vfile]))
-
-      // update the dep graph by asking all emitters whether they depend on this file
-      for (const emitter of cfg.plugins.emitters) {
-        const emitterGraph =
-          (await emitter.getDependencyGraph?.(ctx, processedFiles, staticResources)) ?? null
-
-        if (emitterGraph) {
-          const existingGraph = dependencies[emitter.name]
-          if (existingGraph !== null) {
-            existingGraph.mergeGraph(emitterGraph)
-          } else {
-            // might be the first time we're adding a mardown file
-            dependencies[emitter.name] = emitterGraph
-          }
-        }
-      }
-      break
-    case "change":
-      // invalidate cache when file is changed
-      processedFiles = await parseMarkdown(ctx, [fp])
-      processedFiles.forEach(([tree, vfile]) => contentMap.set(vfile.data.filePath!, [tree, vfile]))
-
-      // only content files can have added/removed dependencies because of transclusions
-      if (path.extname(fp) === ".md") {
-        for (const emitter of cfg.plugins.emitters) {
-          // get new dependencies from all emitters for this file
-          const emitterGraph =
-            (await emitter.getDependencyGraph?.(ctx, processedFiles, staticResources)) ?? null
-
-          // only update the graph if the emitter plugin uses the changed file
-          // eg. Assets plugin ignores md files, so we skip updating the graph
-          if (emitterGraph?.hasNode(fp)) {
-            // merge the new dependencies into the dep graph
-            dependencies[emitter.name]?.updateIncomingEdgesForNode(emitterGraph, fp)
-          }
-        }
-      }
-      break
-    case "delete":
-      toRemove.add(fp)
-      break
+  const pathsToParse: FilePath[] = []
+  for (const [fp, type] of Object.entries(changesSinceLastBuild)) {
+    if (type === "delete" || path.extname(fp) !== ".md") continue
+    const fullPath = joinSegments(argv.directory, toPosixPath(fp)) as FilePath
+    pathsToParse.push(fullPath)
   }
 
-  if (argv.verbose) {
-    console.log(`Updated dependency graphs in ${perf.timeSince()}`)
+  const parsed = await parseMarkdown(ctx, pathsToParse)
+  for (const content of parsed) {
+    contentMap.set(content[1].data.relativePath!, {
+      type: "markdown",
+      content,
+    })
   }
 
-  // EMIT
-  perf.addEvent("rebuild")
+  // update state using changesSinceLastBuild
+  // we do this weird play of add => compute change events => remove
+  // so that partialEmitters can do appropriate cleanup based on the content of deleted files
+  for (const [file, change] of Object.entries(changesSinceLastBuild)) {
+    if (change === "delete") {
+      // universal delete case
+      contentMap.delete(file as FilePath)
+    }
+
+    // manually track non-markdown files as processed files only
+    // contains markdown files
+    if (change === "add" && path.extname(file) !== ".md") {
+      contentMap.set(file as FilePath, {
+        type: "other",
+      })
+    }
+  }
+
+  const changeEvents: ChangeEvent[] = Object.entries(changesSinceLastBuild).map(([fp, type]) => {
+    const path = fp as FilePath
+    const processedContent = contentMap.get(path)
+    if (processedContent?.type === "markdown") {
+      const [_tree, file] = processedContent.content
+      return {
+        type,
+        path,
+        file,
+      }
+    }
+
+    return {
+      type,
+      path,
+    }
+  })
+
+  // update allFiles and then allSlugs with the consistent view of content map
+  ctx.allFiles = Array.from(contentMap.keys())
+  ctx.allSlugs = ctx.allFiles.map((fp) => slugifyFilePath(fp as FilePath))
+  const processedFiles = Array.from(contentMap.values())
+    .filter((file) => file.type === "markdown")
+    .map((file) => file.content)
+
   let emittedFiles = 0
-
   for (const emitter of cfg.plugins.emitters) {
-    const depGraph = dependencies[emitter.name]
-
-    // emitter hasn't defined a dependency graph. call it with all processed files
-    if (depGraph === null) {
-      if (argv.verbose) {
-        console.log(
-          `Emitter ${emitter.name} doesn't define a dependency graph. Calling it with all files...`,
-        )
-      }
-
-      const files = [...contentMap.values()].filter(
-        ([_node, vfile]) => !toRemove.has(vfile.data.filePath!),
-      )
-
-      const emitted = await emitter.emit(ctx, files, staticResources)
-      if (Symbol.asyncIterator in emitted) {
-        // Async generator case
-        for await (const file of emitted) {
-          emittedFiles++
-          if (ctx.argv.verbose) {
-            console.log(`[emit:${emitter.name}] ${file}`)
-          }
-        }
-      } else {
-        // Array case
-        emittedFiles += emitted.length
-        if (ctx.argv.verbose) {
-          for (const file of emitted) {
-            console.log(`[emit:${emitter.name}] ${file}`)
-          }
-        }
-      }
-
+    // Try to use partialEmit if available, otherwise assume the output is static
+    const emitFn = emitter.partialEmit ?? emitter.emit
+    const emitted = await emitFn(ctx, processedFiles, staticResources, changeEvents)
+    if (emitted === null) {
       continue
     }
 
-    // only call the emitter if it uses this file
-    if (depGraph.hasNode(fp)) {
-      // re-emit using all files that are needed for the downstream of this file
-      // eg. for ContentIndex, the dep graph could be:
-      // a.md --> contentIndex.json
-      // b.md ------^
-      //
-      // if a.md changes, we need to re-emit contentIndex.json,
-      // and supply [a.md, b.md] to the emitter
-      const upstreams = [...depGraph.getLeafNodeAncestors(fp)] as FilePath[]
-
-      const upstreamContent = upstreams
-        // filter out non-markdown files
-        .filter((file) => contentMap.has(file))
-        // if file was deleted, don't give it to the emitter
-        .filter((file) => !toRemove.has(file))
-        .map((file) => contentMap.get(file)!)
-
-      const emitted = await emitter.emit(ctx, upstreamContent, staticResources)
-      if (Symbol.asyncIterator in emitted) {
-        // Async generator case
-        for await (const file of emitted) {
-          emittedFiles++
-          if (ctx.argv.verbose) {
-            console.log(`[emit:${emitter.name}] ${file}`)
-          }
-        }
-      } else {
-        // Array case
-        emittedFiles += emitted.length
+    if (Symbol.asyncIterator in emitted) {
+      // Async generator case
+      for await (const file of emitted) {
+        emittedFiles++
         if (ctx.argv.verbose) {
-          for (const file of emitted) {
-            console.log(`[emit:${emitter.name}] ${file}`)
-          }
+          console.log(`[emit:${emitter.name}] ${file}`)
+        }
+      }
+    } else {
+      // Array case
+      emittedFiles += emitted.length
+      if (ctx.argv.verbose) {
+        for (const file of emitted) {
+          console.log(`[emit:${emitter.name}] ${file}`)
         }
       }
     }
   }
 
   console.log(`Emitted ${emittedFiles} files to \`${argv.output}\` in ${perf.timeSince("rebuild")}`)
-
-  // CLEANUP
-  const destinationsToDelete = new Set<FilePath>()
-  for (const file of toRemove) {
-    // remove from cache
-    contentMap.delete(file)
-    Object.values(dependencies).forEach((depGraph) => {
-      // remove the node from dependency graphs
-      depGraph?.removeNode(file)
-      // remove any orphan nodes. eg if a.md is deleted, a.html is orphaned and should be removed
-      const orphanNodes = depGraph?.removeOrphanNodes()
-      orphanNodes?.forEach((node) => {
-        // only delete files that are in the output directory
-        if (node.startsWith(argv.output)) {
-          destinationsToDelete.add(node)
-        }
-      })
-    })
-  }
-  await rimraf([...destinationsToDelete])
-
   console.log(chalk.green(`Done rebuilding in ${perf.timeSince()}`))
-
-  toRemove.clear()
-  release()
+  changes.splice(0, numChangesInBuild)
   clientRefresh()
-}
-
-async function rebuildFromEntrypoint(
-  fp: string,
-  action: FileEvent,
-  clientRefresh: () => void,
-  buildData: BuildData, // note: this function mutates buildData
-) {
-  const { ctx, ignored, mut, initialSlugs, contentMap, toRebuild, toRemove, trackedAssets } =
-    buildData
-
-  const { argv } = ctx
-
-  // don't do anything for gitignored files
-  if (ignored(fp)) {
-    return
-  }
-
-  // dont bother rebuilding for non-content files, just track and refresh
-  fp = toPosixPath(fp)
-  const filePath = joinSegments(argv.directory, fp) as FilePath
-  if (path.extname(fp) !== ".md") {
-    if (action === "add" || action === "change") {
-      trackedAssets.add(filePath)
-    } else if (action === "delete") {
-      trackedAssets.delete(filePath)
-    }
-    clientRefresh()
-    return
-  }
-
-  if (action === "add" || action === "change") {
-    toRebuild.add(filePath)
-  } else if (action === "delete") {
-    toRemove.add(filePath)
-  }
-
-  const buildId = randomIdNonSecure()
-  ctx.buildId = buildId
-  buildData.lastBuildMs = new Date().getTime()
-  const release = await mut.acquire()
-
-  // there's another build after us, release and let them do it
-  if (ctx.buildId !== buildId) {
-    release()
-    return
-  }
-
-  const perf = new PerfTimer()
-  console.log(chalk.yellow("Detected change, rebuilding..."))
-
-  try {
-    const filesToRebuild = [...toRebuild].filter((fp) => !toRemove.has(fp))
-    const parsedContent = await parseMarkdown(ctx, filesToRebuild)
-    for (const content of parsedContent) {
-      const [_tree, vfile] = content
-      contentMap.set(vfile.data.filePath!, content)
-    }
-
-    for (const fp of toRemove) {
-      contentMap.delete(fp)
-    }
-
-    const parsedFiles = [...contentMap.values()]
-    const filteredContent = filterContent(ctx, parsedFiles)
-
-    // re-update slugs
-    const trackedSlugs = [...new Set([...contentMap.keys(), ...toRebuild, ...trackedAssets])]
-      .filter((fp) => !toRemove.has(fp))
-      .map((fp) => slugifyFilePath(path.posix.relative(argv.directory, fp) as FilePath))
-
-    ctx.allSlugs = [...new Set([...initialSlugs, ...trackedSlugs])]
-
-    // TODO: we can probably traverse the link graph to figure out what's safe to delete here
-    // instead of just deleting everything
-    await rimraf(path.join(argv.output, ".*"), { glob: true })
-    await emitContent(ctx, filteredContent)
-    console.log(chalk.green(`Done rebuilding in ${perf.timeSince()}`))
-  } catch (err) {
-    console.log(chalk.yellow(`Rebuild failed. Waiting on a change to fix the error...`))
-    if (argv.verbose) {
-      console.log(chalk.red(err))
-    }
-  }
-
-  clientRefresh()
-  toRebuild.clear()
-  toRemove.clear()
   release()
 }
 
diff --git a/quartz/cfg.ts b/quartz/cfg.ts
index 1c98b93..b5de75d 100644
--- a/quartz/cfg.ts
+++ b/quartz/cfg.ts
@@ -2,7 +2,6 @@
 import { QuartzComponent } from "./components/types"
 import { ValidLocale } from "./i18n"
 import { PluginTypes } from "./plugins/types"
-import { SocialImageOptions } from "./util/og"
 import { Theme } from "./util/theme"
 
 export type Analytics =
diff --git a/quartz/cli/args.js b/quartz/cli/args.js
index 123d0ac..d2408e9 100644
--- a/quartz/cli/args.js
+++ b/quartz/cli/args.js
@@ -71,10 +71,10 @@
     default: false,
     describe: "run a local server to live-preview your Quartz",
   },
-  fastRebuild: {
+  watch: {
     boolean: true,
     default: false,
-    describe: "[experimental] rebuild only the changed files",
+    describe: "watch for changes and rebuild automatically",
   },
   baseDir: {
     string: true,
diff --git a/quartz/cli/handlers.js b/quartz/cli/handlers.js
index 6ef3805..c41bafc 100644
--- a/quartz/cli/handlers.js
+++ b/quartz/cli/handlers.js
@@ -225,6 +225,10 @@
  * @param {*} argv arguments for `build`
  */
 export async function handleBuild(argv) {
+  if (argv.serve) {
+    argv.watch = true
+  }
+
   console.log(chalk.bgGreen.black(`\n Quartz v${version} \n`))
   const ctx = await esbuild.context({
     entryPoints: [fp],
@@ -331,9 +335,10 @@
     clientRefresh()
   }
 
+  let clientRefresh = () => {}
   if (argv.serve) {
     const connections = []
-    const clientRefresh = () => connections.forEach((conn) => conn.send("rebuild"))
+    clientRefresh = () => connections.forEach((conn) => conn.send("rebuild"))
 
     if (argv.baseDir !== "" && !argv.baseDir.startsWith("/")) {
       argv.baseDir = "/" + argv.baseDir
@@ -433,6 +438,7 @@
 
       return serve()
     })
+
     server.listen(argv.port)
     const wss = new WebSocketServer({ port: argv.wsPort })
     wss.on("connection", (ws) => connections.push(ws))
@@ -441,16 +447,27 @@
         `Started a Quartz server listening at http://localhost:${argv.port}${argv.baseDir}`,
       ),
     )
-    console.log("hint: exit with ctrl+c")
-    const paths = await globby(["**/*.ts", "**/*.tsx", "**/*.scss", "package.json"])
+  } else {
+    await build(clientRefresh)
+    ctx.dispose()
+  }
+
+  if (argv.watch) {
+    const paths = await globby([
+      "**/*.ts",
+      "quartz/cli/*.js",
+      "quartz/static/**/*",
+      "**/*.tsx",
+      "**/*.scss",
+      "package.json",
+    ])
     chokidar
       .watch(paths, { ignoreInitial: true })
       .on("add", () => build(clientRefresh))
       .on("change", () => build(clientRefresh))
       .on("unlink", () => build(clientRefresh))
-  } else {
-    await build(() => {})
-    ctx.dispose()
+
+    console.log(chalk.grey("hint: exit with ctrl+c"))
   }
 }
 
diff --git a/quartz/components/renderPage.tsx b/quartz/components/renderPage.tsx
index a43b66c..19324f5 100644
--- a/quartz/components/renderPage.tsx
+++ b/quartz/components/renderPage.tsx
@@ -9,7 +9,6 @@
 import { Root, Element, ElementContent } from "hast"
 import { GlobalConfiguration } from "../cfg"
 import { i18n } from "../i18n"
-import { QuartzPluginData } from "../plugins/vfile"
 
 interface RenderComponents {
   head: QuartzComponent
@@ -25,7 +24,6 @@
 const headerRegex = new RegExp(/h[1-6]/)
 export function pageResources(
   baseDir: FullSlug | RelativeURL,
-  fileData: QuartzPluginData,
   staticResources: StaticResources,
 ): StaticResources {
   const contentIndexPath = joinSegments(baseDir, "static/contentIndex.json")
@@ -65,17 +63,12 @@
   return resources
 }
 
-export function renderPage(
+function renderTranscludes(
+  root: Root,
   cfg: GlobalConfiguration,
   slug: FullSlug,
   componentData: QuartzComponentProps,
-  components: RenderComponents,
-  pageResources: StaticResources,
-): string {
-  // make a deep copy of the tree so we don't remove the transclusion references
-  // for the file cached in contentMap in build.ts
-  const root = clone(componentData.tree) as Root
-
+) {
   // process transcludes in componentData
   visit(root, "element", (node, _index, _parent) => {
     if (node.tagName === "blockquote") {
@@ -191,6 +184,19 @@
       }
     }
   })
+}
+
+export function renderPage(
+  cfg: GlobalConfiguration,
+  slug: FullSlug,
+  componentData: QuartzComponentProps,
+  components: RenderComponents,
+  pageResources: StaticResources,
+): string {
+  // make a deep copy of the tree so we don't remove the transclusion references
+  // for the file cached in contentMap in build.ts
+  const root = clone(componentData.tree) as Root
+  renderTranscludes(root, cfg, slug, componentData)
 
   // set componentData.tree to the edited html that has transclusions rendered
   componentData.tree = root
diff --git a/quartz/components/scripts/darkmode.inline.ts b/quartz/components/scripts/darkmode.inline.ts
index 871eb24..d8dfee9 100644
--- a/quartz/components/scripts/darkmode.inline.ts
+++ b/quartz/components/scripts/darkmode.inline.ts
@@ -10,7 +10,7 @@
 }
 
 document.addEventListener("nav", () => {
-  const switchTheme = (e: Event) => {
+  const switchTheme = () => {
     const newTheme =
       document.documentElement.getAttribute("saved-theme") === "dark" ? "light" : "dark"
     document.documentElement.setAttribute("saved-theme", newTheme)
diff --git a/quartz/depgraph.test.ts b/quartz/depgraph.test.ts
deleted file mode 100644
index 062f13e..0000000
--- a/quartz/depgraph.test.ts
+++ /dev/null
@@ -1,118 +0,0 @@
-import test, { describe } from "node:test"
-import DepGraph from "./depgraph"
-import assert from "node:assert"
-
-describe("DepGraph", () => {
-  test("getLeafNodes", () => {
-    const graph = new DepGraph<string>()
-    graph.addEdge("A", "B")
-    graph.addEdge("B", "C")
-    graph.addEdge("D", "C")
-    assert.deepStrictEqual(graph.getLeafNodes("A"), new Set(["C"]))
-    assert.deepStrictEqual(graph.getLeafNodes("B"), new Set(["C"]))
-    assert.deepStrictEqual(graph.getLeafNodes("C"), new Set(["C"]))
-    assert.deepStrictEqual(graph.getLeafNodes("D"), new Set(["C"]))
-  })
-
-  describe("getLeafNodeAncestors", () => {
-    test("gets correct ancestors in a graph without cycles", () => {
-      const graph = new DepGraph<string>()
-      graph.addEdge("A", "B")
-      graph.addEdge("B", "C")
-      graph.addEdge("D", "B")
-      assert.deepStrictEqual(graph.getLeafNodeAncestors("A"), new Set(["A", "B", "D"]))
-      assert.deepStrictEqual(graph.getLeafNodeAncestors("B"), new Set(["A", "B", "D"]))
-      assert.deepStrictEqual(graph.getLeafNodeAncestors("C"), new Set(["A", "B", "D"]))
-      assert.deepStrictEqual(graph.getLeafNodeAncestors("D"), new Set(["A", "B", "D"]))
-    })
-
-    test("gets correct ancestors in a graph with cycles", () => {
-      const graph = new DepGraph<string>()
-      graph.addEdge("A", "B")
-      graph.addEdge("B", "C")
-      graph.addEdge("C", "A")
-      graph.addEdge("C", "D")
-      assert.deepStrictEqual(graph.getLeafNodeAncestors("A"), new Set(["A", "B", "C"]))
-      assert.deepStrictEqual(graph.getLeafNodeAncestors("B"), new Set(["A", "B", "C"]))
-      assert.deepStrictEqual(graph.getLeafNodeAncestors("C"), new Set(["A", "B", "C"]))
-      assert.deepStrictEqual(graph.getLeafNodeAncestors("D"), new Set(["A", "B", "C"]))
-    })
-  })
-
-  describe("mergeGraph", () => {
-    test("merges two graphs", () => {
-      const graph = new DepGraph<string>()
-      graph.addEdge("A.md", "A.html")
-
-      const other = new DepGraph<string>()
-      other.addEdge("B.md", "B.html")
-
-      graph.mergeGraph(other)
-
-      const expected = {
-        nodes: ["A.md", "A.html", "B.md", "B.html"],
-        edges: [
-          ["A.md", "A.html"],
-          ["B.md", "B.html"],
-        ],
-      }
-
-      assert.deepStrictEqual(graph.export(), expected)
-    })
-  })
-
-  describe("updateIncomingEdgesForNode", () => {
-    test("merges when node exists", () => {
-      // A.md -> B.md -> B.html
-      const graph = new DepGraph<string>()
-      graph.addEdge("A.md", "B.md")
-      graph.addEdge("B.md", "B.html")
-
-      // B.md is edited so it removes the A.md transclusion
-      // and adds C.md transclusion
-      // C.md -> B.md
-      const other = new DepGraph<string>()
-      other.addEdge("C.md", "B.md")
-      other.addEdge("B.md", "B.html")
-
-      // A.md -> B.md removed, C.md -> B.md added
-      // C.md -> B.md -> B.html
-      graph.updateIncomingEdgesForNode(other, "B.md")
-
-      const expected = {
-        nodes: ["A.md", "B.md", "B.html", "C.md"],
-        edges: [
-          ["B.md", "B.html"],
-          ["C.md", "B.md"],
-        ],
-      }
-
-      assert.deepStrictEqual(graph.export(), expected)
-    })
-
-    test("adds node if it does not exist", () => {
-      // A.md -> B.md
-      const graph = new DepGraph<string>()
-      graph.addEdge("A.md", "B.md")
-
-      // Add a new file C.md that transcludes B.md
-      // B.md -> C.md
-      const other = new DepGraph<string>()
-      other.addEdge("B.md", "C.md")
-
-      // B.md -> C.md added
-      // A.md -> B.md -> C.md
-      graph.updateIncomingEdgesForNode(other, "C.md")
-
-      const expected = {
-        nodes: ["A.md", "B.md", "C.md"],
-        edges: [
-          ["A.md", "B.md"],
-          ["B.md", "C.md"],
-        ],
-      }
-
-      assert.deepStrictEqual(graph.export(), expected)
-    })
-  })
-})
diff --git a/quartz/depgraph.ts b/quartz/depgraph.ts
deleted file mode 100644
index 3d048cd..0000000
--- a/quartz/depgraph.ts
+++ /dev/null
@@ -1,228 +0,0 @@
-export default class DepGraph<T> {
-  // node: incoming and outgoing edges
-  _graph = new Map<T, { incoming: Set<T>; outgoing: Set<T> }>()
-
-  constructor() {
-    this._graph = new Map()
-  }
-
-  export(): Object {
-    return {
-      nodes: this.nodes,
-      edges: this.edges,
-    }
-  }
-
-  toString(): string {
-    return JSON.stringify(this.export(), null, 2)
-  }
-
-  // BASIC GRAPH OPERATIONS
-
-  get nodes(): T[] {
-    return Array.from(this._graph.keys())
-  }
-
-  get edges(): [T, T][] {
-    let edges: [T, T][] = []
-    this.forEachEdge((edge) => edges.push(edge))
-    return edges
-  }
-
-  hasNode(node: T): boolean {
-    return this._graph.has(node)
-  }
-
-  addNode(node: T): void {
-    if (!this._graph.has(node)) {
-      this._graph.set(node, { incoming: new Set(), outgoing: new Set() })
-    }
-  }
-
-  // Remove node and all edges connected to it
-  removeNode(node: T): void {
-    if (this._graph.has(node)) {
-      // first remove all edges so other nodes don't have references to this node
-      for (const target of this._graph.get(node)!.outgoing) {
-        this.removeEdge(node, target)
-      }
-      for (const source of this._graph.get(node)!.incoming) {
-        this.removeEdge(source, node)
-      }
-      this._graph.delete(node)
-    }
-  }
-
-  forEachNode(callback: (node: T) => void): void {
-    for (const node of this._graph.keys()) {
-      callback(node)
-    }
-  }
-
-  hasEdge(from: T, to: T): boolean {
-    return Boolean(this._graph.get(from)?.outgoing.has(to))
-  }
-
-  addEdge(from: T, to: T): void {
-    this.addNode(from)
-    this.addNode(to)
-
-    this._graph.get(from)!.outgoing.add(to)
-    this._graph.get(to)!.incoming.add(from)
-  }
-
-  removeEdge(from: T, to: T): void {
-    if (this._graph.has(from) && this._graph.has(to)) {
-      this._graph.get(from)!.outgoing.delete(to)
-      this._graph.get(to)!.incoming.delete(from)
-    }
-  }
-
-  // returns -1 if node does not exist
-  outDegree(node: T): number {
-    return this.hasNode(node) ? this._graph.get(node)!.outgoing.size : -1
-  }
-
-  // returns -1 if node does not exist
-  inDegree(node: T): number {
-    return this.hasNode(node) ? this._graph.get(node)!.incoming.size : -1
-  }
-
-  forEachOutNeighbor(node: T, callback: (neighbor: T) => void): void {
-    this._graph.get(node)?.outgoing.forEach(callback)
-  }
-
-  forEachInNeighbor(node: T, callback: (neighbor: T) => void): void {
-    this._graph.get(node)?.incoming.forEach(callback)
-  }
-
-  forEachEdge(callback: (edge: [T, T]) => void): void {
-    for (const [source, { outgoing }] of this._graph.entries()) {
-      for (const target of outgoing) {
-        callback([source, target])
-      }
-    }
-  }
-
-  // DEPENDENCY ALGORITHMS
-
-  // Add all nodes and edges from other graph to this graph
-  mergeGraph(other: DepGraph<T>): void {
-    other.forEachEdge(([source, target]) => {
-      this.addNode(source)
-      this.addNode(target)
-      this.addEdge(source, target)
-    })
-  }
-
-  // For the node provided:
-  // If node does not exist, add it
-  // If an incoming edge was added in other, it is added in this graph
-  // If an incoming edge was deleted in other, it is deleted in this graph
-  updateIncomingEdgesForNode(other: DepGraph<T>, node: T): void {
-    this.addNode(node)
-
-    // Add edge if it is present in other
-    other.forEachInNeighbor(node, (neighbor) => {
-      this.addEdge(neighbor, node)
-    })
-
-    // For node provided, remove incoming edge if it is absent in other
-    this.forEachEdge(([source, target]) => {
-      if (target === node && !other.hasEdge(source, target)) {
-        this.removeEdge(source, target)
-      }
-    })
-  }
-
-  // Remove all nodes that do not have any incoming or outgoing edges
-  // A node may be orphaned if the only node pointing to it was removed
-  removeOrphanNodes(): Set<T> {
-    let orphanNodes = new Set<T>()
-
-    this.forEachNode((node) => {
-      if (this.inDegree(node) === 0 && this.outDegree(node) === 0) {
-        orphanNodes.add(node)
-      }
-    })
-
-    orphanNodes.forEach((node) => {
-      this.removeNode(node)
-    })
-
-    return orphanNodes
-  }
-
-  // Get all leaf nodes (i.e. destination paths) reachable from the node provided
-  // Eg. if the graph is A -> B -> C
-  //                     D ---^
-  // and the node is B, this function returns [C]
-  getLeafNodes(node: T): Set<T> {
-    let stack: T[] = [node]
-    let visited = new Set<T>()
-    let leafNodes = new Set<T>()
-
-    // DFS
-    while (stack.length > 0) {
-      let node = stack.pop()!
-
-      // If the node is already visited, skip it
-      if (visited.has(node)) {
-        continue
-      }
-      visited.add(node)
-
-      // Check if the node is a leaf node (i.e. destination path)
-      if (this.outDegree(node) === 0) {
-        leafNodes.add(node)
-      }
-
-      // Add all unvisited neighbors to the stack
-      this.forEachOutNeighbor(node, (neighbor) => {
-        if (!visited.has(neighbor)) {
-          stack.push(neighbor)
-        }
-      })
-    }
-
-    return leafNodes
-  }
-
-  // Get all ancestors of the leaf nodes reachable from the node provided
-  // Eg. if the graph is A -> B -> C
-  //                     D ---^
-  // and the node is B, this function returns [A, B, D]
-  getLeafNodeAncestors(node: T): Set<T> {
-    const leafNodes = this.getLeafNodes(node)
-    let visited = new Set<T>()
-    let upstreamNodes = new Set<T>()
-
-    // Backwards DFS for each leaf node
-    leafNodes.forEach((leafNode) => {
-      let stack: T[] = [leafNode]
-
-      while (stack.length > 0) {
-        let node = stack.pop()!
-
-        if (visited.has(node)) {
-          continue
-        }
-        visited.add(node)
-        // Add node if it's not a leaf node (i.e. destination path)
-        // Assumes destination file cannot depend on another destination file
-        if (this.outDegree(node) !== 0) {
-          upstreamNodes.add(node)
-        }
-
-        // Add all unvisited parents to the stack
-        this.forEachInNeighbor(node, (parentNode) => {
-          if (!visited.has(parentNode)) {
-            stack.push(parentNode)
-          }
-        })
-      }
-    })
-
-    return upstreamNodes
-  }
-}
diff --git a/quartz/plugins/emitters/404.tsx b/quartz/plugins/emitters/404.tsx
index 90c9d58..04a006d 100644
--- a/quartz/plugins/emitters/404.tsx
+++ b/quartz/plugins/emitters/404.tsx
@@ -3,13 +3,12 @@
 import BodyConstructor from "../../components/Body"
 import { pageResources, renderPage } from "../../components/renderPage"
 import { FullPageLayout } from "../../cfg"
-import { FilePath, FullSlug } from "../../util/path"
+import { FullSlug } from "../../util/path"
 import { sharedPageComponents } from "../../../quartz.layout"
 import { NotFound } from "../../components"
 import { defaultProcessedContent } from "../vfile"
 import { write } from "./helpers"
 import { i18n } from "../../i18n"
-import DepGraph from "../../depgraph"
 
 export const NotFoundPage: QuartzEmitterPlugin = () => {
   const opts: FullPageLayout = {
@@ -28,9 +27,6 @@
     getQuartzComponents() {
       return [Head, Body, pageBody, Footer]
     },
-    async getDependencyGraph(_ctx, _content, _resources) {
-      return new DepGraph<FilePath>()
-    },
     async *emit(ctx, _content, resources) {
       const cfg = ctx.cfg.configuration
       const slug = "404" as FullSlug
@@ -44,7 +40,7 @@
         description: notFound,
         frontmatter: { title: notFound, tags: [] },
       })
-      const externalResources = pageResources(path, vfile.data, resources)
+      const externalResources = pageResources(path, resources)
       const componentData: QuartzComponentProps = {
         ctx,
         fileData: vfile.data,
@@ -62,5 +58,6 @@
         ext: ".html",
       })
     },
+    async *partialEmit() {},
   }
 }
diff --git a/quartz/plugins/emitters/aliases.ts b/quartz/plugins/emitters/aliases.ts
index a16093b..327cde8 100644
--- a/quartz/plugins/emitters/aliases.ts
+++ b/quartz/plugins/emitters/aliases.ts
@@ -1,46 +1,47 @@
-import { FilePath, joinSegments, resolveRelative, simplifySlug } from "../../util/path"
+import { resolveRelative, simplifySlug } from "../../util/path"
 import { QuartzEmitterPlugin } from "../types"
 import { write } from "./helpers"
-import DepGraph from "../../depgraph"
-import { getAliasSlugs } from "../transformers/frontmatter"
+import { BuildCtx } from "../../util/ctx"
+import { VFile } from "vfile"
+
+async function* processFile(ctx: BuildCtx, file: VFile) {
+  const ogSlug = simplifySlug(file.data.slug!)
+
+  for (const slug of file.data.aliases ?? []) {
+    const redirUrl = resolveRelative(slug, file.data.slug!)
+    yield write({
+      ctx,
+      content: `
+        <!DOCTYPE html>
+        <html lang="en-us">
+        <head>
+        <title>${ogSlug}</title>
+        <link rel="canonical" href="${redirUrl}">
+        <meta name="robots" content="noindex">
+        <meta charset="utf-8">
+        <meta http-equiv="refresh" content="0; url=${redirUrl}">
+        </head>
+        </html>
+        `,
+      slug,
+      ext: ".html",
+    })
+  }
+}
 
 export const AliasRedirects: QuartzEmitterPlugin = () => ({
   name: "AliasRedirects",
-  async getDependencyGraph(ctx, content, _resources) {
-    const graph = new DepGraph<FilePath>()
-
-    const { argv } = ctx
+  async *emit(ctx, content) {
     for (const [_tree, file] of content) {
-      for (const slug of getAliasSlugs(file.data.frontmatter?.aliases ?? [], argv, file)) {
-        graph.addEdge(file.data.filePath!, joinSegments(argv.output, slug + ".html") as FilePath)
-      }
+      yield* processFile(ctx, file)
     }
-
-    return graph
   },
-  async *emit(ctx, content, _resources) {
-    for (const [_tree, file] of content) {
-      const ogSlug = simplifySlug(file.data.slug!)
-
-      for (const slug of file.data.aliases ?? []) {
-        const redirUrl = resolveRelative(slug, file.data.slug!)
-        yield write({
-          ctx,
-          content: `
-            <!DOCTYPE html>
-            <html lang="en-us">
-            <head>
-            <title>${ogSlug}</title>
-            <link rel="canonical" href="${redirUrl}">
-            <meta name="robots" content="noindex">
-            <meta charset="utf-8">
-            <meta http-equiv="refresh" content="0; url=${redirUrl}">
-            </head>
-            </html>
-            `,
-          slug,
-          ext: ".html",
-        })
+  async *partialEmit(ctx, _content, _resources, changeEvents) {
+    for (const changeEvent of changeEvents) {
+      if (!changeEvent.file) continue
+      if (changeEvent.type === "add" || changeEvent.type === "change") {
+        // add new ones if this file still exists
+        yield* processFile(ctx, changeEvent.file)
       }
     }
   },
diff --git a/quartz/plugins/emitters/assets.ts b/quartz/plugins/emitters/assets.ts
index 120d168..d0da66a 100644
--- a/quartz/plugins/emitters/assets.ts
+++ b/quartz/plugins/emitters/assets.ts
@@ -3,7 +3,6 @@
 import path from "path"
 import fs from "fs"
 import { glob } from "../../util/glob"
-import DepGraph from "../../depgraph"
 import { Argv } from "../../util/ctx"
 import { QuartzConfig } from "../../cfg"
 
@@ -12,40 +11,41 @@
   return await glob("**", argv.directory, ["**/*.md", ...cfg.configuration.ignorePatterns])
 }
 
+const copyFile = async (argv: Argv, fp: FilePath) => {
+  const src = joinSegments(argv.directory, fp) as FilePath
+
+  const name = slugifyFilePath(fp)
+  const dest = joinSegments(argv.output, name) as FilePath
+
+  // ensure dir exists
+  const dir = path.dirname(dest) as FilePath
+  await fs.promises.mkdir(dir, { recursive: true })
+
+  await fs.promises.copyFile(src, dest)
+  return dest
+}
+
 export const Assets: QuartzEmitterPlugin = () => {
   return {
     name: "Assets",
-    async getDependencyGraph(ctx, _content, _resources) {
-      const { argv, cfg } = ctx
-      const graph = new DepGraph<FilePath>()
-
+    async *emit({ argv, cfg }) {
       const fps = await filesToCopy(argv, cfg)
-
       for (const fp of fps) {
-        const ext = path.extname(fp)
-        const src = joinSegments(argv.directory, fp) as FilePath
-        const name = (slugifyFilePath(fp as FilePath, true) + ext) as FilePath
-
-        const dest = joinSegments(argv.output, name) as FilePath
-
-        graph.addEdge(src, dest)
+        yield copyFile(argv, fp)
       }
-
-      return graph
     },
-    async *emit({ argv, cfg }, _content, _resources) {
-      const assetsPath = argv.output
-      const fps = await filesToCopy(argv, cfg)
-      for (const fp of fps) {
-        const ext = path.extname(fp)
-        const src = joinSegments(argv.directory, fp) as FilePath
-        const name = (slugifyFilePath(fp as FilePath, true) + ext) as FilePath
+    async *partialEmit(ctx, _content, _resources, changeEvents) {
+      for (const changeEvent of changeEvents) {
+        const ext = path.extname(changeEvent.path)
+        if (ext === ".md") continue
 
-        const dest = joinSegments(assetsPath, name) as FilePath
-        const dir = path.dirname(dest) as FilePath
-        await fs.promises.mkdir(dir, { recursive: true }) // ensure dir exists
-        await fs.promises.copyFile(src, dest)
-        yield dest
+        if (changeEvent.type === "add" || changeEvent.type === "change") {
+          yield copyFile(ctx.argv, changeEvent.path)
+        } else if (changeEvent.type === "delete") {
+          const name = slugifyFilePath(changeEvent.path)
+          const dest = joinSegments(ctx.argv.output, name) as FilePath
+          await fs.promises.unlink(dest)
+        }
       }
     },
   }
diff --git a/quartz/plugins/emitters/cname.ts b/quartz/plugins/emitters/cname.ts
index 897d851..10781db 100644
--- a/quartz/plugins/emitters/cname.ts
+++ b/quartz/plugins/emitters/cname.ts
@@ -2,7 +2,6 @@
 import { QuartzEmitterPlugin } from "../types"
 import fs from "fs"
 import chalk from "chalk"
-import DepGraph from "../../depgraph"
 
 export function extractDomainFromBaseUrl(baseUrl: string) {
   const url = new URL(`https://${baseUrl}`)
@@ -11,10 +10,7 @@
 
 export const CNAME: QuartzEmitterPlugin = () => ({
   name: "CNAME",
-  async getDependencyGraph(_ctx, _content, _resources) {
-    return new DepGraph<FilePath>()
-  },
-  async emit({ argv, cfg }, _content, _resources) {
+  async emit({ argv, cfg }) {
     if (!cfg.configuration.baseUrl) {
       console.warn(chalk.yellow("CNAME emitter requires `baseUrl` to be set in your configuration"))
       return []
@@ -27,4 +23,5 @@
     await fs.promises.writeFile(path, content)
     return [path] as FilePath[]
   },
+  async *partialEmit() {},
 })
diff --git a/quartz/plugins/emitters/componentResources.ts b/quartz/plugins/emitters/componentResources.ts
index fe855ba..540a373 100644
--- a/quartz/plugins/emitters/componentResources.ts
+++ b/quartz/plugins/emitters/componentResources.ts
@@ -1,4 +1,4 @@
-import { FilePath, FullSlug, joinSegments } from "../../util/path"
+import { FullSlug, joinSegments } from "../../util/path"
 import { QuartzEmitterPlugin } from "../types"
 
 // @ts-ignore
@@ -13,7 +13,6 @@
 import { Features, transform } from "lightningcss"
 import { transform as transpile } from "esbuild"
 import { write } from "./helpers"
-import DepGraph from "../../depgraph"
 
 type ComponentResources = {
   css: string[]
@@ -203,9 +202,6 @@
 export const ComponentResources: QuartzEmitterPlugin = () => {
   return {
     name: "ComponentResources",
-    async getDependencyGraph(_ctx, _content, _resources) {
-      return new DepGraph<FilePath>()
-    },
     async *emit(ctx, _content, _resources) {
       const cfg = ctx.cfg.configuration
       // component specific scripts and styles
@@ -281,19 +277,22 @@
           },
           include: Features.MediaQueries,
         }).code.toString(),
-      }),
-        yield write({
-          ctx,
-          slug: "prescript" as FullSlug,
-          ext: ".js",
-          content: prescript,
-        }),
-        yield write({
-          ctx,
-          slug: "postscript" as FullSlug,
-          ext: ".js",
-          content: postscript,
-        })
+      })
+
+      yield write({
+        ctx,
+        slug: "prescript" as FullSlug,
+        ext: ".js",
+        content: prescript,
+      })
+
+      yield write({
+        ctx,
+        slug: "postscript" as FullSlug,
+        ext: ".js",
+        content: postscript,
+      })
     },
+    async *partialEmit() {},
   }
 }
diff --git a/quartz/plugins/emitters/contentIndex.tsx b/quartz/plugins/emitters/contentIndex.tsx
index 6f43bad..01d2e00 100644
--- a/quartz/plugins/emitters/contentIndex.tsx
+++ b/quartz/plugins/emitters/contentIndex.tsx
@@ -7,7 +7,6 @@
 import { toHtml } from "hast-util-to-html"
 import { write } from "./helpers"
 import { i18n } from "../../i18n"
-import DepGraph from "../../depgraph"
 
 export type ContentIndexMap = Map<FullSlug, ContentDetails>
 export type ContentDetails = {
@@ -97,27 +96,7 @@
   opts = { ...defaultOptions, ...opts }
   return {
     name: "ContentIndex",
-    async getDependencyGraph(ctx, content, _resources) {
-      const graph = new DepGraph<FilePath>()
-
-      for (const [_tree, file] of content) {
-        const sourcePath = file.data.filePath!
-
-        graph.addEdge(
-          sourcePath,
-          joinSegments(ctx.argv.output, "static/contentIndex.json") as FilePath,
-        )
-        if (opts?.enableSiteMap) {
-          graph.addEdge(sourcePath, joinSegments(ctx.argv.output, "sitemap.xml") as FilePath)
-        }
-        if (opts?.enableRSS) {
-          graph.addEdge(sourcePath, joinSegments(ctx.argv.output, "index.xml") as FilePath)
-        }
-      }
-
-      return graph
-    },
-    async *emit(ctx, content, _resources) {
+    async *emit(ctx, content) {
       const cfg = ctx.cfg.configuration
       const linkIndex: ContentIndexMap = new Map()
       for (const [tree, file] of content) {
@@ -126,7 +105,7 @@
         if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) {
           linkIndex.set(slug, {
             slug,
-            filePath: file.data.filePath!,
+            filePath: file.data.relativePath!,
             title: file.data.frontmatter?.title!,
             links: file.data.links ?? [],
             tags: file.data.frontmatter?.tags ?? [],
diff --git a/quartz/plugins/emitters/contentPage.tsx b/quartz/plugins/emitters/contentPage.tsx
index f833930..d3f54e9 100644
--- a/quartz/plugins/emitters/contentPage.tsx
+++ b/quartz/plugins/emitters/contentPage.tsx
@@ -1,54 +1,48 @@
 import path from "path"
-import { visit } from "unist-util-visit"
-import { Root } from "hast"
-import { VFile } from "vfile"
 import { QuartzEmitterPlugin } from "../types"
 import { QuartzComponentProps } from "../../components/types"
 import HeaderConstructor from "../../components/Header"
 import BodyConstructor from "../../components/Body"
 import { pageResources, renderPage } from "../../components/renderPage"
 import { FullPageLayout } from "../../cfg"
-import { Argv } from "../../util/ctx"
-import { FilePath, isRelativeURL, joinSegments, pathToRoot } from "../../util/path"
+import { pathToRoot } from "../../util/path"
 import { defaultContentPageLayout, sharedPageComponents } from "../../../quartz.layout"
 import { Content } from "../../components"
 import chalk from "chalk"
 import { write } from "./helpers"
-import DepGraph from "../../depgraph"
+import { BuildCtx } from "../../util/ctx"
+import { Node } from "unist"
+import { StaticResources } from "../../util/resources"
+import { QuartzPluginData } from "../vfile"
 
-// get all the dependencies for the markdown file
-// eg. images, scripts, stylesheets, transclusions
-const parseDependencies = (argv: Argv, hast: Root, file: VFile): string[] => {
-  const dependencies: string[] = []
+async function processContent(
+  ctx: BuildCtx,
+  tree: Node,
+  fileData: QuartzPluginData,
+  allFiles: QuartzPluginData[],
+  opts: FullPageLayout,
+  resources: StaticResources,
+) {
+  const slug = fileData.slug!
+  const cfg = ctx.cfg.configuration
+  const externalResources = pageResources(pathToRoot(slug), resources)
+  const componentData: QuartzComponentProps = {
+    ctx,
+    fileData,
+    externalResources,
+    cfg,
+    children: [],
+    tree,
+    allFiles,
+  }
 
-  visit(hast, "element", (elem): void => {
-    let ref: string | null = null
-
-    if (
-      ["script", "img", "audio", "video", "source", "iframe"].includes(elem.tagName) &&
-      elem?.properties?.src
-    ) {
-      ref = elem.properties.src.toString()
-    } else if (["a", "link"].includes(elem.tagName) && elem?.properties?.href) {
-      // transclusions will create a tags with relative hrefs
-      ref = elem.properties.href.toString()
-    }
-
-    // if it is a relative url, its a local file and we need to add
-    // it to the dependency graph. otherwise, ignore
-    if (ref === null || !isRelativeURL(ref)) {
-      return
-    }
-
-    let fp = path.join(file.data.filePath!, path.relative(argv.directory, ref)).replace(/\\/g, "/")
-    // markdown files have the .md extension stripped in hrefs, add it back here
-    if (!fp.split("/").pop()?.includes(".")) {
-      fp += ".md"
-    }
-    dependencies.push(fp)
+  const content = renderPage(cfg, slug, componentData, opts, externalResources)
+  return write({
+    ctx,
+    content,
+    slug,
+    ext: ".html",
   })
-
-  return dependencies
 }
 
 export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpts) => {
@@ -79,57 +73,22 @@
         Footer,
       ]
     },
-    async getDependencyGraph(ctx, content, _resources) {
-      const graph = new DepGraph<FilePath>()
-
-      for (const [tree, file] of content) {
-        const sourcePath = file.data.filePath!
-        const slug = file.data.slug!
-        graph.addEdge(sourcePath, joinSegments(ctx.argv.output, slug + ".html") as FilePath)
-
-        parseDependencies(ctx.argv, tree as Root, file).forEach((dep) => {
-          graph.addEdge(dep as FilePath, sourcePath)
-        })
-      }
-
-      return graph
-    },
     async *emit(ctx, content, resources) {
-      const cfg = ctx.cfg.configuration
       const allFiles = content.map((c) => c[1].data)
-
       let containsIndex = false
+
       for (const [tree, file] of content) {
         const slug = file.data.slug!
         if (slug === "index") {
           containsIndex = true
         }
 
-        if (file.data.slug?.endsWith("/index")) {
-          continue
-        }
-
-        const externalResources = pageResources(pathToRoot(slug), file.data, resources)
-        const componentData: QuartzComponentProps = {
-          ctx,
-          fileData: file.data,
-          externalResources,
-          cfg,
-          children: [],
-          tree,
-          allFiles,
-        }
-
-        const content = renderPage(cfg, slug, componentData, opts, externalResources)
-        yield write({
-          ctx,
-          content,
-          slug,
-          ext: ".html",
-        })
+        // only process home page, non-tag pages, and non-index pages
+        if (slug.endsWith("/index") || slug.startsWith("tags/")) continue
+        yield processContent(ctx, tree, file.data, allFiles, opts, resources)
       }
 
-      if (!containsIndex && !ctx.argv.fastRebuild) {
+      if (!containsIndex) {
         console.log(
           chalk.yellow(
             `\nWarning: you seem to be missing an \`index.md\` home page file at the root of your \`${ctx.argv.directory}\` folder (\`${path.join(ctx.argv.directory, "index.md")} does not exist\`). This may cause errors when deploying.`,
@@ -137,5 +96,25 @@
         )
       }
     },
+    async *partialEmit(ctx, content, resources, changeEvents) {
+      const allFiles = content.map((c) => c[1].data)
+
+      // find all slugs that changed or were added
+      const changedSlugs = new Set<string>()
+      for (const changeEvent of changeEvents) {
+        if (!changeEvent.file) continue
+        if (changeEvent.type === "add" || changeEvent.type === "change") {
+          changedSlugs.add(changeEvent.file.data.slug!)
+        }
+      }
+
+      for (const [tree, file] of content) {
+        const slug = file.data.slug!
+        if (!changedSlugs.has(slug)) continue
+        if (slug.endsWith("/index") || slug.startsWith("tags/")) continue
+
+        yield processContent(ctx, tree, file.data, allFiles, opts, resources)
+      }
+    },
   }
 }
diff --git a/quartz/plugins/emitters/folderPage.tsx b/quartz/plugins/emitters/folderPage.tsx
index 1c81207..f9b181d 100644
--- a/quartz/plugins/emitters/folderPage.tsx
+++ b/quartz/plugins/emitters/folderPage.tsx
@@ -7,7 +7,6 @@
 import { FullPageLayout } from "../../cfg"
 import path from "path"
 import {
-  FilePath,
   FullSlug,
   SimpleSlug,
   stripSlashes,
@@ -18,13 +17,89 @@
 import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
 import { FolderContent } from "../../components"
 import { write } from "./helpers"
-import { i18n } from "../../i18n"
-import DepGraph from "../../depgraph"
-
+import { i18n, TRANSLATIONS } from "../../i18n"
+import { BuildCtx } from "../../util/ctx"
+import { StaticResources } from "../../util/resources"
 interface FolderPageOptions extends FullPageLayout {
   sort?: (f1: QuartzPluginData, f2: QuartzPluginData) => number
 }
 
+async function* processFolderInfo(
+  ctx: BuildCtx,
+  folderInfo: Record<SimpleSlug, ProcessedContent>,
+  allFiles: QuartzPluginData[],
+  opts: FullPageLayout,
+  resources: StaticResources,
+) {
+  for (const [folder, folderContent] of Object.entries(folderInfo) as [
+    SimpleSlug,
+    ProcessedContent,
+  ][]) {
+    const slug = joinSegments(folder, "index") as FullSlug
+    const [tree, file] = folderContent
+    const cfg = ctx.cfg.configuration
+    const externalResources = pageResources(pathToRoot(slug), resources)
+    const componentData: QuartzComponentProps = {
+      ctx,
+      fileData: file.data,
+      externalResources,
+      cfg,
+      children: [],
+      tree,
+      allFiles,
+    }
+
+    const content = renderPage(cfg, slug, componentData, opts, externalResources)
+    yield write({
+      ctx,
+      content,
+      slug,
+      ext: ".html",
+    })
+  }
+}
+
+function computeFolderInfo(
+  folders: Set<SimpleSlug>,
+  content: ProcessedContent[],
+  locale: keyof typeof TRANSLATIONS,
+): Record<SimpleSlug, ProcessedContent> {
+  // Create default folder descriptions
+  const folderInfo: Record<SimpleSlug, ProcessedContent> = Object.fromEntries(
+    [...folders].map((folder) => [
+      folder,
+      defaultProcessedContent({
+        slug: joinSegments(folder, "index") as FullSlug,
+        frontmatter: {
+          title: `${i18n(locale).pages.folderContent.folder}: ${folder}`,
+          tags: [],
+        },
+      }),
+    ]),
+  )
+
+  // Update with actual content if available
+  for (const [tree, file] of content) {
+    const slug = stripSlashes(simplifySlug(file.data.slug!)) as SimpleSlug
+    if (folders.has(slug)) {
+      folderInfo[slug] = [tree, file]
+    }
+  }
+
+  return folderInfo
+}
+
+function _getFolders(slug: FullSlug): SimpleSlug[] {
+  var folderName = path.dirname(slug ?? "") as SimpleSlug
+  const parentFolderNames = [folderName]
+
+  while (folderName !== ".") {
+    folderName = path.dirname(folderName ?? "") as SimpleSlug
+    parentFolderNames.push(folderName)
+  }
+  return parentFolderNames
+}
+
 export const FolderPage: QuartzEmitterPlugin<Partial<FolderPageOptions>> = (userOpts) => {
   const opts: FullPageLayout = {
     ...sharedPageComponents,
@@ -53,22 +128,6 @@
         Footer,
       ]
     },
-    async getDependencyGraph(_ctx, content, _resources) {
-      // Example graph:
-      // nested/file.md --> nested/index.html
-      // nested/file2.md ------^
-      const graph = new DepGraph<FilePath>()
-
-      content.map(([_tree, vfile]) => {
-        const slug = vfile.data.slug
-        const folderName = path.dirname(slug ?? "") as SimpleSlug
-        if (slug && folderName !== "." && folderName !== "tags") {
-          graph.addEdge(vfile.data.filePath!, joinSegments(folderName, "index.html") as FilePath)
-        }
-      })
-
-      return graph
-    },
     async *emit(ctx, content, resources) {
       const allFiles = content.map((c) => c[1].data)
       const cfg = ctx.cfg.configuration
@@ -83,59 +142,29 @@
         }),
       )
 
-      const folderDescriptions: Record<string, ProcessedContent> = Object.fromEntries(
-        [...folders].map((folder) => [
-          folder,
-          defaultProcessedContent({
-            slug: joinSegments(folder, "index") as FullSlug,
-            frontmatter: {
-              title: `${i18n(cfg.locale).pages.folderContent.folder}: ${folder}`,
-              tags: [],
-            },
-          }),
-        ]),
-      )
+      const folderInfo = computeFolderInfo(folders, content, cfg.locale)
+      yield* processFolderInfo(ctx, folderInfo, allFiles, opts, resources)
+    },
+    async *partialEmit(ctx, content, resources, changeEvents) {
+      const allFiles = content.map((c) => c[1].data)
+      const cfg = ctx.cfg.configuration
 
-      for (const [tree, file] of content) {
-        const slug = stripSlashes(simplifySlug(file.data.slug!)) as SimpleSlug
-        if (folders.has(slug)) {
-          folderDescriptions[slug] = [tree, file]
-        }
+      // Find all folders that need to be updated based on changed files
+      const affectedFolders: Set<SimpleSlug> = new Set()
+      for (const changeEvent of changeEvents) {
+        if (!changeEvent.file) continue
+        const slug = changeEvent.file.data.slug!
+        const folders = _getFolders(slug).filter(
+          (folderName) => folderName !== "." && folderName !== "tags",
+        )
+        folders.forEach((folder) => affectedFolders.add(folder))
       }
 
-      for (const folder of folders) {
-        const slug = joinSegments(folder, "index") as FullSlug
-        const [tree, file] = folderDescriptions[folder]
-        const externalResources = pageResources(pathToRoot(slug), file.data, resources)
-        const componentData: QuartzComponentProps = {
-          ctx,
-          fileData: file.data,
-          externalResources,
-          cfg,
-          children: [],
-          tree,
-          allFiles,
-        }
-
-        const content = renderPage(cfg, slug, componentData, opts, externalResources)
-        yield write({
-          ctx,
-          content,
-          slug,
-          ext: ".html",
-        })
+      // If there are affected folders, rebuild their pages
+      if (affectedFolders.size > 0) {
+        const folderInfo = computeFolderInfo(affectedFolders, content, cfg.locale)
+        yield* processFolderInfo(ctx, folderInfo, allFiles, opts, resources)
       }
     },
   }
 }
-
-function _getFolders(slug: FullSlug): SimpleSlug[] {
-  var folderName = path.dirname(slug ?? "") as SimpleSlug
-  const parentFolderNames = [folderName]
-
-  while (folderName !== ".") {
-    folderName = path.dirname(folderName ?? "") as SimpleSlug
-    parentFolderNames.push(folderName)
-  }
-  return parentFolderNames
-}
diff --git a/quartz/plugins/emitters/ogImage.tsx b/quartz/plugins/emitters/ogImage.tsx
index 056976a..f31cc4b 100644
--- a/quartz/plugins/emitters/ogImage.tsx
+++ b/quartz/plugins/emitters/ogImage.tsx
@@ -4,10 +4,12 @@
 import { FullSlug, getFileExtension } from "../../util/path"
 import { ImageOptions, SocialImageOptions, defaultImage, getSatoriFonts } from "../../util/og"
 import sharp from "sharp"
-import satori from "satori"
+import satori, { SatoriOptions } from "satori"
 import { loadEmoji, getIconCode } from "../../util/emoji"
 import { Readable } from "stream"
 import { write } from "./helpers"
+import { BuildCtx } from "../../util/ctx"
+import { QuartzPluginData } from "../vfile"
 
 const defaultOptions: SocialImageOptions = {
   colorScheme: "lightMode",
@@ -42,6 +44,41 @@
   return sharp(Buffer.from(svg)).webp({ quality: 40 })
 }
 
+async function processOgImage(
+  ctx: BuildCtx,
+  fileData: QuartzPluginData,
+  fonts: SatoriOptions["fonts"],
+  fullOptions: SocialImageOptions,
+) {
+  const cfg = ctx.cfg.configuration
+  const slug = fileData.slug!
+  const titleSuffix = cfg.pageTitleSuffix ?? ""
+  const title =
+    (fileData.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title) + titleSuffix
+  const description =
+    fileData.frontmatter?.socialDescription ??
+    fileData.frontmatter?.description ??
+    unescapeHTML(fileData.description?.trim() ?? i18n(cfg.locale).propertyDefaults.description)
+
+  const stream = await generateSocialImage(
+    {
+      title,
+      description,
+      fonts,
+      cfg,
+      fileData,
+    },
+    fullOptions,
+  )
+
+  return write({
+    ctx,
+    content: stream,
+    slug: `${slug}-og-image` as FullSlug,
+    ext: ".webp",
+  })
+}
+
 export const CustomOgImagesEmitterName = "CustomOgImages"
 export const CustomOgImages: QuartzEmitterPlugin<Partial<SocialImageOptions>> = (userOpts) => {
   const fullOptions = { ...defaultOptions, ...userOpts }
@@ -58,39 +95,23 @@
       const fonts = await getSatoriFonts(headerFont, bodyFont)
 
       for (const [_tree, vfile] of content) {
-        // if this file defines socialImage, we can skip
-        if (vfile.data.frontmatter?.socialImage !== undefined) {
-          continue
+        if (vfile.data.frontmatter?.socialImage !== undefined) continue
+        yield processOgImage(ctx, vfile.data, fonts, fullOptions)
+      }
+    },
+    async *partialEmit(ctx, _content, _resources, changeEvents) {
+      const cfg = ctx.cfg.configuration
+      const headerFont = cfg.theme.typography.header
+      const bodyFont = cfg.theme.typography.body
+      const fonts = await getSatoriFonts(headerFont, bodyFont)
+
+      // find all slugs that changed or were added
+      for (const changeEvent of changeEvents) {
+        if (!changeEvent.file) continue
+        if (changeEvent.file.data.frontmatter?.socialImage !== undefined) continue
+        if (changeEvent.type === "add" || changeEvent.type === "change") {
+          yield processOgImage(ctx, changeEvent.file.data, fonts, fullOptions)
         }
-
-        const slug = vfile.data.slug!
-        const titleSuffix = cfg.pageTitleSuffix ?? ""
-        const title =
-          (vfile.data.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title) + titleSuffix
-        const description =
-          vfile.data.frontmatter?.socialDescription ??
-          vfile.data.frontmatter?.description ??
-          unescapeHTML(
-            vfile.data.description?.trim() ?? i18n(cfg.locale).propertyDefaults.description,
-          )
-
-        const stream = await generateSocialImage(
-          {
-            title,
-            description,
-            fonts,
-            cfg,
-            fileData: vfile.data,
-          },
-          fullOptions,
-        )
-
-        yield write({
-          ctx,
-          content: stream,
-          slug: `${slug}-og-image` as FullSlug,
-          ext: ".webp",
-        })
       }
     },
     externalResources: (ctx) => {
diff --git a/quartz/plugins/emitters/static.ts b/quartz/plugins/emitters/static.ts
index 7c0ecfd..0b45290 100644
--- a/quartz/plugins/emitters/static.ts
+++ b/quartz/plugins/emitters/static.ts
@@ -2,26 +2,11 @@
 import { QuartzEmitterPlugin } from "../types"
 import fs from "fs"
 import { glob } from "../../util/glob"
-import DepGraph from "../../depgraph"
 import { dirname } from "path"
 
 export const Static: QuartzEmitterPlugin = () => ({
   name: "Static",
-  async getDependencyGraph({ argv, cfg }, _content, _resources) {
-    const graph = new DepGraph<FilePath>()
-
-    const staticPath = joinSegments(QUARTZ, "static")
-    const fps = await glob("**", staticPath, cfg.configuration.ignorePatterns)
-    for (const fp of fps) {
-      graph.addEdge(
-        joinSegments("static", fp) as FilePath,
-        joinSegments(argv.output, "static", fp) as FilePath,
-      )
-    }
-
-    return graph
-  },
-  async *emit({ argv, cfg }, _content) {
+  async *emit({ argv, cfg }) {
     const staticPath = joinSegments(QUARTZ, "static")
     const fps = await glob("**", staticPath, cfg.configuration.ignorePatterns)
     const outputStaticPath = joinSegments(argv.output, "static")
@@ -34,4 +19,5 @@
       yield dest
     }
   },
+  async *partialEmit() {},
 })
diff --git a/quartz/plugins/emitters/tagPage.tsx b/quartz/plugins/emitters/tagPage.tsx
index 41cf091..5f23893 100644
--- a/quartz/plugins/emitters/tagPage.tsx
+++ b/quartz/plugins/emitters/tagPage.tsx
@@ -5,23 +5,94 @@
 import { pageResources, renderPage } from "../../components/renderPage"
 import { ProcessedContent, QuartzPluginData, defaultProcessedContent } from "../vfile"
 import { FullPageLayout } from "../../cfg"
-import {
-  FilePath,
-  FullSlug,
-  getAllSegmentPrefixes,
-  joinSegments,
-  pathToRoot,
-} from "../../util/path"
+import { FullSlug, getAllSegmentPrefixes, joinSegments, pathToRoot } from "../../util/path"
 import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
 import { TagContent } from "../../components"
 import { write } from "./helpers"
-import { i18n } from "../../i18n"
-import DepGraph from "../../depgraph"
+import { i18n, TRANSLATIONS } from "../../i18n"
+import { BuildCtx } from "../../util/ctx"
+import { StaticResources } from "../../util/resources"
 
 interface TagPageOptions extends FullPageLayout {
   sort?: (f1: QuartzPluginData, f2: QuartzPluginData) => number
 }
 
+function computeTagInfo(
+  allFiles: QuartzPluginData[],
+  content: ProcessedContent[],
+  locale: keyof typeof TRANSLATIONS,
+): [Set<string>, Record<string, ProcessedContent>] {
+  const tags: Set<string> = new Set(
+    allFiles.flatMap((data) => data.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes),
+  )
+
+  // add base tag
+  tags.add("index")
+
+  const tagDescriptions: Record<string, ProcessedContent> = Object.fromEntries(
+    [...tags].map((tag) => {
+      const title =
+        tag === "index"
+          ? i18n(locale).pages.tagContent.tagIndex
+          : `${i18n(locale).pages.tagContent.tag}: ${tag}`
+      return [
+        tag,
+        defaultProcessedContent({
+          slug: joinSegments("tags", tag) as FullSlug,
+          frontmatter: { title, tags: [] },
+        }),
+      ]
+    }),
+  )
+
+  // Update with actual content if available
+  for (const [tree, file] of content) {
+    const slug = file.data.slug!
+    if (slug.startsWith("tags/")) {
+      const tag = slug.slice("tags/".length)
+      if (tags.has(tag)) {
+        tagDescriptions[tag] = [tree, file]
+        if (file.data.frontmatter?.title === tag) {
+          file.data.frontmatter.title = `${i18n(locale).pages.tagContent.tag}: ${tag}`
+        }
+      }
+    }
+  }
+
+  return [tags, tagDescriptions]
+}
+
+async function processTagPage(
+  ctx: BuildCtx,
+  tag: string,
+  tagContent: ProcessedContent,
+  allFiles: QuartzPluginData[],
+  opts: FullPageLayout,
+  resources: StaticResources,
+) {
+  const slug = joinSegments("tags", tag) as FullSlug
+  const [tree, file] = tagContent
+  const cfg = ctx.cfg.configuration
+  const externalResources = pageResources(pathToRoot(slug), resources)
+  const componentData: QuartzComponentProps = {
+    ctx,
+    fileData: file.data,
+    externalResources,
+    cfg,
+    children: [],
+    tree,
+    allFiles,
+  }
+
+  const content = renderPage(cfg, slug, componentData, opts, externalResources)
+  return write({
+    ctx,
+    content,
+    slug: file.data.slug!,
+    ext: ".html",
+  })
+}
+
 export const TagPage: QuartzEmitterPlugin<Partial<TagPageOptions>> = (userOpts) => {
   const opts: FullPageLayout = {
     ...sharedPageComponents,
@@ -50,88 +121,49 @@
         Footer,
       ]
     },
-    async getDependencyGraph(ctx, content, _resources) {
-      const graph = new DepGraph<FilePath>()
-
-      for (const [_tree, file] of content) {
-        const sourcePath = file.data.filePath!
-        const tags = (file.data.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes)
-        // if the file has at least one tag, it is used in the tag index page
-        if (tags.length > 0) {
-          tags.push("index")
-        }
-
-        for (const tag of tags) {
-          graph.addEdge(
-            sourcePath,
-            joinSegments(ctx.argv.output, "tags", tag + ".html") as FilePath,
-          )
-        }
-      }
-
-      return graph
-    },
     async *emit(ctx, content, resources) {
       const allFiles = content.map((c) => c[1].data)
       const cfg = ctx.cfg.configuration
-
-      const tags: Set<string> = new Set(
-        allFiles.flatMap((data) => data.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes),
-      )
-
-      // add base tag
-      tags.add("index")
-
-      const tagDescriptions: Record<string, ProcessedContent> = Object.fromEntries(
-        [...tags].map((tag) => {
-          const title =
-            tag === "index"
-              ? i18n(cfg.locale).pages.tagContent.tagIndex
-              : `${i18n(cfg.locale).pages.tagContent.tag}: ${tag}`
-          return [
-            tag,
-            defaultProcessedContent({
-              slug: joinSegments("tags", tag) as FullSlug,
-              frontmatter: { title, tags: [] },
-            }),
-          ]
-        }),
-      )
-
-      for (const [tree, file] of content) {
-        const slug = file.data.slug!
-        if (slug.startsWith("tags/")) {
-          const tag = slug.slice("tags/".length)
-          if (tags.has(tag)) {
-            tagDescriptions[tag] = [tree, file]
-            if (file.data.frontmatter?.title === tag) {
-              file.data.frontmatter.title = `${i18n(cfg.locale).pages.tagContent.tag}: ${tag}`
-            }
-          }
-        }
-      }
+      const [tags, tagDescriptions] = computeTagInfo(allFiles, content, cfg.locale)
 
       for (const tag of tags) {
-        const slug = joinSegments("tags", tag) as FullSlug
-        const [tree, file] = tagDescriptions[tag]
-        const externalResources = pageResources(pathToRoot(slug), file.data, resources)
-        const componentData: QuartzComponentProps = {
-          ctx,
-          fileData: file.data,
-          externalResources,
-          cfg,
-          children: [],
-          tree,
-          allFiles,
+        yield processTagPage(ctx, tag, tagDescriptions[tag], allFiles, opts, resources)
+      }
+    },
+    async *partialEmit(ctx, content, resources, changeEvents) {
+      const allFiles = content.map((c) => c[1].data)
+      const cfg = ctx.cfg.configuration
+
+      // Find all tags that need to be updated based on changed files
+      const affectedTags: Set<string> = new Set()
+      for (const changeEvent of changeEvents) {
+        if (!changeEvent.file) continue
+        const slug = changeEvent.file.data.slug!
+
+        // If it's a tag page itself that changed
+        if (slug.startsWith("tags/")) {
+          const tag = slug.slice("tags/".length)
+          affectedTags.add(tag)
         }
 
-        const content = renderPage(cfg, slug, componentData, opts, externalResources)
-        yield write({
-          ctx,
-          content,
-          slug: file.data.slug!,
-          ext: ".html",
-        })
+        // If a file with tags changed, we need to update those tag pages
+        const fileTags = changeEvent.file.data.frontmatter?.tags ?? []
+        fileTags.flatMap(getAllSegmentPrefixes).forEach((tag) => affectedTags.add(tag))
+
+        // Always update the index tag page if any file changes
+        affectedTags.add("index")
+      }
+
+      // If there are affected tags, rebuild their pages
+      if (affectedTags.size > 0) {
+        // We still need to compute all tags because tag pages show all tags
+        const [_tags, tagDescriptions] = computeTagInfo(allFiles, content, cfg.locale)
+
+        for (const tag of affectedTags) {
+          if (tagDescriptions[tag]) {
+            yield processTagPage(ctx, tag, tagDescriptions[tag], allFiles, opts, resources)
+          }
+        }
       }
     },
   }
diff --git a/quartz/plugins/transformers/frontmatter.ts b/quartz/plugins/transformers/frontmatter.ts
index b3a916a..c04c52a 100644
--- a/quartz/plugins/transformers/frontmatter.ts
+++ b/quartz/plugins/transformers/frontmatter.ts
@@ -3,12 +3,9 @@
 import { QuartzTransformerPlugin } from "../types"
 import yaml from "js-yaml"
 import toml from "toml"
-import { FilePath, FullSlug, joinSegments, slugifyFilePath, slugTag } from "../../util/path"
+import { FilePath, FullSlug, getFileExtension, slugifyFilePath, slugTag } from "../../util/path"
 import { QuartzPluginData } from "../vfile"
 import { i18n } from "../../i18n"
-import { Argv } from "../../util/ctx"
-import { VFile } from "vfile"
-import path from "path"
 
 export interface Options {
   delimiters: string | [string, string]
@@ -43,26 +40,24 @@
     .map((tag: string | number) => tag.toString())
 }
 
-export function getAliasSlugs(aliases: string[], argv: Argv, file: VFile): FullSlug[] {
-  const dir = path.posix.relative(argv.directory, path.dirname(file.data.filePath!))
-  const slugs: FullSlug[] = aliases.map(
-    (alias) => path.posix.join(dir, slugifyFilePath(alias as FilePath)) as FullSlug,
-  )
-  const permalink = file.data.frontmatter?.permalink
-  if (typeof permalink === "string") {
-    slugs.push(permalink as FullSlug)
+function getAliasSlugs(aliases: string[]): FullSlug[] {
+  const res: FullSlug[] = []
+  for (const alias of aliases) {
+    const isMd = getFileExtension(alias) === "md"
+    const mockFp = isMd ? alias : alias + ".md"
+    const slug = slugifyFilePath(mockFp as FilePath)
+    res.push(slug)
   }
-  // fix any slugs that have trailing slash
-  return slugs.map((slug) =>
-    slug.endsWith("/") ? (joinSegments(slug, "index") as FullSlug) : slug,
-  )
+
+  return res
 }
 
 export const FrontMatter: QuartzTransformerPlugin<Partial<Options>> = (userOpts) => {
   const opts = { ...defaultOptions, ...userOpts }
   return {
     name: "FrontMatter",
-    markdownPlugins({ cfg, allSlugs, argv }) {
+    markdownPlugins(ctx) {
+      const { cfg, allSlugs } = ctx
       return [
         [remarkFrontmatter, ["yaml", "toml"]],
         () => {
@@ -88,9 +83,18 @@
             const aliases = coerceToArray(coalesceAliases(data, ["aliases", "alias"]))
             if (aliases) {
               data.aliases = aliases // frontmatter
-              const slugs = (file.data.aliases = getAliasSlugs(aliases, argv, file))
-              allSlugs.push(...slugs)
+              file.data.aliases = getAliasSlugs(aliases)
+              allSlugs.push(...file.data.aliases)
             }
+
+            if (data.permalink != null && data.permalink.toString() !== "") {
+              data.permalink = data.permalink.toString() as FullSlug
+              const aliases = file.data.aliases ?? []
+              aliases.push(data.permalink)
+              file.data.aliases = aliases
+              allSlugs.push(data.permalink)
+            }
+
             const cssclasses = coerceToArray(coalesceAliases(data, ["cssclasses", "cssclass"]))
             if (cssclasses) data.cssclasses = cssclasses
 
diff --git a/quartz/plugins/transformers/lastmod.ts b/quartz/plugins/transformers/lastmod.ts
index fd57692..aeabad1 100644
--- a/quartz/plugins/transformers/lastmod.ts
+++ b/quartz/plugins/transformers/lastmod.ts
@@ -31,7 +31,7 @@
   const opts = { ...defaultOptions, ...userOpts }
   return {
     name: "CreatedModifiedDate",
-    markdownPlugins() {
+    markdownPlugins(ctx) {
       return [
         () => {
           let repo: Repository | undefined = undefined
@@ -40,8 +40,8 @@
             let modified: MaybeDate = undefined
             let published: MaybeDate = undefined
 
-            const fp = file.data.filePath!
-            const fullFp = path.isAbsolute(fp) ? fp : path.posix.join(file.cwd, fp)
+            const fp = file.data.relativePath!
+            const fullFp = path.posix.join(ctx.argv.directory, fp)
             for (const source of opts.priority) {
               if (source === "filesystem") {
                 const st = await fs.promises.stat(fullFp)
@@ -56,11 +56,11 @@
                   // Get a reference to the main git repo.
                   // It's either the same as the workdir,
                   // or 1+ level higher in case of a submodule/subtree setup
-                  repo = Repository.discover(file.cwd)
+                  repo = Repository.discover(ctx.argv.directory)
                 }
 
                 try {
-                  modified ||= await repo.getFileLatestModifiedDateAsync(file.data.filePath!)
+                  modified ||= await repo.getFileLatestModifiedDateAsync(fullFp)
                 } catch {
                   console.log(
                     chalk.yellow(
diff --git a/quartz/plugins/transformers/oxhugofm.ts b/quartz/plugins/transformers/oxhugofm.ts
index cdbffcf..0612c7a 100644
--- a/quartz/plugins/transformers/oxhugofm.ts
+++ b/quartz/plugins/transformers/oxhugofm.ts
@@ -54,7 +54,7 @@
     textTransform(_ctx, src) {
       if (opts.wikilinks) {
         src = src.toString()
-        src = src.replaceAll(relrefRegex, (value, ...capture) => {
+        src = src.replaceAll(relrefRegex, (_value, ...capture) => {
           const [text, link] = capture
           return `[${text}](${link})`
         })
@@ -62,7 +62,7 @@
 
       if (opts.removePredefinedAnchor) {
         src = src.toString()
-        src = src.replaceAll(predefinedHeadingIdRegex, (value, ...capture) => {
+        src = src.replaceAll(predefinedHeadingIdRegex, (_value, ...capture) => {
           const [headingText] = capture
           return headingText
         })
@@ -70,7 +70,7 @@
 
       if (opts.removeHugoShortcode) {
         src = src.toString()
-        src = src.replaceAll(hugoShortcodeRegex, (value, ...capture) => {
+        src = src.replaceAll(hugoShortcodeRegex, (_value, ...capture) => {
           const [scContent] = capture
           return scContent
         })
@@ -78,7 +78,7 @@
 
       if (opts.replaceFigureWithMdImg) {
         src = src.toString()
-        src = src.replaceAll(figureTagRegex, (value, ...capture) => {
+        src = src.replaceAll(figureTagRegex, (_value, ...capture) => {
           const [src] = capture
           return `![](${src})`
         })
@@ -86,11 +86,11 @@
 
       if (opts.replaceOrgLatex) {
         src = src.toString()
-        src = src.replaceAll(inlineLatexRegex, (value, ...capture) => {
+        src = src.replaceAll(inlineLatexRegex, (_value, ...capture) => {
           const [eqn] = capture
           return `$${eqn}$`
         })
-        src = src.replaceAll(blockLatexRegex, (value, ...capture) => {
+        src = src.replaceAll(blockLatexRegex, (_value, ...capture) => {
           const [eqn] = capture
           return `$$${eqn}$$`
         })
diff --git a/quartz/plugins/transformers/roam.ts b/quartz/plugins/transformers/roam.ts
index b3be8f5..b6df67a 100644
--- a/quartz/plugins/transformers/roam.ts
+++ b/quartz/plugins/transformers/roam.ts
@@ -1,10 +1,8 @@
 import { QuartzTransformerPlugin } from "../types"
 import { PluggableList } from "unified"
-import { SKIP, visit } from "unist-util-visit"
+import { visit } from "unist-util-visit"
 import { ReplaceFunction, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace"
 import { Root, Html, Paragraph, Text, Link, Parent } from "mdast"
-import { Node } from "unist"
-import { VFile } from "vfile"
 import { BuildVisitor } from "unist-util-visit"
 
 export interface Options {
@@ -34,21 +32,10 @@
 const orRegex = new RegExp(/{{or:(.*?)}}/, "g")
 const TODORegex = new RegExp(/{{.*?\bTODO\b.*?}}/, "g")
 const DONERegex = new RegExp(/{{.*?\bDONE\b.*?}}/, "g")
-const videoRegex = new RegExp(/{{.*?\[\[video\]\].*?\:(.*?)}}/, "g")
-const youtubeRegex = new RegExp(
-  /{{.*?\[\[video\]\].*?(https?:\/\/(?:www\.)?youtu(?:be\.com\/watch\?v=|\.be\/)([\w\-\_]*)(&(amp;)?[\w\?=]*)?)}}/,
-  "g",
-)
 
-// const multimediaRegex = new RegExp(/{{.*?\b(video|audio)\b.*?\:(.*?)}}/, "g")
-
-const audioRegex = new RegExp(/{{.*?\[\[audio\]\].*?\:(.*?)}}/, "g")
-const pdfRegex = new RegExp(/{{.*?\[\[pdf\]\].*?\:(.*?)}}/, "g")
 const blockquoteRegex = new RegExp(/(\[\[>\]\])\s*(.*)/, "g")
 const roamHighlightRegex = new RegExp(/\^\^(.+)\^\^/, "g")
 const roamItalicRegex = new RegExp(/__(.+)__/, "g")
-const tableRegex = new RegExp(/- {{.*?\btable\b.*?}}/, "g") /* TODO */
-const attributeRegex = new RegExp(/\b\w+(?:\s+\w+)*::/, "g") /* TODO */
 
 function isSpecialEmbed(node: Paragraph): boolean {
   if (node.children.length !== 2) return false
@@ -135,7 +122,7 @@
       const plugins: PluggableList = []
 
       plugins.push(() => {
-        return (tree: Root, file: VFile) => {
+        return (tree: Root) => {
           const replacements: [RegExp, ReplaceFunction][] = []
 
           // Handle special embeds (audio, video, PDF)
diff --git a/quartz/plugins/types.ts b/quartz/plugins/types.ts
index 166ec5d..2a7c16c 100644
--- a/quartz/plugins/types.ts
+++ b/quartz/plugins/types.ts
@@ -4,7 +4,7 @@
 import { QuartzComponent } from "../components/types"
 import { FilePath } from "../util/path"
 import { BuildCtx } from "../util/ctx"
-import DepGraph from "../depgraph"
+import { VFile } from "vfile"
 
 export interface PluginTypes {
   transformers: QuartzTransformerPluginInstance[]
@@ -33,26 +33,33 @@
   shouldPublish(ctx: BuildCtx, content: ProcessedContent): boolean
 }
 
+export type ChangeEvent = {
+  type: "add" | "change" | "delete"
+  path: FilePath
+  file?: VFile
+}
+
 export type QuartzEmitterPlugin<Options extends OptionType = undefined> = (
   opts?: Options,
 ) => QuartzEmitterPluginInstance
 export type QuartzEmitterPluginInstance = {
   name: string
-  emit(
+  emit: (
     ctx: BuildCtx,
     content: ProcessedContent[],
     resources: StaticResources,
-  ): Promise<FilePath[]> | AsyncGenerator<FilePath>
+  ) => Promise<FilePath[]> | AsyncGenerator<FilePath>
+  partialEmit?: (
+    ctx: BuildCtx,
+    content: ProcessedContent[],
+    resources: StaticResources,
+    changeEvents: ChangeEvent[],
+  ) => Promise<FilePath[]> | AsyncGenerator<FilePath> | null
   /**
    * Returns the components (if any) that are used in rendering the page.
    * This helps Quartz optimize the page by only including necessary resources
    * for components that are actually used.
    */
   getQuartzComponents?: (ctx: BuildCtx) => QuartzComponent[]
-  getDependencyGraph?(
-    ctx: BuildCtx,
-    content: ProcessedContent[],
-    resources: StaticResources,
-  ): Promise<DepGraph<FilePath>>
   externalResources?: ExternalResourcesFn
 }
diff --git a/quartz/processors/emit.ts b/quartz/processors/emit.ts
index aae119d..00bc9c8 100644
--- a/quartz/processors/emit.ts
+++ b/quartz/processors/emit.ts
@@ -11,7 +11,7 @@
   const perf = new PerfTimer()
   const log = new QuartzLogger(ctx.argv.verbose)
 
-  log.start(`Emitting output files`)
+  log.start(`Emitting files`)
 
   let emittedFiles = 0
   const staticResources = getStaticResourcesFromPlugins(ctx)
@@ -26,7 +26,7 @@
             if (ctx.argv.verbose) {
               console.log(`[emit:${emitter.name}] ${file}`)
             } else {
-              log.updateText(`Emitting output files: ${emitter.name} -> ${chalk.gray(file)}`)
+              log.updateText(`${emitter.name} -> ${chalk.gray(file)}`)
             }
           }
         } else {
@@ -36,7 +36,7 @@
             if (ctx.argv.verbose) {
               console.log(`[emit:${emitter.name}] ${file}`)
             } else {
-              log.updateText(`Emitting output files: ${emitter.name} -> ${chalk.gray(file)}`)
+              log.updateText(`${emitter.name} -> ${chalk.gray(file)}`)
             }
           }
         }
diff --git a/quartz/processors/parse.ts b/quartz/processors/parse.ts
index 479313f..3a0d15a 100644
--- a/quartz/processors/parse.ts
+++ b/quartz/processors/parse.ts
@@ -7,12 +7,13 @@
 import { MarkdownContent, ProcessedContent } from "../plugins/vfile"
 import { PerfTimer } from "../util/perf"
 import { read } from "to-vfile"
-import { FilePath, FullSlug, QUARTZ, slugifyFilePath } from "../util/path"
+import { FilePath, QUARTZ, slugifyFilePath } from "../util/path"
 import path from "path"
 import workerpool, { Promise as WorkerPromise } from "workerpool"
 import { QuartzLogger } from "../util/log"
 import { trace } from "../util/trace"
-import { BuildCtx } from "../util/ctx"
+import { BuildCtx, WorkerSerializableBuildCtx } from "../util/ctx"
+import chalk from "chalk"
 
 export type QuartzMdProcessor = Processor<MDRoot, MDRoot, MDRoot>
 export type QuartzHtmlProcessor = Processor<undefined, MDRoot, HTMLRoot>
@@ -175,21 +176,42 @@
       process.exit(1)
     }
 
-    const mdPromises: WorkerPromise<[MarkdownContent[], FullSlug[]]>[] = []
-    for (const chunk of chunks(fps, CHUNK_SIZE)) {
-      mdPromises.push(pool.exec("parseMarkdown", [ctx.buildId, argv, chunk]))
+    const serializableCtx: WorkerSerializableBuildCtx = {
+      buildId: ctx.buildId,
+      argv: ctx.argv,
+      allSlugs: ctx.allSlugs,
+      allFiles: ctx.allFiles,
+      incremental: ctx.incremental,
     }
-    const mdResults: [MarkdownContent[], FullSlug[]][] =
-      await WorkerPromise.all(mdPromises).catch(errorHandler)
 
-    const childPromises: WorkerPromise<ProcessedContent[]>[] = []
-    for (const [_, extraSlugs] of mdResults) {
-      ctx.allSlugs.push(...extraSlugs)
+    const textToMarkdownPromises: WorkerPromise<MarkdownContent[]>[] = []
+    let processedFiles = 0
+    for (const chunk of chunks(fps, CHUNK_SIZE)) {
+      textToMarkdownPromises.push(pool.exec("parseMarkdown", [serializableCtx, chunk]))
     }
+
+    const mdResults: Array<MarkdownContent[]> = await Promise.all(
+      textToMarkdownPromises.map(async (promise) => {
+        const result = await promise
+        processedFiles += result.length
+        log.updateText(`text->markdown ${chalk.gray(`${processedFiles}/${fps.length}`)}`)
+        return result
+      }),
+    ).catch(errorHandler)
+
+    const markdownToHtmlPromises: WorkerPromise<ProcessedContent[]>[] = []
+    processedFiles = 0
     for (const [mdChunk, _] of mdResults) {
-      childPromises.push(pool.exec("processHtml", [ctx.buildId, argv, mdChunk, ctx.allSlugs]))
+      markdownToHtmlPromises.push(pool.exec("processHtml", [serializableCtx, mdChunk]))
     }
-    const results: ProcessedContent[][] = await WorkerPromise.all(childPromises).catch(errorHandler)
+    const results: ProcessedContent[][] = await Promise.all(
+      markdownToHtmlPromises.map(async (promise) => {
+        const result = await promise
+        processedFiles += result.length
+        log.updateText(`markdown->html ${chalk.gray(`${processedFiles}/${fps.length}`)}`)
+        return result
+      }),
+    ).catch(errorHandler)
 
     res = results.flat()
     await pool.terminate()
diff --git a/quartz/util/ctx.ts b/quartz/util/ctx.ts
index 044d21f..b3e7a37 100644
--- a/quartz/util/ctx.ts
+++ b/quartz/util/ctx.ts
@@ -1,12 +1,12 @@
 import { QuartzConfig } from "../cfg"
-import { FullSlug } from "./path"
+import { FilePath, FullSlug } from "./path"
 
 export interface Argv {
   directory: string
   verbose: boolean
   output: string
   serve: boolean
-  fastRebuild: boolean
+  watch: boolean
   port: number
   wsPort: number
   remoteDevHost?: string
@@ -18,4 +18,8 @@
   argv: Argv
   cfg: QuartzConfig
   allSlugs: FullSlug[]
+  allFiles: FilePath[]
+  incremental: boolean
 }
+
+export type WorkerSerializableBuildCtx = Omit<BuildCtx, "cfg">
diff --git a/quartz/util/log.ts b/quartz/util/log.ts
index 4fcc240..2d53dd3 100644
--- a/quartz/util/log.ts
+++ b/quartz/util/log.ts
@@ -1,18 +1,23 @@
+import truncate from "ansi-truncate"
 import readline from "readline"
 
 export class QuartzLogger {
   verbose: boolean
   private spinnerInterval: NodeJS.Timeout | undefined
   private spinnerText: string = ""
+  private updateSuffix: string = ""
   private spinnerIndex: number = 0
   private readonly spinnerChars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
 
   constructor(verbose: boolean) {
-    this.verbose = verbose
+    const isInteractiveTerminal =
+      process.stdout.isTTY && process.env.TERM !== "dumb" && !process.env.CI
+    this.verbose = verbose || !isInteractiveTerminal
   }
 
   start(text: string) {
     this.spinnerText = text
+
     if (this.verbose) {
       console.log(text)
     } else {
@@ -20,14 +25,22 @@
       this.spinnerInterval = setInterval(() => {
         readline.clearLine(process.stdout, 0)
         readline.cursorTo(process.stdout, 0)
-        process.stdout.write(`${this.spinnerChars[this.spinnerIndex]} ${this.spinnerText}`)
+
+        const columns = process.stdout.columns || 80
+        let output = `${this.spinnerChars[this.spinnerIndex]} ${this.spinnerText}`
+        if (this.updateSuffix) {
+          output += `: ${this.updateSuffix}`
+        }
+
+        const truncated = truncate(output, columns)
+        process.stdout.write(truncated)
         this.spinnerIndex = (this.spinnerIndex + 1) % this.spinnerChars.length
       }, 20)
     }
   }
 
   updateText(text: string) {
-    this.spinnerText = text
+    this.updateSuffix = text
   }
 
   end(text?: string) {
diff --git a/quartz/util/path.ts b/quartz/util/path.ts
index 32d846b..0681fae 100644
--- a/quartz/util/path.ts
+++ b/quartz/util/path.ts
@@ -260,7 +260,7 @@
   return s === suffix || s.endsWith("/" + suffix)
 }
 
-function trimSuffix(s: string, suffix: string): string {
+export function trimSuffix(s: string, suffix: string): string {
   if (endsWith(s, suffix)) {
     s = s.slice(0, -suffix.length)
   }
diff --git a/quartz/worker.ts b/quartz/worker.ts
index c9cd980..f4cf4c6 100644
--- a/quartz/worker.ts
+++ b/quartz/worker.ts
@@ -1,8 +1,8 @@
 import sourceMapSupport from "source-map-support"
 sourceMapSupport.install(options)
 import cfg from "../quartz.config"
-import { Argv, BuildCtx } from "./util/ctx"
-import { FilePath, FullSlug } from "./util/path"
+import { BuildCtx, WorkerSerializableBuildCtx } from "./util/ctx"
+import { FilePath } from "./util/path"
 import {
   createFileParser,
   createHtmlProcessor,
@@ -14,35 +14,24 @@
 
 // only called from worker thread
 export async function parseMarkdown(
-  buildId: string,
-  argv: Argv,
+  partialCtx: WorkerSerializableBuildCtx,
   fps: FilePath[],
-): Promise<[MarkdownContent[], FullSlug[]]> {
-  // this is a hack
-  // we assume markdown parsers can add to `allSlugs`,
-  // but don't actually use them
-  const allSlugs: FullSlug[] = []
+): Promise<MarkdownContent[]> {
   const ctx: BuildCtx = {
-    buildId,
+    ...partialCtx,
     cfg,
-    argv,
-    allSlugs,
   }
-  return [await createFileParser(ctx, fps)(createMdProcessor(ctx)), allSlugs]
+  return await createFileParser(ctx, fps)(createMdProcessor(ctx))
 }
 
 // only called from worker thread
 export function processHtml(
-  buildId: string,
-  argv: Argv,
+  partialCtx: WorkerSerializableBuildCtx,
   mds: MarkdownContent[],
-  allSlugs: FullSlug[],
 ): Promise<ProcessedContent[]> {
   const ctx: BuildCtx = {
-    buildId,
+    ...partialCtx,
     cfg,
-    argv,
-    allSlugs,
   }
   return createMarkdownParser(ctx, mds)(createHtmlProcessor(ctx))
 }
diff --git a/tsconfig.json b/tsconfig.json
index 784ab23..637d096 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -11,6 +11,8 @@
     "skipLibCheck": true,
     "allowSyntheticDefaultImports": true,
     "forceConsistentCasingInFileNames": true,
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
     "esModuleInterop": true,
     "jsx": "react-jsx",
     "jsxImportSource": "preact"

--
Gitblit v1.10.0