diff --git a/docs/authoring content.md b/docs/authoring content.md index ea2ef9415..623357fc3 100644 --- a/docs/authoring content.md +++ b/docs/authoring content.md @@ -1,7 +1,5 @@ --- 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. diff --git a/quartz/build.ts b/quartz/build.ts index 326654571..c8212f049 100644 --- a/quartz/build.ts +++ b/quartz/build.ts @@ -9,7 +9,7 @@ import { parseMarkdown } from "./processors/parse" import { filterContent } from "./processors/filter" import { emitContent } from "./processors/emit" import cfg from "../quartz.config" -import { FilePath, FullSlug, joinSegments, slugifyFilePath } from "./util/path" +import { FilePath, joinSegments, slugifyFilePath } from "./util/path" import chokidar from "chokidar" import { ProcessedContent } from "./plugins/vfile" import { Argv, BuildCtx } from "./util/ctx" @@ -173,7 +173,6 @@ async function rebuild(changes: ChangeEvent[], clientRefresh: () => void, buildD const perf = new PerfTimer() perf.addEvent("rebuild") console.log(chalk.yellow("Detected change, rebuilding...")) - console.log(changes) // update changesSinceLastBuild for (const change of changes) { @@ -181,21 +180,17 @@ async function rebuild(changes: ChangeEvent[], clientRefresh: () => void, buildD } const staticResources = getStaticResourcesFromPlugins(ctx) - const processedFiles = await Promise.all( - Object.entries(changesSinceLastBuild) - .filter(([fp, type]) => type !== "delete" && path.extname(fp) === ".md") - .map(async ([fp, _type]) => { - const fullPath = joinSegments(argv.directory, toPosixPath(fp)) as FilePath - const parsed = await parseMarkdown(ctx, [fullPath]) - parsed.forEach((content) => - contentMap.set(content[1].data.relativePath!, { - type: "markdown", - content, - }), - ) - return parsed - }), - ).then((results) => results.flat()) + for (const [fp, type] of Object.entries(changesSinceLastBuild)) { + if (type === "delete" || path.extname(fp) !== ".md") continue + const fullPath = joinSegments(argv.directory, toPosixPath(fp)) as FilePath + const parsed = await parseMarkdown(ctx, [fullPath]) + for (const content of parsed) { + contentMap.set(content[1].data.relativePath!, { + type: "markdown", + content, + }) + } + } // update state using changesSinceLastBuild // we do this weird play of add => compute change events => remove @@ -226,7 +221,7 @@ async function rebuild(changes: ChangeEvent[], clientRefresh: () => void, buildD file, } } - + return { type, path, @@ -236,16 +231,19 @@ async function rebuild(changes: ChangeEvent[], clientRefresh: () => void, buildD // update allFiles and then allSlugs with the consistent view of content map ctx.allFiles = Array.from(contentMap.keys()) ctx.allSlugs = ctx.allFiles.map((fp) => slugifyFilePath(fp as FilePath)) + const processedFiles = Array.from(contentMap.values()) + .filter((file) => file.type === "markdown") + .map((file) => file.content) let emittedFiles = 0 for (const emitter of cfg.plugins.emitters) { // Try to use partialEmit if available, otherwise assume the output is static - const emitFn = emitter.partialEmit - if (!emitFn) { + const emitFn = emitter.partialEmit ?? emitter.emit + const emitted = await emitFn(ctx, processedFiles, staticResources, changeEvents) + if (emitted === null) { continue } - const emitted = await emitFn(ctx, processedFiles, staticResources, changeEvents) if (Symbol.asyncIterator in emitted) { // Async generator case for await (const file of emitted) { diff --git a/quartz/cfg.ts b/quartz/cfg.ts index 1c98b9363..b5de75dd7 100644 --- a/quartz/cfg.ts +++ b/quartz/cfg.ts @@ -2,7 +2,6 @@ import { ValidDateType } from "./components/Date" import { QuartzComponent } from "./components/types" import { ValidLocale } from "./i18n" import { PluginTypes } from "./plugins/types" -import { SocialImageOptions } from "./util/og" import { Theme } from "./util/theme" export type Analytics = diff --git a/quartz/cli/handlers.js b/quartz/cli/handlers.js index 4ac2a4ab9..c41bafc31 100644 --- a/quartz/cli/handlers.js +++ b/quartz/cli/handlers.js @@ -456,6 +456,7 @@ export async function handleBuild(argv) { const paths = await globby([ "**/*.ts", "quartz/cli/*.js", + "quartz/static/**/*", "**/*.tsx", "**/*.scss", "package.json", diff --git a/quartz/components/renderPage.tsx b/quartz/components/renderPage.tsx index c3f10df04..19324f51e 100644 --- a/quartz/components/renderPage.tsx +++ b/quartz/components/renderPage.tsx @@ -9,7 +9,6 @@ import { visit } from "unist-util-visit" import { Root, Element, ElementContent } from "hast" import { GlobalConfiguration } from "../cfg" import { i18n } from "../i18n" -import { QuartzPluginData } from "../plugins/vfile" interface RenderComponents { head: QuartzComponent @@ -25,7 +24,6 @@ interface RenderComponents { const headerRegex = new RegExp(/h[1-6]/) export function pageResources( baseDir: FullSlug | RelativeURL, - fileData: QuartzPluginData, staticResources: StaticResources, ): StaticResources { const contentIndexPath = joinSegments(baseDir, "static/contentIndex.json") diff --git a/quartz/components/scripts/darkmode.inline.ts b/quartz/components/scripts/darkmode.inline.ts index 871eb24d0..d8dfee964 100644 --- a/quartz/components/scripts/darkmode.inline.ts +++ b/quartz/components/scripts/darkmode.inline.ts @@ -10,7 +10,7 @@ const emitThemeChangeEvent = (theme: "light" | "dark") => { } document.addEventListener("nav", () => { - const switchTheme = (e: Event) => { + const switchTheme = () => { const newTheme = document.documentElement.getAttribute("saved-theme") === "dark" ? "light" : "dark" document.documentElement.setAttribute("saved-theme", newTheme) diff --git a/quartz/plugins/emitters/404.tsx b/quartz/plugins/emitters/404.tsx index b0acae79c..04a006dd1 100644 --- a/quartz/plugins/emitters/404.tsx +++ b/quartz/plugins/emitters/404.tsx @@ -3,7 +3,7 @@ import { QuartzComponentProps } from "../../components/types" import BodyConstructor from "../../components/Body" import { pageResources, renderPage } from "../../components/renderPage" import { FullPageLayout } from "../../cfg" -import { FilePath, FullSlug } from "../../util/path" +import { FullSlug } from "../../util/path" import { sharedPageComponents } from "../../../quartz.layout" import { NotFound } from "../../components" import { defaultProcessedContent } from "../vfile" @@ -40,7 +40,7 @@ export const NotFoundPage: QuartzEmitterPlugin = () => { description: notFound, frontmatter: { title: notFound, tags: [] }, }) - const externalResources = pageResources(path, vfile.data, resources) + const externalResources = pageResources(path, resources) const componentData: QuartzComponentProps = { ctx, fileData: vfile.data, @@ -58,5 +58,6 @@ export const NotFoundPage: QuartzEmitterPlugin = () => { ext: ".html", }) }, + async *partialEmit() {}, } } diff --git a/quartz/plugins/emitters/aliases.ts b/quartz/plugins/emitters/aliases.ts index a26d7bc03..327cde85b 100644 --- a/quartz/plugins/emitters/aliases.ts +++ b/quartz/plugins/emitters/aliases.ts @@ -4,7 +4,7 @@ import { write } from "./helpers" import { BuildCtx } from "../../util/ctx" import { VFile } from "vfile" -async function *processFile(ctx: BuildCtx, file: VFile) { +async function* processFile(ctx: BuildCtx, file: VFile) { const ogSlug = simplifySlug(file.data.slug!) for (const slug of file.data.aliases ?? []) { diff --git a/quartz/plugins/emitters/assets.ts b/quartz/plugins/emitters/assets.ts index a10ba41b7..d0da66ace 100644 --- a/quartz/plugins/emitters/assets.ts +++ b/quartz/plugins/emitters/assets.ts @@ -41,12 +41,12 @@ export const Assets: QuartzEmitterPlugin = () => { if (changeEvent.type === "add" || changeEvent.type === "change") { yield copyFile(ctx.argv, changeEvent.path) - } else if (changeEvent.type === 'delete') { + } else if (changeEvent.type === "delete") { const name = slugifyFilePath(changeEvent.path) const dest = joinSegments(ctx.argv.output, name) as FilePath await fs.promises.unlink(dest) - } + } } - } + }, } } diff --git a/quartz/plugins/emitters/cname.ts b/quartz/plugins/emitters/cname.ts index a18574aaf..10781dbbc 100644 --- a/quartz/plugins/emitters/cname.ts +++ b/quartz/plugins/emitters/cname.ts @@ -23,4 +23,5 @@ export const CNAME: QuartzEmitterPlugin = () => ({ await fs.promises.writeFile(path, content) return [path] as FilePath[] }, + async *partialEmit() {}, }) diff --git a/quartz/plugins/emitters/componentResources.ts b/quartz/plugins/emitters/componentResources.ts index 889e5070a..540a3738c 100644 --- a/quartz/plugins/emitters/componentResources.ts +++ b/quartz/plugins/emitters/componentResources.ts @@ -1,4 +1,4 @@ -import { FilePath, FullSlug, joinSegments } from "../../util/path" +import { FullSlug, joinSegments } from "../../util/path" import { QuartzEmitterPlugin } from "../types" // @ts-ignore @@ -293,5 +293,6 @@ export const ComponentResources: QuartzEmitterPlugin = () => { content: postscript, }) }, + async *partialEmit() {}, } } diff --git a/quartz/plugins/emitters/contentPage.tsx b/quartz/plugins/emitters/contentPage.tsx index 277d2e56b..d3f54e912 100644 --- a/quartz/plugins/emitters/contentPage.tsx +++ b/quartz/plugins/emitters/contentPage.tsx @@ -1,21 +1,50 @@ import path from "path" -import { visit } from "unist-util-visit" -import { Root } from "hast" -import { VFile } from "vfile" import { QuartzEmitterPlugin } from "../types" import { QuartzComponentProps } from "../../components/types" import HeaderConstructor from "../../components/Header" import BodyConstructor from "../../components/Body" import { pageResources, renderPage } from "../../components/renderPage" import { FullPageLayout } from "../../cfg" -import { Argv } from "../../util/ctx" -import { FilePath, isRelativeURL, joinSegments, pathToRoot } from "../../util/path" +import { pathToRoot } from "../../util/path" import { defaultContentPageLayout, sharedPageComponents } from "../../../quartz.layout" import { Content } from "../../components" import chalk from "chalk" import { write } from "./helpers" +import { 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> = (userOpts) => { const opts: FullPageLayout = { ...sharedPageComponents, @@ -45,38 +74,18 @@ export const ContentPage: QuartzEmitterPlugin> = (userOp ] }, async *emit(ctx, content, resources) { - const cfg = ctx.cfg.configuration const allFiles = content.map((c) => c[1].data) - let containsIndex = false + for (const [tree, file] of content) { const slug = file.data.slug! if (slug === "index") { containsIndex = true } - if (file.data.slug?.endsWith("/index")) { - continue - } - - const externalResources = pageResources(pathToRoot(slug), file.data, resources) - const componentData: QuartzComponentProps = { - ctx, - fileData: file.data, - externalResources, - cfg, - children: [], - tree, - allFiles, - } - - const content = renderPage(cfg, slug, componentData, opts, externalResources) - yield write({ - ctx, - content, - slug, - ext: ".html", - }) + // only process home page, non-tag pages, and non-index pages + if (slug.endsWith("/index") || slug.startsWith("tags/")) continue + yield processContent(ctx, tree, file.data, allFiles, opts, resources) } if (!containsIndex) { @@ -87,5 +96,25 @@ export const ContentPage: QuartzEmitterPlugin> = (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() + for (const changeEvent of changeEvents) { + if (!changeEvent.file) continue + if (changeEvent.type === "add" || changeEvent.type === "change") { + changedSlugs.add(changeEvent.file.data.slug!) + } + } + + for (const [tree, file] of content) { + const slug = file.data.slug! + if (!changedSlugs.has(slug)) continue + if (slug.endsWith("/index") || slug.startsWith("tags/")) continue + + yield processContent(ctx, tree, file.data, allFiles, opts, resources) + } + }, } } diff --git a/quartz/plugins/emitters/folderPage.tsx b/quartz/plugins/emitters/folderPage.tsx index cdd2c728c..f9b181dff 100644 --- a/quartz/plugins/emitters/folderPage.tsx +++ b/quartz/plugins/emitters/folderPage.tsx @@ -7,7 +7,6 @@ import { ProcessedContent, QuartzPluginData, defaultProcessedContent } from "../ import { FullPageLayout } from "../../cfg" import path from "path" import { - FilePath, FullSlug, SimpleSlug, stripSlashes, @@ -18,12 +17,89 @@ import { import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout" import { FolderContent } from "../../components" 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 { sort?: (f1: QuartzPluginData, f2: QuartzPluginData) => number } +async function* processFolderInfo( + ctx: BuildCtx, + folderInfo: Record, + 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, + content: ProcessedContent[], + locale: keyof typeof TRANSLATIONS, +): Record { + // Create default folder descriptions + const folderInfo: Record = 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> = (userOpts) => { const opts: FullPageLayout = { ...sharedPageComponents, @@ -66,59 +142,29 @@ export const FolderPage: QuartzEmitterPlugin> = (user }), ) - const folderDescriptions: Record = Object.fromEntries( - [...folders].map((folder) => [ - folder, - defaultProcessedContent({ - slug: joinSegments(folder, "index") as FullSlug, - frontmatter: { - title: `${i18n(cfg.locale).pages.folderContent.folder}: ${folder}`, - tags: [], - }, - }), - ]), - ) + const folderInfo = computeFolderInfo(folders, content, cfg.locale) + yield* processFolderInfo(ctx, folderInfo, allFiles, opts, resources) + }, + async *partialEmit(ctx, content, resources, changeEvents) { + const allFiles = content.map((c) => c[1].data) + const cfg = ctx.cfg.configuration - for (const [tree, file] of content) { - const slug = stripSlashes(simplifySlug(file.data.slug!)) as SimpleSlug - if (folders.has(slug)) { - folderDescriptions[slug] = [tree, file] - } + // Find all folders that need to be updated based on changed files + const affectedFolders: Set = new Set() + for (const changeEvent of changeEvents) { + if (!changeEvent.file) continue + const slug = changeEvent.file.data.slug! + const folders = _getFolders(slug).filter( + (folderName) => folderName !== "." && folderName !== "tags", + ) + folders.forEach((folder) => affectedFolders.add(folder)) } - for (const folder of folders) { - const slug = joinSegments(folder, "index") as FullSlug - const [tree, file] = folderDescriptions[folder] - const externalResources = pageResources(pathToRoot(slug), file.data, resources) - const componentData: QuartzComponentProps = { - ctx, - fileData: file.data, - externalResources, - cfg, - children: [], - tree, - allFiles, - } - - const content = renderPage(cfg, slug, componentData, opts, externalResources) - yield write({ - ctx, - content, - slug, - ext: ".html", - }) + // If there are affected folders, rebuild their pages + if (affectedFolders.size > 0) { + const folderInfo = computeFolderInfo(affectedFolders, content, cfg.locale) + yield* processFolderInfo(ctx, folderInfo, allFiles, opts, resources) } }, } } - -function _getFolders(slug: FullSlug): SimpleSlug[] { - var folderName = path.dirname(slug ?? "") as SimpleSlug - const parentFolderNames = [folderName] - - while (folderName !== ".") { - folderName = path.dirname(folderName ?? "") as SimpleSlug - parentFolderNames.push(folderName) - } - return parentFolderNames -} diff --git a/quartz/plugins/emitters/helpers.ts b/quartz/plugins/emitters/helpers.ts index fc14bd97e..6218178a4 100644 --- a/quartz/plugins/emitters/helpers.ts +++ b/quartz/plugins/emitters/helpers.ts @@ -11,8 +11,6 @@ type WriteOptions = { content: string | Buffer | Readable } -type DeleteOptions = Omit - export const write = async ({ ctx, slug, ext, content }: WriteOptions): Promise => { const pathToPage = joinSegments(ctx.argv.output, slug + ext) as FilePath const dir = path.dirname(pathToPage) diff --git a/quartz/plugins/emitters/ogImage.tsx b/quartz/plugins/emitters/ogImage.tsx index 056976a77..f31cc4bd1 100644 --- a/quartz/plugins/emitters/ogImage.tsx +++ b/quartz/plugins/emitters/ogImage.tsx @@ -4,10 +4,12 @@ import { unescapeHTML } from "../../util/escape" import { FullSlug, getFileExtension } from "../../util/path" import { ImageOptions, SocialImageOptions, defaultImage, getSatoriFonts } from "../../util/og" import sharp from "sharp" -import satori from "satori" +import satori, { SatoriOptions } from "satori" import { loadEmoji, getIconCode } from "../../util/emoji" import { Readable } from "stream" import { write } from "./helpers" +import { BuildCtx } from "../../util/ctx" +import { QuartzPluginData } from "../vfile" const defaultOptions: SocialImageOptions = { colorScheme: "lightMode", @@ -42,6 +44,41 @@ async function generateSocialImage( return sharp(Buffer.from(svg)).webp({ quality: 40 }) } +async function processOgImage( + ctx: BuildCtx, + fileData: QuartzPluginData, + fonts: SatoriOptions["fonts"], + fullOptions: SocialImageOptions, +) { + const cfg = ctx.cfg.configuration + const slug = fileData.slug! + const titleSuffix = cfg.pageTitleSuffix ?? "" + const title = + (fileData.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title) + titleSuffix + const description = + fileData.frontmatter?.socialDescription ?? + fileData.frontmatter?.description ?? + unescapeHTML(fileData.description?.trim() ?? i18n(cfg.locale).propertyDefaults.description) + + const stream = await generateSocialImage( + { + title, + description, + fonts, + cfg, + fileData, + }, + fullOptions, + ) + + return write({ + ctx, + content: stream, + slug: `${slug}-og-image` as FullSlug, + ext: ".webp", + }) +} + export const CustomOgImagesEmitterName = "CustomOgImages" export const CustomOgImages: QuartzEmitterPlugin> = (userOpts) => { const fullOptions = { ...defaultOptions, ...userOpts } @@ -58,39 +95,23 @@ export const CustomOgImages: QuartzEmitterPlugin> = const fonts = await getSatoriFonts(headerFont, bodyFont) for (const [_tree, vfile] of content) { - // if this file defines socialImage, we can skip - if (vfile.data.frontmatter?.socialImage !== undefined) { - continue + if (vfile.data.frontmatter?.socialImage !== undefined) continue + yield processOgImage(ctx, vfile.data, fonts, fullOptions) + } + }, + async *partialEmit(ctx, _content, _resources, changeEvents) { + const cfg = ctx.cfg.configuration + const headerFont = cfg.theme.typography.header + const bodyFont = cfg.theme.typography.body + const fonts = await getSatoriFonts(headerFont, bodyFont) + + // find all slugs that changed or were added + for (const changeEvent of changeEvents) { + if (!changeEvent.file) continue + if (changeEvent.file.data.frontmatter?.socialImage !== undefined) continue + if (changeEvent.type === "add" || changeEvent.type === "change") { + yield processOgImage(ctx, changeEvent.file.data, fonts, fullOptions) } - - const slug = vfile.data.slug! - const titleSuffix = cfg.pageTitleSuffix ?? "" - const title = - (vfile.data.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title) + titleSuffix - const description = - vfile.data.frontmatter?.socialDescription ?? - vfile.data.frontmatter?.description ?? - unescapeHTML( - vfile.data.description?.trim() ?? i18n(cfg.locale).propertyDefaults.description, - ) - - const stream = await generateSocialImage( - { - title, - description, - fonts, - cfg, - fileData: vfile.data, - }, - fullOptions, - ) - - yield write({ - ctx, - content: stream, - slug: `${slug}-og-image` as FullSlug, - ext: ".webp", - }) } }, externalResources: (ctx) => { diff --git a/quartz/plugins/emitters/static.ts b/quartz/plugins/emitters/static.ts index 380ee2893..0b4529083 100644 --- a/quartz/plugins/emitters/static.ts +++ b/quartz/plugins/emitters/static.ts @@ -19,4 +19,5 @@ export const Static: QuartzEmitterPlugin = () => ({ yield dest } }, + async *partialEmit() {}, }) diff --git a/quartz/plugins/emitters/tagPage.tsx b/quartz/plugins/emitters/tagPage.tsx index 44fe24662..5f238932d 100644 --- a/quartz/plugins/emitters/tagPage.tsx +++ b/quartz/plugins/emitters/tagPage.tsx @@ -5,22 +5,94 @@ import BodyConstructor from "../../components/Body" import { pageResources, renderPage } from "../../components/renderPage" import { ProcessedContent, QuartzPluginData, defaultProcessedContent } from "../vfile" import { FullPageLayout } from "../../cfg" -import { - FilePath, - FullSlug, - getAllSegmentPrefixes, - joinSegments, - pathToRoot, -} from "../../util/path" +import { FullSlug, getAllSegmentPrefixes, joinSegments, pathToRoot } from "../../util/path" import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout" import { TagContent } from "../../components" import { write } from "./helpers" -import { i18n } from "../../i18n" +import { i18n, TRANSLATIONS } from "../../i18n" +import { BuildCtx } from "../../util/ctx" +import { StaticResources } from "../../util/resources" interface TagPageOptions extends FullPageLayout { sort?: (f1: QuartzPluginData, f2: QuartzPluginData) => number } +function computeTagInfo( + allFiles: QuartzPluginData[], + content: ProcessedContent[], + locale: keyof typeof TRANSLATIONS, +): [Set, Record] { + const tags: Set = new Set( + allFiles.flatMap((data) => data.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes), + ) + + // add base tag + tags.add("index") + + const tagDescriptions: Record = 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> = (userOpts) => { const opts: FullPageLayout = { ...sharedPageComponents, @@ -52,64 +124,46 @@ export const TagPage: QuartzEmitterPlugin> = (userOpts) async *emit(ctx, content, resources) { const allFiles = content.map((c) => c[1].data) const cfg = ctx.cfg.configuration - - const tags: Set = new Set( - allFiles.flatMap((data) => data.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes), - ) - - // add base tag - tags.add("index") - - const tagDescriptions: Record = Object.fromEntries( - [...tags].map((tag) => { - const title = - tag === "index" - ? i18n(cfg.locale).pages.tagContent.tagIndex - : `${i18n(cfg.locale).pages.tagContent.tag}: ${tag}` - return [ - tag, - defaultProcessedContent({ - slug: joinSegments("tags", tag) as FullSlug, - frontmatter: { title, tags: [] }, - }), - ] - }), - ) - - for (const [tree, file] of content) { - const slug = file.data.slug! - if (slug.startsWith("tags/")) { - const tag = slug.slice("tags/".length) - if (tags.has(tag)) { - tagDescriptions[tag] = [tree, file] - if (file.data.frontmatter?.title === tag) { - file.data.frontmatter.title = `${i18n(cfg.locale).pages.tagContent.tag}: ${tag}` - } - } - } - } + const [tags, tagDescriptions] = computeTagInfo(allFiles, content, cfg.locale) for (const tag of tags) { - const slug = joinSegments("tags", tag) as FullSlug - const [tree, file] = tagDescriptions[tag] - const externalResources = pageResources(pathToRoot(slug), file.data, resources) - const componentData: QuartzComponentProps = { - ctx, - fileData: file.data, - externalResources, - cfg, - children: [], - tree, - allFiles, + yield processTagPage(ctx, tag, tagDescriptions[tag], allFiles, opts, resources) + } + }, + async *partialEmit(ctx, content, resources, changeEvents) { + const allFiles = content.map((c) => c[1].data) + const cfg = ctx.cfg.configuration + + // Find all tags that need to be updated based on changed files + const affectedTags: Set = new Set() + for (const changeEvent of changeEvents) { + if (!changeEvent.file) continue + const slug = changeEvent.file.data.slug! + + // If it's a tag page itself that changed + if (slug.startsWith("tags/")) { + const tag = slug.slice("tags/".length) + affectedTags.add(tag) } - const content = renderPage(cfg, slug, componentData, opts, externalResources) - yield write({ - ctx, - content, - slug: file.data.slug!, - ext: ".html", - }) + // If a file with tags changed, we need to update those tag pages + const fileTags = changeEvent.file.data.frontmatter?.tags ?? [] + fileTags.flatMap(getAllSegmentPrefixes).forEach((tag) => affectedTags.add(tag)) + + // Always update the index tag page if any file changes + affectedTags.add("index") + } + + // If there are affected tags, rebuild their pages + if (affectedTags.size > 0) { + // We still need to compute all tags because tag pages show all tags + const [_tags, tagDescriptions] = computeTagInfo(allFiles, content, cfg.locale) + + for (const tag of affectedTags) { + if (tagDescriptions[tag]) { + yield processTagPage(ctx, tag, tagDescriptions[tag], allFiles, opts, resources) + } + } } }, } diff --git a/quartz/plugins/transformers/frontmatter.ts b/quartz/plugins/transformers/frontmatter.ts index d4f31c204..c04c52a24 100644 --- a/quartz/plugins/transformers/frontmatter.ts +++ b/quartz/plugins/transformers/frontmatter.ts @@ -3,7 +3,7 @@ import remarkFrontmatter from "remark-frontmatter" import { QuartzTransformerPlugin } from "../types" import yaml from "js-yaml" 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 { i18n } from "../../i18n" diff --git a/quartz/plugins/transformers/oxhugofm.ts b/quartz/plugins/transformers/oxhugofm.ts index cdbffcffd..0612c7a9d 100644 --- a/quartz/plugins/transformers/oxhugofm.ts +++ b/quartz/plugins/transformers/oxhugofm.ts @@ -54,7 +54,7 @@ export const OxHugoFlavouredMarkdown: QuartzTransformerPlugin> textTransform(_ctx, src) { if (opts.wikilinks) { src = src.toString() - src = src.replaceAll(relrefRegex, (value, ...capture) => { + src = src.replaceAll(relrefRegex, (_value, ...capture) => { const [text, link] = capture return `[${text}](${link})` }) @@ -62,7 +62,7 @@ export const OxHugoFlavouredMarkdown: QuartzTransformerPlugin> if (opts.removePredefinedAnchor) { src = src.toString() - src = src.replaceAll(predefinedHeadingIdRegex, (value, ...capture) => { + src = src.replaceAll(predefinedHeadingIdRegex, (_value, ...capture) => { const [headingText] = capture return headingText }) @@ -70,7 +70,7 @@ export const OxHugoFlavouredMarkdown: QuartzTransformerPlugin> if (opts.removeHugoShortcode) { src = src.toString() - src = src.replaceAll(hugoShortcodeRegex, (value, ...capture) => { + src = src.replaceAll(hugoShortcodeRegex, (_value, ...capture) => { const [scContent] = capture return scContent }) @@ -78,7 +78,7 @@ export const OxHugoFlavouredMarkdown: QuartzTransformerPlugin> if (opts.replaceFigureWithMdImg) { src = src.toString() - src = src.replaceAll(figureTagRegex, (value, ...capture) => { + src = src.replaceAll(figureTagRegex, (_value, ...capture) => { const [src] = capture return `![](${src})` }) @@ -86,11 +86,11 @@ export const OxHugoFlavouredMarkdown: QuartzTransformerPlugin> if (opts.replaceOrgLatex) { src = src.toString() - src = src.replaceAll(inlineLatexRegex, (value, ...capture) => { + src = src.replaceAll(inlineLatexRegex, (_value, ...capture) => { const [eqn] = capture return `$${eqn}$` }) - src = src.replaceAll(blockLatexRegex, (value, ...capture) => { + src = src.replaceAll(blockLatexRegex, (_value, ...capture) => { const [eqn] = capture return `$$${eqn}$$` }) diff --git a/quartz/plugins/transformers/roam.ts b/quartz/plugins/transformers/roam.ts index b3be8f542..b6df67a8f 100644 --- a/quartz/plugins/transformers/roam.ts +++ b/quartz/plugins/transformers/roam.ts @@ -1,10 +1,8 @@ import { QuartzTransformerPlugin } from "../types" import { PluggableList } from "unified" -import { SKIP, visit } from "unist-util-visit" +import { visit } from "unist-util-visit" import { ReplaceFunction, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace" import { Root, Html, Paragraph, Text, Link, Parent } from "mdast" -import { Node } from "unist" -import { VFile } from "vfile" import { BuildVisitor } from "unist-util-visit" export interface Options { @@ -34,21 +32,10 @@ const defaultOptions: Options = { const orRegex = new RegExp(/{{or:(.*?)}}/, "g") const TODORegex = new RegExp(/{{.*?\bTODO\b.*?}}/, "g") const DONERegex = new RegExp(/{{.*?\bDONE\b.*?}}/, "g") -const videoRegex = new RegExp(/{{.*?\[\[video\]\].*?\:(.*?)}}/, "g") -const youtubeRegex = new RegExp( - /{{.*?\[\[video\]\].*?(https?:\/\/(?:www\.)?youtu(?:be\.com\/watch\?v=|\.be\/)([\w\-\_]*)(&(amp;)?[\w\?=]*)?)}}/, - "g", -) -// const multimediaRegex = new RegExp(/{{.*?\b(video|audio)\b.*?\:(.*?)}}/, "g") - -const audioRegex = new RegExp(/{{.*?\[\[audio\]\].*?\:(.*?)}}/, "g") -const pdfRegex = new RegExp(/{{.*?\[\[pdf\]\].*?\:(.*?)}}/, "g") const blockquoteRegex = new RegExp(/(\[\[>\]\])\s*(.*)/, "g") const roamHighlightRegex = new RegExp(/\^\^(.+)\^\^/, "g") const roamItalicRegex = new RegExp(/__(.+)__/, "g") -const tableRegex = new RegExp(/- {{.*?\btable\b.*?}}/, "g") /* TODO */ -const attributeRegex = new RegExp(/\b\w+(?:\s+\w+)*::/, "g") /* TODO */ function isSpecialEmbed(node: Paragraph): boolean { if (node.children.length !== 2) return false @@ -135,7 +122,7 @@ export const RoamFlavoredMarkdown: QuartzTransformerPlugin | un const plugins: PluggableList = [] plugins.push(() => { - return (tree: Root, file: VFile) => { + return (tree: Root) => { const replacements: [RegExp, ReplaceFunction][] = [] // Handle special embeds (audio, video, PDF) diff --git a/quartz/plugins/types.ts b/quartz/plugins/types.ts index f3627c826..2a7c16c5d 100644 --- a/quartz/plugins/types.ts +++ b/quartz/plugins/types.ts @@ -44,17 +44,17 @@ export type QuartzEmitterPlugin = ( ) => QuartzEmitterPluginInstance export type QuartzEmitterPluginInstance = { name: string - emit( + emit: ( ctx: BuildCtx, content: ProcessedContent[], resources: StaticResources, - ): Promise | AsyncGenerator - partialEmit?( + ) => Promise | AsyncGenerator + partialEmit?: ( ctx: BuildCtx, content: ProcessedContent[], resources: StaticResources, changeEvents: ChangeEvent[], - ): Promise | AsyncGenerator + ) => Promise | AsyncGenerator | null /** * Returns the components (if any) that are used in rendering the page. * This helps Quartz optimize the page by only including necessary resources diff --git a/quartz/processors/parse.ts b/quartz/processors/parse.ts index 56421c8f8..3a0d15a48 100644 --- a/quartz/processors/parse.ts +++ b/quartz/processors/parse.ts @@ -7,7 +7,7 @@ import { Root as HTMLRoot } from "hast" import { MarkdownContent, ProcessedContent } from "../plugins/vfile" import { PerfTimer } from "../util/perf" import { read } from "to-vfile" -import { FilePath, FullSlug, QUARTZ, slugifyFilePath } from "../util/path" +import { FilePath, QUARTZ, slugifyFilePath } from "../util/path" import path from "path" import workerpool, { Promise as WorkerPromise } from "workerpool" import { QuartzLogger } from "../util/log" diff --git a/quartz/worker.ts b/quartz/worker.ts index f47726441..f4cf4c600 100644 --- a/quartz/worker.ts +++ b/quartz/worker.ts @@ -1,8 +1,8 @@ import sourceMapSupport from "source-map-support" sourceMapSupport.install(options) import cfg from "../quartz.config" -import { Argv, BuildCtx, WorkerSerializableBuildCtx } from "./util/ctx" -import { FilePath, FullSlug } from "./util/path" +import { BuildCtx, WorkerSerializableBuildCtx } from "./util/ctx" +import { FilePath } from "./util/path" import { createFileParser, createHtmlProcessor, diff --git a/tsconfig.json b/tsconfig.json index 784ab231b..637d09605 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,8 @@ "skipLibCheck": true, "allowSyntheticDefaultImports": true, "forceConsistentCasingInFileNames": true, + "noUnusedLocals": true, + "noUnusedParameters": true, "esModuleInterop": true, "jsx": "react-jsx", "jsxImportSource": "preact"