incremental all the things

This commit is contained in:
Jacky Zhao 2025-03-15 22:00:37 -07:00
parent f528d6139e
commit 7681a86815
24 changed files with 377 additions and 242 deletions

View File

@ -1,7 +1,5 @@
--- ---
title: Authoring Content title: Authoring Content
aliases:
- test/author 1
--- ---
All of the content in your Quartz should go in the `/content` folder. The content for the home page of your Quartz lives in `content/index.md`. If you've [[index#🪴 Get Started|setup Quartz]] already, this folder should already be initialized. Any Markdown in this folder will get processed by Quartz. All of the content in your Quartz should go in the `/content` folder. The content for the home page of your Quartz lives in `content/index.md`. If you've [[index#🪴 Get Started|setup Quartz]] already, this folder should already be initialized. Any Markdown in this folder will get processed by Quartz.

View File

@ -9,7 +9,7 @@ import { parseMarkdown } from "./processors/parse"
import { filterContent } from "./processors/filter" import { filterContent } from "./processors/filter"
import { emitContent } from "./processors/emit" import { emitContent } from "./processors/emit"
import cfg from "../quartz.config" import cfg from "../quartz.config"
import { FilePath, FullSlug, joinSegments, slugifyFilePath } from "./util/path" import { FilePath, joinSegments, slugifyFilePath } from "./util/path"
import chokidar from "chokidar" import chokidar from "chokidar"
import { ProcessedContent } from "./plugins/vfile" import { ProcessedContent } from "./plugins/vfile"
import { Argv, BuildCtx } from "./util/ctx" import { Argv, BuildCtx } from "./util/ctx"
@ -173,7 +173,6 @@ async function rebuild(changes: ChangeEvent[], clientRefresh: () => void, buildD
const perf = new PerfTimer() const perf = new PerfTimer()
perf.addEvent("rebuild") perf.addEvent("rebuild")
console.log(chalk.yellow("Detected change, rebuilding...")) console.log(chalk.yellow("Detected change, rebuilding..."))
console.log(changes)
// update changesSinceLastBuild // update changesSinceLastBuild
for (const change of changes) { for (const change of changes) {
@ -181,21 +180,17 @@ async function rebuild(changes: ChangeEvent[], clientRefresh: () => void, buildD
} }
const staticResources = getStaticResourcesFromPlugins(ctx) const staticResources = getStaticResourcesFromPlugins(ctx)
const processedFiles = await Promise.all( for (const [fp, type] of Object.entries(changesSinceLastBuild)) {
Object.entries(changesSinceLastBuild) if (type === "delete" || path.extname(fp) !== ".md") continue
.filter(([fp, type]) => type !== "delete" && path.extname(fp) === ".md") const fullPath = joinSegments(argv.directory, toPosixPath(fp)) as FilePath
.map(async ([fp, _type]) => { const parsed = await parseMarkdown(ctx, [fullPath])
const fullPath = joinSegments(argv.directory, toPosixPath(fp)) as FilePath for (const content of parsed) {
const parsed = await parseMarkdown(ctx, [fullPath]) contentMap.set(content[1].data.relativePath!, {
parsed.forEach((content) => type: "markdown",
contentMap.set(content[1].data.relativePath!, { content,
type: "markdown", })
content, }
}), }
)
return parsed
}),
).then((results) => results.flat())
// update state using changesSinceLastBuild // update state using changesSinceLastBuild
// we do this weird play of add => compute change events => remove // we do this weird play of add => compute change events => remove
@ -236,16 +231,19 @@ async function rebuild(changes: ChangeEvent[], clientRefresh: () => void, buildD
// update allFiles and then allSlugs with the consistent view of content map // update allFiles and then allSlugs with the consistent view of content map
ctx.allFiles = Array.from(contentMap.keys()) ctx.allFiles = Array.from(contentMap.keys())
ctx.allSlugs = ctx.allFiles.map((fp) => slugifyFilePath(fp as FilePath)) ctx.allSlugs = ctx.allFiles.map((fp) => slugifyFilePath(fp as FilePath))
const processedFiles = Array.from(contentMap.values())
.filter((file) => file.type === "markdown")
.map((file) => file.content)
let emittedFiles = 0 let emittedFiles = 0
for (const emitter of cfg.plugins.emitters) { for (const emitter of cfg.plugins.emitters) {
// Try to use partialEmit if available, otherwise assume the output is static // Try to use partialEmit if available, otherwise assume the output is static
const emitFn = emitter.partialEmit const emitFn = emitter.partialEmit ?? emitter.emit
if (!emitFn) { const emitted = await emitFn(ctx, processedFiles, staticResources, changeEvents)
if (emitted === null) {
continue continue
} }
const emitted = await emitFn(ctx, processedFiles, staticResources, changeEvents)
if (Symbol.asyncIterator in emitted) { if (Symbol.asyncIterator in emitted) {
// Async generator case // Async generator case
for await (const file of emitted) { for await (const file of emitted) {

View File

@ -2,7 +2,6 @@ import { ValidDateType } from "./components/Date"
import { QuartzComponent } from "./components/types" import { QuartzComponent } from "./components/types"
import { ValidLocale } from "./i18n" import { ValidLocale } from "./i18n"
import { PluginTypes } from "./plugins/types" import { PluginTypes } from "./plugins/types"
import { SocialImageOptions } from "./util/og"
import { Theme } from "./util/theme" import { Theme } from "./util/theme"
export type Analytics = export type Analytics =

View File

@ -456,6 +456,7 @@ export async function handleBuild(argv) {
const paths = await globby([ const paths = await globby([
"**/*.ts", "**/*.ts",
"quartz/cli/*.js", "quartz/cli/*.js",
"quartz/static/**/*",
"**/*.tsx", "**/*.tsx",
"**/*.scss", "**/*.scss",
"package.json", "package.json",

View File

@ -9,7 +9,6 @@ import { visit } from "unist-util-visit"
import { Root, Element, ElementContent } from "hast" import { Root, Element, ElementContent } from "hast"
import { GlobalConfiguration } from "../cfg" import { GlobalConfiguration } from "../cfg"
import { i18n } from "../i18n" import { i18n } from "../i18n"
import { QuartzPluginData } from "../plugins/vfile"
interface RenderComponents { interface RenderComponents {
head: QuartzComponent head: QuartzComponent
@ -25,7 +24,6 @@ interface RenderComponents {
const headerRegex = new RegExp(/h[1-6]/) const headerRegex = new RegExp(/h[1-6]/)
export function pageResources( export function pageResources(
baseDir: FullSlug | RelativeURL, baseDir: FullSlug | RelativeURL,
fileData: QuartzPluginData,
staticResources: StaticResources, staticResources: StaticResources,
): StaticResources { ): StaticResources {
const contentIndexPath = joinSegments(baseDir, "static/contentIndex.json") const contentIndexPath = joinSegments(baseDir, "static/contentIndex.json")

View File

@ -10,7 +10,7 @@ const emitThemeChangeEvent = (theme: "light" | "dark") => {
} }
document.addEventListener("nav", () => { document.addEventListener("nav", () => {
const switchTheme = (e: Event) => { const switchTheme = () => {
const newTheme = const newTheme =
document.documentElement.getAttribute("saved-theme") === "dark" ? "light" : "dark" document.documentElement.getAttribute("saved-theme") === "dark" ? "light" : "dark"
document.documentElement.setAttribute("saved-theme", newTheme) document.documentElement.setAttribute("saved-theme", newTheme)

View File

@ -3,7 +3,7 @@ import { QuartzComponentProps } from "../../components/types"
import BodyConstructor from "../../components/Body" import BodyConstructor from "../../components/Body"
import { pageResources, renderPage } from "../../components/renderPage" import { pageResources, renderPage } from "../../components/renderPage"
import { FullPageLayout } from "../../cfg" import { FullPageLayout } from "../../cfg"
import { FilePath, FullSlug } from "../../util/path" import { FullSlug } from "../../util/path"
import { sharedPageComponents } from "../../../quartz.layout" import { sharedPageComponents } from "../../../quartz.layout"
import { NotFound } from "../../components" import { NotFound } from "../../components"
import { defaultProcessedContent } from "../vfile" import { defaultProcessedContent } from "../vfile"
@ -40,7 +40,7 @@ export const NotFoundPage: QuartzEmitterPlugin = () => {
description: notFound, description: notFound,
frontmatter: { title: notFound, tags: [] }, frontmatter: { title: notFound, tags: [] },
}) })
const externalResources = pageResources(path, vfile.data, resources) const externalResources = pageResources(path, resources)
const componentData: QuartzComponentProps = { const componentData: QuartzComponentProps = {
ctx, ctx,
fileData: vfile.data, fileData: vfile.data,
@ -58,5 +58,6 @@ export const NotFoundPage: QuartzEmitterPlugin = () => {
ext: ".html", ext: ".html",
}) })
}, },
async *partialEmit() {},
} }
} }

View File

@ -4,7 +4,7 @@ import { write } from "./helpers"
import { BuildCtx } from "../../util/ctx" import { BuildCtx } from "../../util/ctx"
import { VFile } from "vfile" import { VFile } from "vfile"
async function *processFile(ctx: BuildCtx, file: VFile) { async function* processFile(ctx: BuildCtx, file: VFile) {
const ogSlug = simplifySlug(file.data.slug!) const ogSlug = simplifySlug(file.data.slug!)
for (const slug of file.data.aliases ?? []) { for (const slug of file.data.aliases ?? []) {

View File

@ -41,12 +41,12 @@ export const Assets: QuartzEmitterPlugin = () => {
if (changeEvent.type === "add" || changeEvent.type === "change") { if (changeEvent.type === "add" || changeEvent.type === "change") {
yield copyFile(ctx.argv, changeEvent.path) yield copyFile(ctx.argv, changeEvent.path)
} else if (changeEvent.type === 'delete') { } else if (changeEvent.type === "delete") {
const name = slugifyFilePath(changeEvent.path) const name = slugifyFilePath(changeEvent.path)
const dest = joinSegments(ctx.argv.output, name) as FilePath const dest = joinSegments(ctx.argv.output, name) as FilePath
await fs.promises.unlink(dest) await fs.promises.unlink(dest)
} }
} }
} },
} }
} }

View File

@ -23,4 +23,5 @@ export const CNAME: QuartzEmitterPlugin = () => ({
await fs.promises.writeFile(path, content) await fs.promises.writeFile(path, content)
return [path] as FilePath[] return [path] as FilePath[]
}, },
async *partialEmit() {},
}) })

View File

@ -1,4 +1,4 @@
import { FilePath, FullSlug, joinSegments } from "../../util/path" import { FullSlug, joinSegments } from "../../util/path"
import { QuartzEmitterPlugin } from "../types" import { QuartzEmitterPlugin } from "../types"
// @ts-ignore // @ts-ignore
@ -293,5 +293,6 @@ export const ComponentResources: QuartzEmitterPlugin = () => {
content: postscript, content: postscript,
}) })
}, },
async *partialEmit() {},
} }
} }

