This commit is contained in:
semanticdata 2024-02-10 20:03:04 -06:00
commit 928675f489
28 changed files with 867 additions and 19 deletions

View File

@ -17,6 +17,10 @@ import { glob, toPosixPath } from "./util/glob"
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
@ -29,8 +33,11 @@ type BuildData = {
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,
@ -68,12 +75,24 @@ async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) {
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)
}
}
@ -83,9 +102,11 @@ async function startServing(
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
@ -95,6 +116,7 @@ async function startServing(
const buildData: BuildData = {
ctx,
mut,
dependencies,
contentMap,
ignored: await isGitIgnored(),
initialSlugs: ctx.allSlugs,
@ -110,19 +132,181 @@ async function startServing(
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
) {

View File

@ -71,6 +71,11 @@ export const BuildArgv = {
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: "",

96
quartz/depgraph.test.ts Normal file
View File

@ -0,0 +1,96 @@
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)
})
})
})

187
quartz/depgraph.ts Normal file
View File

@ -0,0 +1,187 @@
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
}
}

View File

@ -1,4 +1,4 @@
import { Translation } from "./locales/definition"
import { Translation, CalloutTranslation } from "./locales/definition"
import en from "./locales/en-US"
import fr from "./locales/fr-FR"
import ja from "./locales/ja-JP"
@ -6,6 +6,7 @@ import de from "./locales/de-DE"
import nl from "./locales/nl-NL"
import ro from "./locales/ro-RO"
import es from "./locales/es-ES"
import ar from "./locales/ar-SA"
import uk from "./locales/uk-UA"
export const TRANSLATIONS = {
@ -17,9 +18,30 @@ export const TRANSLATIONS = {
"ro-RO": ro,
"ro-MD": ro,
"es-ES": es,
"ar-SA": ar,
"ar-AE": ar,
"ar-QA": ar,
"ar-BH": ar,
"ar-KW": ar,
"ar-OM": ar,
"ar-YE": ar,
"ar-IR": ar,
"ar-SY": ar,
"ar-IQ": ar,
"ar-JO": ar,
"ar-PL": ar,
"ar-LB": ar,
"ar-EG": ar,
"ar-SD": ar,
"ar-LY": ar,
"ar-MA": ar,
"ar-TN": ar,
"ar-DZ": ar,
"ar-MR": ar,
"uk-UA": uk,
} as const
export const defaultTranslation = "en-US"
export const i18n = (locale: ValidLocale): Translation => TRANSLATIONS[locale ?? defaultTranslation]
export type ValidLocale = keyof typeof TRANSLATIONS
export type ValidCallout = keyof CalloutTranslation

View File

@ -0,0 +1,80 @@
import { Translation } from "./definition"
export default {
propertyDefaults: {
title: "غير معنون",
description: "لم يتم تقديم أي وصف",
},
components: {
callout: {
note: "ملاحظة",
abstract: "ملخص",
info: "معلومات",
todo: "للقيام",
tip: "نصيحة",
success: "نجاح",
question: "سؤال",
warning: "تحذير",
failure: "فشل",
danger: "خطر",
bug: "خلل",
example: "مثال",
quote: "اقتباس",
},
backlinks: {
title: "وصلات العودة",
noBacklinksFound: "لا يوجد وصلات عودة",
},
themeToggle: {
lightMode: "الوضع النهاري",
darkMode: "الوضع الليلي",
},
explorer: {
title: "المستعرض",
},
footer: {
createdWith: "أُنشئ باستخدام",
},
graph: {
title: "التمثيل التفاعلي",
},
recentNotes: {
title: "آخر الملاحظات",
seeRemainingMore: ({ remaining }) => `تصفح ${remaining} أكثر →`,
},
transcludes: {
transcludeOf: ({ targetSlug }) => `مقتبس من ${targetSlug}`,
linkToOriginal: "وصلة للملاحظة الرئيسة",
},
search: {
title: "بحث",
searchBarPlaceholder: "ابحث عن شيء ما",
},
tableOfContents: {
title: "فهرس المحتويات",
},
},
pages: {
rss: {
recentNotes: "آخر الملاحظات",
lastFewNotes: ({ count }) => `آخر ${count} ملاحظة`,
},
error: {
title: "غير موجود",
notFound: "إما أن هذه الصفحة خاصة أو غير موجودة.",
},
folderContent: {
folder: "مجلد",
itemsUnderFolder: ({ count }) =>
count === 1 ? "يوجد عنصر واحد فقط تحت هذا المجلد" : `يوجد ${count} عناصر تحت هذا المجلد.`,
},
tagContent: {
tag: "الوسم",
tagIndex: "مؤشر الوسم",
itemsUnderTag: ({ count }) =>
count === 1 ? "يوجد عنصر واحد فقط تحت هذا الوسم" : `يوجد ${count} عناصر تحت هذا الوسم.`,
showingFirst: ({ count }) => `إظهار أول ${count} أوسمة.`,
totalTags: ({ count }) => `يوجد ${count} أوسمة.`,
},
},
} as const satisfies Translation

View File

@ -6,6 +6,21 @@ export default {
description: "Keine Beschreibung angegeben",
},
components: {
callout: {
note: "Hinweis",
abstract: "Zusammenfassung",
info: "Info",
todo: "Zu erledigen",
tip: "Tipp",
success: "Erfolg",
question: "Frage",
warning: "Warnung",
failure: "Misserfolg",
danger: "Gefahr",
bug: "Fehler",
example: "Beispiel",
quote: "Zitat",
},
backlinks: {
title: "Backlinks",
noBacklinksFound: "Keine Backlinks gefunden",

View File

@ -1,11 +1,28 @@
import { FullSlug } from "../../util/path"
export interface CalloutTranslation {
note: string
abstract: string
info: string
todo: string
tip: string
success: string
question: string
warning: string
failure: string
danger: string
bug: string
example: string
quote: string
}
export interface Translation {
propertyDefaults: {
title: string
description: string
}
components: {
callout: CalloutTranslation
backlinks: {
title: string
noBacklinksFound: string

View File

@ -6,6 +6,21 @@ export default {
description: "No description provided",
},
components: {
callout: {
note: "Note",
abstract: "Abstract",
info: "Info",
todo: "Todo",
tip: "Tip",
success: "Success",
question: "Question",
warning: "Warning",
failure: "Failure",
danger: "Danger",
bug: "Bug",
example: "Example",
quote: "Quote",
},
backlinks: {
title: "Backlinks",
noBacklinksFound: "No backlinks found",

View File

@ -6,6 +6,21 @@ export default {
description: "Sin descripción",
},
components: {
callout: {
note: "Nota",
abstract: "Resumen",
info: "Información",
todo: "Por hacer",
tip: "Consejo",
success: "Éxito",
question: "Pregunta",
warning: "Advertencia",
failure: "Fallo",
danger: "Peligro",
bug: "Error",
example: "Ejemplo",
quote: "Cita",
},
backlinks: {
title: "Enlaces de Retroceso",
noBacklinksFound: "No se han encontrado enlaces traseros",

View File

@ -6,6 +6,21 @@ export default {
description: "Aucune description fournie",
},
components: {
callout: {
note: "Note",
abstract: "Résumé",
info: "Info",
todo: "À faire",
tip: "Conseil",
success: "Succès",
question: "Question",
warning: "Avertissement",
failure: "Échec",
danger: "Danger",
bug: "Bogue",
example: "Exemple",
quote: "Citation",
},
backlinks: {
title: "Liens retour",
noBacklinksFound: "Aucun lien retour trouvé",

View File

@ -6,6 +6,21 @@ export default {
description: "説明なし",
},
components: {
callout: {
note: "ノート",
abstract: "抄録",
info: "情報",
todo: "やるべきこと",
tip: "ヒント",
success: "成功",
question: "質問",
warning: "警告",
failure: "失敗",
danger: "危険",
bug: "バグ",
example: "例",
quote: "引用",
},
backlinks: {
title: "バックリンク",
noBacklinksFound: "バックリンクはありません",

View File

@ -6,6 +6,21 @@ export default {
description: "Geen beschrijving gegeven.",
},
components: {
callout: {
note: "Notitie",
abstract: "Samenvatting",
info: "Info",
todo: "Te doen",
tip: "Tip",
success: "Succes",
question: "Vraag",
warning: "Waarschuwing",
failure: "Mislukking",
danger: "Gevaar",
bug: "Bug",
example: "Voorbeeld",
quote: "Citaat",
},
backlinks: {
title: "Backlinks",
noBacklinksFound: "Geen backlinks gevonden",

View File

@ -6,6 +6,21 @@ export default {
description: "Nici o descriere furnizată",
},
components: {
callout: {
note: "Notă",
abstract: "Rezumat",
info: "Informație",
todo: "De făcut",
tip: "Sfat",
success: "Succes",
question: "Întrebare",
warning: "Avertisment",
failure: "Eșec",
danger: "Pericol",
bug: "Bug",
example: "Exemplu",
quote: "Citat",
},
backlinks: {
title: "Legături înapoi",
noBacklinksFound: "Nu s-au găsit legături înapoi",

View File

@ -6,6 +6,21 @@ export default {
description: "Опис не надано",
},
components: {
callout: {
note: "Примітка",
abstract: "Абстракт",
info: "Інформація",
todo: "Завдання",
tip: "Порада",
success: "Успіх",
question: "Питання",
warning: "Попередження",
failure: "Невдача",
danger: "Небезпека",
bug: "Баг",
example: "Приклад",
quote: "Цитата",
},
backlinks: {
title: "Зворотні посилання",
noBacklinksFound: "Зворотних посилань не знайдено",

View File

@ -9,6 +9,7 @@ import { NotFound } from "../../components"
import { defaultProcessedContent } from "../vfile"
import { write } from "./helpers"
import { i18n } from "../../i18n"
import DepGraph from "../../depgraph"
export const NotFoundPage: QuartzEmitterPlugin = () => {
const opts: FullPageLayout = {
@ -27,6 +28,9 @@ export const NotFoundPage: QuartzEmitterPlugin = () => {
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

View File

@ -2,12 +2,17 @@ import { FilePath, FullSlug, joinSegments, resolveRelative, simplifySlug } from
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[] = []

View File

@ -3,6 +3,7 @@ import { QuartzEmitterPlugin } from "../types"
import path from "path"
import fs from "fs"
import { glob } from "../../util/glob"
import DepGraph from "../../depgraph"
export const Assets: QuartzEmitterPlugin = () => {
return {
@ -10,6 +11,24 @@ export const Assets: QuartzEmitterPlugin = () => {
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

View File

@ -2,6 +2,7 @@ import { FilePath, joinSegments } from "../../util/path"
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}`)
@ -13,6 +14,9 @@ export const CNAME: QuartzEmitterPlugin = () => ({
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"))

View File

@ -14,6 +14,7 @@ import { googleFontHref, joinStyles } from "../../util/theme"
import { Features, transform } from "lightningcss"
import { transform as transpile } from "esbuild"
import { write } from "./helpers"
import DepGraph from "../../depgraph"
type ComponentResources = {
css: string[]
@ -149,9 +150,10 @@ function addGlobalPageResources(
loadTime: "afterDOMReady",
contentType: "inline",
script: `
const socket = new WebSocket('${wsUrl}')
socket.addEventListener('message', () => document.location.reload())
`,
const socket = new WebSocket('${wsUrl}')
// reload(true) ensures resources like images and scripts are fetched again in firefox
socket.addEventListener('message', () => document.location.reload(true))
`,
})
}
}
@ -171,6 +173,24 @@ export const ComponentResources: QuartzEmitterPlugin<Options> = (opts?: Partial<
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
@ -201,7 +221,10 @@ export const ComponentResources: QuartzEmitterPlugin<Options> = (opts?: Partial<
// the static name of this file.
const [filename, ext] = url.split("/").pop()!.split(".")
googleFontsStyleSheet = googleFontsStyleSheet.replace(url, `/fonts/${filename}.ttf`)
googleFontsStyleSheet = googleFontsStyleSheet.replace(
url,
`/static/fonts/${filename}.ttf`,
)
promises.push(
fetch(url)
@ -214,7 +237,7 @@ export const ComponentResources: QuartzEmitterPlugin<Options> = (opts?: Partial<
.then((buf) =>
write({
ctx,
slug: joinSegments("fonts", filename) as FullSlug,
slug: joinSegments("static", "fonts", filename) as FullSlug,
ext: `.${ext}`,
content: Buffer.from(buf),
}),

View File

@ -7,6 +7,7 @@ import { QuartzEmitterPlugin } from "../types"
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 = {
@ -92,6 +93,26 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
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[] = []

View File

@ -4,11 +4,12 @@ import HeaderConstructor from "../../components/Header"
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 = {
@ -27,6 +28,18 @@ export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOp
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[] = []
@ -60,7 +73,7 @@ export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOp
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.`,

View File

@ -19,6 +19,7 @@ import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.lay
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 = {
@ -37,6 +38,13 @@ export const FolderPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpt
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)

View File

@ -2,12 +2,27 @@ import { FilePath, QUARTZ, joinSegments } from "../../util/path"
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)

View File

@ -16,6 +16,7 @@ import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.lay
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 = {
@ -34,6 +35,10 @@ export const TagPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpts)
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)

View File

@ -1,5 +1,5 @@
import { QuartzTransformerPlugin } from "../types"
import { Root, Html, BlockContent, DefinitionContent, Paragraph, Code } from "mdast"
import { Blockquote, Root, Html, BlockContent, DefinitionContent, Paragraph, Code } from "mdast"
import { Element, Literal, Root as HtmlRoot } from "hast"
import { ReplaceFunction, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace"
import { slug as slugAnchor } from "github-slugger"
@ -17,6 +17,7 @@ import { toHtml } from "hast-util-to-html"
import { PhrasingContent } from "mdast-util-find-and-replace/lib"
import { capitalize } from "../../util/lang"
import { PluggableList } from "unified"
import { ValidCallout, i18n } from "../../i18n"
export interface Options {
comments: boolean
@ -185,8 +186,9 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
return src
},
markdownPlugins() {
markdownPlugins(ctx) {
const plugins: PluggableList = []
const cfg = ctx.cfg.configuration
// regex replacements
plugins.push(() => {
@ -407,7 +409,12 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
children: [
{
type: "text",
value: useDefaultTitle ? capitalize(calloutType) : titleContent + " ",
value: useDefaultTitle
? capitalize(
i18n(cfg.locale).components.callout[calloutType as ValidCallout] ??
calloutType,
)
: titleContent + " ",
},
...restOfTitle,
],
@ -443,13 +450,19 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
// replace first line of blockquote with title and rest of the paragraph text
node.children.splice(0, 1, ...blockquoteContent)
const classNames = ["callout", calloutType]
if (collapse) {
classNames.push("is-collapsible")
}
if (defaultState === "collapsed") {
classNames.push("is-collapsed")
}
// add properties to base blockquote
node.data = {
hProperties: {
...(node.data?.hProperties ?? {}),
className: `callout ${calloutType} ${collapse ? "is-collapsible" : ""} ${
defaultState === "collapsed" ? "is-collapsed" : ""
}`,
className: classNames.join(" "),
"data-callout": calloutType,
"data-callout-fold": collapse,
},

View File

@ -4,6 +4,7 @@ import { ProcessedContent } from "./vfile"
import { QuartzComponent } from "../components/types"
import { FilePath } from "../util/path"
import { BuildCtx } from "../util/ctx"
import DepGraph from "../depgraph"
export interface PluginTypes {
transformers: QuartzTransformerPluginInstance[]
@ -38,4 +39,9 @@ export type QuartzEmitterPluginInstance = {
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>>
}

View File

@ -6,6 +6,7 @@ export interface Argv {
verbose: boolean
output: string
serve: boolean
fastRebuild: boolean
port: number
wsPort: number
remoteDevHost?: string