diff --git a/quartz/plugins/emitters/aliases.ts b/quartz/plugins/emitters/aliases.ts index 9cb9bd576..e9bfcb4bb 100644 --- a/quartz/plugins/emitters/aliases.ts +++ b/quartz/plugins/emitters/aliases.ts @@ -1,4 +1,4 @@ -import { FullSlug, isRelativeURL, resolveRelative, simplifySlug } from "../../util/path" +import { FullSlug } from "../../util/path" import { QuartzEmitterPlugin } from "../types" import { write } from "./helpers" import { BuildCtx } from "../../util/ctx" @@ -6,16 +6,17 @@ import { VFile } from "vfile" import path from "path" async function* processFile(ctx: BuildCtx, file: VFile) { - const ogSlug = simplifySlug(file.data.slug!) + const { utils } = ctx + const ogSlug = utils!.path.simplify(file.data.slug!) for (const aliasTarget of file.data.aliases ?? []) { const aliasTargetSlug = ( - isRelativeURL(aliasTarget) + utils!.path.isRelativeURL(aliasTarget) ? path.normalize(path.join(ogSlug, "..", aliasTarget)) : aliasTarget ) as FullSlug - const redirUrl = resolveRelative(aliasTargetSlug, ogSlug) + const redirUrl = utils!.path.resolveRelative(aliasTargetSlug, ogSlug) yield write({ ctx, content: ` diff --git a/quartz/plugins/emitters/assets.ts b/quartz/plugins/emitters/assets.ts index d0da66ace..93b5b1c61 100644 --- a/quartz/plugins/emitters/assets.ts +++ b/quartz/plugins/emitters/assets.ts @@ -1,21 +1,22 @@ -import { FilePath, joinSegments, slugifyFilePath } from "../../util/path" +import { FilePath } from "../../util/path" import { QuartzEmitterPlugin } from "../types" import path from "path" import fs from "fs" import { glob } from "../../util/glob" import { Argv } from "../../util/ctx" import { QuartzConfig } from "../../cfg" +import { PluginUtilities } from "../plugin-context" const filesToCopy = async (argv: Argv, cfg: QuartzConfig) => { // glob all non MD files in content folder and copy it over return await glob("**", argv.directory, ["**/*.md", ...cfg.configuration.ignorePatterns]) } -const copyFile = async (argv: Argv, fp: FilePath) => { - const src = joinSegments(argv.directory, fp) as FilePath +const copyFile = async (argv: Argv, fp: FilePath, utils: PluginUtilities) => { + const src = utils.path.join(argv.directory, fp) as FilePath - const name = slugifyFilePath(fp) - const dest = joinSegments(argv.output, name) as FilePath + const name = utils.path.slugify(fp) + const dest = utils.path.join(argv.output, name) as FilePath // ensure dir exists const dir = path.dirname(dest) as FilePath @@ -28,22 +29,24 @@ const copyFile = async (argv: Argv, fp: FilePath) => { export const Assets: QuartzEmitterPlugin = () => { return { name: "Assets", - async *emit({ argv, cfg }) { + async *emit(ctx) { + const { argv, cfg, utils } = ctx const fps = await filesToCopy(argv, cfg) for (const fp of fps) { - yield copyFile(argv, fp) + yield copyFile(argv, fp, utils!) } }, async *partialEmit(ctx, _content, _resources, changeEvents) { + const { utils } = ctx for (const changeEvent of changeEvents) { const ext = path.extname(changeEvent.path) if (ext === ".md") continue if (changeEvent.type === "add" || changeEvent.type === "change") { - yield copyFile(ctx.argv, changeEvent.path) + yield copyFile(ctx.argv, changeEvent.path, utils!) } else if (changeEvent.type === "delete") { - const name = slugifyFilePath(changeEvent.path) - const dest = joinSegments(ctx.argv.output, name) as FilePath + const name = utils!.path.slugify(changeEvent.path) + const dest = utils!.path.join(ctx.argv.output, name) as FilePath await fs.promises.unlink(dest) } } diff --git a/quartz/plugins/emitters/componentResources.ts b/quartz/plugins/emitters/componentResources.ts index 9c5ee186f..77a61bd00 100644 --- a/quartz/plugins/emitters/componentResources.ts +++ b/quartz/plugins/emitters/componentResources.ts @@ -1,4 +1,4 @@ -import { FullSlug, joinSegments } from "../../util/path" +import { FullSlug } from "../../util/path" import { QuartzEmitterPlugin } from "../types" // @ts-ignore @@ -311,7 +311,7 @@ export const ComponentResources: QuartzEmitterPlugin = () => { const buf = await res.arrayBuffer() yield write({ ctx, - slug: joinSegments("static", "fonts", fontFile.filename) as FullSlug, + slug: ctx.utils!.path.join("static", "fonts", fontFile.filename) as FullSlug, ext: `.${fontFile.extension}`, content: Buffer.from(buf), }) diff --git a/quartz/plugins/emitters/contentIndex.tsx b/quartz/plugins/emitters/contentIndex.tsx index 56392b358..503cec6b8 100644 --- a/quartz/plugins/emitters/contentIndex.tsx +++ b/quartz/plugins/emitters/contentIndex.tsx @@ -1,12 +1,12 @@ import { Root } from "hast" import { GlobalConfiguration } from "../../cfg" import { getDate } from "../../components/Date" -import { escapeHTML } from "../../util/escape" -import { FilePath, FullSlug, SimpleSlug, joinSegments, simplifySlug } from "../../util/path" +import { FilePath, FullSlug, SimpleSlug } from "../../util/path" import { QuartzEmitterPlugin } from "../types" import { toHtml } from "hast-util-to-html" import { write } from "./helpers" import { i18n } from "../../i18n" +import { PluginUtilities } from "../plugin-context" export type ContentIndexMap = Map export type ContentDetails = { @@ -39,25 +39,34 @@ const defaultOptions: Options = { includeEmptyFiles: true, } -function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndexMap): string { +function generateSiteMap( + cfg: GlobalConfiguration, + idx: ContentIndexMap, + utils: PluginUtilities, +): string { const base = cfg.baseUrl ?? "" const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => ` - https://${joinSegments(base, encodeURI(slug))} + https://${utils.path.join(base, encodeURI(slug))} ${content.date && `${content.date.toISOString()}`} ` const urls = Array.from(idx) - .map(([slug, content]) => createURLEntry(simplifySlug(slug), content)) + .map(([slug, content]) => createURLEntry(utils.path.simplify(slug), content)) .join("") return `${urls}` } -function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndexMap, limit?: number): string { +function generateRSSFeed( + cfg: GlobalConfiguration, + idx: ContentIndexMap, + utils: PluginUtilities, + limit?: number, +): string { const base = cfg.baseUrl ?? "" const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => ` - ${escapeHTML(content.title)} - https://${joinSegments(base, encodeURI(slug))} - https://${joinSegments(base, encodeURI(slug))} + ${utils.escape.html(content.title)} + https://${utils.path.join(base, encodeURI(slug))} + https://${utils.path.join(base, encodeURI(slug))} ${content.date?.toUTCString()} ` @@ -74,16 +83,16 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndexMap, limit?: return f1.title.localeCompare(f2.title) }) - .map(([slug, content]) => createURLEntry(simplifySlug(slug), content)) + .map(([slug, content]) => createURLEntry(utils.path.simplify(slug), content)) .slice(0, limit ?? idx.size) .join("") return ` - ${escapeHTML(cfg.pageTitle)} + ${utils.escape.html(cfg.pageTitle)} https://${base} - ${!!limit ? i18n(cfg.locale).pages.rss.lastFewNotes({ count: limit }) : i18n(cfg.locale).pages.rss.recentNotes} on ${escapeHTML( + ${!!limit ? i18n(cfg.locale).pages.rss.lastFewNotes({ count: limit }) : i18n(cfg.locale).pages.rss.recentNotes} on ${utils.escape.html( cfg.pageTitle, )} Quartz -- quartz.jzhao.xyz @@ -97,6 +106,7 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { return { name: "ContentIndex", async *emit(ctx, content) { + const { utils } = ctx const cfg = ctx.cfg.configuration const linkIndex: ContentIndexMap = new Map() for (const [tree, file] of content) { @@ -111,7 +121,7 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { tags: file.data.frontmatter?.tags ?? [], content: file.data.text ?? "", richContent: opts?.rssFullHtml - ? escapeHTML(toHtml(tree as Root, { allowDangerousHtml: true })) + ? utils!.escape.html(toHtml(tree as Root, { allowDangerousHtml: true })) : undefined, date: date, description: file.data.description ?? "", @@ -122,7 +132,7 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { if (opts?.enableSiteMap) { yield write({ ctx, - content: generateSiteMap(cfg, linkIndex), + content: generateSiteMap(cfg, linkIndex, utils!), slug: "sitemap" as FullSlug, ext: ".xml", }) @@ -131,13 +141,13 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { if (opts?.enableRSS) { yield write({ ctx, - content: generateRSSFeed(cfg, linkIndex, opts.rssLimit), + content: generateRSSFeed(cfg, linkIndex, utils!, opts.rssLimit), slug: (opts?.rssSlug ?? "index") as FullSlug, ext: ".xml", }) } - const fp = joinSegments("static", "contentIndex") as FullSlug + const fp = utils!.path.join("static", "contentIndex") as FullSlug const simplifiedIndex = Object.fromEntries( Array.from(linkIndex).map(([slug, content]) => { // remove description and from content index as nothing downstream diff --git a/quartz/plugins/emitters/contentPage.tsx b/quartz/plugins/emitters/contentPage.tsx index c3410ecc3..e9d53eb7a 100644 --- a/quartz/plugins/emitters/contentPage.tsx +++ b/quartz/plugins/emitters/contentPage.tsx @@ -5,7 +5,6 @@ import HeaderConstructor from "../../components/Header" import BodyConstructor from "../../components/Body" import { pageResources, renderPage } from "../../components/renderPage" import { FullPageLayout } from "../../cfg" -import { pathToRoot } from "../../util/path" import { defaultContentPageLayout, sharedPageComponents } from "../../../quartz.layout" import { Content } from "../../components" import { styleText } from "util" @@ -25,7 +24,7 @@ async function processContent( ) { const slug = fileData.slug! const cfg = ctx.cfg.configuration - const externalResources = pageResources(pathToRoot(slug), resources) + const externalResources = pageResources(ctx.utils!.path.toRoot(slug), resources) const componentData: QuartzComponentProps = { ctx, fileData, diff --git a/quartz/plugins/emitters/favicon.ts b/quartz/plugins/emitters/favicon.ts index b05f9309d..970905672 100644 --- a/quartz/plugins/emitters/favicon.ts +++ b/quartz/plugins/emitters/favicon.ts @@ -1,18 +1,18 @@ import sharp from "sharp" -import { joinSegments, QUARTZ, FullSlug } from "../../util/path" +import { FullSlug } from "../../util/path" import { QuartzEmitterPlugin } from "../types" import { write } from "./helpers" -import { BuildCtx } from "../../util/ctx" export const Favicon: QuartzEmitterPlugin = () => ({ name: "Favicon", - async *emit({ argv }) { - const iconPath = joinSegments(QUARTZ, "static", "icon.png") + async *emit(ctx) { + const { utils } = ctx + const iconPath = utils!.path.join(utils!.path.QUARTZ, "static", "icon.png") const faviconContent = sharp(iconPath).resize(48, 48).toFormat("png") yield write({ - ctx: { argv } as BuildCtx, + ctx, slug: "favicon" as FullSlug, ext: ".ico", content: faviconContent, diff --git a/quartz/plugins/emitters/folderPage.tsx b/quartz/plugins/emitters/folderPage.tsx index f9b181dff..d5a22075d 100644 --- a/quartz/plugins/emitters/folderPage.tsx +++ b/quartz/plugins/emitters/folderPage.tsx @@ -6,20 +6,15 @@ import { pageResources, renderPage } from "../../components/renderPage" import { ProcessedContent, QuartzPluginData, defaultProcessedContent } from "../vfile" import { FullPageLayout } from "../../cfg" import path from "path" -import { - FullSlug, - SimpleSlug, - stripSlashes, - joinSegments, - pathToRoot, - simplifySlug, -} from "../../util/path" +import { FullSlug, SimpleSlug } from "../../util/path" import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout" import { FolderContent } from "../../components" import { write } from "./helpers" import { i18n, TRANSLATIONS } from "../../i18n" import { BuildCtx } from "../../util/ctx" import { StaticResources } from "../../util/resources" +import { PluginUtilities } from "../plugin-context" + interface FolderPageOptions extends FullPageLayout { sort?: (f1: QuartzPluginData, f2: QuartzPluginData) => number } @@ -31,14 +26,15 @@ async function* processFolderInfo( opts: FullPageLayout, resources: StaticResources, ) { + const { utils } = ctx for (const [folder, folderContent] of Object.entries(folderInfo) as [ SimpleSlug, ProcessedContent, ][]) { - const slug = joinSegments(folder, "index") as FullSlug + const slug = utils!.path.join(folder, "index") as FullSlug const [tree, file] = folderContent const cfg = ctx.cfg.configuration - const externalResources = pageResources(pathToRoot(slug), resources) + const externalResources = pageResources(utils!.path.toRoot(slug), resources) const componentData: QuartzComponentProps = { ctx, fileData: file.data, @@ -63,13 +59,14 @@ function computeFolderInfo( folders: Set, content: ProcessedContent[], locale: keyof typeof TRANSLATIONS, + utils: PluginUtilities, ): Record { // Create default folder descriptions const folderInfo: Record = Object.fromEntries( [...folders].map((folder) => [ folder, defaultProcessedContent({ - slug: joinSegments(folder, "index") as FullSlug, + slug: utils.path.join(folder, "index") as FullSlug, frontmatter: { title: `${i18n(locale).pages.folderContent.folder}: ${folder}`, tags: [], @@ -80,7 +77,7 @@ function computeFolderInfo( // Update with actual content if available for (const [tree, file] of content) { - const slug = stripSlashes(simplifySlug(file.data.slug!)) as SimpleSlug + const slug = utils.path.stripSlashes(utils.path.simplify(file.data.slug!)) as SimpleSlug if (folders.has(slug)) { folderInfo[slug] = [tree, file] } @@ -129,6 +126,7 @@ export const FolderPage: QuartzEmitterPlugin> = (user ] }, async *emit(ctx, content, resources) { + const { utils } = ctx const allFiles = content.map((c) => c[1].data) const cfg = ctx.cfg.configuration @@ -142,10 +140,11 @@ export const FolderPage: QuartzEmitterPlugin> = (user }), ) - const folderInfo = computeFolderInfo(folders, content, cfg.locale) + const folderInfo = computeFolderInfo(folders, content, cfg.locale, utils!) yield* processFolderInfo(ctx, folderInfo, allFiles, opts, resources) }, async *partialEmit(ctx, content, resources, changeEvents) { + const { utils } = ctx const allFiles = content.map((c) => c[1].data) const cfg = ctx.cfg.configuration @@ -162,7 +161,7 @@ export const FolderPage: QuartzEmitterPlugin> = (user // If there are affected folders, rebuild their pages if (affectedFolders.size > 0) { - const folderInfo = computeFolderInfo(affectedFolders, content, cfg.locale) + const folderInfo = computeFolderInfo(affectedFolders, content, cfg.locale, utils!) yield* processFolderInfo(ctx, folderInfo, allFiles, opts, resources) } }, diff --git a/quartz/plugins/emitters/helpers.ts b/quartz/plugins/emitters/helpers.ts index 6218178a4..014b04b66 100644 --- a/quartz/plugins/emitters/helpers.ts +++ b/quartz/plugins/emitters/helpers.ts @@ -1,7 +1,7 @@ import path from "path" import fs from "fs" import { BuildCtx } from "../../util/ctx" -import { FilePath, FullSlug, joinSegments } from "../../util/path" +import { FilePath, FullSlug } from "../../util/path" import { Readable } from "stream" type WriteOptions = { @@ -12,7 +12,7 @@ type WriteOptions = { } export const write = async ({ ctx, slug, ext, content }: WriteOptions): Promise => { - const pathToPage = joinSegments(ctx.argv.output, slug + ext) as FilePath + const pathToPage = ctx.utils!.path.join(ctx.argv.output, slug + ext) as FilePath const dir = path.dirname(pathToPage) await fs.promises.mkdir(dir, { recursive: true }) await fs.promises.writeFile(pathToPage, content) diff --git a/quartz/plugins/emitters/ogImage.tsx b/quartz/plugins/emitters/ogImage.tsx index 813d9348c..c666c1175 100644 --- a/quartz/plugins/emitters/ogImage.tsx +++ b/quartz/plugins/emitters/ogImage.tsx @@ -1,7 +1,6 @@ import { QuartzEmitterPlugin } from "../types" import { i18n } from "../../i18n" -import { unescapeHTML } from "../../util/escape" -import { FullSlug, getFileExtension, isAbsoluteURL, joinSegments, QUARTZ } from "../../util/path" +import { FullSlug } from "../../util/path" import { ImageOptions, SocialImageOptions, defaultImage, getSatoriFonts } from "../../util/og" import sharp from "sharp" import satori, { SatoriOptions } from "satori" @@ -12,6 +11,7 @@ import { BuildCtx } from "../../util/ctx" import { QuartzPluginData } from "../vfile" import fs from "node:fs/promises" import { styleText } from "util" +import { PluginUtilities } from "../plugin-context" const defaultOptions: SocialImageOptions = { colorScheme: "lightMode", @@ -28,9 +28,10 @@ const defaultOptions: SocialImageOptions = { async function generateSocialImage( { cfg, description, fonts, title, fileData }: ImageOptions, userOpts: SocialImageOptions, + utils: PluginUtilities, ): Promise { const { width, height } = userOpts - const iconPath = joinSegments(QUARTZ, "static", "icon.png") + const iconPath = utils.path.join(utils.path.QUARTZ, "static", "icon.png") let iconBase64: string | undefined = undefined try { const iconData = await fs.readFile(iconPath) @@ -71,6 +72,7 @@ async function processOgImage( fonts: SatoriOptions["fonts"], fullOptions: SocialImageOptions, ) { + const { utils } = ctx const cfg = ctx.cfg.configuration const slug = fileData.slug! const titleSuffix = cfg.pageTitleSuffix ?? "" @@ -79,7 +81,9 @@ async function processOgImage( const description = fileData.frontmatter?.socialDescription ?? fileData.frontmatter?.description ?? - unescapeHTML(fileData.description?.trim() ?? i18n(cfg.locale).propertyDefaults.description) + utils!.escape.unescape( + fileData.description?.trim() ?? i18n(cfg.locale).propertyDefaults.description, + ) const stream = await generateSocialImage( { @@ -90,6 +94,7 @@ async function processOgImage( fileData, }, fullOptions, + utils!, ) return write({ @@ -136,6 +141,7 @@ export const CustomOgImages: QuartzEmitterPlugin> = } }, externalResources: (ctx) => { + const { utils } = ctx if (!ctx.cfg.configuration.baseUrl) { return {} } @@ -148,7 +154,7 @@ export const CustomOgImages: QuartzEmitterPlugin> = let userDefinedOgImagePath = pageData.frontmatter?.socialImage if (userDefinedOgImagePath) { - userDefinedOgImagePath = isAbsoluteURL(userDefinedOgImagePath) + userDefinedOgImagePath = utils!.path.isAbsoluteURL(userDefinedOgImagePath) ? userDefinedOgImagePath : `https://${baseUrl}/static/${userDefinedOgImagePath}` } @@ -158,7 +164,7 @@ export const CustomOgImages: QuartzEmitterPlugin> = : undefined const defaultOgImagePath = `https://${baseUrl}/static/og-image.png` const ogImagePath = userDefinedOgImagePath ?? generatedOgImagePath ?? defaultOgImagePath - const ogImageMimeType = `image/${getFileExtension(ogImagePath) ?? "png"}` + const ogImageMimeType = `image/${utils!.path.getFileExtension(ogImagePath) ?? "png"}` return ( <> {!userDefinedOgImagePath && ( diff --git a/quartz/plugins/emitters/static.ts b/quartz/plugins/emitters/static.ts index 0b4529083..778f3f7eb 100644 --- a/quartz/plugins/emitters/static.ts +++ b/quartz/plugins/emitters/static.ts @@ -1,4 +1,4 @@ -import { FilePath, QUARTZ, joinSegments } from "../../util/path" +import { FilePath } from "../../util/path" import { QuartzEmitterPlugin } from "../types" import fs from "fs" import { glob } from "../../util/glob" @@ -6,14 +6,15 @@ import { dirname } from "path" export const Static: QuartzEmitterPlugin = () => ({ name: "Static", - async *emit({ argv, cfg }) { - const staticPath = joinSegments(QUARTZ, "static") + async *emit(ctx) { + const { argv, cfg, utils } = ctx + const staticPath = utils!.path.join(utils!.path.QUARTZ, "static") const fps = await glob("**", staticPath, cfg.configuration.ignorePatterns) - const outputStaticPath = joinSegments(argv.output, "static") + const outputStaticPath = utils!.path.join(argv.output, "static") await fs.promises.mkdir(outputStaticPath, { recursive: true }) for (const fp of fps) { - const src = joinSegments(staticPath, fp) as FilePath - const dest = joinSegments(outputStaticPath, fp) as FilePath + const src = utils!.path.join(staticPath, fp) as FilePath + const dest = utils!.path.join(outputStaticPath, fp) as FilePath await fs.promises.mkdir(dirname(dest), { recursive: true }) await fs.promises.copyFile(src, dest) yield dest diff --git a/quartz/plugins/emitters/tagPage.tsx b/quartz/plugins/emitters/tagPage.tsx index 5f238932d..0dd5ab9cf 100644 --- a/quartz/plugins/emitters/tagPage.tsx +++ b/quartz/plugins/emitters/tagPage.tsx @@ -5,13 +5,14 @@ import BodyConstructor from "../../components/Body" import { pageResources, renderPage } from "../../components/renderPage" import { ProcessedContent, QuartzPluginData, defaultProcessedContent } from "../vfile" import { FullPageLayout } from "../../cfg" -import { FullSlug, getAllSegmentPrefixes, joinSegments, pathToRoot } from "../../util/path" +import { FullSlug } from "../../util/path" import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout" import { TagContent } from "../../components" import { write } from "./helpers" import { i18n, TRANSLATIONS } from "../../i18n" import { BuildCtx } from "../../util/ctx" import { StaticResources } from "../../util/resources" +import { PluginUtilities } from "../plugin-context" interface TagPageOptions extends FullPageLayout { sort?: (f1: QuartzPluginData, f2: QuartzPluginData) => number @@ -21,9 +22,12 @@ function computeTagInfo( allFiles: QuartzPluginData[], content: ProcessedContent[], locale: keyof typeof TRANSLATIONS, + utils: PluginUtilities, ): [Set, Record] { const tags: Set = new Set( - allFiles.flatMap((data) => data.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes), + allFiles + .flatMap((data) => data.frontmatter?.tags ?? []) + .flatMap(utils.path.getAllSegmentPrefixes), ) // add base tag @@ -38,7 +42,7 @@ function computeTagInfo( return [ tag, defaultProcessedContent({ - slug: joinSegments("tags", tag) as FullSlug, + slug: utils.path.join("tags", tag) as FullSlug, frontmatter: { title, tags: [] }, }), ] @@ -70,10 +74,11 @@ async function processTagPage( opts: FullPageLayout, resources: StaticResources, ) { - const slug = joinSegments("tags", tag) as FullSlug + const { utils } = ctx + const slug = utils!.path.join("tags", tag) as FullSlug const [tree, file] = tagContent const cfg = ctx.cfg.configuration - const externalResources = pageResources(pathToRoot(slug), resources) + const externalResources = pageResources(utils!.path.toRoot(slug), resources) const componentData: QuartzComponentProps = { ctx, fileData: file.data, @@ -122,15 +127,17 @@ export const TagPage: QuartzEmitterPlugin> = (userOpts) ] }, async *emit(ctx, content, resources) { + const { utils } = ctx const allFiles = content.map((c) => c[1].data) const cfg = ctx.cfg.configuration - const [tags, tagDescriptions] = computeTagInfo(allFiles, content, cfg.locale) + const [tags, tagDescriptions] = computeTagInfo(allFiles, content, cfg.locale, utils!) for (const tag of tags) { yield processTagPage(ctx, tag, tagDescriptions[tag], allFiles, opts, resources) } }, async *partialEmit(ctx, content, resources, changeEvents) { + const { utils } = ctx const allFiles = content.map((c) => c[1].data) const cfg = ctx.cfg.configuration @@ -148,7 +155,7 @@ export const TagPage: QuartzEmitterPlugin> = (userOpts) // 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)) + fileTags.flatMap(utils!.path.getAllSegmentPrefixes).forEach((tag) => affectedTags.add(tag)) // Always update the index tag page if any file changes affectedTags.add("index") @@ -157,7 +164,7 @@ export const TagPage: QuartzEmitterPlugin> = (userOpts) // 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) + const [_tags, tagDescriptions] = computeTagInfo(allFiles, content, cfg.locale, utils!) for (const tag of affectedTags) { if (tagDescriptions[tag]) { diff --git a/quartz/plugins/plugin-context.ts b/quartz/plugins/plugin-context.ts index aba758a8f..261b1d6d9 100644 --- a/quartz/plugins/plugin-context.ts +++ b/quartz/plugins/plugin-context.ts @@ -11,9 +11,17 @@ import { pathToRoot, splitAnchor, joinSegments, + getAllSegmentPrefixes, + getFileExtension, + isAbsoluteURL, + isRelativeURL, + resolveRelative, + slugTag, + stripSlashes, + QUARTZ, } from "../util/path" import { JSResource, CSSResource } from "../util/resources" -import { escapeHTML } from "../util/escape" +import { escapeHTML, unescapeHTML } from "../util/escape" /** * Plugin utility interface providing abstraction over common utility functions @@ -25,8 +33,16 @@ export interface PluginUtilities { simplify: (slug: FullSlug) => SimpleSlug transform: (from: FullSlug, to: string, opts: TransformOptions) => RelativeURL toRoot: (slug: FullSlug) => RelativeURL - split: (slug: FullSlug) => [FullSlug, string] - join: (...segments: string[]) => FilePath + split: (slug: string) => [string, string] + join: (...segments: string[]) => string + getAllSegmentPrefixes: (tags: string) => string[] + getFileExtension: (s: string) => string | undefined + isAbsoluteURL: (s: string) => boolean + isRelativeURL: (s: string) => boolean + resolveRelative: (current: FullSlug, target: FullSlug | SimpleSlug) => RelativeURL + slugTag: (tag: string) => string + stripSlashes: (s: string, onlyStripPrefix?: boolean) => string + QUARTZ: string } // Resource management @@ -36,9 +52,10 @@ export interface PluginUtilities { createCSS: (resource: CSSResource) => CSSResource } - // Other utilities as needed + // HTML escape utilities escape: { html: (text: string) => string + unescape: (html: string) => string } } @@ -59,11 +76,19 @@ export function createPluginUtilities(): PluginUtilities { simplify: simplifySlug, transform: transformLink, toRoot: pathToRoot, - split: (slug: FullSlug) => { + split: (slug: string) => { const [path, anchor] = splitAnchor(slug) - return [path as FullSlug, anchor] + return [path, anchor] }, - join: (...segments: string[]) => joinSegments(...segments) as FilePath, + join: (...segments: string[]) => joinSegments(...segments), + getAllSegmentPrefixes, + getFileExtension, + isAbsoluteURL, + isRelativeURL, + resolveRelative, + slugTag, + stripSlashes, + QUARTZ, }, resources: { createExternalJS: ( @@ -86,6 +111,7 @@ export function createPluginUtilities(): PluginUtilities { }, escape: { html: escapeHTML, + unescape: unescapeHTML, }, } } diff --git a/quartz/plugins/test-helpers.ts b/quartz/plugins/test-helpers.ts index c2f8fd8a0..79733cd5c 100644 --- a/quartz/plugins/test-helpers.ts +++ b/quartz/plugins/test-helpers.ts @@ -109,8 +109,67 @@ function createMockUtilities(): PluginUtilities { simplify: (slug: FullSlug) => slug as unknown as SimpleSlug, transform: (_from: FullSlug, to: string, _opts: TransformOptions) => to as RelativeURL, toRoot: (_slug: FullSlug) => "/" as RelativeURL, - split: (slug: FullSlug) => [slug, ""], - join: (...segments: string[]) => segments.join("/") as FilePath, + split: (slug: string) => { + // Mock implementation of splitAnchor with special PDF handling + let [fp, anchor] = slug.split("#", 2) + if (fp.endsWith(".pdf")) { + return [fp, anchor === undefined ? "" : `#${anchor}`] + } + // Simplified anchor sluggification (production uses github-slugger) + anchor = anchor === undefined ? "" : "#" + anchor.toLowerCase().replace(/\s+/g, "-") + return [fp, anchor] + }, + join: (...segments: string[]) => segments.join("/"), + getAllSegmentPrefixes: (tags: string) => { + const segments = tags.split("/") + const results: string[] = [] + for (let i = 0; i < segments.length; i++) { + results.push(segments.slice(0, i + 1).join("/")) + } + return results + }, + getFileExtension: (s: string) => s.match(/\.[A-Za-z0-9]+$/)?.[0], + isAbsoluteURL: (s: string) => { + try { + new URL(s) + return true + } catch { + return false + } + }, + isRelativeURL: (s: string) => { + // 1. Starts with '.' or '..' + if (!/^\.{1,2}/.test(s)) return false + // 2. Does not end with 'index' + if (s.endsWith("index")) return false + // 3. File extension is not .md or .html + const ext = s.match(/\.[A-Za-z0-9]+$/)?.[0]?.toLowerCase() + if (ext === ".md" || ext === ".html") return false + return true + }, + resolveRelative: (_current: FullSlug, target: FullSlug | SimpleSlug) => + target as unknown as RelativeURL, + slugTag: (tag: string) => { + // Mock sluggify function similar to production + const sluggify = (segment: string) => + segment + .toLowerCase() + .replace(/[&%?#]/g, "") // remove special chars + .replace(/\s+/g, "-") // replace spaces with dashes + .replace(/-+/g, "-") // collapse multiple dashes + .replace(/^-+|-+$/g, "") // trim leading/trailing dashes + return tag.split("/").map(sluggify).join("/") + }, + stripSlashes: (s: string, onlyStripPrefix?: boolean) => { + if (s.startsWith("/")) { + s = s.substring(1) + } + if (!onlyStripPrefix && s.endsWith("/")) { + s = s.slice(0, -1) + } + return s + }, + QUARTZ: "quartz", }, resources: { createExternalJS: (src: string, loadTime?: "beforeDOMReady" | "afterDOMReady") => ({ @@ -127,6 +186,16 @@ function createMockUtilities(): PluginUtilities { }, escape: { html: (text: string) => text.replace(/[&<>"']/g, (m) => `&#${m.charCodeAt(0)};`), + // Note: This mock implementation mirrors the production code in util/escape.ts + // which has a known limitation of potential double-unescaping. + // This is acceptable as it matches the real implementation for testing purposes. + unescape: (html: string) => + html + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'"), }, } } diff --git a/quartz/plugins/transformers/description.ts b/quartz/plugins/transformers/description.ts index 3f8519b32..6045b39bf 100644 --- a/quartz/plugins/transformers/description.ts +++ b/quartz/plugins/transformers/description.ts @@ -1,7 +1,6 @@ import { Root as HTMLRoot } from "hast" import { toString } from "hast-util-to-string" import { QuartzTransformerPlugin } from "../types" -import { escapeHTML } from "../../util/escape" export interface Options { descriptionLength: number @@ -20,16 +19,26 @@ const urlRegex = new RegExp( "g", ) +/** + * @plugin Description + * @category Transformer + * + * @reads vfile.data.frontmatter.description + * @writes vfile.data.description + * @writes vfile.data.text + * + * @dependencies None + */ export const Description: QuartzTransformerPlugin> = (userOpts) => { const opts = { ...defaultOptions, ...userOpts } return { name: "Description", - htmlPlugins() { + htmlPlugins(ctx) { return [ () => { return async (tree: HTMLRoot, file) => { let frontMatterDescription = file.data.frontmatter?.description - let text = escapeHTML(toString(tree)) + let text = ctx.utils!.escape.html(toString(tree)) if (opts.replaceExternalLinks) { frontMatterDescription = frontMatterDescription?.replace( diff --git a/quartz/plugins/transformers/frontmatter.ts b/quartz/plugins/transformers/frontmatter.ts index 30aba91cc..c7ba287f4 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, slugifyFilePath, slugTag } from "../../util/path" +import { FilePath, FullSlug } from "../../util/path" import { QuartzPluginData } from "../vfile" import { i18n } from "../../i18n" @@ -40,18 +40,6 @@ function coerceToArray(input: string | string[]): string[] | undefined { .map((tag: string | number) => tag.toString()) } -function getAliasSlugs(aliases: string[]): FullSlug[] { - const res: FullSlug[] = [] - for (const alias of aliases) { - const isMd = getFileExtension(alias) === "md" - const mockFp = isMd ? alias : alias + ".md" - const slug = slugifyFilePath(mockFp as FilePath) - res.push(slug) - } - - return res -} - /** * @plugin FrontMatter * @category Transformer @@ -69,10 +57,23 @@ export const FrontMatter: QuartzTransformerPlugin> = (userOpts) return { name: "FrontMatter", markdownPlugins(ctx) { - const { cfg } = ctx + const { cfg, utils } = ctx // Note: Temporarily casting allSlugs to mutable for backward compatibility // This should be refactored in the future to collect aliases separately const allSlugs = ctx.allSlugs as FullSlug[] + + // Helper function to get alias slugs using ctx.utils + const getAliasSlugs = (aliases: string[]): FullSlug[] => { + const res: FullSlug[] = [] + for (const alias of aliases) { + const isMd = utils!.path.getFileExtension(alias) === "md" + const mockFp = isMd ? alias : alias + ".md" + const slug = utils!.path.slugify(mockFp as FilePath) + res.push(slug) + } + return res + } + return [ [remarkFrontmatter, ["yaml", "toml"]], () => { @@ -93,7 +94,7 @@ export const FrontMatter: QuartzTransformerPlugin> = (userOpts) } const tags = coerceToArray(coalesceAliases(data, ["tags", "tag"])) - if (tags) data.tags = [...new Set(tags.map((tag: string) => slugTag(tag)))] + if (tags) data.tags = [...new Set(tags.map((tag: string) => utils!.path.slugTag(tag)))] const aliases = coerceToArray(coalesceAliases(data, ["aliases", "alias"])) if (aliases) { diff --git a/quartz/plugins/transformers/links.ts b/quartz/plugins/transformers/links.ts index 9541a1ed4..b17e864a1 100644 --- a/quartz/plugins/transformers/links.ts +++ b/quartz/plugins/transformers/links.ts @@ -1,14 +1,5 @@ import { QuartzTransformerPlugin } from "../types" -import { - FullSlug, - RelativeURL, - SimpleSlug, - TransformOptions, - stripSlashes, - simplifySlug, - splitAnchor, - transformLink, -} from "../../util/path" +import { FullSlug, RelativeURL, SimpleSlug, TransformOptions } from "../../util/path" import path from "path" import { visit } from "unist-util-visit" import isAbsoluteUrl from "is-absolute-url" @@ -46,10 +37,11 @@ export const CrawlLinks: QuartzTransformerPlugin> = (userOpts) return { name: "LinkProcessing", htmlPlugins(ctx) { + const { utils } = ctx return [ () => { return (tree: Root, file) => { - const curSlug = simplifySlug(file.data.slug!) + const curSlug = utils!.path.simplify(file.data.slug!) const outgoing: Set = new Set() const transformOptions: TransformOptions = { @@ -112,7 +104,7 @@ export const CrawlLinks: QuartzTransformerPlugin> = (userOpts) isAbsoluteUrl(dest, { httpOnly: false }) || dest.startsWith("#") ) if (isInternal) { - dest = node.properties.href = transformLink( + dest = node.properties.href = utils!.path.transform( file.data.slug!, dest, transformOptions, @@ -120,16 +112,21 @@ export const CrawlLinks: QuartzTransformerPlugin> = (userOpts) // url.resolve is considered legacy // WHATWG equivalent https://nodejs.dev/en/api/v18/url/#urlresolvefrom-to - const url = new URL(dest, "https://base.com/" + stripSlashes(curSlug, true)) + const url = new URL( + dest, + "https://base.com/" + utils!.path.stripSlashes(curSlug, true), + ) const canonicalDest = url.pathname - let [destCanonical, _destAnchor] = splitAnchor(canonicalDest) + let [destCanonical, _destAnchor] = utils!.path.split(canonicalDest) if (destCanonical.endsWith("/")) { destCanonical += "index" } // need to decodeURIComponent here as WHATWG URL percent-encodes everything - const full = decodeURIComponent(stripSlashes(destCanonical, true)) as FullSlug - const simple = simplifySlug(full) + const full = decodeURIComponent( + utils!.path.stripSlashes(destCanonical, true), + ) as FullSlug + const simple = utils!.path.simplify(full) outgoing.add(simple) node.properties["data-slug"] = full } @@ -158,7 +155,7 @@ export const CrawlLinks: QuartzTransformerPlugin> = (userOpts) if (!isAbsoluteUrl(node.properties.src, { httpOnly: false })) { let dest = node.properties.src as RelativeURL - dest = node.properties.src = transformLink( + dest = node.properties.src = utils!.path.transform( file.data.slug!, dest, transformOptions, diff --git a/quartz/plugins/transformers/ofm.ts b/quartz/plugins/transformers/ofm.ts index 7a523aa59..95f074780 100644 --- a/quartz/plugins/transformers/ofm.ts +++ b/quartz/plugins/transformers/ofm.ts @@ -13,7 +13,6 @@ import { ReplaceFunction, findAndReplace as mdastFindReplace } from "mdast-util- import rehypeRaw from "rehype-raw" import { SKIP, visit } from "unist-util-visit" import path from "path" -import { splitAnchor } from "../../util/path" import { JSResource, CSSResource } from "../../util/resources" // @ts-ignore import calloutScript from "../../components/scripts/callout.inline" @@ -22,7 +21,7 @@ import checkboxScript from "../../components/scripts/checkbox.inline" // @ts-ignore import mermaidScript from "../../components/scripts/mermaid.inline" import mermaidStyle from "../../components/styles/mermaid.inline.scss" -import { FilePath, pathToRoot, slugTag, slugifyFilePath } from "../../util/path" +import { FilePath } from "../../util/path" import { toHast } from "mdast-util-to-hast" import { toHtml } from "hast-util-to-html" import { capitalize } from "../../util/lang" @@ -158,7 +157,8 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin> return { name: "ObsidianFlavoredMarkdown", - textTransform(_ctx, src) { + textTransform(ctx, src) { + const { utils } = ctx // do comments at text level if (opts.comments) { src = src.replace(commentRegex, "") @@ -192,7 +192,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin> src = src.replace(wikilinkRegex, (value, ...capture) => { const [rawFp, rawHeader, rawAlias]: (string | undefined)[] = capture - const [fp, anchor] = splitAnchor(`${rawFp ?? ""}${rawHeader ?? ""}`) + const [fp, anchor] = utils!.path.split(`${rawFp ?? ""}${rawHeader ?? ""}`) const blockRef = Boolean(rawHeader?.startsWith("#^")) ? "^" : "" const displayAnchor = anchor ? `#${blockRef}${anchor.trim().replace(/^#+/, "")}` : "" const displayAlias = rawAlias ?? rawHeader?.replace("#", "|") ?? "" @@ -209,13 +209,14 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin> return src }, markdownPlugins(ctx) { + const { utils } = ctx const plugins: PluggableList = [] // regex replacements plugins.push(() => { return (tree: Root, file) => { const replacements: [RegExp, string | ReplaceFunction][] = [] - const base = pathToRoot(file.data.slug!) + const base = utils!.path.toRoot(file.data.slug!) if (opts.wikilinks) { replacements.push([ @@ -229,7 +230,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin> // embed cases if (value.startsWith("!")) { const ext: string = path.extname(fp).toLowerCase() - const url = slugifyFilePath(fp as FilePath) + const url = utils!.path.slugify(fp as FilePath) if ([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg", ".webp"].includes(ext)) { const match = wikilinkImageEmbedRegex.exec(alias ?? "") const alt = match?.groups?.alt ?? "" @@ -279,7 +280,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin> // treat as broken link if slug not in ctx.allSlugs if (opts.disableBrokenWikilinks) { - const slug = slugifyFilePath(fp as FilePath) + const slug = utils!.path.slugify(fp as FilePath) const exists = ctx.allSlugs && ctx.allSlugs.includes(slug) if (!exists) { return { @@ -342,7 +343,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin> return false } - tag = slugTag(tag) + tag = utils!.path.slugTag(tag) if (file.data.frontmatter) { const noteTags = file.data.frontmatter.tags ?? [] file.data.frontmatter.tags = [...new Set([...noteTags, tag])]