View File

@ -1,21 +1,50 @@
import path from "path" import path from "path"
import { visit } from "unist-util-visit"
import { Root } from "hast"
import { VFile } from "vfile"
import { QuartzEmitterPlugin } from "../types" import { QuartzEmitterPlugin } from "../types"
import { QuartzComponentProps } from "../../components/types" import { QuartzComponentProps } from "../../components/types"
import HeaderConstructor from "../../components/Header" import HeaderConstructor from "../../components/Header"
import BodyConstructor from "../../components/Body" import BodyConstructor from "../../components/Body"
import { pageResources, renderPage } from "../../components/renderPage" import { pageResources, renderPage } from "../../components/renderPage"
import { FullPageLayout } from "../../cfg" import { FullPageLayout } from "../../cfg"
import { Argv } from "../../util/ctx" import { pathToRoot } from "../../util/path"
import { FilePath, isRelativeURL, joinSegments, pathToRoot } from "../../util/path"
import { defaultContentPageLayout, sharedPageComponents } from "../../../quartz.layout" import { defaultContentPageLayout, sharedPageComponents } from "../../../quartz.layout"
import { Content } from "../../components" import { Content } from "../../components"
import chalk from "chalk" import chalk from "chalk"
import { write } from "./helpers" import { write } from "./helpers"
import { BuildCtx } from "../../util/ctx"
import { Node } from "unist"
import { StaticResources } from "../../util/resources"
import { QuartzPluginData } from "../vfile"
async function processContent(
ctx: BuildCtx,
tree: Node,
fileData: QuartzPluginData,
allFiles: QuartzPluginData[],
opts: FullPageLayout,
resources: StaticResources,
) {
const slug = fileData.slug!
const cfg = ctx.cfg.configuration
const externalResources = pageResources(pathToRoot(slug), resources)
const componentData: QuartzComponentProps = {
ctx,
fileData,
externalResources,
cfg,
children: [],
tree,
allFiles,
}
const content = renderPage(cfg, slug, componentData, opts, externalResources)
return write({
ctx,
content,
slug,
ext: ".html",
})
}
// TODO check for transclusions in partial rebuild
export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpts) => { export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpts) => {
const opts: FullPageLayout = { const opts: FullPageLayout = {
...sharedPageComponents, ...sharedPageComponents,
@ -45,38 +74,18 @@ export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOp
] ]
}, },
async *emit(ctx, content, resources) { async *emit(ctx, content, resources) {
const cfg = ctx.cfg.configuration
const allFiles = content.map((c) => c[1].data) const allFiles = content.map((c) => c[1].data)
let containsIndex = false let containsIndex = false
for (const [tree, file] of content) { for (const [tree, file] of content) {
const slug = file.data.slug! const slug = file.data.slug!
if (slug === "index") { if (slug === "index") {
containsIndex = true containsIndex = true
} }
if (file.data.slug?.endsWith("/index")) { // only process home page, non-tag pages, and non-index pages
continue if (slug.endsWith("/index") || slug.startsWith("tags/")) continue
} yield processContent(ctx, tree, file.data, allFiles, opts, resources)
const externalResources = pageResources(pathToRoot(slug), file.data, resources)
const componentData: QuartzComponentProps = {
ctx,
fileData: file.data,
externalResources,
cfg,
children: [],
tree,
allFiles,
}
const content = renderPage(cfg, slug, componentData, opts, externalResources)
yield write({
ctx,
content,
slug,
ext: ".html",
})
} }
if (!containsIndex) { if (!containsIndex) {
@ -87,5 +96,25 @@ export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOp
) )
} }
}, },
async *partialEmit(ctx, content, resources, changeEvents) {
const allFiles = content.map((c) => c[1].data)
// find all slugs that changed or were added
const changedSlugs = new Set<string>()
for (const changeEvent of changeEvents) {
if (!changeEvent.file) continue
if (changeEvent.type === "add" || changeEvent.type === "change") {
changedSlugs.add(changeEvent.file.data.slug!)
}
}
for (const [tree, file] of content) {
const slug = file.data.slug!
if (!changedSlugs.has(slug)) continue
if (slug.endsWith("/index") || slug.startsWith("tags/")) continue
yield processContent(ctx, tree, file.data, allFiles, opts, resources)
}
},
} }
} }

