mirror of
https://github.com/pxdg-afk/paxad.git
synced 2026-08-23 01:01:06 -04:00
Initial commit
This commit is contained in:
33
quartz/plugins/pageTypes/404.ts
Normal file
33
quartz/plugins/pageTypes/404.ts
Normal file
@@ -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,
|
||||
})
|
||||
339
quartz/plugins/pageTypes/dispatcher.ts
Normal file
339
quartz/plugins/pageTypes/dispatcher.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
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[]
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
3
quartz/plugins/pageTypes/index.ts
Normal file
3
quartz/plugins/pageTypes/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { match } from "./matchers"
|
||||
export { NotFoundPageType } from "./404"
|
||||
export { PageTypeDispatcher } from "./dispatcher"
|
||||
39
quartz/plugins/pageTypes/matchers.ts
Normal file
39
quartz/plugins/pageTypes/matchers.ts
Normal file
@@ -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