The Trans Directory
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

perf: incremental rebuild (--fastRebuild v2 but default) (#1841)

* checkpoint

* incremental all the things

* properly splice changes array

* smol doc update

* update docs

* make fancy logger dumb in ci

authored by

Jacky Zhao and committed by
GitHub
a7372079 a72b1a42

+755 -1139
+16 -2
docs/advanced/making plugins.md
··· 221 221 222 222 export type QuartzEmitterPluginInstance = { 223 223 name: string 224 - emit(ctx: BuildCtx, content: ProcessedContent[], resources: StaticResources): Promise<FilePath[]> 224 + emit( 225 + ctx: BuildCtx, 226 + content: ProcessedContent[], 227 + resources: StaticResources, 228 + ): Promise<FilePath[]> | AsyncGenerator<FilePath> 229 + partialEmit?( 230 + ctx: BuildCtx, 231 + content: ProcessedContent[], 232 + resources: StaticResources, 233 + changeEvents: ChangeEvent[], 234 + ): Promise<FilePath[]> | AsyncGenerator<FilePath> | null 225 235 getQuartzComponents(ctx: BuildCtx): QuartzComponent[] 226 236 } 227 237 ``` 228 238 229 - 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. 239 + 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. 240 + 241 + - `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. 242 + - `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. 243 + - `getQuartzComponents` declares which Quartz components the emitter uses to construct its pages. 230 244 231 245 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: 232 246
+1 -1
docs/index.md
··· 32 32 ## 🔧 Features 33 33 34 34 - [[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 35 - - Hot-reload for both configuration and content 35 + - Hot-reload on configuration edits and incremental rebuilds for content edits 36 36 - Simple JSX layouts and [[creating components|page components]] 37 37 - [[SPA Routing|Ridiculously fast page loads]] and tiny bundle sizes 38 38 - Fully-customizable parsing, filtering, and page generation through [[making plugins|plugins]]
+20 -2
package-lock.json
··· 1 1 { 2 2 "name": "@jackyzha0/quartz", 3 - "version": "4.4.1", 3 + "version": "4.5.0", 4 4 "lockfileVersion": 3, 5 5 "requires": true, 6 6 "packages": { 7 7 "": { 8 8 "name": "@jackyzha0/quartz", 9 - "version": "4.4.1", 9 + "version": "4.5.0", 10 10 "license": "MIT", 11 11 "dependencies": { 12 12 "@clack/prompts": "^0.10.0", ··· 14 14 "@myriaddreamin/rehype-typst": "^0.5.4", 15 15 "@napi-rs/simple-git": "0.1.19", 16 16 "@tweenjs/tween.js": "^25.0.0", 17 + "ansi-truncate": "^1.2.0", 17 18 "async-mutex": "^0.5.0", 18 19 "chalk": "^5.4.1", 19 20 "chokidar": "^4.0.3", ··· 34 35 "mdast-util-to-hast": "^13.2.0", 35 36 "mdast-util-to-string": "^4.0.0", 36 37 "micromorph": "^0.4.5", 38 + "minimatch": "^10.0.1", 37 39 "pixi.js": "^8.8.1", 38 40 "preact": "^10.26.4", 39 41 "preact-render-to-string": "^6.5.13", ··· 2032 2034 "url": "https://github.com/chalk/ansi-styles?sponsor=1" 2033 2035 } 2034 2036 }, 2037 + "node_modules/ansi-truncate": { 2038 + "version": "1.2.0", 2039 + "resolved": "https://registry.npmjs.org/ansi-truncate/-/ansi-truncate-1.2.0.tgz", 2040 + "integrity": "sha512-/SLVrxNIP8o8iRHjdK3K9s2hDqdvb86NEjZOAB6ecWFsOo+9obaby97prnvAPn6j7ExXCpbvtlJFYPkkspg4BQ==", 2041 + "license": "MIT", 2042 + "dependencies": { 2043 + "fast-string-truncated-width": "^1.2.0" 2044 + } 2045 + }, 2035 2046 "node_modules/argparse": { 2036 2047 "version": "2.0.1", 2037 2048 "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", ··· 3057 3068 "engines": { 3058 3069 "node": ">=8.6.0" 3059 3070 } 3071 + }, 3072 + "node_modules/fast-string-truncated-width": { 3073 + "version": "1.2.1", 3074 + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-1.2.1.tgz", 3075 + "integrity": "sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow==", 3076 + "license": "MIT" 3060 3077 }, 3061 3078 "node_modules/fastq": { 3062 3079 "version": "1.19.0", ··· 5238 5255 "version": "10.0.1", 5239 5256 "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz", 5240 5257 "integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==", 5258 + "license": "ISC", 5241 5259 "dependencies": { 5242 5260 "brace-expansion": "^2.0.1" 5243 5261 },
+3 -1
package.json
··· 2 2 "name": "@jackyzha0/quartz", 3 3 "description": "🌱 publish your digital garden and notes as a website", 4 4 "private": true, 5 - "version": "4.4.1", 5 + "version": "4.5.0", 6 6 "type": "module", 7 7 "author": "jackyzha0 <j.zhao2k19@gmail.com>", 8 8 "license": "MIT", ··· 40 40 "@myriaddreamin/rehype-typst": "^0.5.4", 41 41 "@napi-rs/simple-git": "0.1.19", 42 42 "@tweenjs/tween.js": "^25.0.0", 43 + "ansi-truncate": "^1.2.0", 43 44 "async-mutex": "^0.5.0", 44 45 "chalk": "^5.4.1", 45 46 "chokidar": "^4.0.3", ··· 60 61 "mdast-util-to-hast": "^13.2.0", 61 62 "mdast-util-to-string": "^4.0.0", 62 63 "micromorph": "^0.4.5", 64 + "minimatch": "^10.0.1", 63 65 "pixi.js": "^8.8.1", 64 66 "preact": "^10.26.4", 65 67 "preact-render-to-string": "^6.5.13",
+1 -1
quartz.config.ts
··· 57 57 transformers: [ 58 58 Plugin.FrontMatter(), 59 59 Plugin.CreatedModifiedDate({ 60 - priority: ["frontmatter", "filesystem"], 60 + priority: ["git", "frontmatter", "filesystem"], 61 61 }), 62 62 Plugin.SyntaxHighlighting({ 63 63 theme: {
+144 -286
quartz/build.ts
··· 9 9 import { filterContent } from "./processors/filter" 10 10 import { emitContent } from "./processors/emit" 11 11 import cfg from "../quartz.config" 12 - import { FilePath, FullSlug, joinSegments, slugifyFilePath } from "./util/path" 12 + import { FilePath, joinSegments, slugifyFilePath } from "./util/path" 13 13 import chokidar from "chokidar" 14 14 import { ProcessedContent } from "./plugins/vfile" 15 15 import { Argv, BuildCtx } from "./util/ctx" ··· 17 17 import { trace } from "./util/trace" 18 18 import { options } from "./util/sourcemap" 19 19 import { Mutex } from "async-mutex" 20 - import DepGraph from "./depgraph" 21 20 import { getStaticResourcesFromPlugins } from "./plugins" 22 21 import { randomIdNonSecure } from "./util/random" 22 + import { ChangeEvent } from "./plugins/types" 23 + import { minimatch } from "minimatch" 23 24 24 - type Dependencies = Record<string, DepGraph<FilePath> | null> 25 + type ContentMap = Map< 26 + FilePath, 27 + | { 28 + type: "markdown" 29 + content: ProcessedContent 30 + } 31 + | { 32 + type: "other" 33 + } 34 + > 25 35 26 36 type BuildData = { 27 37 ctx: BuildCtx 28 38 ignored: GlobbyFilterFunction 29 39 mut: Mutex 30 - initialSlugs: FullSlug[] 31 - // TODO merge contentMap and trackedAssets 32 - contentMap: Map<FilePath, ProcessedContent> 33 - trackedAssets: Set<FilePath> 34 - toRebuild: Set<FilePath> 35 - toRemove: Set<FilePath> 40 + contentMap: ContentMap 41 + changesSinceLastBuild: Record<FilePath, ChangeEvent["type"]> 36 42 lastBuildMs: number 37 - dependencies: Dependencies 38 43 } 39 - 40 - type FileEvent = "add" | "change" | "delete" 41 44 42 45 async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) { 43 46 const ctx: BuildCtx = { ··· 45 48 argv, 46 49 cfg, 47 50 allSlugs: [], 51 + allFiles: [], 52 + incremental: false, 48 53 } 49 54 50 55 const perf = new PerfTimer() ··· 67 72 68 73 perf.addEvent("glob") 69 74 const allFiles = await glob("**/*.*", argv.directory, cfg.configuration.ignorePatterns) 70 - const fps = allFiles.filter((fp) => fp.endsWith(".md")).sort() 75 + const markdownPaths = allFiles.filter((fp) => fp.endsWith(".md")).sort() 71 76 console.log( 72 - `Found ${fps.length} input files from \`${argv.directory}\` in ${perf.timeSince("glob")}`, 77 + `Found ${markdownPaths.length} input files from \`${argv.directory}\` in ${perf.timeSince("glob")}`, 73 78 ) 74 79 75 - const filePaths = fps.map((fp) => joinSegments(argv.directory, fp) as FilePath) 80 + const filePaths = markdownPaths.map((fp) => joinSegments(argv.directory, fp) as FilePath) 81 + ctx.allFiles = allFiles 76 82 ctx.allSlugs = allFiles.map((fp) => slugifyFilePath(fp as FilePath)) 77 83 78 84 const parsedFiles = await parseMarkdown(ctx, filePaths) 79 85 const filteredContent = filterContent(ctx, parsedFiles) 80 86 81 - const dependencies: Record<string, DepGraph<FilePath> | null> = {} 82 - 83 - // Only build dependency graphs if we're doing a fast rebuild 84 - if (argv.fastRebuild) { 85 - const staticResources = getStaticResourcesFromPlugins(ctx) 86 - for (const emitter of cfg.plugins.emitters) { 87 - dependencies[emitter.name] = 88 - (await emitter.getDependencyGraph?.(ctx, filteredContent, staticResources)) ?? null 89 - } 90 - } 91 - 92 87 await emitContent(ctx, filteredContent) 93 - console.log(chalk.green(`Done processing ${fps.length} files in ${perf.timeSince()}`)) 88 + console.log(chalk.green(`Done processing ${markdownPaths.length} files in ${perf.timeSince()}`)) 94 89 release() 95 90 96 - if (argv.serve) { 97 - return startServing(ctx, mut, parsedFiles, clientRefresh, dependencies) 91 + if (argv.watch) { 92 + ctx.incremental = true 93 + return startWatching(ctx, mut, parsedFiles, clientRefresh) 98 94 } 99 95 } 100 96 101 97 // setup watcher for rebuilds 102 - async function startServing( 98 + async function startWatching( 103 99 ctx: BuildCtx, 104 100 mut: Mutex, 105 101 initialContent: ProcessedContent[], 106 102 clientRefresh: () => void, 107 - dependencies: Dependencies, // emitter name: dep graph 108 103 ) { 109 - const { argv } = ctx 104 + const { argv, allFiles } = ctx 110 105 111 - // cache file parse results 112 - const contentMap = new Map<FilePath, ProcessedContent>() 106 + const contentMap: ContentMap = new Map() 107 + for (const filePath of allFiles) { 108 + contentMap.set(filePath, { 109 + type: "other", 110 + }) 111 + } 112 + 113 113 for (const content of initialContent) { 114 114 const [_tree, vfile] = content 115 - contentMap.set(vfile.data.filePath!, content) 115 + contentMap.set(vfile.data.relativePath!, { 116 + type: "markdown", 117 + content, 118 + }) 116 119 } 117 120 121 + const gitIgnoredMatcher = await isGitIgnored() 118 122 const buildData: BuildData = { 119 123 ctx, 120 124 mut, 121 - dependencies, 122 125 contentMap, 123 - ignored: await isGitIgnored(), 124 - initialSlugs: ctx.allSlugs, 125 - toRebuild: new Set<FilePath>(), 126 - toRemove: new Set<FilePath>(), 127 - trackedAssets: new Set<FilePath>(), 126 + ignored: (path) => { 127 + if (gitIgnoredMatcher(path)) return true 128 + const pathStr = path.toString() 129 + for (const pattern of cfg.configuration.ignorePatterns) { 130 + if (minimatch(pathStr, pattern)) { 131 + return true 132 + } 133 + } 134 + 135 + return false 136 + }, 137 + 138 + changesSinceLastBuild: {}, 128 139 lastBuildMs: 0, 129 140 } 130 141 ··· 134 145 ignoreInitial: true, 135 146 }) 136 147 137 - const buildFromEntry = argv.fastRebuild ? partialRebuildFromEntrypoint : rebuildFromEntrypoint 148 + const changes: ChangeEvent[] = [] 138 149 watcher 139 - .on("add", (fp) => buildFromEntry(fp as string, "add", clientRefresh, buildData)) 140 - .on("change", (fp) => buildFromEntry(fp as string, "change", clientRefresh, buildData)) 141 - .on("unlink", (fp) => buildFromEntry(fp as string, "delete", clientRefresh, buildData)) 150 + .on("add", (fp) => { 151 + if (buildData.ignored(fp)) return 152 + changes.push({ path: fp as FilePath, type: "add" }) 153 + void rebuild(changes, clientRefresh, buildData) 154 + }) 155 + .on("change", (fp) => { 156 + if (buildData.ignored(fp)) return 157 + changes.push({ path: fp as FilePath, type: "change" }) 158 + void rebuild(changes, clientRefresh, buildData) 159 + }) 160 + .on("unlink", (fp) => { 161 + if (buildData.ignored(fp)) return 162 + changes.push({ path: fp as FilePath, type: "delete" }) 163 + void rebuild(changes, clientRefresh, buildData) 164 + }) 142 165 143 166 return async () => { 144 167 await watcher.close() 145 168 } 146 169 } 147 170 148 - async function partialRebuildFromEntrypoint( 149 - filepath: string, 150 - action: FileEvent, 151 - clientRefresh: () => void, 152 - buildData: BuildData, // note: this function mutates buildData 153 - ) { 154 - const { ctx, ignored, dependencies, contentMap, mut, toRemove } = buildData 171 + async function rebuild(changes: ChangeEvent[], clientRefresh: () => void, buildData: BuildData) { 172 + const { ctx, contentMap, mut, changesSinceLastBuild } = buildData 155 173 const { argv, cfg } = ctx 156 174 157 - // don't do anything for gitignored files 158 - if (ignored(filepath)) { 159 - return 160 - } 161 - 162 175 const buildId = randomIdNonSecure() 163 176 ctx.buildId = buildId 164 177 buildData.lastBuildMs = new Date().getTime() 178 + const numChangesInBuild = changes.length 165 179 const release = await mut.acquire() 166 180 167 181 // if there's another build after us, release and let them do it ··· 171 185 } 172 186 173 187 const perf = new PerfTimer() 188 + perf.addEvent("rebuild") 174 189 console.log(chalk.yellow("Detected change, rebuilding...")) 175 190 176 - // UPDATE DEP GRAPH 177 - const fp = joinSegments(argv.directory, toPosixPath(filepath)) as FilePath 191 + // update changesSinceLastBuild 192 + for (const change of changes) { 193 + changesSinceLastBuild[change.path] = change.type 194 + } 178 195 179 196 const staticResources = getStaticResourcesFromPlugins(ctx) 180 - let processedFiles: ProcessedContent[] = [] 181 - 182 - switch (action) { 183 - case "add": 184 - // add to cache when new file is added 185 - processedFiles = await parseMarkdown(ctx, [fp]) 186 - processedFiles.forEach(([tree, vfile]) => contentMap.set(vfile.data.filePath!, [tree, vfile])) 187 - 188 - // update the dep graph by asking all emitters whether they depend on this file 189 - for (const emitter of cfg.plugins.emitters) { 190 - const emitterGraph = 191 - (await emitter.getDependencyGraph?.(ctx, processedFiles, staticResources)) ?? null 192 - 193 - if (emitterGraph) { 194 - const existingGraph = dependencies[emitter.name] 195 - if (existingGraph !== null) { 196 - existingGraph.mergeGraph(emitterGraph) 197 - } else { 198 - // might be the first time we're adding a mardown file 199 - dependencies[emitter.name] = emitterGraph 200 - } 201 - } 202 - } 203 - break 204 - case "change": 205 - // invalidate cache when file is changed 206 - processedFiles = await parseMarkdown(ctx, [fp]) 207 - processedFiles.forEach(([tree, vfile]) => contentMap.set(vfile.data.filePath!, [tree, vfile])) 208 - 209 - // only content files can have added/removed dependencies because of transclusions 210 - if (path.extname(fp) === ".md") { 211 - for (const emitter of cfg.plugins.emitters) { 212 - // get new dependencies from all emitters for this file 213 - const emitterGraph = 214 - (await emitter.getDependencyGraph?.(ctx, processedFiles, staticResources)) ?? null 215 - 216 - // only update the graph if the emitter plugin uses the changed file 217 - // eg. Assets plugin ignores md files, so we skip updating the graph 218 - if (emitterGraph?.hasNode(fp)) { 219 - // merge the new dependencies into the dep graph 220 - dependencies[emitter.name]?.updateIncomingEdgesForNode(emitterGraph, fp) 221 - } 222 - } 223 - } 224 - break 225 - case "delete": 226 - toRemove.add(fp) 227 - break 197 + const pathsToParse: FilePath[] = [] 198 + for (const [fp, type] of Object.entries(changesSinceLastBuild)) { 199 + if (type === "delete" || path.extname(fp) !== ".md") continue 200 + const fullPath = joinSegments(argv.directory, toPosixPath(fp)) as FilePath 201 + pathsToParse.push(fullPath) 228 202 } 229 203 230 - if (argv.verbose) { 231 - console.log(`Updated dependency graphs in ${perf.timeSince()}`) 204 + const parsed = await parseMarkdown(ctx, pathsToParse) 205 + for (const content of parsed) { 206 + contentMap.set(content[1].data.relativePath!, { 207 + type: "markdown", 208 + content, 209 + }) 232 210 } 233 211 234 - // EMIT 235 - perf.addEvent("rebuild") 236 - let emittedFiles = 0 212 + // update state using changesSinceLastBuild 213 + // we do this weird play of add => compute change events => remove 214 + // so that partialEmitters can do appropriate cleanup based on the content of deleted files 215 + for (const [file, change] of Object.entries(changesSinceLastBuild)) { 216 + if (change === "delete") { 217 + // universal delete case 218 + contentMap.delete(file as FilePath) 219 + } 237 220 238 - for (const emitter of cfg.plugins.emitters) { 239 - const depGraph = dependencies[emitter.name] 221 + // manually track non-markdown files as processed files only 222 + // contains markdown files 223 + if (change === "add" && path.extname(file) !== ".md") { 224 + contentMap.set(file as FilePath, { 225 + type: "other", 226 + }) 227 + } 228 + } 240 229 241 - // emitter hasn't defined a dependency graph. call it with all processed files 242 - if (depGraph === null) { 243 - if (argv.verbose) { 244 - console.log( 245 - `Emitter ${emitter.name} doesn't define a dependency graph. Calling it with all files...`, 246 - ) 230 + const changeEvents: ChangeEvent[] = Object.entries(changesSinceLastBuild).map(([fp, type]) => { 231 + const path = fp as FilePath 232 + const processedContent = contentMap.get(path) 233 + if (processedContent?.type === "markdown") { 234 + const [_tree, file] = processedContent.content 235 + return { 236 + type, 237 + path, 238 + file, 247 239 } 240 + } 248 241 249 - const files = [...contentMap.values()].filter( 250 - ([_node, vfile]) => !toRemove.has(vfile.data.filePath!), 251 - ) 242 + return { 243 + type, 244 + path, 245 + } 246 + }) 252 247 253 - const emitted = await emitter.emit(ctx, files, staticResources) 254 - if (Symbol.asyncIterator in emitted) { 255 - // Async generator case 256 - for await (const file of emitted) { 257 - emittedFiles++ 258 - if (ctx.argv.verbose) { 259 - console.log(`[emit:${emitter.name}] ${file}`) 260 - } 261 - } 262 - } else { 263 - // Array case 264 - emittedFiles += emitted.length 265 - if (ctx.argv.verbose) { 266 - for (const file of emitted) { 267 - console.log(`[emit:${emitter.name}] ${file}`) 268 - } 269 - } 270 - } 248 + // update allFiles and then allSlugs with the consistent view of content map 249 + ctx.allFiles = Array.from(contentMap.keys()) 250 + ctx.allSlugs = ctx.allFiles.map((fp) => slugifyFilePath(fp as FilePath)) 251 + const processedFiles = Array.from(contentMap.values()) 252 + .filter((file) => file.type === "markdown") 253 + .map((file) => file.content) 271 254 255 + let emittedFiles = 0 256 + for (const emitter of cfg.plugins.emitters) { 257 + // Try to use partialEmit if available, otherwise assume the output is static 258 + const emitFn = emitter.partialEmit ?? emitter.emit 259 + const emitted = await emitFn(ctx, processedFiles, staticResources, changeEvents) 260 + if (emitted === null) { 272 261 continue 273 262 } 274 263 275 - // only call the emitter if it uses this file 276 - if (depGraph.hasNode(fp)) { 277 - // re-emit using all files that are needed for the downstream of this file 278 - // eg. for ContentIndex, the dep graph could be: 279 - // a.md --> contentIndex.json 280 - // b.md ------^ 281 - // 282 - // if a.md changes, we need to re-emit contentIndex.json, 283 - // and supply [a.md, b.md] to the emitter 284 - const upstreams = [...depGraph.getLeafNodeAncestors(fp)] as FilePath[] 285 - 286 - const upstreamContent = upstreams 287 - // filter out non-markdown files 288 - .filter((file) => contentMap.has(file)) 289 - // if file was deleted, don't give it to the emitter 290 - .filter((file) => !toRemove.has(file)) 291 - .map((file) => contentMap.get(file)!) 292 - 293 - const emitted = await emitter.emit(ctx, upstreamContent, staticResources) 294 - if (Symbol.asyncIterator in emitted) { 295 - // Async generator case 296 - for await (const file of emitted) { 297 - emittedFiles++ 298 - if (ctx.argv.verbose) { 299 - console.log(`[emit:${emitter.name}] ${file}`) 300 - } 264 + if (Symbol.asyncIterator in emitted) { 265 + // Async generator case 266 + for await (const file of emitted) { 267 + emittedFiles++ 268 + if (ctx.argv.verbose) { 269 + console.log(`[emit:${emitter.name}] ${file}`) 301 270 } 302 - } else { 303 - // Array case 304 - emittedFiles += emitted.length 305 - if (ctx.argv.verbose) { 306 - for (const file of emitted) { 307 - console.log(`[emit:${emitter.name}] ${file}`) 308 - } 271 + } 272 + } else { 273 + // Array case 274 + emittedFiles += emitted.length 275 + if (ctx.argv.verbose) { 276 + for (const file of emitted) { 277 + console.log(`[emit:${emitter.name}] ${file}`) 309 278 } 310 279 } 311 280 } 312 281 } 313 282 314 283 console.log(`Emitted ${emittedFiles} files to \`${argv.output}\` in ${perf.timeSince("rebuild")}`) 315 - 316 - // CLEANUP 317 - const destinationsToDelete = new Set<FilePath>() 318 - for (const file of toRemove) { 319 - // remove from cache 320 - contentMap.delete(file) 321 - Object.values(dependencies).forEach((depGraph) => { 322 - // remove the node from dependency graphs 323 - depGraph?.removeNode(file) 324 - // remove any orphan nodes. eg if a.md is deleted, a.html is orphaned and should be removed 325 - const orphanNodes = depGraph?.removeOrphanNodes() 326 - orphanNodes?.forEach((node) => { 327 - // only delete files that are in the output directory 328 - if (node.startsWith(argv.output)) { 329 - destinationsToDelete.add(node) 330 - } 331 - }) 332 - }) 333 - } 334 - await rimraf([...destinationsToDelete]) 335 - 336 284 console.log(chalk.green(`Done rebuilding in ${perf.timeSince()}`)) 337 - 338 - toRemove.clear() 339 - release() 285 + changes.splice(0, numChangesInBuild) 340 286 clientRefresh() 341 - } 342 - 343 - async function rebuildFromEntrypoint( 344 - fp: string, 345 - action: FileEvent, 346 - clientRefresh: () => void, 347 - buildData: BuildData, // note: this function mutates buildData 348 - ) { 349 - const { ctx, ignored, mut, initialSlugs, contentMap, toRebuild, toRemove, trackedAssets } = 350 - buildData 351 - 352 - const { argv } = ctx 353 - 354 - // don't do anything for gitignored files 355 - if (ignored(fp)) { 356 - return 357 - } 358 - 359 - // dont bother rebuilding for non-content files, just track and refresh 360 - fp = toPosixPath(fp) 361 - const filePath = joinSegments(argv.directory, fp) as FilePath 362 - if (path.extname(fp) !== ".md") { 363 - if (action === "add" || action === "change") { 364 - trackedAssets.add(filePath) 365 - } else if (action === "delete") { 366 - trackedAssets.delete(filePath) 367 - } 368 - clientRefresh() 369 - return 370 - } 371 - 372 - if (action === "add" || action === "change") { 373 - toRebuild.add(filePath) 374 - } else if (action === "delete") { 375 - toRemove.add(filePath) 376 - } 377 - 378 - const buildId = randomIdNonSecure() 379 - ctx.buildId = buildId 380 - buildData.lastBuildMs = new Date().getTime() 381 - const release = await mut.acquire() 382 - 383 - // there's another build after us, release and let them do it 384 - if (ctx.buildId !== buildId) { 385 - release() 386 - return 387 - } 388 - 389 - const perf = new PerfTimer() 390 - console.log(chalk.yellow("Detected change, rebuilding...")) 391 - 392 - try { 393 - const filesToRebuild = [...toRebuild].filter((fp) => !toRemove.has(fp)) 394 - const parsedContent = await parseMarkdown(ctx, filesToRebuild) 395 - for (const content of parsedContent) { 396 - const [_tree, vfile] = content 397 - contentMap.set(vfile.data.filePath!, content) 398 - } 399 - 400 - for (const fp of toRemove) { 401 - contentMap.delete(fp) 402 - } 403 - 404 - const parsedFiles = [...contentMap.values()] 405 - const filteredContent = filterContent(ctx, parsedFiles) 406 - 407 - // re-update slugs 408 - const trackedSlugs = [...new Set([...contentMap.keys(), ...toRebuild, ...trackedAssets])] 409 - .filter((fp) => !toRemove.has(fp)) 410 - .map((fp) => slugifyFilePath(path.posix.relative(argv.directory, fp) as FilePath)) 411 - 412 - ctx.allSlugs = [...new Set([...initialSlugs, ...trackedSlugs])] 413 - 414 - // TODO: we can probably traverse the link graph to figure out what's safe to delete here 415 - // instead of just deleting everything 416 - await rimraf(path.join(argv.output, ".*"), { glob: true }) 417 - await emitContent(ctx, filteredContent) 418 - console.log(chalk.green(`Done rebuilding in ${perf.timeSince()}`)) 419 - } catch (err) { 420 - console.log(chalk.yellow(`Rebuild failed. Waiting on a change to fix the error...`)) 421 - if (argv.verbose) { 422 - console.log(chalk.red(err)) 423 - } 424 - } 425 - 426 - clientRefresh() 427 - toRebuild.clear() 428 - toRemove.clear() 429 287 release() 430 288 } 431 289
-1
quartz/cfg.ts
··· 2 2 import { QuartzComponent } from "./components/types" 3 3 import { ValidLocale } from "./i18n" 4 4 import { PluginTypes } from "./plugins/types" 5 - import { SocialImageOptions } from "./util/og" 6 5 import { Theme } from "./util/theme" 7 6 8 7 export type Analytics =
+2 -2
quartz/cli/args.js
··· 71 71 default: false, 72 72 describe: "run a local server to live-preview your Quartz", 73 73 }, 74 - fastRebuild: { 74 + watch: { 75 75 boolean: true, 76 76 default: false, 77 - describe: "[experimental] rebuild only the changed files", 77 + describe: "watch for changes and rebuild automatically", 78 78 }, 79 79 baseDir: { 80 80 string: true,
+23 -6
quartz/cli/handlers.js
··· 225 225 * @param {*} argv arguments for `build` 226 226 */ 227 227 export async function handleBuild(argv) { 228 + if (argv.serve) { 229 + argv.watch = true 230 + } 231 + 228 232 console.log(chalk.bgGreen.black(`\n Quartz v${version} \n`)) 229 233 const ctx = await esbuild.context({ 230 234 entryPoints: [fp], ··· 331 335 clientRefresh() 332 336 } 333 337 338 + let clientRefresh = () => {} 334 339 if (argv.serve) { 335 340 const connections = [] 336 - const clientRefresh = () => connections.forEach((conn) => conn.send("rebuild")) 341 + clientRefresh = () => connections.forEach((conn) => conn.send("rebuild")) 337 342 338 343 if (argv.baseDir !== "" && !argv.baseDir.startsWith("/")) { 339 344 argv.baseDir = "/" + argv.baseDir ··· 433 438 434 439 return serve() 435 440 }) 441 + 436 442 server.listen(argv.port) 437 443 const wss = new WebSocketServer({ port: argv.wsPort }) 438 444 wss.on("connection", (ws) => connections.push(ws)) ··· 441 447 `Started a Quartz server listening at http://localhost:${argv.port}${argv.baseDir}`, 442 448 ), 443 449 ) 444 - console.log("hint: exit with ctrl+c") 445 - const paths = await globby(["**/*.ts", "**/*.tsx", "**/*.scss", "package.json"]) 450 + } else { 451 + await build(clientRefresh) 452 + ctx.dispose() 453 + } 454 + 455 + if (argv.watch) { 456 + const paths = await globby([ 457 + "**/*.ts", 458 + "quartz/cli/*.js", 459 + "quartz/static/**/*", 460 + "**/*.tsx", 461 + "**/*.scss", 462 + "package.json", 463 + ]) 446 464 chokidar 447 465 .watch(paths, { ignoreInitial: true }) 448 466 .on("add", () => build(clientRefresh)) 449 467 .on("change", () => build(clientRefresh)) 450 468 .on("unlink", () => build(clientRefresh)) 451 - } else { 452 - await build(() => {}) 453 - ctx.dispose() 469 + 470 + console.log(chalk.grey("hint: exit with ctrl+c")) 454 471 } 455 472 } 456 473
+16 -10
quartz/components/renderPage.tsx
··· 9 9 import { Root, Element, ElementContent } from "hast" 10 10 import { GlobalConfiguration } from "../cfg" 11 11 import { i18n } from "../i18n" 12 - import { QuartzPluginData } from "../plugins/vfile" 13 12 14 13 interface RenderComponents { 15 14 head: QuartzComponent ··· 25 24 const headerRegex = new RegExp(/h[1-6]/) 26 25 export function pageResources( 27 26 baseDir: FullSlug | RelativeURL, 28 - fileData: QuartzPluginData, 29 27 staticResources: StaticResources, 30 28 ): StaticResources { 31 29 const contentIndexPath = joinSegments(baseDir, "static/contentIndex.json") ··· 65 63 return resources 66 64 } 67 65 68 - export function renderPage( 66 + function renderTranscludes( 67 + root: Root, 69 68 cfg: GlobalConfiguration, 70 69 slug: FullSlug, 71 70 componentData: QuartzComponentProps, 72 - components: RenderComponents, 73 - pageResources: StaticResources, 74 - ): string { 75 - // make a deep copy of the tree so we don't remove the transclusion references 76 - // for the file cached in contentMap in build.ts 77 - const root = clone(componentData.tree) as Root 78 - 71 + ) { 79 72 // process transcludes in componentData 80 73 visit(root, "element", (node, _index, _parent) => { 81 74 if (node.tagName === "blockquote") { ··· 191 184 } 192 185 } 193 186 }) 187 + } 188 + 189 + export function renderPage( 190 + cfg: GlobalConfiguration, 191 + slug: FullSlug, 192 + componentData: QuartzComponentProps, 193 + components: RenderComponents, 194 + pageResources: StaticResources, 195 + ): string { 196 + // make a deep copy of the tree so we don't remove the transclusion references 197 + // for the file cached in contentMap in build.ts 198 + const root = clone(componentData.tree) as Root 199 + renderTranscludes(root, cfg, slug, componentData) 194 200 195 201 // set componentData.tree to the edited html that has transclusions rendered 196 202 componentData.tree = root
+1 -1
quartz/components/scripts/darkmode.inline.ts
··· 10 10 } 11 11 12 12 document.addEventListener("nav", () => { 13 - const switchTheme = (e: Event) => { 13 + const switchTheme = () => { 14 14 const newTheme = 15 15 document.documentElement.getAttribute("saved-theme") === "dark" ? "light" : "dark" 16 16 document.documentElement.setAttribute("saved-theme", newTheme)
-118
quartz/depgraph.test.ts
··· 1 - import test, { describe } from "node:test" 2 - import DepGraph from "./depgraph" 3 - import assert from "node:assert" 4 - 5 - describe("DepGraph", () => { 6 - test("getLeafNodes", () => { 7 - const graph = new DepGraph<string>() 8 - graph.addEdge("A", "B") 9 - graph.addEdge("B", "C") 10 - graph.addEdge("D", "C") 11 - assert.deepStrictEqual(graph.getLeafNodes("A"), new Set(["C"])) 12 - assert.deepStrictEqual(graph.getLeafNodes("B"), new Set(["C"])) 13 - assert.deepStrictEqual(graph.getLeafNodes("C"), new Set(["C"])) 14 - assert.deepStrictEqual(graph.getLeafNodes("D"), new Set(["C"])) 15 - }) 16 - 17 - describe("getLeafNodeAncestors", () => { 18 - test("gets correct ancestors in a graph without cycles", () => { 19 - const graph = new DepGraph<string>() 20 - graph.addEdge("A", "B") 21 - graph.addEdge("B", "C") 22 - graph.addEdge("D", "B") 23 - assert.deepStrictEqual(graph.getLeafNodeAncestors("A"), new Set(["A", "B", "D"])) 24 - assert.deepStrictEqual(graph.getLeafNodeAncestors("B"), new Set(["A", "B", "D"])) 25 - assert.deepStrictEqual(graph.getLeafNodeAncestors("C"), new Set(["A", "B", "D"])) 26 - assert.deepStrictEqual(graph.getLeafNodeAncestors("D"), new Set(["A", "B", "D"])) 27 - }) 28 - 29 - test("gets correct ancestors in a graph with cycles", () => { 30 - const graph = new DepGraph<string>() 31 - graph.addEdge("A", "B") 32 - graph.addEdge("B", "C") 33 - graph.addEdge("C", "A") 34 - graph.addEdge("C", "D") 35 - assert.deepStrictEqual(graph.getLeafNodeAncestors("A"), new Set(["A", "B", "C"])) 36 - assert.deepStrictEqual(graph.getLeafNodeAncestors("B"), new Set(["A", "B", "C"])) 37 - assert.deepStrictEqual(graph.getLeafNodeAncestors("C"), new Set(["A", "B", "C"])) 38 - assert.deepStrictEqual(graph.getLeafNodeAncestors("D"), new Set(["A", "B", "C"])) 39 - }) 40 - }) 41 - 42 - describe("mergeGraph", () => { 43 - test("merges two graphs", () => { 44 - const graph = new DepGraph<string>() 45 - graph.addEdge("A.md", "A.html") 46 - 47 - const other = new DepGraph<string>() 48 - other.addEdge("B.md", "B.html") 49 - 50 - graph.mergeGraph(other) 51 - 52 - const expected = { 53 - nodes: ["A.md", "A.html", "B.md", "B.html"], 54 - edges: [ 55 - ["A.md", "A.html"], 56 - ["B.md", "B.html"], 57 - ], 58 - } 59 - 60 - assert.deepStrictEqual(graph.export(), expected) 61 - }) 62 - }) 63 - 64 - describe("updateIncomingEdgesForNode", () => { 65 - test("merges when node exists", () => { 66 - // A.md -> B.md -> B.html 67 - const graph = new DepGraph<string>() 68 - graph.addEdge("A.md", "B.md") 69 - graph.addEdge("B.md", "B.html") 70 - 71 - // B.md is edited so it removes the A.md transclusion 72 - // and adds C.md transclusion 73 - // C.md -> B.md 74 - const other = new DepGraph<string>() 75 - other.addEdge("C.md", "B.md") 76 - other.addEdge("B.md", "B.html") 77 - 78 - // A.md -> B.md removed, C.md -> B.md added 79 - // C.md -> B.md -> B.html 80 - graph.updateIncomingEdgesForNode(other, "B.md") 81 - 82 - const expected = { 83 - nodes: ["A.md", "B.md", "B.html", "C.md"], 84 - edges: [ 85 - ["B.md", "B.html"], 86 - ["C.md", "B.md"], 87 - ], 88 - } 89 - 90 - assert.deepStrictEqual(graph.export(), expected) 91 - }) 92 - 93 - test("adds node if it does not exist", () => { 94 - // A.md -> B.md 95 - const graph = new DepGraph<string>() 96 - graph.addEdge("A.md", "B.md") 97 - 98 - // Add a new file C.md that transcludes B.md 99 - // B.md -> C.md 100 - const other = new DepGraph<string>() 101 - other.addEdge("B.md", "C.md") 102 - 103 - // B.md -> C.md added 104 - // A.md -> B.md -> C.md 105 - graph.updateIncomingEdgesForNode(other, "C.md") 106 - 107 - const expected = { 108 - nodes: ["A.md", "B.md", "C.md"], 109 - edges: [ 110 - ["A.md", "B.md"], 111 - ["B.md", "C.md"], 112 - ], 113 - } 114 - 115 - assert.deepStrictEqual(graph.export(), expected) 116 - }) 117 - }) 118 - })
-228
quartz/depgraph.ts
··· 1 - export default class DepGraph<T> { 2 - // node: incoming and outgoing edges 3 - _graph = new Map<T, { incoming: Set<T>; outgoing: Set<T> }>() 4 - 5 - constructor() { 6 - this._graph = new Map() 7 - } 8 - 9 - export(): Object { 10 - return { 11 - nodes: this.nodes, 12 - edges: this.edges, 13 - } 14 - } 15 - 16 - toString(): string { 17 - return JSON.stringify(this.export(), null, 2) 18 - } 19 - 20 - // BASIC GRAPH OPERATIONS 21 - 22 - get nodes(): T[] { 23 - return Array.from(this._graph.keys()) 24 - } 25 - 26 - get edges(): [T, T][] { 27 - let edges: [T, T][] = [] 28 - this.forEachEdge((edge) => edges.push(edge)) 29 - return edges 30 - } 31 - 32 - hasNode(node: T): boolean { 33 - return this._graph.has(node) 34 - } 35 - 36 - addNode(node: T): void { 37 - if (!this._graph.has(node)) { 38 - this._graph.set(node, { incoming: new Set(), outgoing: new Set() }) 39 - } 40 - } 41 - 42 - // Remove node and all edges connected to it 43 - removeNode(node: T): void { 44 - if (this._graph.has(node)) { 45 - // first remove all edges so other nodes don't have references to this node 46 - for (const target of this._graph.get(node)!.outgoing) { 47 - this.removeEdge(node, target) 48 - } 49 - for (const source of this._graph.get(node)!.incoming) { 50 - this.removeEdge(source, node) 51 - } 52 - this._graph.delete(node) 53 - } 54 - } 55 - 56 - forEachNode(callback: (node: T) => void): void { 57 - for (const node of this._graph.keys()) { 58 - callback(node) 59 - } 60 - } 61 - 62 - hasEdge(from: T, to: T): boolean { 63 - return Boolean(this._graph.get(from)?.outgoing.has(to)) 64 - } 65 - 66 - addEdge(from: T, to: T): void { 67 - this.addNode(from) 68 - this.addNode(to) 69 - 70 - this._graph.get(from)!.outgoing.add(to) 71 - this._graph.get(to)!.incoming.add(from) 72 - } 73 - 74 - removeEdge(from: T, to: T): void { 75 - if (this._graph.has(from) && this._graph.has(to)) { 76 - this._graph.get(from)!.outgoing.delete(to) 77 - this._graph.get(to)!.incoming.delete(from) 78 - } 79 - } 80 - 81 - // returns -1 if node does not exist 82 - outDegree(node: T): number { 83 - return this.hasNode(node) ? this._graph.get(node)!.outgoing.size : -1 84 - } 85 - 86 - // returns -1 if node does not exist 87 - inDegree(node: T): number { 88 - return this.hasNode(node) ? this._graph.get(node)!.incoming.size : -1 89 - } 90 - 91 - forEachOutNeighbor(node: T, callback: (neighbor: T) => void): void { 92 - this._graph.get(node)?.outgoing.forEach(callback) 93 - } 94 - 95 - forEachInNeighbor(node: T, callback: (neighbor: T) => void): void { 96 - this._graph.get(node)?.incoming.forEach(callback) 97 - } 98 - 99 - forEachEdge(callback: (edge: [T, T]) => void): void { 100 - for (const [source, { outgoing }] of this._graph.entries()) { 101 - for (const target of outgoing) { 102 - callback([source, target]) 103 - } 104 - } 105 - } 106 - 107 - // DEPENDENCY ALGORITHMS 108 - 109 - // Add all nodes and edges from other graph to this graph 110 - mergeGraph(other: DepGraph<T>): void { 111 - other.forEachEdge(([source, target]) => { 112 - this.addNode(source) 113 - this.addNode(target) 114 - this.addEdge(source, target) 115 - }) 116 - } 117 - 118 - // For the node provided: 119 - // If node does not exist, add it 120 - // If an incoming edge was added in other, it is added in this graph 121 - // If an incoming edge was deleted in other, it is deleted in this graph 122 - updateIncomingEdgesForNode(other: DepGraph<T>, node: T): void { 123 - this.addNode(node) 124 - 125 - // Add edge if it is present in other 126 - other.forEachInNeighbor(node, (neighbor) => { 127 - this.addEdge(neighbor, node) 128 - }) 129 - 130 - // For node provided, remove incoming edge if it is absent in other 131 - this.forEachEdge(([source, target]) => { 132 - if (target === node && !other.hasEdge(source, target)) { 133 - this.removeEdge(source, target) 134 - } 135 - }) 136 - } 137 - 138 - // Remove all nodes that do not have any incoming or outgoing edges 139 - // A node may be orphaned if the only node pointing to it was removed 140 - removeOrphanNodes(): Set<T> { 141 - let orphanNodes = new Set<T>() 142 - 143 - this.forEachNode((node) => { 144 - if (this.inDegree(node) === 0 && this.outDegree(node) === 0) { 145 - orphanNodes.add(node) 146 - } 147 - }) 148 - 149 - orphanNodes.forEach((node) => { 150 - this.removeNode(node) 151 - }) 152 - 153 - return orphanNodes 154 - } 155 - 156 - // Get all leaf nodes (i.e. destination paths) reachable from the node provided 157 - // Eg. if the graph is A -> B -> C 158 - // D ---^ 159 - // and the node is B, this function returns [C] 160 - getLeafNodes(node: T): Set<T> { 161 - let stack: T[] = [node] 162 - let visited = new Set<T>() 163 - let leafNodes = new Set<T>() 164 - 165 - // DFS 166 - while (stack.length > 0) { 167 - let node = stack.pop()! 168 - 169 - // If the node is already visited, skip it 170 - if (visited.has(node)) { 171 - continue 172 - } 173 - visited.add(node) 174 - 175 - // Check if the node is a leaf node (i.e. destination path) 176 - if (this.outDegree(node) === 0) { 177 - leafNodes.add(node) 178 - } 179 - 180 - // Add all unvisited neighbors to the stack 181 - this.forEachOutNeighbor(node, (neighbor) => { 182 - if (!visited.has(neighbor)) { 183 - stack.push(neighbor) 184 - } 185 - }) 186 - } 187 - 188 - return leafNodes 189 - } 190 - 191 - // Get all ancestors of the leaf nodes reachable from the node provided 192 - // Eg. if the graph is A -> B -> C 193 - // D ---^ 194 - // and the node is B, this function returns [A, B, D] 195 - getLeafNodeAncestors(node: T): Set<T> { 196 - const leafNodes = this.getLeafNodes(node) 197 - let visited = new Set<T>() 198 - let upstreamNodes = new Set<T>() 199 - 200 - // Backwards DFS for each leaf node 201 - leafNodes.forEach((leafNode) => { 202 - let stack: T[] = [leafNode] 203 - 204 - while (stack.length > 0) { 205 - let node = stack.pop()! 206 - 207 - if (visited.has(node)) { 208 - continue 209 - } 210 - visited.add(node) 211 - // Add node if it's not a leaf node (i.e. destination path) 212 - // Assumes destination file cannot depend on another destination file 213 - if (this.outDegree(node) !== 0) { 214 - upstreamNodes.add(node) 215 - } 216 - 217 - // Add all unvisited parents to the stack 218 - this.forEachInNeighbor(node, (parentNode) => { 219 - if (!visited.has(parentNode)) { 220 - stack.push(parentNode) 221 - } 222 - }) 223 - } 224 - }) 225 - 226 - return upstreamNodes 227 - } 228 - }
+3 -6
quartz/plugins/emitters/404.tsx
··· 3 3 import BodyConstructor from "../../components/Body" 4 4 import { pageResources, renderPage } from "../../components/renderPage" 5 5 import { FullPageLayout } from "../../cfg" 6 - import { FilePath, FullSlug } from "../../util/path" 6 + import { FullSlug } from "../../util/path" 7 7 import { sharedPageComponents } from "../../../quartz.layout" 8 8 import { NotFound } from "../../components" 9 9 import { defaultProcessedContent } from "../vfile" 10 10 import { write } from "./helpers" 11 11 import { i18n } from "../../i18n" 12 - import DepGraph from "../../depgraph" 13 12 14 13 export const NotFoundPage: QuartzEmitterPlugin = () => { 15 14 const opts: FullPageLayout = { ··· 28 27 getQuartzComponents() { 29 28 return [Head, Body, pageBody, Footer] 30 29 }, 31 - async getDependencyGraph(_ctx, _content, _resources) { 32 - return new DepGraph<FilePath>() 33 - }, 34 30 async *emit(ctx, _content, resources) { 35 31 const cfg = ctx.cfg.configuration 36 32 const slug = "404" as FullSlug ··· 44 40 description: notFound, 45 41 frontmatter: { title: notFound, tags: [] }, 46 42 }) 47 - const externalResources = pageResources(path, vfile.data, resources) 43 + const externalResources = pageResources(path, resources) 48 44 const componentData: QuartzComponentProps = { 49 45 ctx, 50 46 fileData: vfile.data, ··· 62 58 ext: ".html", 63 59 }) 64 60 }, 61 + async *partialEmit() {}, 65 62 } 66 63 }
+36 -35
quartz/plugins/emitters/aliases.ts
··· 1 - import { FilePath, joinSegments, resolveRelative, simplifySlug } from "../../util/path" 1 + import { resolveRelative, simplifySlug } from "../../util/path" 2 2 import { QuartzEmitterPlugin } from "../types" 3 3 import { write } from "./helpers" 4 - import DepGraph from "../../depgraph" 5 - import { getAliasSlugs } from "../transformers/frontmatter" 4 + import { BuildCtx } from "../../util/ctx" 5 + import { VFile } from "vfile" 6 + 7 + async function* processFile(ctx: BuildCtx, file: VFile) { 8 + const ogSlug = simplifySlug(file.data.slug!) 9 + 10 + for (const slug of file.data.aliases ?? []) { 11 + const redirUrl = resolveRelative(slug, file.data.slug!) 12 + yield write({ 13 + ctx, 14 + content: ` 15 + <!DOCTYPE html> 16 + <html lang="en-us"> 17 + <head> 18 + <title>${ogSlug}</title> 19 + <link rel="canonical" href="${redirUrl}"> 20 + <meta name="robots" content="noindex"> 21 + <meta charset="utf-8"> 22 + <meta http-equiv="refresh" content="0; url=${redirUrl}"> 23 + </head> 24 + </html> 25 + `, 26 + slug, 27 + ext: ".html", 28 + }) 29 + } 30 + } 6 31 7 32 export const AliasRedirects: QuartzEmitterPlugin = () => ({ 8 33 name: "AliasRedirects", 9 - async getDependencyGraph(ctx, content, _resources) { 10 - const graph = new DepGraph<FilePath>() 11 - 12 - const { argv } = ctx 34 + async *emit(ctx, content) { 13 35 for (const [_tree, file] of content) { 14 - for (const slug of getAliasSlugs(file.data.frontmatter?.aliases ?? [], argv, file)) { 15 - graph.addEdge(file.data.filePath!, joinSegments(argv.output, slug + ".html") as FilePath) 16 - } 36 + yield* processFile(ctx, file) 17 37 } 18 - 19 - return graph 20 38 }, 21 - async *emit(ctx, content, _resources) { 22 - for (const [_tree, file] of content) { 23 - const ogSlug = simplifySlug(file.data.slug!) 24 - 25 - for (const slug of file.data.aliases ?? []) { 26 - const redirUrl = resolveRelative(slug, file.data.slug!) 27 - yield write({ 28 - ctx, 29 - content: ` 30 - <!DOCTYPE html> 31 - <html lang="en-us"> 32 - <head> 33 - <title>${ogSlug}</title> 34 - <link rel="canonical" href="${redirUrl}"> 35 - <meta name="robots" content="noindex"> 36 - <meta charset="utf-8"> 37 - <meta http-equiv="refresh" content="0; url=${redirUrl}"> 38 - </head> 39 - </html> 40 - `, 41 - slug, 42 - ext: ".html", 43 - }) 39 + async *partialEmit(ctx, _content, _resources, changeEvents) { 40 + for (const changeEvent of changeEvents) { 41 + if (!changeEvent.file) continue 42 + if (changeEvent.type === "add" || changeEvent.type === "change") { 43 + // add new ones if this file still exists 44 + yield* processFile(ctx, changeEvent.file) 44 45 } 45 46 } 46 47 },
+27 -27
quartz/plugins/emitters/assets.ts
··· 3 3 import path from "path" 4 4 import fs from "fs" 5 5 import { glob } from "../../util/glob" 6 - import DepGraph from "../../depgraph" 7 6 import { Argv } from "../../util/ctx" 8 7 import { QuartzConfig } from "../../cfg" 9 8 ··· 12 11 return await glob("**", argv.directory, ["**/*.md", ...cfg.configuration.ignorePatterns]) 13 12 } 14 13 14 + const copyFile = async (argv: Argv, fp: FilePath) => { 15 + const src = joinSegments(argv.directory, fp) as FilePath 16 + 17 + const name = slugifyFilePath(fp) 18 + const dest = joinSegments(argv.output, name) as FilePath 19 + 20 + // ensure dir exists 21 + const dir = path.dirname(dest) as FilePath 22 + await fs.promises.mkdir(dir, { recursive: true }) 23 + 24 + await fs.promises.copyFile(src, dest) 25 + return dest 26 + } 27 + 15 28 export const Assets: QuartzEmitterPlugin = () => { 16 29 return { 17 30 name: "Assets", 18 - async getDependencyGraph(ctx, _content, _resources) { 19 - const { argv, cfg } = ctx 20 - const graph = new DepGraph<FilePath>() 21 - 31 + async *emit({ argv, cfg }) { 22 32 const fps = await filesToCopy(argv, cfg) 23 - 24 33 for (const fp of fps) { 25 - const ext = path.extname(fp) 26 - const src = joinSegments(argv.directory, fp) as FilePath 27 - const name = (slugifyFilePath(fp as FilePath, true) + ext) as FilePath 28 - 29 - const dest = joinSegments(argv.output, name) as FilePath 30 - 31 - graph.addEdge(src, dest) 34 + yield copyFile(argv, fp) 32 35 } 33 - 34 - return graph 35 36 }, 36 - async *emit({ argv, cfg }, _content, _resources) { 37 - const assetsPath = argv.output 38 - const fps = await filesToCopy(argv, cfg) 39 - for (const fp of fps) { 40 - const ext = path.extname(fp) 41 - const src = joinSegments(argv.directory, fp) as FilePath 42 - const name = (slugifyFilePath(fp as FilePath, true) + ext) as FilePath 37 + async *partialEmit(ctx, _content, _resources, changeEvents) { 38 + for (const changeEvent of changeEvents) { 39 + const ext = path.extname(changeEvent.path) 40 + if (ext === ".md") continue 43 41 44 - const dest = joinSegments(assetsPath, name) as FilePath 45 - const dir = path.dirname(dest) as FilePath 46 - await fs.promises.mkdir(dir, { recursive: true }) // ensure dir exists 47 - await fs.promises.copyFile(src, dest) 48 - yield dest 42 + if (changeEvent.type === "add" || changeEvent.type === "change") { 43 + yield copyFile(ctx.argv, changeEvent.path) 44 + } else if (changeEvent.type === "delete") { 45 + const name = slugifyFilePath(changeEvent.path) 46 + const dest = joinSegments(ctx.argv.output, name) as FilePath 47 + await fs.promises.unlink(dest) 48 + } 49 49 } 50 50 }, 51 51 }
+2 -5
quartz/plugins/emitters/cname.ts
··· 2 2 import { QuartzEmitterPlugin } from "../types" 3 3 import fs from "fs" 4 4 import chalk from "chalk" 5 - import DepGraph from "../../depgraph" 6 5 7 6 export function extractDomainFromBaseUrl(baseUrl: string) { 8 7 const url = new URL(`https://${baseUrl}`) ··· 11 10 12 11 export const CNAME: QuartzEmitterPlugin = () => ({ 13 12 name: "CNAME", 14 - async getDependencyGraph(_ctx, _content, _resources) { 15 - return new DepGraph<FilePath>() 16 - }, 17 - async emit({ argv, cfg }, _content, _resources) { 13 + async emit({ argv, cfg }) { 18 14 if (!cfg.configuration.baseUrl) { 19 15 console.warn(chalk.yellow("CNAME emitter requires `baseUrl` to be set in your configuration")) 20 16 return [] ··· 27 23 await fs.promises.writeFile(path, content) 28 24 return [path] as FilePath[] 29 25 }, 26 + async *partialEmit() {}, 30 27 })
+17 -18
quartz/plugins/emitters/componentResources.ts
··· 1 - import { FilePath, FullSlug, joinSegments } from "../../util/path" 1 + import { FullSlug, joinSegments } from "../../util/path" 2 2 import { QuartzEmitterPlugin } from "../types" 3 3 4 4 // @ts-ignore ··· 13 13 import { Features, transform } from "lightningcss" 14 14 import { transform as transpile } from "esbuild" 15 15 import { write } from "./helpers" 16 - import DepGraph from "../../depgraph" 17 16 18 17 type ComponentResources = { 19 18 css: string[] ··· 203 202 export const ComponentResources: QuartzEmitterPlugin = () => { 204 203 return { 205 204 name: "ComponentResources", 206 - async getDependencyGraph(_ctx, _content, _resources) { 207 - return new DepGraph<FilePath>() 208 - }, 209 205 async *emit(ctx, _content, _resources) { 210 206 const cfg = ctx.cfg.configuration 211 207 // component specific scripts and styles ··· 281 277 }, 282 278 include: Features.MediaQueries, 283 279 }).code.toString(), 284 - }), 285 - yield write({ 286 - ctx, 287 - slug: "prescript" as FullSlug, 288 - ext: ".js", 289 - content: prescript, 290 - }), 291 - yield write({ 292 - ctx, 293 - slug: "postscript" as FullSlug, 294 - ext: ".js", 295 - content: postscript, 296 - }) 280 + }) 281 + 282 + yield write({ 283 + ctx, 284 + slug: "prescript" as FullSlug, 285 + ext: ".js", 286 + content: prescript, 287 + }) 288 + 289 + yield write({ 290 + ctx, 291 + slug: "postscript" as FullSlug, 292 + ext: ".js", 293 + content: postscript, 294 + }) 297 295 }, 296 + async *partialEmit() {}, 298 297 } 299 298 }
+2 -23
quartz/plugins/emitters/contentIndex.tsx
··· 7 7 import { toHtml } from "hast-util-to-html" 8 8 import { write } from "./helpers" 9 9 import { i18n } from "../../i18n" 10 - import DepGraph from "../../depgraph" 11 10 12 11 export type ContentIndexMap = Map<FullSlug, ContentDetails> 13 12 export type ContentDetails = { ··· 97 96 opts = { ...defaultOptions, ...opts } 98 97 return { 99 98 name: "ContentIndex", 100 - async getDependencyGraph(ctx, content, _resources) { 101 - const graph = new DepGraph<FilePath>() 102 - 103 - for (const [_tree, file] of content) { 104 - const sourcePath = file.data.filePath! 105 - 106 - graph.addEdge( 107 - sourcePath, 108 - joinSegments(ctx.argv.output, "static/contentIndex.json") as FilePath, 109 - ) 110 - if (opts?.enableSiteMap) { 111 - graph.addEdge(sourcePath, joinSegments(ctx.argv.output, "sitemap.xml") as FilePath) 112 - } 113 - if (opts?.enableRSS) { 114 - graph.addEdge(sourcePath, joinSegments(ctx.argv.output, "index.xml") as FilePath) 115 - } 116 - } 117 - 118 - return graph 119 - }, 120 - async *emit(ctx, content, _resources) { 99 + async *emit(ctx, content) { 121 100 const cfg = ctx.cfg.configuration 122 101 const linkIndex: ContentIndexMap = new Map() 123 102 for (const [tree, file] of content) { ··· 126 105 if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) { 127 106 linkIndex.set(slug, { 128 107 slug, 129 - filePath: file.data.filePath!, 108 + filePath: file.data.relativePath!, 130 109 title: file.data.frontmatter?.title!, 131 110 links: file.data.links ?? [], 132 111 tags: file.data.frontmatter?.tags ?? [],
+56 -77
quartz/plugins/emitters/contentPage.tsx
··· 1 1 import path from "path" 2 - import { visit } from "unist-util-visit" 3 - import { Root } from "hast" 4 - import { VFile } from "vfile" 5 2 import { QuartzEmitterPlugin } from "../types" 6 3 import { QuartzComponentProps } from "../../components/types" 7 4 import HeaderConstructor from "../../components/Header" 8 5 import BodyConstructor from "../../components/Body" 9 6 import { pageResources, renderPage } from "../../components/renderPage" 10 7 import { FullPageLayout } from "../../cfg" 11 - import { Argv } from "../../util/ctx" 12 - import { FilePath, isRelativeURL, joinSegments, pathToRoot } from "../../util/path" 8 + import { pathToRoot } from "../../util/path" 13 9 import { defaultContentPageLayout, sharedPageComponents } from "../../../quartz.layout" 14 10 import { Content } from "../../components" 15 11 import chalk from "chalk" 16 12 import { write } from "./helpers" 17 - import DepGraph from "../../depgraph" 13 + import { BuildCtx } from "../../util/ctx" 14 + import { Node } from "unist" 15 + import { StaticResources } from "../../util/resources" 16 + import { QuartzPluginData } from "../vfile" 18 17 19 - // get all the dependencies for the markdown file 20 - // eg. images, scripts, stylesheets, transclusions 21 - const parseDependencies = (argv: Argv, hast: Root, file: VFile): string[] => { 22 - const dependencies: string[] = [] 18 + async function processContent( 19 + ctx: BuildCtx, 20 + tree: Node, 21 + fileData: QuartzPluginData, 22 + allFiles: QuartzPluginData[], 23 + opts: FullPageLayout, 24 + resources: StaticResources, 25 + ) { 26 + const slug = fileData.slug! 27 + const cfg = ctx.cfg.configuration 28 + const externalResources = pageResources(pathToRoot(slug), resources) 29 + const componentData: QuartzComponentProps = { 30 + ctx, 31 + fileData, 32 + externalResources, 33 + cfg, 34 + children: [], 35 + tree, 36 + allFiles, 37 + } 23 38 24 - visit(hast, "element", (elem): void => { 25 - let ref: string | null = null 26 - 27 - if ( 28 - ["script", "img", "audio", "video", "source", "iframe"].includes(elem.tagName) && 29 - elem?.properties?.src 30 - ) { 31 - ref = elem.properties.src.toString() 32 - } else if (["a", "link"].includes(elem.tagName) && elem?.properties?.href) { 33 - // transclusions will create a tags with relative hrefs 34 - ref = elem.properties.href.toString() 35 - } 36 - 37 - // if it is a relative url, its a local file and we need to add 38 - // it to the dependency graph. otherwise, ignore 39 - if (ref === null || !isRelativeURL(ref)) { 40 - return 41 - } 42 - 43 - let fp = path.join(file.data.filePath!, path.relative(argv.directory, ref)).replace(/\\/g, "/") 44 - // markdown files have the .md extension stripped in hrefs, add it back here 45 - if (!fp.split("/").pop()?.includes(".")) { 46 - fp += ".md" 47 - } 48 - dependencies.push(fp) 39 + const content = renderPage(cfg, slug, componentData, opts, externalResources) 40 + return write({ 41 + ctx, 42 + content, 43 + slug, 44 + ext: ".html", 49 45 }) 50 - 51 - return dependencies 52 46 } 53 47 54 48 export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpts) => { ··· 79 73 Footer, 80 74 ] 81 75 }, 82 - async getDependencyGraph(ctx, content, _resources) { 83 - const graph = new DepGraph<FilePath>() 84 - 85 - for (const [tree, file] of content) { 86 - const sourcePath = file.data.filePath! 87 - const slug = file.data.slug! 88 - graph.addEdge(sourcePath, joinSegments(ctx.argv.output, slug + ".html") as FilePath) 89 - 90 - parseDependencies(ctx.argv, tree as Root, file).forEach((dep) => { 91 - graph.addEdge(dep as FilePath, sourcePath) 92 - }) 93 - } 94 - 95 - return graph 96 - }, 97 76 async *emit(ctx, content, resources) { 98 - const cfg = ctx.cfg.configuration 99 77 const allFiles = content.map((c) => c[1].data) 100 - 101 78 let containsIndex = false 79 + 102 80 for (const [tree, file] of content) { 103 81 const slug = file.data.slug! 104 82 if (slug === "index") { 105 83 containsIndex = true 106 84 } 107 85 108 - if (file.data.slug?.endsWith("/index")) { 109 - continue 110 - } 111 - 112 - const externalResources = pageResources(pathToRoot(slug), file.data, resources) 113 - const componentData: QuartzComponentProps = { 114 - ctx, 115 - fileData: file.data, 116 - externalResources, 117 - cfg, 118 - children: [], 119 - tree, 120 - allFiles, 121 - } 122 - 123 - const content = renderPage(cfg, slug, componentData, opts, externalResources) 124 - yield write({ 125 - ctx, 126 - content, 127 - slug, 128 - ext: ".html", 129 - }) 86 + // only process home page, non-tag pages, and non-index pages 87 + if (slug.endsWith("/index") || slug.startsWith("tags/")) continue 88 + yield processContent(ctx, tree, file.data, allFiles, opts, resources) 130 89 } 131 90 132 - if (!containsIndex && !ctx.argv.fastRebuild) { 91 + if (!containsIndex) { 133 92 console.log( 134 93 chalk.yellow( 135 94 `\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.`, 136 95 ), 137 96 ) 97 + } 98 + }, 99 + async *partialEmit(ctx, content, resources, changeEvents) { 100 + const allFiles = content.map((c) => c[1].data) 101 + 102 + // find all slugs that changed or were added 103 + const changedSlugs = new Set<string>() 104 + for (const changeEvent of changeEvents) { 105 + if (!changeEvent.file) continue 106 + if (changeEvent.type === "add" || changeEvent.type === "change") { 107 + changedSlugs.add(changeEvent.file.data.slug!) 108 + } 109 + } 110 + 111 + for (const [tree, file] of content) { 112 + const slug = file.data.slug! 113 + if (!changedSlugs.has(slug)) continue 114 + if (slug.endsWith("/index") || slug.startsWith("tags/")) continue 115 + 116 + yield processContent(ctx, tree, file.data, allFiles, opts, resources) 138 117 } 139 118 }, 140 119 }
+98 -69
quartz/plugins/emitters/folderPage.tsx
··· 7 7 import { FullPageLayout } from "../../cfg" 8 8 import path from "path" 9 9 import { 10 - FilePath, 11 10 FullSlug, 12 11 SimpleSlug, 13 12 stripSlashes, ··· 18 17 import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout" 19 18 import { FolderContent } from "../../components" 20 19 import { write } from "./helpers" 21 - import { i18n } from "../../i18n" 22 - import DepGraph from "../../depgraph" 23 - 20 + import { i18n, TRANSLATIONS } from "../../i18n" 21 + import { BuildCtx } from "../../util/ctx" 22 + import { StaticResources } from "../../util/resources" 24 23 interface FolderPageOptions extends FullPageLayout { 25 24 sort?: (f1: QuartzPluginData, f2: QuartzPluginData) => number 26 25 } 27 26 27 + async function* processFolderInfo( 28 + ctx: BuildCtx, 29 + folderInfo: Record<SimpleSlug, ProcessedContent>, 30 + allFiles: QuartzPluginData[], 31 + opts: FullPageLayout, 32 + resources: StaticResources, 33 + ) { 34 + for (const [folder, folderContent] of Object.entries(folderInfo) as [ 35 + SimpleSlug, 36 + ProcessedContent, 37 + ][]) { 38 + const slug = joinSegments(folder, "index") as FullSlug 39 + const [tree, file] = folderContent 40 + const cfg = ctx.cfg.configuration 41 + const externalResources = pageResources(pathToRoot(slug), resources) 42 + const componentData: QuartzComponentProps = { 43 + ctx, 44 + fileData: file.data, 45 + externalResources, 46 + cfg, 47 + children: [], 48 + tree, 49 + allFiles, 50 + } 51 + 52 + const content = renderPage(cfg, slug, componentData, opts, externalResources) 53 + yield write({ 54 + ctx, 55 + content, 56 + slug, 57 + ext: ".html", 58 + }) 59 + } 60 + } 61 + 62 + function computeFolderInfo( 63 + folders: Set<SimpleSlug>, 64 + content: ProcessedContent[], 65 + locale: keyof typeof TRANSLATIONS, 66 + ): Record<SimpleSlug, ProcessedContent> { 67 + // Create default folder descriptions 68 + const folderInfo: Record<SimpleSlug, ProcessedContent> = Object.fromEntries( 69 + [...folders].map((folder) => [ 70 + folder, 71 + defaultProcessedContent({ 72 + slug: joinSegments(folder, "index") as FullSlug, 73 + frontmatter: { 74 + title: `${i18n(locale).pages.folderContent.folder}: ${folder}`, 75 + tags: [], 76 + }, 77 + }), 78 + ]), 79 + ) 80 + 81 + // Update with actual content if available 82 + for (const [tree, file] of content) { 83 + const slug = stripSlashes(simplifySlug(file.data.slug!)) as SimpleSlug 84 + if (folders.has(slug)) { 85 + folderInfo[slug] = [tree, file] 86 + } 87 + } 88 + 89 + return folderInfo 90 + } 91 + 92 + function _getFolders(slug: FullSlug): SimpleSlug[] { 93 + var folderName = path.dirname(slug ?? "") as SimpleSlug 94 + const parentFolderNames = [folderName] 95 + 96 + while (folderName !== ".") { 97 + folderName = path.dirname(folderName ?? "") as SimpleSlug 98 + parentFolderNames.push(folderName) 99 + } 100 + return parentFolderNames 101 + } 102 + 28 103 export const FolderPage: QuartzEmitterPlugin<Partial<FolderPageOptions>> = (userOpts) => { 29 104 const opts: FullPageLayout = { 30 105 ...sharedPageComponents, ··· 53 128 Footer, 54 129 ] 55 130 }, 56 - async getDependencyGraph(_ctx, content, _resources) { 57 - // Example graph: 58 - // nested/file.md --> nested/index.html 59 - // nested/file2.md ------^ 60 - const graph = new DepGraph<FilePath>() 61 - 62 - content.map(([_tree, vfile]) => { 63 - const slug = vfile.data.slug 64 - const folderName = path.dirname(slug ?? "") as SimpleSlug 65 - if (slug && folderName !== "." && folderName !== "tags") { 66 - graph.addEdge(vfile.data.filePath!, joinSegments(folderName, "index.html") as FilePath) 67 - } 68 - }) 69 - 70 - return graph 71 - }, 72 131 async *emit(ctx, content, resources) { 73 132 const allFiles = content.map((c) => c[1].data) 74 133 const cfg = ctx.cfg.configuration ··· 83 142 }), 84 143 ) 85 144 86 - const folderDescriptions: Record<string, ProcessedContent> = Object.fromEntries( 87 - [...folders].map((folder) => [ 88 - folder, 89 - defaultProcessedContent({ 90 - slug: joinSegments(folder, "index") as FullSlug, 91 - frontmatter: { 92 - title: `${i18n(cfg.locale).pages.folderContent.folder}: ${folder}`, 93 - tags: [], 94 - }, 95 - }), 96 - ]), 97 - ) 145 + const folderInfo = computeFolderInfo(folders, content, cfg.locale) 146 + yield* processFolderInfo(ctx, folderInfo, allFiles, opts, resources) 147 + }, 148 + async *partialEmit(ctx, content, resources, changeEvents) { 149 + const allFiles = content.map((c) => c[1].data) 150 + const cfg = ctx.cfg.configuration 98 151 99 - for (const [tree, file] of content) { 100 - const slug = stripSlashes(simplifySlug(file.data.slug!)) as SimpleSlug 101 - if (folders.has(slug)) { 102 - folderDescriptions[slug] = [tree, file] 103 - } 152 + // Find all folders that need to be updated based on changed files 153 + const affectedFolders: Set<SimpleSlug> = new Set() 154 + for (const changeEvent of changeEvents) { 155 + if (!changeEvent.file) continue 156 + const slug = changeEvent.file.data.slug! 157 + const folders = _getFolders(slug).filter( 158 + (folderName) => folderName !== "." && folderName !== "tags", 159 + ) 160 + folders.forEach((folder) => affectedFolders.add(folder)) 104 161 } 105 162 106 - for (const folder of folders) { 107 - const slug = joinSegments(folder, "index") as FullSlug 108 - const [tree, file] = folderDescriptions[folder] 109 - const externalResources = pageResources(pathToRoot(slug), file.data, resources) 110 - const componentData: QuartzComponentProps = { 111 - ctx, 112 - fileData: file.data, 113 - externalResources, 114 - cfg, 115 - children: [], 116 - tree, 117 - allFiles, 118 - } 119 - 120 - const content = renderPage(cfg, slug, componentData, opts, externalResources) 121 - yield write({ 122 - ctx, 123 - content, 124 - slug, 125 - ext: ".html", 126 - }) 163 + // If there are affected folders, rebuild their pages 164 + if (affectedFolders.size > 0) { 165 + const folderInfo = computeFolderInfo(affectedFolders, content, cfg.locale) 166 + yield* processFolderInfo(ctx, folderInfo, allFiles, opts, resources) 127 167 } 128 168 }, 129 169 } 130 170 } 131 - 132 - function _getFolders(slug: FullSlug): SimpleSlug[] { 133 - var folderName = path.dirname(slug ?? "") as SimpleSlug 134 - const parentFolderNames = [folderName] 135 - 136 - while (folderName !== ".") { 137 - folderName = path.dirname(folderName ?? "") as SimpleSlug 138 - parentFolderNames.push(folderName) 139 - } 140 - return parentFolderNames 141 - }
+54 -33
quartz/plugins/emitters/ogImage.tsx
··· 4 4 import { FullSlug, getFileExtension } from "../../util/path" 5 5 import { ImageOptions, SocialImageOptions, defaultImage, getSatoriFonts } from "../../util/og" 6 6 import sharp from "sharp" 7 - import satori from "satori" 7 + import satori, { SatoriOptions } from "satori" 8 8 import { loadEmoji, getIconCode } from "../../util/emoji" 9 9 import { Readable } from "stream" 10 10 import { write } from "./helpers" 11 + import { BuildCtx } from "../../util/ctx" 12 + import { QuartzPluginData } from "../vfile" 11 13 12 14 const defaultOptions: SocialImageOptions = { 13 15 colorScheme: "lightMode", ··· 42 44 return sharp(Buffer.from(svg)).webp({ quality: 40 }) 43 45 } 44 46 47 + async function processOgImage( 48 + ctx: BuildCtx, 49 + fileData: QuartzPluginData, 50 + fonts: SatoriOptions["fonts"], 51 + fullOptions: SocialImageOptions, 52 + ) { 53 + const cfg = ctx.cfg.configuration 54 + const slug = fileData.slug! 55 + const titleSuffix = cfg.pageTitleSuffix ?? "" 56 + const title = 57 + (fileData.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title) + titleSuffix 58 + const description = 59 + fileData.frontmatter?.socialDescription ?? 60 + fileData.frontmatter?.description ?? 61 + unescapeHTML(fileData.description?.trim() ?? i18n(cfg.locale).propertyDefaults.description) 62 + 63 + const stream = await generateSocialImage( 64 + { 65 + title, 66 + description, 67 + fonts, 68 + cfg, 69 + fileData, 70 + }, 71 + fullOptions, 72 + ) 73 + 74 + return write({ 75 + ctx, 76 + content: stream, 77 + slug: `${slug}-og-image` as FullSlug, 78 + ext: ".webp", 79 + }) 80 + } 81 + 45 82 export const CustomOgImagesEmitterName = "CustomOgImages" 46 83 export const CustomOgImages: QuartzEmitterPlugin<Partial<SocialImageOptions>> = (userOpts) => { 47 84 const fullOptions = { ...defaultOptions, ...userOpts } ··· 58 95 const fonts = await getSatoriFonts(headerFont, bodyFont) 59 96 60 97 for (const [_tree, vfile] of content) { 61 - // if this file defines socialImage, we can skip 62 - if (vfile.data.frontmatter?.socialImage !== undefined) { 63 - continue 64 - } 98 + if (vfile.data.frontmatter?.socialImage !== undefined) continue 99 + yield processOgImage(ctx, vfile.data, fonts, fullOptions) 100 + } 101 + }, 102 + async *partialEmit(ctx, _content, _resources, changeEvents) { 103 + const cfg = ctx.cfg.configuration 104 + const headerFont = cfg.theme.typography.header 105 + const bodyFont = cfg.theme.typography.body 106 + const fonts = await getSatoriFonts(headerFont, bodyFont) 65 107 66 - const slug = vfile.data.slug! 67 - const titleSuffix = cfg.pageTitleSuffix ?? "" 68 - const title = 69 - (vfile.data.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title) + titleSuffix 70 - const description = 71 - vfile.data.frontmatter?.socialDescription ?? 72 - vfile.data.frontmatter?.description ?? 73 - unescapeHTML( 74 - vfile.data.description?.trim() ?? i18n(cfg.locale).propertyDefaults.description, 75 - ) 76 - 77 - const stream = await generateSocialImage( 78 - { 79 - title, 80 - description, 81 - fonts, 82 - cfg, 83 - fileData: vfile.data, 84 - }, 85 - fullOptions, 86 - ) 87 - 88 - yield write({ 89 - ctx, 90 - content: stream, 91 - slug: `${slug}-og-image` as FullSlug, 92 - ext: ".webp", 93 - }) 108 + // find all slugs that changed or were added 109 + for (const changeEvent of changeEvents) { 110 + if (!changeEvent.file) continue 111 + if (changeEvent.file.data.frontmatter?.socialImage !== undefined) continue 112 + if (changeEvent.type === "add" || changeEvent.type === "change") { 113 + yield processOgImage(ctx, changeEvent.file.data, fonts, fullOptions) 114 + } 94 115 } 95 116 }, 96 117 externalResources: (ctx) => {
+2 -16
quartz/plugins/emitters/static.ts
··· 2 2 import { QuartzEmitterPlugin } from "../types" 3 3 import fs from "fs" 4 4 import { glob } from "../../util/glob" 5 - import DepGraph from "../../depgraph" 6 5 import { dirname } from "path" 7 6 8 7 export const Static: QuartzEmitterPlugin = () => ({ 9 8 name: "Static", 10 - async getDependencyGraph({ argv, cfg }, _content, _resources) { 11 - const graph = new DepGraph<FilePath>() 12 - 13 - const staticPath = joinSegments(QUARTZ, "static") 14 - const fps = await glob("**", staticPath, cfg.configuration.ignorePatterns) 15 - for (const fp of fps) { 16 - graph.addEdge( 17 - joinSegments("static", fp) as FilePath, 18 - joinSegments(argv.output, "static", fp) as FilePath, 19 - ) 20 - } 21 - 22 - return graph 23 - }, 24 - async *emit({ argv, cfg }, _content) { 9 + async *emit({ argv, cfg }) { 25 10 const staticPath = joinSegments(QUARTZ, "static") 26 11 const fps = await glob("**", staticPath, cfg.configuration.ignorePatterns) 27 12 const outputStaticPath = joinSegments(argv.output, "static") ··· 34 19 yield dest 35 20 } 36 21 }, 22 + async *partialEmit() {}, 37 23 })
+110 -78
quartz/plugins/emitters/tagPage.tsx
··· 5 5 import { pageResources, renderPage } from "../../components/renderPage" 6 6 import { ProcessedContent, QuartzPluginData, defaultProcessedContent } from "../vfile" 7 7 import { FullPageLayout } from "../../cfg" 8 - import { 9 - FilePath, 10 - FullSlug, 11 - getAllSegmentPrefixes, 12 - joinSegments, 13 - pathToRoot, 14 - } from "../../util/path" 8 + import { FullSlug, getAllSegmentPrefixes, joinSegments, pathToRoot } from "../../util/path" 15 9 import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout" 16 10 import { TagContent } from "../../components" 17 11 import { write } from "./helpers" 18 - import { i18n } from "../../i18n" 19 - import DepGraph from "../../depgraph" 12 + import { i18n, TRANSLATIONS } from "../../i18n" 13 + import { BuildCtx } from "../../util/ctx" 14 + import { StaticResources } from "../../util/resources" 20 15 21 16 interface TagPageOptions extends FullPageLayout { 22 17 sort?: (f1: QuartzPluginData, f2: QuartzPluginData) => number 23 18 } 24 19 20 + function computeTagInfo( 21 + allFiles: QuartzPluginData[], 22 + content: ProcessedContent[], 23 + locale: keyof typeof TRANSLATIONS, 24 + ): [Set<string>, Record<string, ProcessedContent>] { 25 + const tags: Set<string> = new Set( 26 + allFiles.flatMap((data) => data.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes), 27 + ) 28 + 29 + // add base tag 30 + tags.add("index") 31 + 32 + const tagDescriptions: Record<string, ProcessedContent> = Object.fromEntries( 33 + [...tags].map((tag) => { 34 + const title = 35 + tag === "index" 36 + ? i18n(locale).pages.tagContent.tagIndex 37 + : `${i18n(locale).pages.tagContent.tag}: ${tag}` 38 + return [ 39 + tag, 40 + defaultProcessedContent({ 41 + slug: joinSegments("tags", tag) as FullSlug, 42 + frontmatter: { title, tags: [] }, 43 + }), 44 + ] 45 + }), 46 + ) 47 + 48 + // Update with actual content if available 49 + for (const [tree, file] of content) { 50 + const slug = file.data.slug! 51 + if (slug.startsWith("tags/")) { 52 + const tag = slug.slice("tags/".length) 53 + if (tags.has(tag)) { 54 + tagDescriptions[tag] = [tree, file] 55 + if (file.data.frontmatter?.title === tag) { 56 + file.data.frontmatter.title = `${i18n(locale).pages.tagContent.tag}: ${tag}` 57 + } 58 + } 59 + } 60 + } 61 + 62 + return [tags, tagDescriptions] 63 + } 64 + 65 + async function processTagPage( 66 + ctx: BuildCtx, 67 + tag: string, 68 + tagContent: ProcessedContent, 69 + allFiles: QuartzPluginData[], 70 + opts: FullPageLayout, 71 + resources: StaticResources, 72 + ) { 73 + const slug = joinSegments("tags", tag) as FullSlug 74 + const [tree, file] = tagContent 75 + const cfg = ctx.cfg.configuration 76 + const externalResources = pageResources(pathToRoot(slug), resources) 77 + const componentData: QuartzComponentProps = { 78 + ctx, 79 + fileData: file.data, 80 + externalResources, 81 + cfg, 82 + children: [], 83 + tree, 84 + allFiles, 85 + } 86 + 87 + const content = renderPage(cfg, slug, componentData, opts, externalResources) 88 + return write({ 89 + ctx, 90 + content, 91 + slug: file.data.slug!, 92 + ext: ".html", 93 + }) 94 + } 95 + 25 96 export const TagPage: QuartzEmitterPlugin<Partial<TagPageOptions>> = (userOpts) => { 26 97 const opts: FullPageLayout = { 27 98 ...sharedPageComponents, ··· 50 121 Footer, 51 122 ] 52 123 }, 53 - async getDependencyGraph(ctx, content, _resources) { 54 - const graph = new DepGraph<FilePath>() 55 - 56 - for (const [_tree, file] of content) { 57 - const sourcePath = file.data.filePath! 58 - const tags = (file.data.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes) 59 - // if the file has at least one tag, it is used in the tag index page 60 - if (tags.length > 0) { 61 - tags.push("index") 62 - } 124 + async *emit(ctx, content, resources) { 125 + const allFiles = content.map((c) => c[1].data) 126 + const cfg = ctx.cfg.configuration 127 + const [tags, tagDescriptions] = computeTagInfo(allFiles, content, cfg.locale) 63 128 64 - for (const tag of tags) { 65 - graph.addEdge( 66 - sourcePath, 67 - joinSegments(ctx.argv.output, "tags", tag + ".html") as FilePath, 68 - ) 69 - } 129 + for (const tag of tags) { 130 + yield processTagPage(ctx, tag, tagDescriptions[tag], allFiles, opts, resources) 70 131 } 71 - 72 - return graph 73 132 }, 74 - async *emit(ctx, content, resources) { 133 + async *partialEmit(ctx, content, resources, changeEvents) { 75 134 const allFiles = content.map((c) => c[1].data) 76 135 const cfg = ctx.cfg.configuration 77 136 78 - const tags: Set<string> = new Set( 79 - allFiles.flatMap((data) => data.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes), 80 - ) 81 - 82 - // add base tag 83 - tags.add("index") 84 - 85 - const tagDescriptions: Record<string, ProcessedContent> = Object.fromEntries( 86 - [...tags].map((tag) => { 87 - const title = 88 - tag === "index" 89 - ? i18n(cfg.locale).pages.tagContent.tagIndex 90 - : `${i18n(cfg.locale).pages.tagContent.tag}: ${tag}` 91 - return [ 92 - tag, 93 - defaultProcessedContent({ 94 - slug: joinSegments("tags", tag) as FullSlug, 95 - frontmatter: { title, tags: [] }, 96 - }), 97 - ] 98 - }), 99 - ) 137 + // Find all tags that need to be updated based on changed files 138 + const affectedTags: Set<string> = new Set() 139 + for (const changeEvent of changeEvents) { 140 + if (!changeEvent.file) continue 141 + const slug = changeEvent.file.data.slug! 100 142 101 - for (const [tree, file] of content) { 102 - const slug = file.data.slug! 143 + // If it's a tag page itself that changed 103 144 if (slug.startsWith("tags/")) { 104 145 const tag = slug.slice("tags/".length) 105 - if (tags.has(tag)) { 106 - tagDescriptions[tag] = [tree, file] 107 - if (file.data.frontmatter?.title === tag) { 108 - file.data.frontmatter.title = `${i18n(cfg.locale).pages.tagContent.tag}: ${tag}` 109 - } 110 - } 146 + affectedTags.add(tag) 111 147 } 148 + 149 + // If a file with tags changed, we need to update those tag pages 150 + const fileTags = changeEvent.file.data.frontmatter?.tags ?? [] 151 + fileTags.flatMap(getAllSegmentPrefixes).forEach((tag) => affectedTags.add(tag)) 152 + 153 + // Always update the index tag page if any file changes 154 + affectedTags.add("index") 112 155 } 113 156 114 - for (const tag of tags) { 115 - const slug = joinSegments("tags", tag) as FullSlug 116 - const [tree, file] = tagDescriptions[tag] 117 - const externalResources = pageResources(pathToRoot(slug), file.data, resources) 118 - const componentData: QuartzComponentProps = { 119 - ctx, 120 - fileData: file.data, 121 - externalResources, 122 - cfg, 123 - children: [], 124 - tree, 125 - allFiles, 157 + // If there are affected tags, rebuild their pages 158 + if (affectedTags.size > 0) { 159 + // We still need to compute all tags because tag pages show all tags 160 + const [_tags, tagDescriptions] = computeTagInfo(allFiles, content, cfg.locale) 161 + 162 + for (const tag of affectedTags) { 163 + if (tagDescriptions[tag]) { 164 + yield processTagPage(ctx, tag, tagDescriptions[tag], allFiles, opts, resources) 165 + } 126 166 } 127 - 128 - const content = renderPage(cfg, slug, componentData, opts, externalResources) 129 - yield write({ 130 - ctx, 131 - content, 132 - slug: file.data.slug!, 133 - ext: ".html", 134 - }) 135 167 } 136 168 }, 137 169 }
+23 -19
quartz/plugins/transformers/frontmatter.ts
··· 3 3 import { QuartzTransformerPlugin } from "../types" 4 4 import yaml from "js-yaml" 5 5 import toml from "toml" 6 - import { FilePath, FullSlug, joinSegments, slugifyFilePath, slugTag } from "../../util/path" 6 + import { FilePath, FullSlug, getFileExtension, slugifyFilePath, slugTag } from "../../util/path" 7 7 import { QuartzPluginData } from "../vfile" 8 8 import { i18n } from "../../i18n" 9 - import { Argv } from "../../util/ctx" 10 - import { VFile } from "vfile" 11 - import path from "path" 12 9 13 10 export interface Options { 14 11 delimiters: string | [string, string] ··· 43 40 .map((tag: string | number) => tag.toString()) 44 41 } 45 42 46 - export function getAliasSlugs(aliases: string[], argv: Argv, file: VFile): FullSlug[] { 47 - const dir = path.posix.relative(argv.directory, path.dirname(file.data.filePath!)) 48 - const slugs: FullSlug[] = aliases.map( 49 - (alias) => path.posix.join(dir, slugifyFilePath(alias as FilePath)) as FullSlug, 50 - ) 51 - const permalink = file.data.frontmatter?.permalink 52 - if (typeof permalink === "string") { 53 - slugs.push(permalink as FullSlug) 43 + function getAliasSlugs(aliases: string[]): FullSlug[] { 44 + const res: FullSlug[] = [] 45 + for (const alias of aliases) { 46 + const isMd = getFileExtension(alias) === "md" 47 + const mockFp = isMd ? alias : alias + ".md" 48 + const slug = slugifyFilePath(mockFp as FilePath) 49 + res.push(slug) 54 50 } 55 - // fix any slugs that have trailing slash 56 - return slugs.map((slug) => 57 - slug.endsWith("/") ? (joinSegments(slug, "index") as FullSlug) : slug, 58 - ) 51 + 52 + return res 59 53 } 60 54 61 55 export const FrontMatter: QuartzTransformerPlugin<Partial<Options>> = (userOpts) => { 62 56 const opts = { ...defaultOptions, ...userOpts } 63 57 return { 64 58 name: "FrontMatter", 65 - markdownPlugins({ cfg, allSlugs, argv }) { 59 + markdownPlugins(ctx) { 60 + const { cfg, allSlugs } = ctx 66 61 return [ 67 62 [remarkFrontmatter, ["yaml", "toml"]], 68 63 () => { ··· 88 83 const aliases = coerceToArray(coalesceAliases(data, ["aliases", "alias"])) 89 84 if (aliases) { 90 85 data.aliases = aliases // frontmatter 91 - const slugs = (file.data.aliases = getAliasSlugs(aliases, argv, file)) 92 - allSlugs.push(...slugs) 86 + file.data.aliases = getAliasSlugs(aliases) 87 + allSlugs.push(...file.data.aliases) 88 + } 89 + 90 + if (data.permalink != null && data.permalink.toString() !== "") { 91 + data.permalink = data.permalink.toString() as FullSlug 92 + const aliases = file.data.aliases ?? [] 93 + aliases.push(data.permalink) 94 + file.data.aliases = aliases 95 + allSlugs.push(data.permalink) 93 96 } 97 + 94 98 const cssclasses = coerceToArray(coalesceAliases(data, ["cssclasses", "cssclass"])) 95 99 if (cssclasses) data.cssclasses = cssclasses 96 100
+5 -5
quartz/plugins/transformers/lastmod.ts
··· 31 31 const opts = { ...defaultOptions, ...userOpts } 32 32 return { 33 33 name: "CreatedModifiedDate", 34 - markdownPlugins() { 34 + markdownPlugins(ctx) { 35 35 return [ 36 36 () => { 37 37 let repo: Repository | undefined = undefined ··· 40 40 let modified: MaybeDate = undefined 41 41 let published: MaybeDate = undefined 42 42 43 - const fp = file.data.filePath! 44 - const fullFp = path.isAbsolute(fp) ? fp : path.posix.join(file.cwd, fp) 43 + const fp = file.data.relativePath! 44 + const fullFp = path.posix.join(ctx.argv.directory, fp) 45 45 for (const source of opts.priority) { 46 46 if (source === "filesystem") { 47 47 const st = await fs.promises.stat(fullFp) ··· 56 56 // Get a reference to the main git repo. 57 57 // It's either the same as the workdir, 58 58 // or 1+ level higher in case of a submodule/subtree setup 59 - repo = Repository.discover(file.cwd) 59 + repo = Repository.discover(ctx.argv.directory) 60 60 } 61 61 62 62 try { 63 - modified ||= await repo.getFileLatestModifiedDateAsync(file.data.filePath!) 63 + modified ||= await repo.getFileLatestModifiedDateAsync(fullFp) 64 64 } catch { 65 65 console.log( 66 66 chalk.yellow(
+6 -6
quartz/plugins/transformers/oxhugofm.ts
··· 54 54 textTransform(_ctx, src) { 55 55 if (opts.wikilinks) { 56 56 src = src.toString() 57 - src = src.replaceAll(relrefRegex, (value, ...capture) => { 57 + src = src.replaceAll(relrefRegex, (_value, ...capture) => { 58 58 const [text, link] = capture 59 59 return `[${text}](${link})` 60 60 }) ··· 62 62 63 63 if (opts.removePredefinedAnchor) { 64 64 src = src.toString() 65 - src = src.replaceAll(predefinedHeadingIdRegex, (value, ...capture) => { 65 + src = src.replaceAll(predefinedHeadingIdRegex, (_value, ...capture) => { 66 66 const [headingText] = capture 67 67 return headingText 68 68 }) ··· 70 70 71 71 if (opts.removeHugoShortcode) { 72 72 src = src.toString() 73 - src = src.replaceAll(hugoShortcodeRegex, (value, ...capture) => { 73 + src = src.replaceAll(hugoShortcodeRegex, (_value, ...capture) => { 74 74 const [scContent] = capture 75 75 return scContent 76 76 }) ··· 78 78 79 79 if (opts.replaceFigureWithMdImg) { 80 80 src = src.toString() 81 - src = src.replaceAll(figureTagRegex, (value, ...capture) => { 81 + src = src.replaceAll(figureTagRegex, (_value, ...capture) => { 82 82 const [src] = capture 83 83 return `![](${src})` 84 84 }) ··· 86 86 87 87 if (opts.replaceOrgLatex) { 88 88 src = src.toString() 89 - src = src.replaceAll(inlineLatexRegex, (value, ...capture) => { 89 + src = src.replaceAll(inlineLatexRegex, (_value, ...capture) => { 90 90 const [eqn] = capture 91 91 return `$${eqn}$` 92 92 }) 93 - src = src.replaceAll(blockLatexRegex, (value, ...capture) => { 93 + src = src.replaceAll(blockLatexRegex, (_value, ...capture) => { 94 94 const [eqn] = capture 95 95 return `$$${eqn}$$` 96 96 })
+2 -15
quartz/plugins/transformers/roam.ts
··· 1 1 import { QuartzTransformerPlugin } from "../types" 2 2 import { PluggableList } from "unified" 3 - import { SKIP, visit } from "unist-util-visit" 3 + import { visit } from "unist-util-visit" 4 4 import { ReplaceFunction, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace" 5 5 import { Root, Html, Paragraph, Text, Link, Parent } from "mdast" 6 - import { Node } from "unist" 7 - import { VFile } from "vfile" 8 6 import { BuildVisitor } from "unist-util-visit" 9 7 10 8 export interface Options { ··· 34 32 const orRegex = new RegExp(/{{or:(.*?)}}/, "g") 35 33 const TODORegex = new RegExp(/{{.*?\bTODO\b.*?}}/, "g") 36 34 const DONERegex = new RegExp(/{{.*?\bDONE\b.*?}}/, "g") 37 - const videoRegex = new RegExp(/{{.*?\[\[video\]\].*?\:(.*?)}}/, "g") 38 - const youtubeRegex = new RegExp( 39 - /{{.*?\[\[video\]\].*?(https?:\/\/(?:www\.)?youtu(?:be\.com\/watch\?v=|\.be\/)([\w\-\_]*)(&(amp;)?[\w\?=]*)?)}}/, 40 - "g", 41 - ) 42 35 43 - // const multimediaRegex = new RegExp(/{{.*?\b(video|audio)\b.*?\:(.*?)}}/, "g") 44 - 45 - const audioRegex = new RegExp(/{{.*?\[\[audio\]\].*?\:(.*?)}}/, "g") 46 - const pdfRegex = new RegExp(/{{.*?\[\[pdf\]\].*?\:(.*?)}}/, "g") 47 36 const blockquoteRegex = new RegExp(/(\[\[>\]\])\s*(.*)/, "g") 48 37 const roamHighlightRegex = new RegExp(/\^\^(.+)\^\^/, "g") 49 38 const roamItalicRegex = new RegExp(/__(.+)__/, "g") 50 - const tableRegex = new RegExp(/- {{.*?\btable\b.*?}}/, "g") /* TODO */ 51 - const attributeRegex = new RegExp(/\b\w+(?:\s+\w+)*::/, "g") /* TODO */ 52 39 53 40 function isSpecialEmbed(node: Paragraph): boolean { 54 41 if (node.children.length !== 2) return false ··· 135 122 const plugins: PluggableList = [] 136 123 137 124 plugins.push(() => { 138 - return (tree: Root, file: VFile) => { 125 + return (tree: Root) => { 139 126 const replacements: [RegExp, ReplaceFunction][] = [] 140 127 141 128 // Handle special embeds (audio, video, PDF)
+15 -8
quartz/plugins/types.ts
··· 4 4 import { QuartzComponent } from "../components/types" 5 5 import { FilePath } from "../util/path" 6 6 import { BuildCtx } from "../util/ctx" 7 - import DepGraph from "../depgraph" 7 + import { VFile } from "vfile" 8 8 9 9 export interface PluginTypes { 10 10 transformers: QuartzTransformerPluginInstance[] ··· 33 33 shouldPublish(ctx: BuildCtx, content: ProcessedContent): boolean 34 34 } 35 35 36 + export type ChangeEvent = { 37 + type: "add" | "change" | "delete" 38 + path: FilePath 39 + file?: VFile 40 + } 41 + 36 42 export type QuartzEmitterPlugin<Options extends OptionType = undefined> = ( 37 43 opts?: Options, 38 44 ) => QuartzEmitterPluginInstance 39 45 export type QuartzEmitterPluginInstance = { 40 46 name: string 41 - emit( 47 + emit: ( 48 + ctx: BuildCtx, 49 + content: ProcessedContent[], 50 + resources: StaticResources, 51 + ) => Promise<FilePath[]> | AsyncGenerator<FilePath> 52 + partialEmit?: ( 42 53 ctx: BuildCtx, 43 54 content: ProcessedContent[], 44 55 resources: StaticResources, 45 - ): Promise<FilePath[]> | AsyncGenerator<FilePath> 56 + changeEvents: ChangeEvent[], 57 + ) => Promise<FilePath[]> | AsyncGenerator<FilePath> | null 46 58 /** 47 59 * Returns the components (if any) that are used in rendering the page. 48 60 * This helps Quartz optimize the page by only including necessary resources 49 61 * for components that are actually used. 50 62 */ 51 63 getQuartzComponents?: (ctx: BuildCtx) => QuartzComponent[] 52 - getDependencyGraph?( 53 - ctx: BuildCtx, 54 - content: ProcessedContent[], 55 - resources: StaticResources, 56 - ): Promise<DepGraph<FilePath>> 57 64 externalResources?: ExternalResourcesFn 58 65 }
+3 -3
quartz/processors/emit.ts
··· 11 11 const perf = new PerfTimer() 12 12 const log = new QuartzLogger(ctx.argv.verbose) 13 13 14 - log.start(`Emitting output files`) 14 + log.start(`Emitting files`) 15 15 16 16 let emittedFiles = 0 17 17 const staticResources = getStaticResourcesFromPlugins(ctx) ··· 26 26 if (ctx.argv.verbose) { 27 27 console.log(`[emit:${emitter.name}] ${file}`) 28 28 } else { 29 - log.updateText(`Emitting output files: ${emitter.name} -> ${chalk.gray(file)}`) 29 + log.updateText(`${emitter.name} -> ${chalk.gray(file)}`) 30 30 } 31 31 } 32 32 } else { ··· 36 36 if (ctx.argv.verbose) { 37 37 console.log(`[emit:${emitter.name}] ${file}`) 38 38 } else { 39 - log.updateText(`Emitting output files: ${emitter.name} -> ${chalk.gray(file)}`) 39 + log.updateText(`${emitter.name} -> ${chalk.gray(file)}`) 40 40 } 41 41 } 42 42 }
+34 -12
quartz/processors/parse.ts
··· 7 7 import { MarkdownContent, ProcessedContent } from "../plugins/vfile" 8 8 import { PerfTimer } from "../util/perf" 9 9 import { read } from "to-vfile" 10 - import { FilePath, FullSlug, QUARTZ, slugifyFilePath } from "../util/path" 10 + import { FilePath, QUARTZ, slugifyFilePath } from "../util/path" 11 11 import path from "path" 12 12 import workerpool, { Promise as WorkerPromise } from "workerpool" 13 13 import { QuartzLogger } from "../util/log" 14 14 import { trace } from "../util/trace" 15 - import { BuildCtx } from "../util/ctx" 15 + import { BuildCtx, WorkerSerializableBuildCtx } from "../util/ctx" 16 + import chalk from "chalk" 16 17 17 18 export type QuartzMdProcessor = Processor<MDRoot, MDRoot, MDRoot> 18 19 export type QuartzHtmlProcessor = Processor<undefined, MDRoot, HTMLRoot> ··· 175 176 process.exit(1) 176 177 } 177 178 178 - const mdPromises: WorkerPromise<[MarkdownContent[], FullSlug[]]>[] = [] 179 + const serializableCtx: WorkerSerializableBuildCtx = { 180 + buildId: ctx.buildId, 181 + argv: ctx.argv, 182 + allSlugs: ctx.allSlugs, 183 + allFiles: ctx.allFiles, 184 + incremental: ctx.incremental, 185 + } 186 + 187 + const textToMarkdownPromises: WorkerPromise<MarkdownContent[]>[] = [] 188 + let processedFiles = 0 179 189 for (const chunk of chunks(fps, CHUNK_SIZE)) { 180 - mdPromises.push(pool.exec("parseMarkdown", [ctx.buildId, argv, chunk])) 190 + textToMarkdownPromises.push(pool.exec("parseMarkdown", [serializableCtx, chunk])) 181 191 } 182 - const mdResults: [MarkdownContent[], FullSlug[]][] = 183 - await WorkerPromise.all(mdPromises).catch(errorHandler) 192 + 193 + const mdResults: Array<MarkdownContent[]> = await Promise.all( 194 + textToMarkdownPromises.map(async (promise) => { 195 + const result = await promise 196 + processedFiles += result.length 197 + log.updateText(`text->markdown ${chalk.gray(`${processedFiles}/${fps.length}`)}`) 198 + return result 199 + }), 200 + ).catch(errorHandler) 184 201 185 - const childPromises: WorkerPromise<ProcessedContent[]>[] = [] 186 - for (const [_, extraSlugs] of mdResults) { 187 - ctx.allSlugs.push(...extraSlugs) 188 - } 202 + const markdownToHtmlPromises: WorkerPromise<ProcessedContent[]>[] = [] 203 + processedFiles = 0 189 204 for (const [mdChunk, _] of mdResults) { 190 - childPromises.push(pool.exec("processHtml", [ctx.buildId, argv, mdChunk, ctx.allSlugs])) 205 + markdownToHtmlPromises.push(pool.exec("processHtml", [serializableCtx, mdChunk])) 191 206 } 192 - const results: ProcessedContent[][] = await WorkerPromise.all(childPromises).catch(errorHandler) 207 + const results: ProcessedContent[][] = await Promise.all( 208 + markdownToHtmlPromises.map(async (promise) => { 209 + const result = await promise 210 + processedFiles += result.length 211 + log.updateText(`markdown->html ${chalk.gray(`${processedFiles}/${fps.length}`)}`) 212 + return result 213 + }), 214 + ).catch(errorHandler) 193 215 194 216 res = results.flat() 195 217 await pool.terminate()
+6 -2
quartz/util/ctx.ts
··· 1 1 import { QuartzConfig } from "../cfg" 2 - import { FullSlug } from "./path" 2 + import { FilePath, FullSlug } from "./path" 3 3 4 4 export interface Argv { 5 5 directory: string 6 6 verbose: boolean 7 7 output: string 8 8 serve: boolean 9 - fastRebuild: boolean 9 + watch: boolean 10 10 port: number 11 11 wsPort: number 12 12 remoteDevHost?: string ··· 18 18 argv: Argv 19 19 cfg: QuartzConfig 20 20 allSlugs: FullSlug[] 21 + allFiles: FilePath[] 22 + incremental: boolean 21 23 } 24 + 25 + export type WorkerSerializableBuildCtx = Omit<BuildCtx, "cfg">
+16 -3
quartz/util/log.ts
··· 1 + import truncate from "ansi-truncate" 1 2 import readline from "readline" 2 3 3 4 export class QuartzLogger { 4 5 verbose: boolean 5 6 private spinnerInterval: NodeJS.Timeout | undefined 6 7 private spinnerText: string = "" 8 + private updateSuffix: string = "" 7 9 private spinnerIndex: number = 0 8 10 private readonly spinnerChars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] 9 11 10 12 constructor(verbose: boolean) { 11 - this.verbose = verbose 13 + const isInteractiveTerminal = 14 + process.stdout.isTTY && process.env.TERM !== "dumb" && !process.env.CI 15 + this.verbose = verbose || !isInteractiveTerminal 12 16 } 13 17 14 18 start(text: string) { 15 19 this.spinnerText = text 20 + 16 21 if (this.verbose) { 17 22 console.log(text) 18 23 } else { ··· 20 25 this.spinnerInterval = setInterval(() => { 21 26 readline.clearLine(process.stdout, 0) 22 27 readline.cursorTo(process.stdout, 0) 23 - process.stdout.write(`${this.spinnerChars[this.spinnerIndex]} ${this.spinnerText}`) 28 + 29 + const columns = process.stdout.columns || 80 30 + let output = `${this.spinnerChars[this.spinnerIndex]} ${this.spinnerText}` 31 + if (this.updateSuffix) { 32 + output += `: ${this.updateSuffix}` 33 + } 34 + 35 + const truncated = truncate(output, columns) 36 + process.stdout.write(truncated) 24 37 this.spinnerIndex = (this.spinnerIndex + 1) % this.spinnerChars.length 25 38 }, 20) 26 39 } 27 40 } 28 41 29 42 updateText(text: string) { 30 - this.spinnerText = text 43 + this.updateSuffix = text 31 44 } 32 45 33 46 end(text?: string) {
+1 -1
quartz/util/path.ts
··· 260 260 return s === suffix || s.endsWith("/" + suffix) 261 261 } 262 262 263 - function trimSuffix(s: string, suffix: string): string { 263 + export function trimSuffix(s: string, suffix: string): string { 264 264 if (endsWith(s, suffix)) { 265 265 s = s.slice(0, -suffix.length) 266 266 }
+8 -19
quartz/worker.ts
··· 1 1 import sourceMapSupport from "source-map-support" 2 2 sourceMapSupport.install(options) 3 3 import cfg from "../quartz.config" 4 - import { Argv, BuildCtx } from "./util/ctx" 5 - import { FilePath, FullSlug } from "./util/path" 4 + import { BuildCtx, WorkerSerializableBuildCtx } from "./util/ctx" 5 + import { FilePath } from "./util/path" 6 6 import { 7 7 createFileParser, 8 8 createHtmlProcessor, ··· 14 14 15 15 // only called from worker thread 16 16 export async function parseMarkdown( 17 - buildId: string, 18 - argv: Argv, 17 + partialCtx: WorkerSerializableBuildCtx, 19 18 fps: FilePath[], 20 - ): Promise<[MarkdownContent[], FullSlug[]]> { 21 - // this is a hack 22 - // we assume markdown parsers can add to `allSlugs`, 23 - // but don't actually use them 24 - const allSlugs: FullSlug[] = [] 19 + ): Promise<MarkdownContent[]> { 25 20 const ctx: BuildCtx = { 26 - buildId, 21 + ...partialCtx, 27 22 cfg, 28 - argv, 29 - allSlugs, 30 23 } 31 - return [await createFileParser(ctx, fps)(createMdProcessor(ctx)), allSlugs] 24 + return await createFileParser(ctx, fps)(createMdProcessor(ctx)) 32 25 } 33 26 34 27 // only called from worker thread 35 28 export function processHtml( 36 - buildId: string, 37 - argv: Argv, 29 + partialCtx: WorkerSerializableBuildCtx, 38 30 mds: MarkdownContent[], 39 - allSlugs: FullSlug[], 40 31 ): Promise<ProcessedContent[]> { 41 32 const ctx: BuildCtx = { 42 - buildId, 33 + ...partialCtx, 43 34 cfg, 44 - argv, 45 - allSlugs, 46 35 } 47 36 return createMarkdownParser(ctx, mds)(createHtmlProcessor(ctx)) 48 37 }
+2
tsconfig.json
··· 11 11 "skipLibCheck": true, 12 12 "allowSyntheticDefaultImports": true, 13 13 "forceConsistentCasingInFileNames": true, 14 + "noUnusedLocals": true, 15 + "noUnusedParameters": true, 14 16 "esModuleInterop": true, 15 17 "jsx": "react-jsx", 16 18 "jsxImportSource": "preact"