View File

@ -7,7 +7,6 @@ import { ProcessedContent, QuartzPluginData, defaultProcessedContent } from "../
import { FullPageLayout } from "../../cfg" import { FullPageLayout } from "../../cfg"
import path from "path" import path from "path"
import { import {
FilePath,
FullSlug, FullSlug,
SimpleSlug, SimpleSlug,
stripSlashes, stripSlashes,
@ -18,12 +17,89 @@ import {
import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout" import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
import { FolderContent } from "../../components" import { FolderContent } from "../../components"
import { write } from "./helpers" import { write } from "./helpers"
import { i18n } from "../../i18n" import { i18n, TRANSLATIONS } from "../../i18n"
import { BuildCtx } from "../../util/ctx"
import { StaticResources } from "../../util/resources"
interface FolderPageOptions extends FullPageLayout { interface FolderPageOptions extends FullPageLayout {
sort?: (f1: QuartzPluginData, f2: QuartzPluginData) => number sort?: (f1: QuartzPluginData, f2: QuartzPluginData) => number
} }
async function* processFolderInfo(
ctx: BuildCtx,
folderInfo: Record<SimpleSlug, ProcessedContent>,
allFiles: QuartzPluginData[],
opts: FullPageLayout,
resources: StaticResources,
) {
for (const [folder, folderContent] of Object.entries(folderInfo) as [
SimpleSlug,
ProcessedContent,
][]) {
const slug = joinSegments(folder, "index") as FullSlug
const [tree, file] = folderContent
const cfg = ctx.cfg.configuration
const externalResources = pageResources(pathToRoot(slug), resources)
const componentData: QuartzComponentProps = {
ctx,
fileData: file.data,
externalResources,
cfg,
children: [],
tree,
allFiles,
}
const content = renderPage(cfg, slug, componentData, opts, externalResources)
yield write({
ctx,
content,
slug,
ext: ".html",
})
}
}
function computeFolderInfo(
folders: Set<SimpleSlug>,
content: ProcessedContent[],
locale: keyof typeof TRANSLATIONS,
): Record<SimpleSlug, ProcessedContent> {
// Create default folder descriptions
const folderInfo: Record<SimpleSlug, ProcessedContent> = Object.fromEntries(
[...folders].map((folder) => [
folder,
defaultProcessedContent({
slug: joinSegments(folder, "index") as FullSlug,
frontmatter: {
title: `${i18n(locale).pages.folderContent.folder}: ${folder}`,
tags: [],
},
}),
]),
)
// Update with actual content if available
for (const [tree, file] of content) {
const slug = stripSlashes(simplifySlug(file.data.slug!)) as SimpleSlug
if (folders.has(slug)) {
folderInfo[slug] = [tree, file]
}
}
return folderInfo
}
function _getFolders(slug: FullSlug): SimpleSlug[] {
var folderName = path.dirname(slug ?? "") as SimpleSlug
const parentFolderNames = [folderName]
while (folderName !== ".") {
folderName = path.dirname(folderName ?? "") as SimpleSlug
parentFolderNames.push(folderName)
}
return parentFolderNames
}
export const FolderPage: QuartzEmitterPlugin<Partial<FolderPageOptions>> = (userOpts) => { export const FolderPage: QuartzEmitterPlugin<Partial<FolderPageOptions>> = (userOpts) => {
const opts: FullPageLayout = { const opts: FullPageLayout = {
...sharedPageComponents, ...sharedPageComponents,
@ -66,59 +142,29 @@ export const FolderPage: QuartzEmitterPlugin<Partial<FolderPageOptions>> = (user
}), }),
) )
const folderDescriptions: Record<string, ProcessedContent> = Object.fromEntries( const folderInfo = computeFolderInfo(folders, content, cfg.locale)
[...folders].map((folder) => [ yield* processFolderInfo(ctx, folderInfo, allFiles, opts, resources)
folder, },
defaultProcessedContent({ async *partialEmit(ctx, content, resources, changeEvents) {
slug: joinSegments(folder, "index") as FullSlug, const allFiles = content.map((c) => c[1].data)
frontmatter: { const cfg = ctx.cfg.configuration
title: `${i18n(cfg.locale).pages.folderContent.folder}: ${folder}`,
tags: [],
},
}),
]),
)
for (const [tree, file] of content) { // Find all folders that need to be updated based on changed files
const slug = stripSlashes(simplifySlug(file.data.slug!)) as SimpleSlug const affectedFolders: Set<SimpleSlug> = new Set()
if (folders.has(slug)) { for (const changeEvent of changeEvents) {
folderDescriptions[slug] = [tree, file] if (!changeEvent.file) continue
} const slug = changeEvent.file.data.slug!
const folders = _getFolders(slug).filter(
(folderName) => folderName !== "." && folderName !== "tags",
)
folders.forEach((folder) => affectedFolders.add(folder))
} }
for (const folder of folders) { // If there are affected folders, rebuild their pages
const slug = joinSegments(folder, "index") as FullSlug if (affectedFolders.size > 0) {
const [tree, file] = folderDescriptions[folder] const folderInfo = computeFolderInfo(affectedFolders, content, cfg.locale)
const externalResources = pageResources(pathToRoot(slug), file.data, resources) yield* processFolderInfo(ctx, folderInfo, allFiles, opts, resources)
const componentData: QuartzComponentProps = {
ctx,
fileData: file.data,
externalResources,
cfg,
children: [],
tree,
allFiles,
}
const content = renderPage(cfg, slug, componentData, opts, externalResources)
yield write({
ctx,
content,
slug,
ext: ".html",
})
} }
}, },
} }
} }
function _getFolders(slug: FullSlug): SimpleSlug[] {
var folderName = path.dirname(slug ?? "") as SimpleSlug
const parentFolderNames = [folderName]
while (folderName !== ".") {
folderName = path.dirname(folderName ?? "") as SimpleSlug
parentFolderNames.push(folderName)
}
return parentFolderNames
}

