feat(experimental): partial rebuilds (#716)
2 files added
15 files modified
| | |
| | | "docs": "npx quartz build --serve -d docs", |
| | | "check": "tsc --noEmit && npx prettier . --check", |
| | | "format": "npx prettier . --write", |
| | | "test": "tsx ./quartz/util/path.test.ts", |
| | | "test": "tsx ./quartz/util/path.test.ts && tsx ./quartz/depgraph.test.ts", |
| | | "profile": "0x -D prof ./quartz/bootstrap-cli.mjs build --concurrency=1" |
| | | }, |
| | | "engines": { |
| | |
| | | import { trace } from "./util/trace" |
| | | import { options } from "./util/sourcemap" |
| | | import { Mutex } from "async-mutex" |
| | | import DepGraph from "./depgraph" |
| | | import { getStaticResourcesFromPlugins } from "./plugins" |
| | | |
| | | type Dependencies = Record<string, DepGraph<FilePath> | null> |
| | | |
| | | type BuildData = { |
| | | ctx: BuildCtx |
| | |
| | | toRebuild: Set<FilePath> |
| | | toRemove: Set<FilePath> |
| | | lastBuildMs: number |
| | | dependencies: Dependencies |
| | | } |
| | | |
| | | type FileEvent = "add" | "change" | "delete" |
| | | |
| | | async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) { |
| | | const ctx: BuildCtx = { |
| | | argv, |
| | |
| | | |
| | | const parsedFiles = await parseMarkdown(ctx, filePaths) |
| | | const filteredContent = filterContent(ctx, parsedFiles) |
| | | |
| | | const dependencies: Record<string, DepGraph<FilePath> | null> = {} |
| | | |
| | | // Only build dependency graphs if we're doing a fast rebuild |
| | | if (argv.fastRebuild) { |
| | | const staticResources = getStaticResourcesFromPlugins(ctx) |
| | | for (const emitter of cfg.plugins.emitters) { |
| | | dependencies[emitter.name] = |
| | | (await emitter.getDependencyGraph?.(ctx, filteredContent, staticResources)) ?? null |
| | | } |
| | | } |
| | | |
| | | await emitContent(ctx, filteredContent) |
| | | console.log(chalk.green(`Done processing ${fps.length} files in ${perf.timeSince()}`)) |
| | | release() |
| | | |
| | | if (argv.serve) { |
| | | return startServing(ctx, mut, parsedFiles, clientRefresh) |
| | | return startServing(ctx, mut, parsedFiles, clientRefresh, dependencies) |
| | | } |
| | | } |
| | | |
| | |
| | | mut: Mutex, |
| | | initialContent: ProcessedContent[], |
| | | clientRefresh: () => void, |
| | | dependencies: Dependencies, // emitter name: dep graph |
| | | ) { |
| | | const { argv } = ctx |
| | | |
| | | // cache file parse results |
| | | const contentMap = new Map<FilePath, ProcessedContent>() |
| | | for (const content of initialContent) { |
| | | const [_tree, vfile] = content |
| | |
| | | const buildData: BuildData = { |
| | | ctx, |
| | | mut, |
| | | dependencies, |
| | | contentMap, |
| | | ignored: await isGitIgnored(), |
| | | initialSlugs: ctx.allSlugs, |
| | |
| | | ignoreInitial: true, |
| | | }) |
| | | |
| | | const buildFromEntry = argv.fastRebuild ? partialRebuildFromEntrypoint : rebuildFromEntrypoint |
| | | watcher |
| | | .on("add", (fp) => rebuildFromEntrypoint(fp, "add", clientRefresh, buildData)) |
| | | .on("change", (fp) => rebuildFromEntrypoint(fp, "change", clientRefresh, buildData)) |
| | | .on("unlink", (fp) => rebuildFromEntrypoint(fp, "delete", clientRefresh, buildData)) |
| | | .on("add", (fp) => buildFromEntry(fp, "add", clientRefresh, buildData)) |
| | | .on("change", (fp) => buildFromEntry(fp, "change", clientRefresh, buildData)) |
| | | .on("unlink", (fp) => buildFromEntry(fp, "delete", clientRefresh, buildData)) |
| | | |
| | | return async () => { |
| | | await watcher.close() |
| | | } |
| | | } |
| | | |
| | | async function partialRebuildFromEntrypoint( |
| | | filepath: string, |
| | | action: FileEvent, |
| | | clientRefresh: () => void, |
| | | buildData: BuildData, // note: this function mutates buildData |
| | | ) { |
| | | const { ctx, ignored, dependencies, contentMap, mut, toRemove } = buildData |
| | | const { argv, cfg } = ctx |
| | | |
| | | // don't do anything for gitignored files |
| | | if (ignored(filepath)) { |
| | | return |
| | | } |
| | | |
| | | const buildStart = new Date().getTime() |
| | | buildData.lastBuildMs = buildStart |
| | | const release = await mut.acquire() |
| | | if (buildData.lastBuildMs > buildStart) { |
| | | release() |
| | | return |
| | | } |
| | | |
| | | const perf = new PerfTimer() |
| | | console.log(chalk.yellow("Detected change, rebuilding...")) |
| | | |
| | | // UPDATE DEP GRAPH |
| | | const fp = joinSegments(argv.directory, toPosixPath(filepath)) as FilePath |
| | | |
| | | const staticResources = getStaticResourcesFromPlugins(ctx) |
| | | let processedFiles: ProcessedContent[] = [] |
| | | |
| | | switch (action) { |
| | | case "add": |
| | | // add to cache when new file is added |
| | | processedFiles = await parseMarkdown(ctx, [fp]) |
| | | processedFiles.forEach(([tree, vfile]) => contentMap.set(vfile.data.filePath!, [tree, vfile])) |
| | | |
| | | // update the dep graph by asking all emitters whether they depend on this file |
| | | for (const emitter of cfg.plugins.emitters) { |
| | | const emitterGraph = |
| | | (await emitter.getDependencyGraph?.(ctx, processedFiles, staticResources)) ?? null |
| | | |
| | | // emmiter may not define a dependency graph. nothing to update if so |
| | | if (emitterGraph) { |
| | | dependencies[emitter.name]?.updateIncomingEdgesForNode(emitterGraph, fp) |
| | | } |
| | | } |
| | | break |
| | | case "change": |
| | | // invalidate cache when file is changed |
| | | processedFiles = await parseMarkdown(ctx, [fp]) |
| | | processedFiles.forEach(([tree, vfile]) => contentMap.set(vfile.data.filePath!, [tree, vfile])) |
| | | |
| | | // only content files can have added/removed dependencies because of transclusions |
| | | if (path.extname(fp) === ".md") { |
| | | for (const emitter of cfg.plugins.emitters) { |
| | | // get new dependencies from all emitters for this file |
| | | const emitterGraph = |
| | | (await emitter.getDependencyGraph?.(ctx, processedFiles, staticResources)) ?? null |
| | | |
| | | // emmiter may not define a dependency graph. nothing to update if so |
| | | if (emitterGraph) { |
| | | // merge the new dependencies into the dep graph |
| | | dependencies[emitter.name]?.updateIncomingEdgesForNode(emitterGraph, fp) |
| | | } |
| | | } |
| | | } |
| | | break |
| | | case "delete": |
| | | toRemove.add(fp) |
| | | break |
| | | } |
| | | |
| | | if (argv.verbose) { |
| | | console.log(`Updated dependency graphs in ${perf.timeSince()}`) |
| | | } |
| | | |
| | | // EMIT |
| | | perf.addEvent("rebuild") |
| | | let emittedFiles = 0 |
| | | const destinationsToDelete = new Set<FilePath>() |
| | | |
| | | for (const emitter of cfg.plugins.emitters) { |
| | | const depGraph = dependencies[emitter.name] |
| | | |
| | | // emitter hasn't defined a dependency graph. call it with all processed files |
| | | if (depGraph === null) { |
| | | if (argv.verbose) { |
| | | console.log( |
| | | `Emitter ${emitter.name} doesn't define a dependency graph. Calling it with all files...`, |
| | | ) |
| | | } |
| | | |
| | | const files = [...contentMap.values()].filter( |
| | | ([_node, vfile]) => !toRemove.has(vfile.data.filePath!), |
| | | ) |
| | | |
| | | const emittedFps = await emitter.emit(ctx, files, staticResources) |
| | | |
| | | if (ctx.argv.verbose) { |
| | | for (const file of emittedFps) { |
| | | console.log(`[emit:${emitter.name}] ${file}`) |
| | | } |
| | | } |
| | | |
| | | emittedFiles += emittedFps.length |
| | | continue |
| | | } |
| | | |
| | | // only call the emitter if it uses this file |
| | | if (depGraph.hasNode(fp)) { |
| | | // re-emit using all files that are needed for the downstream of this file |
| | | // eg. for ContentIndex, the dep graph could be: |
| | | // a.md --> contentIndex.json |
| | | // b.md ------^ |
| | | // |
| | | // if a.md changes, we need to re-emit contentIndex.json, |
| | | // and supply [a.md, b.md] to the emitter |
| | | const upstreams = [...depGraph.getLeafNodeAncestors(fp)] as FilePath[] |
| | | |
| | | if (action === "delete" && upstreams.length === 1) { |
| | | // if there's only one upstream, the destination is solely dependent on this file |
| | | destinationsToDelete.add(upstreams[0]) |
| | | } |
| | | |
| | | const upstreamContent = upstreams |
| | | // filter out non-markdown files |
| | | .filter((file) => contentMap.has(file)) |
| | | // if file was deleted, don't give it to the emitter |
| | | .filter((file) => !toRemove.has(file)) |
| | | .map((file) => contentMap.get(file)!) |
| | | |
| | | const emittedFps = await emitter.emit(ctx, upstreamContent, staticResources) |
| | | |
| | | if (ctx.argv.verbose) { |
| | | for (const file of emittedFps) { |
| | | console.log(`[emit:${emitter.name}] ${file}`) |
| | | } |
| | | } |
| | | |
| | | emittedFiles += emittedFps.length |
| | | } |
| | | } |
| | | |
| | | console.log(`Emitted ${emittedFiles} files to \`${argv.output}\` in ${perf.timeSince("rebuild")}`) |
| | | |
| | | // CLEANUP |
| | | // delete files that are solely dependent on this file |
| | | await rimraf([...destinationsToDelete]) |
| | | for (const file of toRemove) { |
| | | // remove from cache |
| | | contentMap.delete(file) |
| | | // remove the node from dependency graphs |
| | | Object.values(dependencies).forEach((depGraph) => depGraph?.removeNode(file)) |
| | | } |
| | | |
| | | toRemove.clear() |
| | | release() |
| | | clientRefresh() |
| | | } |
| | | |
| | | async function rebuildFromEntrypoint( |
| | | fp: string, |
| | | action: "add" | "change" | "delete", |
| | | action: FileEvent, |
| | | clientRefresh: () => void, |
| | | buildData: BuildData, // note: this function mutates buildData |
| | | ) { |
| | |
| | | default: false, |
| | | describe: "run a local server to live-preview your Quartz", |
| | | }, |
| | | fastRebuild: { |
| | | boolean: true, |
| | | default: false, |
| | | describe: "[experimental] rebuild only the changed files", |
| | | }, |
| | | baseDir: { |
| | | string: true, |
| | | default: "", |
| New file |
| | |
| | | import test, { describe } from "node:test" |
| | | import DepGraph from "./depgraph" |
| | | import assert from "node:assert" |
| | | |
| | | describe("DepGraph", () => { |
| | | test("getLeafNodes", () => { |
| | | const graph = new DepGraph<string>() |
| | | graph.addEdge("A", "B") |
| | | graph.addEdge("B", "C") |
| | | graph.addEdge("D", "C") |
| | | assert.deepStrictEqual(graph.getLeafNodes("A"), new Set(["C"])) |
| | | assert.deepStrictEqual(graph.getLeafNodes("B"), new Set(["C"])) |
| | | assert.deepStrictEqual(graph.getLeafNodes("C"), new Set(["C"])) |
| | | assert.deepStrictEqual(graph.getLeafNodes("D"), new Set(["C"])) |
| | | }) |
| | | |
| | | describe("getLeafNodeAncestors", () => { |
| | | test("gets correct ancestors in a graph without cycles", () => { |
| | | const graph = new DepGraph<string>() |
| | | graph.addEdge("A", "B") |
| | | graph.addEdge("B", "C") |
| | | graph.addEdge("D", "B") |
| | | assert.deepStrictEqual(graph.getLeafNodeAncestors("A"), new Set(["A", "B", "D"])) |
| | | assert.deepStrictEqual(graph.getLeafNodeAncestors("B"), new Set(["A", "B", "D"])) |
| | | assert.deepStrictEqual(graph.getLeafNodeAncestors("C"), new Set(["A", "B", "D"])) |
| | | assert.deepStrictEqual(graph.getLeafNodeAncestors("D"), new Set(["A", "B", "D"])) |
| | | }) |
| | | |
| | | test("gets correct ancestors in a graph with cycles", () => { |
| | | const graph = new DepGraph<string>() |
| | | graph.addEdge("A", "B") |
| | | graph.addEdge("B", "C") |
| | | graph.addEdge("C", "A") |
| | | graph.addEdge("C", "D") |
| | | assert.deepStrictEqual(graph.getLeafNodeAncestors("A"), new Set(["A", "B", "C"])) |
| | | assert.deepStrictEqual(graph.getLeafNodeAncestors("B"), new Set(["A", "B", "C"])) |
| | | assert.deepStrictEqual(graph.getLeafNodeAncestors("C"), new Set(["A", "B", "C"])) |
| | | assert.deepStrictEqual(graph.getLeafNodeAncestors("D"), new Set(["A", "B", "C"])) |
| | | }) |
| | | }) |
| | | |
| | | describe("updateIncomingEdgesForNode", () => { |
| | | test("merges when node exists", () => { |
| | | // A.md -> B.md -> B.html |
| | | const graph = new DepGraph<string>() |
| | | graph.addEdge("A.md", "B.md") |
| | | graph.addEdge("B.md", "B.html") |
| | | |
| | | // B.md is edited so it removes the A.md transclusion |
| | | // and adds C.md transclusion |
| | | // C.md -> B.md |
| | | const other = new DepGraph<string>() |
| | | other.addEdge("C.md", "B.md") |
| | | other.addEdge("B.md", "B.html") |
| | | |
| | | // A.md -> B.md removed, C.md -> B.md added |
| | | // C.md -> B.md -> B.html |
| | | graph.updateIncomingEdgesForNode(other, "B.md") |
| | | |
| | | const expected = { |
| | | nodes: ["A.md", "B.md", "B.html", "C.md"], |
| | | edges: [ |
| | | ["B.md", "B.html"], |
| | | ["C.md", "B.md"], |
| | | ], |
| | | } |
| | | |
| | | assert.deepStrictEqual(graph.export(), expected) |
| | | }) |
| | | |
| | | test("adds node if it does not exist", () => { |
| | | // A.md -> B.md |
| | | const graph = new DepGraph<string>() |
| | | graph.addEdge("A.md", "B.md") |
| | | |
| | | // Add a new file C.md that transcludes B.md |
| | | // B.md -> C.md |
| | | const other = new DepGraph<string>() |
| | | other.addEdge("B.md", "C.md") |
| | | |
| | | // B.md -> C.md added |
| | | // A.md -> B.md -> C.md |
| | | graph.updateIncomingEdgesForNode(other, "C.md") |
| | | |
| | | const expected = { |
| | | nodes: ["A.md", "B.md", "C.md"], |
| | | edges: [ |
| | | ["A.md", "B.md"], |
| | | ["B.md", "C.md"], |
| | | ], |
| | | } |
| | | |
| | | assert.deepStrictEqual(graph.export(), expected) |
| | | }) |
| | | }) |
| | | }) |
| New file |
| | |
| | | export default class DepGraph<T> { |
| | | // node: incoming and outgoing edges |
| | | _graph = new Map<T, { incoming: Set<T>; outgoing: Set<T> }>() |
| | | |
| | | constructor() { |
| | | this._graph = new Map() |
| | | } |
| | | |
| | | export(): Object { |
| | | return { |
| | | nodes: this.nodes, |
| | | edges: this.edges, |
| | | } |
| | | } |
| | | |
| | | toString(): string { |
| | | return JSON.stringify(this.export(), null, 2) |
| | | } |
| | | |
| | | // BASIC GRAPH OPERATIONS |
| | | |
| | | get nodes(): T[] { |
| | | return Array.from(this._graph.keys()) |
| | | } |
| | | |
| | | get edges(): [T, T][] { |
| | | let edges: [T, T][] = [] |
| | | this.forEachEdge((edge) => edges.push(edge)) |
| | | return edges |
| | | } |
| | | |
| | | hasNode(node: T): boolean { |
| | | return this._graph.has(node) |
| | | } |
| | | |
| | | addNode(node: T): void { |
| | | if (!this._graph.has(node)) { |
| | | this._graph.set(node, { incoming: new Set(), outgoing: new Set() }) |
| | | } |
| | | } |
| | | |
| | | removeNode(node: T): void { |
| | | if (this._graph.has(node)) { |
| | | this._graph.delete(node) |
| | | } |
| | | } |
| | | |
| | | hasEdge(from: T, to: T): boolean { |
| | | return Boolean(this._graph.get(from)?.outgoing.has(to)) |
| | | } |
| | | |
| | | addEdge(from: T, to: T): void { |
| | | this.addNode(from) |
| | | this.addNode(to) |
| | | |
| | | this._graph.get(from)!.outgoing.add(to) |
| | | this._graph.get(to)!.incoming.add(from) |
| | | } |
| | | |
| | | removeEdge(from: T, to: T): void { |
| | | if (this._graph.has(from) && this._graph.has(to)) { |
| | | this._graph.get(from)!.outgoing.delete(to) |
| | | this._graph.get(to)!.incoming.delete(from) |
| | | } |
| | | } |
| | | |
| | | // returns -1 if node does not exist |
| | | outDegree(node: T): number { |
| | | return this.hasNode(node) ? this._graph.get(node)!.outgoing.size : -1 |
| | | } |
| | | |
| | | // returns -1 if node does not exist |
| | | inDegree(node: T): number { |
| | | return this.hasNode(node) ? this._graph.get(node)!.incoming.size : -1 |
| | | } |
| | | |
| | | forEachOutNeighbor(node: T, callback: (neighbor: T) => void): void { |
| | | this._graph.get(node)?.outgoing.forEach(callback) |
| | | } |
| | | |
| | | forEachInNeighbor(node: T, callback: (neighbor: T) => void): void { |
| | | this._graph.get(node)?.incoming.forEach(callback) |
| | | } |
| | | |
| | | forEachEdge(callback: (edge: [T, T]) => void): void { |
| | | for (const [source, { outgoing }] of this._graph.entries()) { |
| | | for (const target of outgoing) { |
| | | callback([source, target]) |
| | | } |
| | | } |
| | | } |
| | | |
| | | // DEPENDENCY ALGORITHMS |
| | | |
| | | // For the node provided: |
| | | // If node does not exist, add it |
| | | // If an incoming edge was added in other, it is added in this graph |
| | | // If an incoming edge was deleted in other, it is deleted in this graph |
| | | updateIncomingEdgesForNode(other: DepGraph<T>, node: T): void { |
| | | this.addNode(node) |
| | | |
| | | // Add edge if it is present in other |
| | | other.forEachInNeighbor(node, (neighbor) => { |
| | | this.addEdge(neighbor, node) |
| | | }) |
| | | |
| | | // For node provided, remove incoming edge if it is absent in other |
| | | this.forEachEdge(([source, target]) => { |
| | | if (target === node && !other.hasEdge(source, target)) { |
| | | this.removeEdge(source, target) |
| | | } |
| | | }) |
| | | } |
| | | |
| | | // Get all leaf nodes (i.e. destination paths) reachable from the node provided |
| | | // Eg. if the graph is A -> B -> C |
| | | // D ---^ |
| | | // and the node is B, this function returns [C] |
| | | getLeafNodes(node: T): Set<T> { |
| | | let stack: T[] = [node] |
| | | let visited = new Set<T>() |
| | | let leafNodes = new Set<T>() |
| | | |
| | | // DFS |
| | | while (stack.length > 0) { |
| | | let node = stack.pop()! |
| | | |
| | | // If the node is already visited, skip it |
| | | if (visited.has(node)) { |
| | | continue |
| | | } |
| | | visited.add(node) |
| | | |
| | | // Check if the node is a leaf node (i.e. destination path) |
| | | if (this.outDegree(node) === 0) { |
| | | leafNodes.add(node) |
| | | } |
| | | |
| | | // Add all unvisited neighbors to the stack |
| | | this.forEachOutNeighbor(node, (neighbor) => { |
| | | if (!visited.has(neighbor)) { |
| | | stack.push(neighbor) |
| | | } |
| | | }) |
| | | } |
| | | |
| | | return leafNodes |
| | | } |
| | | |
| | | // Get all ancestors of the leaf nodes reachable from the node provided |
| | | // Eg. if the graph is A -> B -> C |
| | | // D ---^ |
| | | // and the node is B, this function returns [A, B, D] |
| | | getLeafNodeAncestors(node: T): Set<T> { |
| | | const leafNodes = this.getLeafNodes(node) |
| | | let visited = new Set<T>() |
| | | let upstreamNodes = new Set<T>() |
| | | |
| | | // Backwards DFS for each leaf node |
| | | leafNodes.forEach((leafNode) => { |
| | | let stack: T[] = [leafNode] |
| | | |
| | | while (stack.length > 0) { |
| | | let node = stack.pop()! |
| | | |
| | | if (visited.has(node)) { |
| | | continue |
| | | } |
| | | visited.add(node) |
| | | // Add node if it's not a leaf node (i.e. destination path) |
| | | // Assumes destination file cannot depend on another destination file |
| | | if (this.outDegree(node) !== 0) { |
| | | upstreamNodes.add(node) |
| | | } |
| | | |
| | | // Add all unvisited parents to the stack |
| | | this.forEachInNeighbor(node, (parentNode) => { |
| | | if (!visited.has(parentNode)) { |
| | | stack.push(parentNode) |
| | | } |
| | | }) |
| | | } |
| | | }) |
| | | |
| | | return upstreamNodes |
| | | } |
| | | } |
| | |
| | | import { defaultProcessedContent } from "../vfile" |
| | | import { write } from "./helpers" |
| | | import { i18n } from "../../i18n" |
| | | import DepGraph from "../../depgraph" |
| | | |
| | | export const NotFoundPage: QuartzEmitterPlugin = () => { |
| | | const opts: FullPageLayout = { |
| | |
| | | getQuartzComponents() { |
| | | return [Head, Body, pageBody, Footer] |
| | | }, |
| | | async getDependencyGraph(_ctx, _content, _resources) { |
| | | return new DepGraph<FilePath>() |
| | | }, |
| | | async emit(ctx, _content, resources): Promise<FilePath[]> { |
| | | const cfg = ctx.cfg.configuration |
| | | const slug = "404" as FullSlug |
| | |
| | | import { QuartzEmitterPlugin } from "../types" |
| | | import path from "path" |
| | | import { write } from "./helpers" |
| | | import DepGraph from "../../depgraph" |
| | | |
| | | export const AliasRedirects: QuartzEmitterPlugin = () => ({ |
| | | name: "AliasRedirects", |
| | | getQuartzComponents() { |
| | | return [] |
| | | }, |
| | | async getDependencyGraph(_ctx, _content, _resources) { |
| | | // TODO implement |
| | | return new DepGraph<FilePath>() |
| | | }, |
| | | async emit(ctx, content, _resources): Promise<FilePath[]> { |
| | | const { argv } = ctx |
| | | const fps: FilePath[] = [] |
| | |
| | | import path from "path" |
| | | import fs from "fs" |
| | | import { glob } from "../../util/glob" |
| | | import DepGraph from "../../depgraph" |
| | | |
| | | export const Assets: QuartzEmitterPlugin = () => { |
| | | return { |
| | |
| | | getQuartzComponents() { |
| | | return [] |
| | | }, |
| | | async getDependencyGraph(ctx, _content, _resources) { |
| | | const { argv, cfg } = ctx |
| | | const graph = new DepGraph<FilePath>() |
| | | |
| | | const fps = await glob("**", argv.directory, ["**/*.md", ...cfg.configuration.ignorePatterns]) |
| | | |
| | | for (const fp of fps) { |
| | | const ext = path.extname(fp) |
| | | const src = joinSegments(argv.directory, fp) as FilePath |
| | | const name = (slugifyFilePath(fp as FilePath, true) + ext) as FilePath |
| | | |
| | | const dest = joinSegments(argv.output, name) as FilePath |
| | | |
| | | graph.addEdge(src, dest) |
| | | } |
| | | |
| | | return graph |
| | | }, |
| | | async emit({ argv, cfg }, _content, _resources): Promise<FilePath[]> { |
| | | // glob all non MD/MDX/HTML files in content folder and copy it over |
| | | const assetsPath = argv.output |
| | |
| | | import { QuartzEmitterPlugin } from "../types" |
| | | import fs from "fs" |
| | | import chalk from "chalk" |
| | | import DepGraph from "../../depgraph" |
| | | |
| | | export function extractDomainFromBaseUrl(baseUrl: string) { |
| | | const url = new URL(`https://${baseUrl}`) |
| | |
| | | getQuartzComponents() { |
| | | return [] |
| | | }, |
| | | async getDependencyGraph(_ctx, _content, _resources) { |
| | | return new DepGraph<FilePath>() |
| | | }, |
| | | async emit({ argv, cfg }, _content, _resources): Promise<FilePath[]> { |
| | | if (!cfg.configuration.baseUrl) { |
| | | console.warn(chalk.yellow("CNAME emitter requires `baseUrl` to be set in your configuration")) |
| | |
| | | import { Features, transform } from "lightningcss" |
| | | import { transform as transpile } from "esbuild" |
| | | import { write } from "./helpers" |
| | | import DepGraph from "../../depgraph" |
| | | |
| | | type ComponentResources = { |
| | | css: string[] |
| | |
| | | contentType: "inline", |
| | | script: ` |
| | | const socket = new WebSocket('${wsUrl}') |
| | | socket.addEventListener('message', () => document.location.reload()) |
| | | // reload(true) ensures resources like images and scripts are fetched again in firefox |
| | | socket.addEventListener('message', () => document.location.reload(true)) |
| | | `, |
| | | }) |
| | | } |
| | |
| | | getQuartzComponents() { |
| | | return [] |
| | | }, |
| | | async getDependencyGraph(ctx, content, _resources) { |
| | | // This emitter adds static resources to the `resources` parameter. One |
| | | // important resource this emitter adds is the code to start a websocket |
| | | // connection and listen to rebuild messages, which triggers a page reload. |
| | | // The resources parameter with the reload logic is later used by the |
| | | // ContentPage emitter while creating the final html page. In order for |
| | | // the reload logic to be included, and so for partial rebuilds to work, |
| | | // we need to run this emitter for all markdown files. |
| | | const graph = new DepGraph<FilePath>() |
| | | |
| | | for (const [_tree, file] of content) { |
| | | const sourcePath = file.data.filePath! |
| | | const slug = file.data.slug! |
| | | graph.addEdge(sourcePath, joinSegments(ctx.argv.output, slug + ".html") as FilePath) |
| | | } |
| | | |
| | | return graph |
| | | }, |
| | | async emit(ctx, _content, resources): Promise<FilePath[]> { |
| | | const promises: Promise<FilePath>[] = [] |
| | | const cfg = ctx.cfg.configuration |
| | |
| | | import { toHtml } from "hast-util-to-html" |
| | | import { write } from "./helpers" |
| | | import { i18n } from "../../i18n" |
| | | import DepGraph from "../../depgraph" |
| | | |
| | | export type ContentIndex = Map<FullSlug, ContentDetails> |
| | | export type ContentDetails = { |
| | |
| | | opts = { ...defaultOptions, ...opts } |
| | | return { |
| | | name: "ContentIndex", |
| | | async getDependencyGraph(ctx, content, _resources) { |
| | | const graph = new DepGraph<FilePath>() |
| | | |
| | | for (const [_tree, file] of content) { |
| | | const sourcePath = file.data.filePath! |
| | | |
| | | graph.addEdge( |
| | | sourcePath, |
| | | joinSegments(ctx.argv.output, "static/contentIndex.json") as FilePath, |
| | | ) |
| | | if (opts?.enableSiteMap) { |
| | | graph.addEdge(sourcePath, joinSegments(ctx.argv.output, "sitemap.xml") as FilePath) |
| | | } |
| | | if (opts?.enableRSS) { |
| | | graph.addEdge(sourcePath, joinSegments(ctx.argv.output, "index.xml") as FilePath) |
| | | } |
| | | } |
| | | |
| | | return graph |
| | | }, |
| | | async emit(ctx, content, _resources) { |
| | | const cfg = ctx.cfg.configuration |
| | | const emitted: FilePath[] = [] |
| | |
| | | import BodyConstructor from "../../components/Body" |
| | | import { pageResources, renderPage } from "../../components/renderPage" |
| | | import { FullPageLayout } from "../../cfg" |
| | | import { FilePath, pathToRoot } from "../../util/path" |
| | | import { FilePath, joinSegments, pathToRoot } from "../../util/path" |
| | | import { defaultContentPageLayout, sharedPageComponents } from "../../../quartz.layout" |
| | | import { Content } from "../../components" |
| | | import chalk from "chalk" |
| | | import { write } from "./helpers" |
| | | import DepGraph from "../../depgraph" |
| | | |
| | | export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpts) => { |
| | | const opts: FullPageLayout = { |
| | |
| | | getQuartzComponents() { |
| | | return [Head, Header, Body, ...header, ...beforeBody, pageBody, ...left, ...right, Footer] |
| | | }, |
| | | async getDependencyGraph(ctx, content, _resources) { |
| | | // TODO handle transclusions |
| | | const graph = new DepGraph<FilePath>() |
| | | |
| | | for (const [_tree, file] of content) { |
| | | const sourcePath = file.data.filePath! |
| | | const slug = file.data.slug! |
| | | graph.addEdge(sourcePath, joinSegments(ctx.argv.output, slug + ".html") as FilePath) |
| | | } |
| | | |
| | | return graph |
| | | }, |
| | | async emit(ctx, content, resources): Promise<FilePath[]> { |
| | | const cfg = ctx.cfg.configuration |
| | | const fps: FilePath[] = [] |
| | |
| | | fps.push(fp) |
| | | } |
| | | |
| | | if (!containsIndex) { |
| | | if (!containsIndex && !ctx.argv.fastRebuild) { |
| | | console.log( |
| | | chalk.yellow( |
| | | `\nWarning: you seem to be missing an \`index.md\` home page file at the root of your \`${ctx.argv.directory}\` folder. This may cause errors when deploying.`, |
| | |
| | | import { FolderContent } from "../../components" |
| | | import { write } from "./helpers" |
| | | import { i18n } from "../../i18n" |
| | | import DepGraph from "../../depgraph" |
| | | |
| | | export const FolderPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpts) => { |
| | | const opts: FullPageLayout = { |
| | |
| | | getQuartzComponents() { |
| | | return [Head, Header, Body, ...header, ...beforeBody, pageBody, ...left, ...right, Footer] |
| | | }, |
| | | async getDependencyGraph(ctx, content, _resources) { |
| | | // Example graph: |
| | | // nested/file.md --> nested/file.html |
| | | // \-------> nested/index.html |
| | | // TODO implement |
| | | return new DepGraph<FilePath>() |
| | | }, |
| | | async emit(ctx, content, resources): Promise<FilePath[]> { |
| | | const fps: FilePath[] = [] |
| | | const allFiles = content.map((c) => c[1].data) |
| | |
| | | import { QuartzEmitterPlugin } from "../types" |
| | | import fs from "fs" |
| | | import { glob } from "../../util/glob" |
| | | import DepGraph from "../../depgraph" |
| | | |
| | | export const Static: QuartzEmitterPlugin = () => ({ |
| | | name: "Static", |
| | | getQuartzComponents() { |
| | | return [] |
| | | }, |
| | | async getDependencyGraph({ argv, cfg }, _content, _resources) { |
| | | const graph = new DepGraph<FilePath>() |
| | | |
| | | const staticPath = joinSegments(QUARTZ, "static") |
| | | const fps = await glob("**", staticPath, cfg.configuration.ignorePatterns) |
| | | for (const fp of fps) { |
| | | graph.addEdge( |
| | | joinSegments("static", fp) as FilePath, |
| | | joinSegments(argv.output, "static", fp) as FilePath, |
| | | ) |
| | | } |
| | | |
| | | return graph |
| | | }, |
| | | async emit({ argv, cfg }, _content, _resources): Promise<FilePath[]> { |
| | | const staticPath = joinSegments(QUARTZ, "static") |
| | | const fps = await glob("**", staticPath, cfg.configuration.ignorePatterns) |
| | |
| | | import { TagContent } from "../../components" |
| | | import { write } from "./helpers" |
| | | import { i18n } from "../../i18n" |
| | | import DepGraph from "../../depgraph" |
| | | |
| | | export const TagPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpts) => { |
| | | const opts: FullPageLayout = { |
| | |
| | | getQuartzComponents() { |
| | | return [Head, Header, Body, ...header, ...beforeBody, pageBody, ...left, ...right, Footer] |
| | | }, |
| | | async getDependencyGraph(ctx, _content, _resources) { |
| | | // TODO implement |
| | | return new DepGraph<FilePath>() |
| | | }, |
| | | async emit(ctx, content, resources): Promise<FilePath[]> { |
| | | const fps: FilePath[] = [] |
| | | const allFiles = content.map((c) => c[1].data) |
| | |
| | | import { QuartzComponent } from "../components/types" |
| | | import { FilePath } from "../util/path" |
| | | import { BuildCtx } from "../util/ctx" |
| | | import DepGraph from "../depgraph" |
| | | |
| | | export interface PluginTypes { |
| | | transformers: QuartzTransformerPluginInstance[] |
| | |
| | | name: string |
| | | emit(ctx: BuildCtx, content: ProcessedContent[], resources: StaticResources): Promise<FilePath[]> |
| | | getQuartzComponents(ctx: BuildCtx): QuartzComponent[] |
| | | getDependencyGraph?( |
| | | ctx: BuildCtx, |
| | | content: ProcessedContent[], |
| | | resources: StaticResources, |
| | | ): Promise<DepGraph<FilePath>> |
| | | } |
| | |
| | | verbose: boolean |
| | | output: string |
| | | serve: boolean |
| | | fastRebuild: boolean |
| | | port: number |
| | | wsPort: number |
| | | remoteDevHost?: string |