init from template
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
import { QuartzPageTypePlugin } from "../types"
|
||||
import { match } from "./matchers"
|
||||
import { NotFound } from "../../components"
|
||||
import { defaultProcessedContent } from "../vfile"
|
||||
import { i18n } from "../../i18n"
|
||||
import { FullSlug } from "../../util/path"
|
||||
|
||||
export const NotFoundPageType: QuartzPageTypePlugin = () => ({
|
||||
name: "404",
|
||||
priority: -1,
|
||||
match: match.none(),
|
||||
generate({ cfg }) {
|
||||
const notFound = i18n(cfg.locale).pages.error.title
|
||||
const slug = "404" as FullSlug
|
||||
const [, vfile] = defaultProcessedContent({
|
||||
slug,
|
||||
text: notFound,
|
||||
description: notFound,
|
||||
frontmatter: { title: notFound, tags: [] },
|
||||
})
|
||||
|
||||
return [
|
||||
{
|
||||
slug,
|
||||
title: notFound,
|
||||
data: vfile.data,
|
||||
},
|
||||
]
|
||||
},
|
||||
layout: "404",
|
||||
frame: "minimal",
|
||||
body: NotFound,
|
||||
})
|
||||
@@ -0,0 +1,144 @@
|
||||
import test, { describe } from "node:test"
|
||||
import assert from "node:assert"
|
||||
import { collectComponents, resolveLayout } from "./dispatcher"
|
||||
import { QuartzPageTypePluginInstance } from "../types"
|
||||
import { QuartzComponent } from "../../components/types"
|
||||
|
||||
const StubA: QuartzComponent = (() => null) as unknown as QuartzComponent
|
||||
const StubB: QuartzComponent = (() => null) as unknown as QuartzComponent
|
||||
const StubHead: QuartzComponent = (() => null) as unknown as QuartzComponent
|
||||
|
||||
function makePageType(
|
||||
overrides: Partial<QuartzPageTypePluginInstance> = {},
|
||||
): QuartzPageTypePluginInstance {
|
||||
return {
|
||||
name: "test-page-type",
|
||||
layout: "content",
|
||||
match: () => true,
|
||||
body: () => (() => null) as unknown as QuartzComponent,
|
||||
...overrides,
|
||||
} as QuartzPageTypePluginInstance
|
||||
}
|
||||
|
||||
describe("resolveLayout", () => {
|
||||
test("footer defaults to [] when sharedDefaults omits footer", () => {
|
||||
const result = resolveLayout(makePageType(), { head: StubHead }, {})
|
||||
assert.deepStrictEqual(result.footer, [])
|
||||
})
|
||||
|
||||
test("header defaults to [] when sharedDefaults omits header", () => {
|
||||
const result = resolveLayout(makePageType(), { head: StubHead }, {})
|
||||
assert.deepStrictEqual(result.header, [])
|
||||
})
|
||||
|
||||
test("footer from sharedDefaults is used when no override", () => {
|
||||
const result = resolveLayout(makePageType(), { head: StubHead, footer: [StubA] }, {})
|
||||
assert.deepStrictEqual(result.footer, [StubA])
|
||||
})
|
||||
|
||||
test("byPageType override replaces footer", () => {
|
||||
const result = resolveLayout(
|
||||
makePageType(),
|
||||
{ head: StubHead, footer: [StubA] },
|
||||
{ content: { footer: [StubB] } },
|
||||
)
|
||||
assert.deepStrictEqual(result.footer, [StubB])
|
||||
})
|
||||
|
||||
test("byPageType override clears footer with []", () => {
|
||||
const result = resolveLayout(
|
||||
makePageType(),
|
||||
{ head: StubHead, footer: [StubA] },
|
||||
{ content: { footer: [] } },
|
||||
)
|
||||
assert.deepStrictEqual(result.footer, [])
|
||||
})
|
||||
|
||||
test("byPageType override clears header with []", () => {
|
||||
const result = resolveLayout(
|
||||
makePageType(),
|
||||
{ head: StubHead, header: [StubA] },
|
||||
{ content: { header: [] } },
|
||||
)
|
||||
assert.deepStrictEqual(result.header, [])
|
||||
})
|
||||
|
||||
test("all array slots default to [] when sharedDefaults only has head", () => {
|
||||
const result = resolveLayout(makePageType(), { head: StubHead }, {})
|
||||
assert.deepStrictEqual(result.header, [])
|
||||
assert.deepStrictEqual(result.left, [])
|
||||
assert.deepStrictEqual(result.right, [])
|
||||
assert.deepStrictEqual(result.beforeBody, [])
|
||||
assert.deepStrictEqual(result.afterBody, [])
|
||||
assert.deepStrictEqual(result.footer, [])
|
||||
})
|
||||
|
||||
test("preserves component references through override", () => {
|
||||
const result = resolveLayout(makePageType(), { head: StubHead, footer: [StubA, StubB] }, {})
|
||||
assert.strictEqual(result.footer[0], StubA)
|
||||
assert.strictEqual(result.footer[1], StubB)
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveLayout frame resolution", () => {
|
||||
test("config override frame wins over page type frame", () => {
|
||||
const result = resolveLayout(
|
||||
makePageType({ frame: "minimal" }),
|
||||
{ head: StubHead },
|
||||
{ content: { frame: "full-width" } },
|
||||
)
|
||||
assert.strictEqual(result.frame, "full-width")
|
||||
})
|
||||
|
||||
test("page type frame wins when no config override", () => {
|
||||
const result = resolveLayout(makePageType({ frame: "minimal" }), { head: StubHead }, {})
|
||||
assert.strictEqual(result.frame, "minimal")
|
||||
})
|
||||
|
||||
test("defaults to 'default' when no frame specified", () => {
|
||||
const result = resolveLayout(makePageType(), { head: StubHead }, {})
|
||||
assert.strictEqual(result.frame, "default")
|
||||
})
|
||||
|
||||
test("defaults to 'default' when byPageType entry exists but has no frame", () => {
|
||||
const result = resolveLayout(makePageType(), { head: StubHead }, { content: { left: [StubA] } })
|
||||
assert.strictEqual(result.frame, "default")
|
||||
})
|
||||
})
|
||||
|
||||
describe("collectComponents", () => {
|
||||
test("collects all unique components across page types", () => {
|
||||
const pageTypes = [makePageType(), makePageType({ layout: "landing" })]
|
||||
const sharedDefaults = { head: StubHead }
|
||||
const byPageType = {
|
||||
content: { footer: [StubA] },
|
||||
landing: { footer: [StubB] },
|
||||
}
|
||||
|
||||
const result = collectComponents(pageTypes, sharedDefaults, byPageType)
|
||||
assert.ok(result.includes(StubA))
|
||||
assert.ok(result.includes(StubB))
|
||||
})
|
||||
|
||||
test("deduplicates shared components", () => {
|
||||
const pageTypes = [makePageType(), makePageType({ layout: "landing" })]
|
||||
const sharedDefaults = { head: StubHead }
|
||||
const byPageType = {
|
||||
content: { left: [StubA] },
|
||||
landing: { left: [StubA] },
|
||||
}
|
||||
|
||||
const result = collectComponents(pageTypes, sharedDefaults, byPageType)
|
||||
const matches = result.filter((component) => component === StubA)
|
||||
assert.strictEqual(matches.length, 1)
|
||||
})
|
||||
|
||||
test("handles empty footer and header arrays", () => {
|
||||
const pageTypes = [makePageType({ layout: "empty" })]
|
||||
const sharedDefaults = { head: StubHead }
|
||||
const byPageType = { empty: { footer: [], header: [] } }
|
||||
|
||||
const result = collectComponents(pageTypes, sharedDefaults, byPageType)
|
||||
assert.ok(result.every((component) => component))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,341 @@
|
||||
import { QuartzEmitterPlugin, QuartzPageTypePluginInstance, TreeTransform } from "../types"
|
||||
import { QuartzComponent, QuartzComponentProps } from "../../components/types"
|
||||
import { pageResources, renderPage } from "../../components/renderPage"
|
||||
import { FullPageLayout } from "../../cfg"
|
||||
import { FilePath, FullSlug, pathToRoot } from "../../util/path"
|
||||
import { ProcessedContent, defaultProcessedContent } from "../vfile"
|
||||
import { write } from "../emitters/helpers"
|
||||
import { BuildCtx, trieFromAllFiles } from "../../util/ctx"
|
||||
import { StaticResources } from "../../util/resources"
|
||||
import { render } from "preact-render-to-string"
|
||||
import { fromHtml } from "hast-util-from-html"
|
||||
import { Root as HtmlRoot } from "hast"
|
||||
|
||||
function getPageTypes(ctx: BuildCtx): QuartzPageTypePluginInstance[] {
|
||||
return (ctx.cfg.plugins.pageTypes ?? []) as unknown as QuartzPageTypePluginInstance[]
|
||||
}
|
||||
|
||||
/** @internal Exported for testing only. */
|
||||
export function resolveLayout(
|
||||
pageType: QuartzPageTypePluginInstance,
|
||||
sharedDefaults: Partial<FullPageLayout>,
|
||||
byPageType: Record<string, Partial<FullPageLayout>>,
|
||||
): FullPageLayout {
|
||||
const overrides = byPageType[pageType.layout] ?? {}
|
||||
// Frame priority: config override > page type declaration > default
|
||||
const frame = overrides.frame ?? pageType.frame ?? "default"
|
||||
return {
|
||||
head: overrides.head ?? sharedDefaults.head!,
|
||||
header: overrides.header ?? sharedDefaults.header ?? [],
|
||||
beforeBody: overrides.beforeBody ?? sharedDefaults.beforeBody ?? [],
|
||||
pageBody: pageType.body(undefined),
|
||||
afterBody: overrides.afterBody ?? sharedDefaults.afterBody ?? [],
|
||||
left: overrides.left ?? sharedDefaults.left ?? [],
|
||||
right: overrides.right ?? sharedDefaults.right ?? [],
|
||||
footer: overrides.footer ?? sharedDefaults.footer ?? [],
|
||||
frame,
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal Exported for testing only. */
|
||||
export function collectComponents(
|
||||
pageTypes: QuartzPageTypePluginInstance[],
|
||||
sharedDefaults: Partial<FullPageLayout>,
|
||||
byPageType: Record<string, Partial<FullPageLayout>>,
|
||||
): QuartzComponent[] {
|
||||
const seen = new Set<QuartzComponent>()
|
||||
for (const pt of pageTypes) {
|
||||
const layout = resolveLayout(pt, sharedDefaults, byPageType)
|
||||
const all = [
|
||||
layout.head,
|
||||
...layout.header,
|
||||
...layout.beforeBody,
|
||||
layout.pageBody,
|
||||
...layout.afterBody,
|
||||
...layout.left,
|
||||
...layout.right,
|
||||
...layout.footer,
|
||||
]
|
||||
for (const c of all) {
|
||||
if (c) seen.add(c)
|
||||
}
|
||||
}
|
||||
return [...seen]
|
||||
}
|
||||
|
||||
interface DispatcherOptions {
|
||||
defaults: Partial<FullPageLayout>
|
||||
byPageType: Record<string, Partial<FullPageLayout>>
|
||||
}
|
||||
|
||||
async function emitPage(
|
||||
ctx: BuildCtx,
|
||||
slug: FullSlug,
|
||||
tree: ProcessedContent[0],
|
||||
fileData: ProcessedContent[1]["data"],
|
||||
allFiles: ProcessedContent[1]["data"][],
|
||||
layout: FullPageLayout,
|
||||
resources: StaticResources,
|
||||
treeTransforms?: TreeTransform[],
|
||||
) {
|
||||
const cfg = ctx.cfg.configuration
|
||||
// For the 404 page, use an absolute base path so assets resolve correctly
|
||||
// when the hosting provider serves 404.html from any URL depth.
|
||||
// During local dev (--serve), the dev server strips baseDir itself and
|
||||
// serves files from root, so the 404 page must use "/" to avoid requesting
|
||||
// assets under a path prefix that the dev server doesn't serve.
|
||||
const baseDir =
|
||||
slug === "404"
|
||||
? ((ctx.argv.serve
|
||||
? "/"
|
||||
: new URL(`https://${cfg.baseUrl ?? "example.com"}`).pathname) as FullSlug)
|
||||
: pathToRoot(slug)
|
||||
const externalResources = pageResources(baseDir, resources, ctx)
|
||||
const componentData: QuartzComponentProps = {
|
||||
ctx,
|
||||
fileData,
|
||||
externalResources,
|
||||
cfg,
|
||||
children: [],
|
||||
tree,
|
||||
allFiles,
|
||||
}
|
||||
|
||||
return write({
|
||||
ctx,
|
||||
content: renderPage(cfg, slug, componentData, layout, externalResources, treeTransforms),
|
||||
slug,
|
||||
ext: ".html",
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Render each virtual page's Body component to HTML and parse it to a hast tree,
|
||||
* populating both the ProcessedContent tree and vfile.data.htmlAst so that
|
||||
* transclusion (e.g. ![[file.canvas]]) can inline the virtual page's content.
|
||||
*/
|
||||
function populateVirtualPageHtmlAst(
|
||||
virtualEntries: Array<{
|
||||
tree: ProcessedContent[0]
|
||||
vfile: ProcessedContent[1]
|
||||
layout: FullPageLayout
|
||||
vpSlug: FullSlug
|
||||
}>,
|
||||
ctx: BuildCtx,
|
||||
allFiles: ProcessedContent[1]["data"][],
|
||||
resources: StaticResources,
|
||||
) {
|
||||
const cfg = ctx.cfg.configuration
|
||||
for (const ve of virtualEntries) {
|
||||
const BodyComponent = ve.layout.pageBody
|
||||
const externalResources = pageResources(pathToRoot(ve.vpSlug), resources, ctx)
|
||||
const componentData: QuartzComponentProps = {
|
||||
ctx,
|
||||
fileData: ve.vfile.data,
|
||||
externalResources,
|
||||
cfg,
|
||||
children: [],
|
||||
tree: ve.tree,
|
||||
allFiles,
|
||||
}
|
||||
try {
|
||||
const htmlString = render(BodyComponent(componentData))
|
||||
const htmlAst = fromHtml(htmlString, { fragment: true }) as HtmlRoot
|
||||
ve.vfile.data.htmlAst = htmlAst
|
||||
} catch {
|
||||
// Body rendering failed — leave htmlAst empty so transclusion falls
|
||||
// back to the default title-only display.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const PageTypeDispatcher: QuartzEmitterPlugin<Partial<DispatcherOptions>> = (userOpts) => {
|
||||
const defaults = userOpts?.defaults ?? {}
|
||||
const byPageType = userOpts?.byPageType ?? {}
|
||||
|
||||
return {
|
||||
name: "PageTypeDispatcher",
|
||||
getQuartzComponents(ctx) {
|
||||
const pageTypes = getPageTypes(ctx)
|
||||
return collectComponents(pageTypes, defaults, byPageType)
|
||||
},
|
||||
async *emit(ctx, content, resources) {
|
||||
const pageTypes = [...getPageTypes(ctx)].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
|
||||
const cfg = ctx.cfg.configuration
|
||||
const allFiles = content.map((c) => c[1].data)
|
||||
|
||||
// Collect tree transforms from all page type plugins
|
||||
const treeTransforms: TreeTransform[] = pageTypes.flatMap(
|
||||
(pt) => pt.treeTransforms?.(ctx) ?? [],
|
||||
)
|
||||
|
||||
// Ensure trie is available for components that need folder hierarchy (e.g. FolderContent)
|
||||
ctx.trie ??= trieFromAllFiles(allFiles)
|
||||
|
||||
// Phase 1: Generate all virtual pages first so their data is available in allFiles
|
||||
// for transclude resolution in renderPage (e.g. ![[file.canvas]], ![[file.base]])
|
||||
const virtualEntries: Array<{
|
||||
tree: ProcessedContent[0]
|
||||
vfile: ProcessedContent[1]
|
||||
layout: FullPageLayout
|
||||
vpSlug: FullSlug
|
||||
}> = []
|
||||
for (const pt of pageTypes) {
|
||||
if (!pt.generate) continue
|
||||
const virtualPages = pt.generate({ content, cfg, ctx })
|
||||
const layout = resolveLayout(pt, defaults, byPageType)
|
||||
for (const vp of virtualPages) {
|
||||
const vpSlug = vp.slug as FullSlug
|
||||
const vpRelativePath = (vpSlug + ".md") as FilePath
|
||||
const [tree, vfile] = defaultProcessedContent({
|
||||
slug: vpSlug,
|
||||
relativePath: vpRelativePath,
|
||||
frontmatter: { title: vp.title, tags: [] },
|
||||
...vp.data,
|
||||
})
|
||||
if (vpSlug !== "404") {
|
||||
ctx.virtualPages.push([tree, vfile])
|
||||
}
|
||||
virtualEntries.push({ tree, vfile, layout, vpSlug })
|
||||
}
|
||||
}
|
||||
|
||||
// Merge virtual page data into allFiles before populating htmlAst so that
|
||||
// Body components rendered during populateVirtualPageHtmlAst can resolve
|
||||
// cross-virtual-page embeds (e.g. a .base file embedded in a .canvas file).
|
||||
// The vfile.data objects are shared by reference, so htmlAst set on earlier
|
||||
// entries becomes visible to later entries in the same pass.
|
||||
const allFilesWithVirtual = [...allFiles, ...virtualEntries.map((ve) => ve.vfile.data)]
|
||||
|
||||
// Render Body components to populate htmlAst for transclusion
|
||||
populateVirtualPageHtmlAst(virtualEntries, ctx, allFilesWithVirtual, resources)
|
||||
|
||||
// Phase 2: Emit regular pages (with virtual page data available for transclusion)
|
||||
for (const [tree, file] of content) {
|
||||
const slug = file.data.slug!
|
||||
const fileData = file.data
|
||||
for (const pt of pageTypes) {
|
||||
if (pt.match({ slug, fileData, cfg })) {
|
||||
const layout = resolveLayout(pt, defaults, byPageType)
|
||||
yield emitPage(
|
||||
ctx,
|
||||
slug,
|
||||
tree,
|
||||
fileData,
|
||||
allFilesWithVirtual,
|
||||
layout,
|
||||
resources,
|
||||
treeTransforms,
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Emit virtual pages
|
||||
for (const ve of virtualEntries) {
|
||||
yield emitPage(
|
||||
ctx,
|
||||
ve.vpSlug,
|
||||
ve.tree,
|
||||
ve.vfile.data,
|
||||
allFilesWithVirtual,
|
||||
ve.layout,
|
||||
resources,
|
||||
treeTransforms,
|
||||
)
|
||||
}
|
||||
},
|
||||
async *partialEmit(ctx, content, resources, changeEvents) {
|
||||
const pageTypes = [...getPageTypes(ctx)].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
|
||||
const cfg = ctx.cfg.configuration
|
||||
const allFiles = content.map((c) => c[1].data)
|
||||
|
||||
// Collect tree transforms from all page type plugins
|
||||
const treeTransforms: TreeTransform[] = pageTypes.flatMap(
|
||||
(pt) => pt.treeTransforms?.(ctx) ?? [],
|
||||
)
|
||||
|
||||
// Rebuild trie on partial emit to reflect file changes
|
||||
ctx.trie = trieFromAllFiles(allFiles)
|
||||
|
||||
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!)
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 1: Generate all virtual pages first so their data is available in allFiles
|
||||
const virtualEntries: Array<{
|
||||
tree: ProcessedContent[0]
|
||||
vfile: ProcessedContent[1]
|
||||
layout: FullPageLayout
|
||||
vpSlug: FullSlug
|
||||
}> = []
|
||||
for (const pt of pageTypes) {
|
||||
if (!pt.generate) continue
|
||||
const virtualPages = pt.generate({ content, cfg, ctx })
|
||||
const layout = resolveLayout(pt, defaults, byPageType)
|
||||
for (const vp of virtualPages) {
|
||||
const vpSlug = vp.slug as FullSlug
|
||||
const vpRelativePath = (vpSlug + ".md") as FilePath
|
||||
const [tree, vfile] = defaultProcessedContent({
|
||||
slug: vpSlug,
|
||||
relativePath: vpRelativePath,
|
||||
frontmatter: { title: vp.title, tags: [] },
|
||||
...vp.data,
|
||||
})
|
||||
if (vpSlug !== "404") {
|
||||
ctx.virtualPages.push([tree, vfile])
|
||||
}
|
||||
virtualEntries.push({ tree, vfile, layout, vpSlug })
|
||||
}
|
||||
}
|
||||
|
||||
const allFilesWithVirtual = [...allFiles, ...virtualEntries.map((ve) => ve.vfile.data)]
|
||||
|
||||
// Render Body components to populate htmlAst for transclusion
|
||||
populateVirtualPageHtmlAst(virtualEntries, ctx, allFilesWithVirtual, resources)
|
||||
|
||||
// Phase 2: Emit changed regular pages
|
||||
for (const [tree, file] of content) {
|
||||
const slug = file.data.slug!
|
||||
if (!changedSlugs.has(slug)) continue
|
||||
|
||||
const fileData = file.data
|
||||
for (const pt of pageTypes) {
|
||||
if (pt.match({ slug, fileData, cfg })) {
|
||||
const layout = resolveLayout(pt, defaults, byPageType)
|
||||
yield emitPage(
|
||||
ctx,
|
||||
slug,
|
||||
tree,
|
||||
fileData,
|
||||
allFilesWithVirtual,
|
||||
layout,
|
||||
resources,
|
||||
treeTransforms,
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Emit virtual pages
|
||||
for (const ve of virtualEntries) {
|
||||
yield emitPage(
|
||||
ctx,
|
||||
ve.vpSlug,
|
||||
ve.tree,
|
||||
ve.vfile.data,
|
||||
allFilesWithVirtual,
|
||||
ve.layout,
|
||||
resources,
|
||||
treeTransforms,
|
||||
)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { match } from "./matchers"
|
||||
export { NotFoundPageType } from "./404"
|
||||
export { PageTypeDispatcher } from "./dispatcher"
|
||||
@@ -0,0 +1,39 @@
|
||||
import { PageMatcher } from "../types"
|
||||
|
||||
export const match = {
|
||||
ext: (extension: string): PageMatcher => {
|
||||
const normalized = extension.startsWith(".") ? extension : `.${extension}`
|
||||
return ({ slug }) => slug.endsWith(normalized) || !slug.includes(".")
|
||||
},
|
||||
|
||||
slugPrefix: (prefix: string): PageMatcher => {
|
||||
return ({ slug }) => slug.startsWith(prefix)
|
||||
},
|
||||
|
||||
frontmatter: (key: string, predicate: (value: unknown) => boolean): PageMatcher => {
|
||||
return ({ fileData }) => {
|
||||
const fm = fileData.frontmatter as Record<string, unknown> | undefined
|
||||
return fm ? predicate(fm[key]) : false
|
||||
}
|
||||
},
|
||||
|
||||
and: (...matchers: PageMatcher[]): PageMatcher => {
|
||||
return (args) => matchers.every((m) => m(args))
|
||||
},
|
||||
|
||||
or: (...matchers: PageMatcher[]): PageMatcher => {
|
||||
return (args) => matchers.some((m) => m(args))
|
||||
},
|
||||
|
||||
not: (matcher: PageMatcher): PageMatcher => {
|
||||
return (args) => !matcher(args)
|
||||
},
|
||||
|
||||
all: (): PageMatcher => {
|
||||
return () => true
|
||||
},
|
||||
|
||||
none: (): PageMatcher => {
|
||||
return () => false
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user