View File

@ -11,8 +11,6 @@ type WriteOptions = {
content: string | Buffer | Readable content: string | Buffer | Readable
} }
type DeleteOptions = Omit<WriteOptions, "content">
export const write = async ({ ctx, slug, ext, content }: WriteOptions): Promise<FilePath> => { export const write = async ({ ctx, slug, ext, content }: WriteOptions): Promise<FilePath> => {
const pathToPage = joinSegments(ctx.argv.output, slug + ext) as FilePath const pathToPage = joinSegments(ctx.argv.output, slug + ext) as FilePath
const dir = path.dirname(pathToPage) const dir = path.dirname(pathToPage)

View File

@ -4,10 +4,12 @@ import { unescapeHTML } from "../../util/escape"
import { FullSlug, getFileExtension } from "../../util/path" import { FullSlug, getFileExtension } from "../../util/path"
import { ImageOptions, SocialImageOptions, defaultImage, getSatoriFonts } from "../../util/og" import { ImageOptions, SocialImageOptions, defaultImage, getSatoriFonts } from "../../util/og"
import sharp from "sharp" import sharp from "sharp"
import satori from "satori" import satori, { SatoriOptions } from "satori"
import { loadEmoji, getIconCode } from "../../util/emoji" import { loadEmoji, getIconCode } from "../../util/emoji"
import { Readable } from "stream" import { Readable } from "stream"
import { write } from "./helpers" import { write } from "./helpers"
import { BuildCtx } from "../../util/ctx"
import { QuartzPluginData } from "../vfile"
const defaultOptions: SocialImageOptions = { const defaultOptions: SocialImageOptions = {
colorScheme: "lightMode", colorScheme: "lightMode",
@ -42,6 +44,41 @@ async function generateSocialImage(
return sharp(Buffer.from(svg)).webp({ quality: 40 }) return sharp(Buffer.from(svg)).webp({ quality: 40 })
} }
async function processOgImage(
ctx: BuildCtx,
fileData: QuartzPluginData,
fonts: SatoriOptions["fonts"],
fullOptions: SocialImageOptions,
) {
const cfg = ctx.cfg.configuration
const slug = fileData.slug!
const titleSuffix = cfg.pageTitleSuffix ?? ""
const title =
(fileData.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title) + titleSuffix
const description =
fileData.frontmatter?.socialDescription ??
fileData.frontmatter?.description ??
unescapeHTML(fileData.description?.trim() ?? i18n(cfg.locale).propertyDefaults.description)
const stream = await generateSocialImage(
{
title,
description,
fonts,
cfg,
fileData,
},
fullOptions,
)
return write({
ctx,
content: stream,
slug: `${slug}-og-image` as FullSlug,
ext: ".webp",
})
}
export const CustomOgImagesEmitterName = "CustomOgImages" export const CustomOgImagesEmitterName = "CustomOgImages"
export const CustomOgImages: QuartzEmitterPlugin<Partial<SocialImageOptions>> = (userOpts) => { export const CustomOgImages: QuartzEmitterPlugin<Partial<SocialImageOptions>> = (userOpts) => {
const fullOptions = { ...defaultOptions, ...userOpts } const fullOptions = { ...defaultOptions, ...userOpts }
@ -58,39 +95,23 @@ export const CustomOgImages: QuartzEmitterPlugin<Partial<SocialImageOptions>> =
const fonts = await getSatoriFonts(headerFont, bodyFont) const fonts = await getSatoriFonts(headerFont, bodyFont)
for (const [_tree, vfile] of content) { for (const [_tree, vfile] of content) {
// if this file defines socialImage, we can skip if (vfile.data.frontmatter?.socialImage !== undefined) continue
if (vfile.data.frontmatter?.socialImage !== undefined) { yield processOgImage(ctx, vfile.data, fonts, fullOptions)
continue }
},
async *partialEmit(ctx, _content, _resources, changeEvents) {
const cfg = ctx.cfg.configuration
const headerFont = cfg.theme.typography.header
const bodyFont = cfg.theme.typography.body
const fonts = await getSatoriFonts(headerFont, bodyFont)
// find all slugs that changed or were added
for (const changeEvent of changeEvents) {
if (!changeEvent.file) continue
if (changeEvent.file.data.frontmatter?.socialImage !== undefined) continue
if (changeEvent.type === "add" || changeEvent.type === "change") {
yield processOgImage(ctx, changeEvent.file.data, fonts, fullOptions)
} }
const slug = vfile.data.slug!
const titleSuffix = cfg.pageTitleSuffix ?? ""
const title =
(vfile.data.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title) + titleSuffix
const description =
vfile.data.frontmatter?.socialDescription ??
vfile.data.frontmatter?.description ??
unescapeHTML(
vfile.data.description?.trim() ?? i18n(cfg.locale).propertyDefaults.description,
)
const stream = await generateSocialImage(
{
title,
description,
fonts,
cfg,
fileData: vfile.data,
},
fullOptions,
)
yield write({
ctx,
content: stream,
slug: `${slug}-og-image` as FullSlug,
ext: ".webp",
})
} }
}, },
externalResources: (ctx) => { externalResources: (ctx) => {

View File

@ -19,4 +19,5 @@ export const Static: QuartzEmitterPlugin = () => ({
yield dest yield dest
} }
}, },
async *partialEmit() {},
}) })

View File

@ -5,22 +5,94 @@ import BodyConstructor from "../../components/Body"
import { pageResources, renderPage } from "../../components/renderPage" import { pageResources, renderPage } from "../../components/renderPage"
import { ProcessedContent, QuartzPluginData, defaultProcessedContent } from "../vfile" import { ProcessedContent, QuartzPluginData, defaultProcessedContent } from "../vfile"
import { FullPageLayout } from "../../cfg" import { FullPageLayout } from "../../cfg"
import { import { FullSlug, getAllSegmentPrefixes, joinSegments, pathToRoot } from "../../util/path"
FilePath,
FullSlug,
getAllSegmentPrefixes,
joinSegments,
pathToRoot,
} from "../../util/path"
import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout" import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
import { TagContent } from "../../components" import { TagContent } from "../../components"
import { write } from "./helpers" import { write } from "./helpers"
import { i18n } from "../../i18n" import { i18n, TRANSLATIONS } from "../../i18n"
import { BuildCtx } from "../../util/ctx"
import { StaticResources } from "../../util/resources"
interface TagPageOptions extends FullPageLayout { interface TagPageOptions extends FullPageLayout {
sort?: (f1: QuartzPluginData, f2: QuartzPluginData) => number sort?: (f1: QuartzPluginData, f2: QuartzPluginData) => number
} }
function computeTagInfo(
allFiles: QuartzPluginData[],
content: ProcessedContent[],
locale: keyof typeof TRANSLATIONS,
): [Set<string>, Record<string, ProcessedContent>] {
const tags: Set<string> = new Set(
allFiles.flatMap((data) => data.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes),
)
// add base tag
tags.add("index")
const tagDescriptions: Record<string, ProcessedContent> = Object.fromEntries(
[...tags].map((tag) => {
const title =
tag === "index"
? i18n(locale).pages.tagContent.tagIndex
: `${i18n(locale).pages.tagContent.tag}: ${tag}`
return [
tag,
defaultProcessedContent({
slug: joinSegments("tags", tag) as FullSlug,
frontmatter: { title, tags: [] },
}),
]
}),
)
// Update with actual content if available
for (const [tree, file] of content) {
const slug = file.data.slug!
if (slug.startsWith("tags/")) {
const tag = slug.slice("tags/".length)
if (tags.has(tag)) {
tagDescriptions[tag] = [tree, file]
if (file.data.frontmatter?.title === tag) {
file.data.frontmatter.title = `${i18n(locale).pages.tagContent.tag}: ${tag}`
}
}
}
}
return [tags, tagDescriptions]
}
async function processTagPage(
ctx: BuildCtx,
tag: string,
tagContent: ProcessedContent,
allFiles: QuartzPluginData[],
opts: FullPageLayout,
resources: StaticResources,
) {
const slug = joinSegments("tags", tag) as FullSlug
const [tree, file] = tagContent
const cfg = ctx.cfg.configuration
const externalResources = pageResources(pathToRoot(slug), resources)
const componentData: QuartzComponentProps = {
ctx,
fileData: file.data,
externalResources,
cfg,
children: [],
tree,
allFiles,
}
const content = renderPage(cfg, slug, componentData, opts, externalResources)
return write({
ctx,
content,
slug: file.data.slug!,
ext: ".html",
})
}
export const TagPage: QuartzEmitterPlugin<Partial<TagPageOptions>> = (userOpts) => { export const TagPage: QuartzEmitterPlugin<Partial<TagPageOptions>> = (userOpts) => {
const opts: FullPageLayout = { const opts: FullPageLayout = {
...sharedPageComponents, ...sharedPageComponents,
@ -52,64 +124,46 @@ export const TagPage: QuartzEmitterPlugin<Partial<TagPageOptions>> = (userOpts)
async *emit(ctx, content, resources) { async *emit(ctx, content, resources) {
const allFiles = content.map((c) => c[1].data) const allFiles = content.map((c) => c[1].data)
const cfg = ctx.cfg.configuration const cfg = ctx.cfg.configuration
const [tags, tagDescriptions] = computeTagInfo(allFiles, content, cfg.locale)
const tags: Set<string> = new Set(
allFiles.flatMap((data) => data.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes),
)
// add base tag
tags.add("index")
const tagDescriptions: Record<string, ProcessedContent> = Object.fromEntries(
[...tags].map((tag) => {
const title =
tag === "index"
? i18n(cfg.locale).pages.tagContent.tagIndex
: `${i18n(cfg.locale).pages.tagContent.tag}: ${tag}`
return [
tag,
defaultProcessedContent({
slug: joinSegments("tags", tag) as FullSlug,
frontmatter: { title, tags: [] },
}),
]
}),
)
for (const [tree, file] of content) {
const slug = file.data.slug!
if (slug.startsWith("tags/")) {
const tag = slug.slice("tags/".length)
if (tags.has(tag)) {
tagDescriptions[tag] = [tree, file]
if (file.data.frontmatter?.title === tag) {
file.data.frontmatter.title = `${i18n(cfg.locale).pages.tagContent.tag}: ${tag}`
}
}
}
}
for (const tag of tags) { for (const tag of tags) {
const slug = joinSegments("tags", tag) as FullSlug yield processTagPage(ctx, tag, tagDescriptions[tag], allFiles, opts, resources)
const [tree, file] = tagDescriptions[tag] }
const externalResources = pageResources(pathToRoot(slug), file.data, resources) },
const componentData: QuartzComponentProps = { async *partialEmit(ctx, content, resources, changeEvents) {
ctx, const allFiles = content.map((c) => c[1].data)
fileData: file.data, const cfg = ctx.cfg.configuration
externalResources,
cfg, // Find all tags that need to be updated based on changed files
children: [], const affectedTags: Set<string> = new Set()
tree, for (const changeEvent of changeEvents) {
allFiles, if (!changeEvent.file) continue
const slug = changeEvent.file.data.slug!
// If it's a tag page itself that changed
if (slug.startsWith("tags/")) {
const tag = slug.slice("tags/".length)
affectedTags.add(tag)
} }
const content = renderPage(cfg, slug, componentData, opts, externalResources) // If a file with tags changed, we need to update those tag pages
yield write({ const fileTags = changeEvent.file.data.frontmatter?.tags ?? []
ctx, fileTags.flatMap(getAllSegmentPrefixes).forEach((tag) => affectedTags.add(tag))
content,
slug: file.data.slug!, // Always update the index tag page if any file changes
ext: ".html", affectedTags.add("index")
}) }
// If there are affected tags, rebuild their pages
if (affectedTags.size > 0) {
// We still need to compute all tags because tag pages show all tags
const [_tags, tagDescriptions] = computeTagInfo(allFiles, content, cfg.locale)
for (const tag of affectedTags) {
if (tagDescriptions[tag]) {
yield processTagPage(ctx, tag, tagDescriptions[tag], allFiles, opts, resources)
}
}
} }
}, },
} }

View File

@ -3,7 +3,7 @@ import remarkFrontmatter from "remark-frontmatter"
import { QuartzTransformerPlugin } from "../types" import { QuartzTransformerPlugin } from "../types"
import yaml from "js-yaml" import yaml from "js-yaml"
import toml from "toml" import toml from "toml"
import { FilePath, FullSlug, getFileExtension, joinSegments, slugifyFilePath, slugTag, trimSuffix } from "../../util/path" import { FilePath, FullSlug, getFileExtension, slugifyFilePath, slugTag } from "../../util/path"
import { QuartzPluginData } from "../vfile" import { QuartzPluginData } from "../vfile"
import { i18n } from "../../i18n" import { i18n } from "../../i18n"

View File

@ -54,7 +54,7 @@ export const OxHugoFlavouredMarkdown: QuartzTransformerPlugin<Partial<Options>>
textTransform(_ctx, src) { textTransform(_ctx, src) {
if (opts.wikilinks) { if (opts.wikilinks) {
src = src.toString() src = src.toString()
src = src.replaceAll(relrefRegex, (value, ...capture) => { src = src.replaceAll(relrefRegex, (_value, ...capture) => {
const [text, link] = capture const [text, link] = capture
return `[${text}](${link})` return `[${text}](${link})`
}) })
@ -62,7 +62,7 @@ export const OxHugoFlavouredMarkdown: QuartzTransformerPlugin<Partial<Options>>
if (opts.removePredefinedAnchor) { if (opts.removePredefinedAnchor) {
src = src.toString() src = src.toString()
src = src.replaceAll(predefinedHeadingIdRegex, (value, ...capture) => { src = src.replaceAll(predefinedHeadingIdRegex, (_value, ...capture) => {
const [headingText] = capture const [headingText] = capture
return headingText return headingText
}) })
@ -70,7 +70,7 @@ export const OxHugoFlavouredMarkdown: QuartzTransformerPlugin<Partial<Options>>
if (opts.removeHugoShortcode) { if (opts.removeHugoShortcode) {
src = src.toString() src = src.toString()
src = src.replaceAll(hugoShortcodeRegex, (value, ...capture) => { src = src.replaceAll(hugoShortcodeRegex, (_value, ...capture) => {
const [scContent] = capture const [scContent] = capture
return scContent return scContent
}) })
@ -78,7 +78,7 @@ export const OxHugoFlavouredMarkdown: QuartzTransformerPlugin<Partial<Options>>
if (opts.replaceFigureWithMdImg) { if (opts.replaceFigureWithMdImg) {
src = src.toString() src = src.toString()
src = src.replaceAll(figureTagRegex, (value, ...capture) => { src = src.replaceAll(figureTagRegex, (_value, ...capture) => {
const [src] = capture const [src] = capture
return `![](${src})` return `![](${src})`
}) })
@ -86,11 +86,11 @@ export const OxHugoFlavouredMarkdown: QuartzTransformerPlugin<Partial<Options>>
if (opts.replaceOrgLatex) { if (opts.replaceOrgLatex) {
src = src.toString() src = src.toString()
src = src.replaceAll(inlineLatexRegex, (value, ...capture) => { src = src.replaceAll(inlineLatexRegex, (_value, ...capture) => {
const [eqn] = capture const [eqn] = capture
return `$${eqn}$` return `$${eqn}$`
}) })
src = src.replaceAll(blockLatexRegex, (value, ...capture) => { src = src.replaceAll(blockLatexRegex, (_value, ...capture) => {
const [eqn] = capture const [eqn] = capture
return `$$${eqn}$$` return `$$${eqn}$$`
}) })

View File

@ -1,10 +1,8 @@
import { QuartzTransformerPlugin } from "../types" import { QuartzTransformerPlugin } from "../types"
import { PluggableList } from "unified" import { PluggableList } from "unified"
import { SKIP, visit } from "unist-util-visit" import { visit } from "unist-util-visit"
import { ReplaceFunction, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace" import { ReplaceFunction, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace"
import { Root, Html, Paragraph, Text, Link, Parent } from "mdast" import { Root, Html, Paragraph, Text, Link, Parent } from "mdast"
import { Node } from "unist"
import { VFile } from "vfile"
import { BuildVisitor } from "unist-util-visit" import { BuildVisitor } from "unist-util-visit"
export interface Options { export interface Options {
@ -34,21 +32,10 @@ const defaultOptions: Options = {
const orRegex = new RegExp(/{{or:(.*?)}}/, "g") const orRegex = new RegExp(/{{or:(.*?)}}/, "g")
const TODORegex = new RegExp(/{{.*?\bTODO\b.*?}}/, "g") const TODORegex = new RegExp(/{{.*?\bTODO\b.*?}}/, "g")
const DONERegex = new RegExp(/{{.*?\bDONE\b.*?}}/, "g") const DONERegex = new RegExp(/{{.*?\bDONE\b.*?}}/, "g")
const videoRegex = new RegExp(/{{.*?\[\[video\]\].*?\:(.*?)}}/, "g")
const youtubeRegex = new RegExp(
/{{.*?\[\[video\]\].*?(https?:\/\/(?:www\.)?youtu(?:be\.com\/watch\?v=|\.be\/)([\w\-\_]*)(&(amp;)?[\w\?=]*)?)}}/,
"g",
)
// const multimediaRegex = new RegExp(/{{.*?\b(video|audio)\b.*?\:(.*?)}}/, "g")
const audioRegex = new RegExp(/{{.*?\[\[audio\]\].*?\:(.*?)}}/, "g")
const pdfRegex = new RegExp(/{{.*?\[\[pdf\]\].*?\:(.*?)}}/, "g")
const blockquoteRegex = new RegExp(/(\[\[>\]\])\s*(.*)/, "g") const blockquoteRegex = new RegExp(/(\[\[>\]\])\s*(.*)/, "g")
const roamHighlightRegex = new RegExp(/\^\^(.+)\^\^/, "g") const roamHighlightRegex = new RegExp(/\^\^(.+)\^\^/, "g")
const roamItalicRegex = new RegExp(/__(.+)__/, "g") const roamItalicRegex = new RegExp(/__(.+)__/, "g")
const tableRegex = new RegExp(/- {{.*?\btable\b.*?}}/, "g") /* TODO */
const attributeRegex = new RegExp(/\b\w+(?:\s+\w+)*::/, "g") /* TODO */
function isSpecialEmbed(node: Paragraph): boolean { function isSpecialEmbed(node: Paragraph): boolean {
if (node.children.length !== 2) return false if (node.children.length !== 2) return false
@ -135,7 +122,7 @@ export const RoamFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> | un
const plugins: PluggableList = [] const plugins: PluggableList = []
plugins.push(() => { plugins.push(() => {
return (tree: Root, file: VFile) => { return (tree: Root) => {
const replacements: [RegExp, ReplaceFunction][] = [] const replacements: [RegExp, ReplaceFunction][] = []
// Handle special embeds (audio, video, PDF) // Handle special embeds (audio, video, PDF)

View File

@ -44,17 +44,17 @@ export type QuartzEmitterPlugin<Options extends OptionType = undefined> = (
) => QuartzEmitterPluginInstance ) => QuartzEmitterPluginInstance
export type QuartzEmitterPluginInstance = { export type QuartzEmitterPluginInstance = {
name: string name: string
emit( emit: (
ctx: BuildCtx, ctx: BuildCtx,
content: ProcessedContent[], content: ProcessedContent[],
resources: StaticResources, resources: StaticResources,
): Promise<FilePath[]> | AsyncGenerator<FilePath> ) => Promise<FilePath[]> | AsyncGenerator<FilePath>
partialEmit?( partialEmit?: (
ctx: BuildCtx, ctx: BuildCtx,
content: ProcessedContent[], content: ProcessedContent[],
resources: StaticResources, resources: StaticResources,
changeEvents: ChangeEvent[], changeEvents: ChangeEvent[],
): Promise<FilePath[]> | AsyncGenerator<FilePath> ) => Promise<FilePath[]> | AsyncGenerator<FilePath> | null
/** /**
* Returns the components (if any) that are used in rendering the page. * Returns the components (if any) that are used in rendering the page.
* This helps Quartz optimize the page by only including necessary resources * This helps Quartz optimize the page by only including necessary resources

View File

@ -7,7 +7,7 @@ import { Root as HTMLRoot } from "hast"
import { MarkdownContent, ProcessedContent } from "../plugins/vfile" import { MarkdownContent, ProcessedContent } from "../plugins/vfile"
import { PerfTimer } from "../util/perf" import { PerfTimer } from "../util/perf"
import { read } from "to-vfile" import { read } from "to-vfile"
import { FilePath, FullSlug, QUARTZ, slugifyFilePath } from "../util/path" import { FilePath, QUARTZ, slugifyFilePath } from "../util/path"
import path from "path" import path from "path"
import workerpool, { Promise as WorkerPromise } from "workerpool" import workerpool, { Promise as WorkerPromise } from "workerpool"
import { QuartzLogger } from "../util/log" import { QuartzLogger } from "../util/log"

View File

@ -1,8 +1,8 @@
import sourceMapSupport from "source-map-support" import sourceMapSupport from "source-map-support"
sourceMapSupport.install(options) sourceMapSupport.install(options)
import cfg from "../quartz.config" import cfg from "../quartz.config"
import { Argv, BuildCtx, WorkerSerializableBuildCtx } from "./util/ctx" import { BuildCtx, WorkerSerializableBuildCtx } from "./util/ctx"
import { FilePath, FullSlug } from "./util/path" import { FilePath } from "./util/path"
import { import {
createFileParser, createFileParser,
createHtmlProcessor, createHtmlProcessor,

View File

@ -11,6 +11,8 @@
"skipLibCheck": true, "skipLibCheck": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"esModuleInterop": true, "esModuleInterop": true,
"jsx": "react-jsx", "jsx": "react-jsx",
"jsxImportSource": "preact" "jsxImportSource": "preact"