feat: unify layout slot architecture - make header/footer configurable positions, footer as array, add defaultPosition fallback, add tests, update docs
This commit is contained in:
+1
-1
@@ -97,7 +97,7 @@ export interface FullPageLayout {
|
||||
afterBody: QuartzComponent[]
|
||||
left: QuartzComponent[]
|
||||
right: QuartzComponent[]
|
||||
footer: QuartzComponent
|
||||
footer: QuartzComponent[]
|
||||
/** Page frame name (e.g. "default", "full-width", "minimal"). Defaults to "default". */
|
||||
frame?: string
|
||||
}
|
||||
|
||||
@@ -230,6 +230,9 @@ plugins:
|
||||
links:
|
||||
GitHub: https://github.com/jackyzha0/quartz
|
||||
Discord Community: https://discord.gg/cRFFHYye7t
|
||||
layout:
|
||||
position: footer
|
||||
priority: 50
|
||||
- source: "@quartz-community/recent-notes"
|
||||
enabled: true
|
||||
options:
|
||||
|
||||
@@ -224,6 +224,9 @@ plugins:
|
||||
links:
|
||||
GitHub: https://github.com/jackyzha0/quartz
|
||||
Discord Community: https://discord.gg/cRFFHYye7t
|
||||
layout:
|
||||
position: footer
|
||||
priority: 50
|
||||
- source: "@quartz-community/recent-notes"
|
||||
enabled: false
|
||||
- source: "@quartz-community/spacer"
|
||||
|
||||
@@ -235,6 +235,9 @@ plugins:
|
||||
links:
|
||||
GitHub: https://github.com/jackyzha0/quartz
|
||||
Discord Community: https://discord.gg/cRFFHYye7t
|
||||
layout:
|
||||
position: footer
|
||||
priority: 50
|
||||
- source: "@quartz-community/recent-notes"
|
||||
enabled: false
|
||||
- source: "@quartz-community/spacer"
|
||||
|
||||
@@ -236,6 +236,9 @@ plugins:
|
||||
links:
|
||||
GitHub: https://github.com/jackyzha0/quartz
|
||||
Discord Community: https://discord.gg/cRFFHYye7t
|
||||
layout:
|
||||
position: footer
|
||||
priority: 50
|
||||
- source: "@quartz-community/recent-notes"
|
||||
enabled: false
|
||||
- source: "@quartz-community/spacer"
|
||||
|
||||
@@ -19,7 +19,7 @@ export const DefaultFrame: PageFrame = {
|
||||
afterBody,
|
||||
left,
|
||||
right,
|
||||
footer: Footer,
|
||||
footer,
|
||||
}: PageFrameProps) {
|
||||
return (
|
||||
<>
|
||||
@@ -54,7 +54,9 @@ export const DefaultFrame: PageFrame = {
|
||||
<BodyComponent {...componentData} />
|
||||
))}
|
||||
</div>
|
||||
<Footer {...componentData} />
|
||||
{footer.map((FooterComponent) => (
|
||||
<FooterComponent {...componentData} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -19,7 +19,7 @@ export const FullWidthFrame: PageFrame = {
|
||||
beforeBody,
|
||||
pageBody: Content,
|
||||
afterBody,
|
||||
footer: Footer,
|
||||
footer,
|
||||
}: PageFrameProps) {
|
||||
return (
|
||||
<>
|
||||
@@ -44,7 +44,9 @@ export const FullWidthFrame: PageFrame = {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Footer {...componentData} />
|
||||
{footer.map((FooterComponent) => (
|
||||
<FooterComponent {...componentData} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -10,13 +10,15 @@ import { PageFrame, PageFrameProps } from "./types"
|
||||
*/
|
||||
export const MinimalFrame: PageFrame = {
|
||||
name: "minimal",
|
||||
render({ componentData, pageBody: Content, footer: Footer }: PageFrameProps) {
|
||||
render({ componentData, pageBody: Content, footer }: PageFrameProps) {
|
||||
return (
|
||||
<>
|
||||
<div class="center minimal">
|
||||
<Content {...componentData} />
|
||||
</div>
|
||||
<Footer {...componentData} />
|
||||
{footer.map((FooterComponent) => (
|
||||
<FooterComponent {...componentData} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -22,8 +22,8 @@ export interface PageFrameProps {
|
||||
left: QuartzComponent[]
|
||||
/** Right sidebar components */
|
||||
right: QuartzComponent[]
|
||||
/** Footer component */
|
||||
footer: QuartzComponent
|
||||
/** Footer components */
|
||||
footer: QuartzComponent[]
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,8 @@ export interface ComponentManifest {
|
||||
quartzVersion?: string
|
||||
author?: string
|
||||
homepage?: string
|
||||
defaultPosition?: string
|
||||
defaultPriority?: number
|
||||
}
|
||||
|
||||
export interface RegisteredComponent {
|
||||
|
||||
@@ -25,7 +25,7 @@ interface RenderComponents {
|
||||
afterBody: QuartzComponent[]
|
||||
left: QuartzComponent[]
|
||||
right: QuartzComponent[]
|
||||
footer: QuartzComponent
|
||||
footer: QuartzComponent[]
|
||||
frame?: string
|
||||
}
|
||||
|
||||
@@ -329,7 +329,7 @@ export function renderPage(
|
||||
afterBody,
|
||||
left,
|
||||
right,
|
||||
footer: Footer,
|
||||
footer,
|
||||
frame: frameName,
|
||||
} = components
|
||||
const Body = BodyConstructor()
|
||||
@@ -360,7 +360,7 @@ export function renderPage(
|
||||
afterBody,
|
||||
left,
|
||||
right,
|
||||
footer: Footer,
|
||||
footer,
|
||||
}),
|
||||
]}
|
||||
</Body>
|
||||
|
||||
@@ -644,7 +644,7 @@ export async function loadQuartzLayout(layoutOverrides?: {
|
||||
return oldLayout.layout
|
||||
}
|
||||
|
||||
const enabledWithLayout = json.plugins.filter((e) => e.enabled && e.layout)
|
||||
const enabledWithLayout = json.plugins.filter((e) => e.enabled)
|
||||
const layoutConfig = json.layout ?? {}
|
||||
|
||||
// Build default layout for all page types
|
||||
@@ -672,7 +672,7 @@ export async function loadQuartzLayout(layoutOverrides?: {
|
||||
if (Array.isArray(components) && components.length === 0) {
|
||||
const key = pos as keyof Pick<
|
||||
FullPageLayout,
|
||||
"left" | "right" | "beforeBody" | "afterBody"
|
||||
"header" | "left" | "right" | "beforeBody" | "afterBody" | "footer"
|
||||
>
|
||||
if (key in ptLayout) {
|
||||
;(ptLayout as Record<string, unknown>)[key] = []
|
||||
@@ -690,46 +690,20 @@ export async function loadQuartzLayout(layoutOverrides?: {
|
||||
}
|
||||
}
|
||||
|
||||
// Add Head (built-in) and Footer (plugin)
|
||||
const HeadModule = await import("../../components/Head")
|
||||
const head = HeadModule.default()
|
||||
|
||||
// Find footer from component registry (loaded during plugin instantiation)
|
||||
const footerEntry = json.plugins.find(
|
||||
(e) => e.enabled && extractPluginName(e.source) === "footer",
|
||||
)
|
||||
let footer: QuartzComponent | undefined
|
||||
if (footerEntry) {
|
||||
// Try registry lookup: plugin name ("footer") or export name ("Footer")
|
||||
const footerReg = componentRegistry.get("footer") ?? componentRegistry.get("Footer")
|
||||
if (footerReg) {
|
||||
if (typeof footerReg.component === "function" && !("displayName" in footerReg.component)) {
|
||||
// It's a constructor — use registry cache for consistent instances
|
||||
const footerOverrides = componentRegistry.getOptionOverrides("footer")
|
||||
const opts = { ...footerEntry.options, ...footerOverrides }
|
||||
footer = componentRegistry.instantiate(
|
||||
footerReg.component as QuartzComponentConstructor,
|
||||
Object.keys(opts).length > 0 ? opts : undefined,
|
||||
)
|
||||
} else {
|
||||
footer = footerReg.component as QuartzComponent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply structural defaults
|
||||
defaultLayout.head = head
|
||||
defaultLayout.header = defaultLayout.header ?? []
|
||||
if (footer) {
|
||||
defaultLayout.footer = footer
|
||||
}
|
||||
defaultLayout.footer = defaultLayout.footer ?? []
|
||||
|
||||
// Ensure all byPageType entries inherit structural slots
|
||||
for (const pageType of Object.keys(byPageType)) {
|
||||
const pt = byPageType[pageType]
|
||||
if (!pt.head) pt.head = head
|
||||
if (!pt.header) pt.header = []
|
||||
if (footer && !pt.footer) pt.footer = footer
|
||||
if (!pt.header) pt.header = defaultLayout.header
|
||||
if (!pt.footer) pt.footer = defaultLayout.footer
|
||||
}
|
||||
|
||||
const mergedDefaults = { ...defaultLayout, ...layoutOverrides?.defaults }
|
||||
@@ -756,10 +730,12 @@ function buildLayoutForEntries(
|
||||
groupOptions?: PluginLayoutDeclaration["groupOptions"]
|
||||
}[]
|
||||
> = {
|
||||
header: [],
|
||||
left: [],
|
||||
right: [],
|
||||
beforeBody: [],
|
||||
afterBody: [],
|
||||
footer: [],
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
@@ -828,6 +804,48 @@ function buildLayoutForEntries(
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.enabled || entry.layout) continue
|
||||
|
||||
const name = extractPluginName(entry.source)
|
||||
const registered =
|
||||
componentRegistry.get(name) ??
|
||||
componentRegistry.get(`${formatSourceDisplay(entry.source)}/${name}`)
|
||||
const pascalName = name
|
||||
.split("-")
|
||||
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
||||
.join("")
|
||||
const reg = registered ?? componentRegistry.get(pascalName)
|
||||
if (!reg) continue
|
||||
|
||||
const layoutDefaults = reg.manifest
|
||||
const defaultPosition = layoutDefaults?.defaultPosition
|
||||
if (!defaultPosition) continue
|
||||
|
||||
const posArray = positions[defaultPosition]
|
||||
if (!posArray) {
|
||||
continue
|
||||
}
|
||||
|
||||
let component: QuartzComponent
|
||||
if (typeof reg.component === "function" && !("displayName" in reg.component)) {
|
||||
const tsOverrides = componentRegistry.getOptionOverrides(name)
|
||||
const opts = { ...entry.options, ...tsOverrides }
|
||||
const optsArg = Object.keys(opts).length > 0 ? opts : undefined
|
||||
component = componentRegistry.instantiate(
|
||||
reg.component as QuartzComponentConstructor,
|
||||
optsArg,
|
||||
)
|
||||
} else {
|
||||
component = reg.component as QuartzComponent
|
||||
}
|
||||
|
||||
posArray.push({
|
||||
component,
|
||||
priority: layoutDefaults?.defaultPriority ?? 50,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by priority and resolve groups
|
||||
const result: Partial<FullPageLayout> = {}
|
||||
|
||||
@@ -837,7 +855,7 @@ function buildLayoutForEntries(
|
||||
const resolved = resolveGroups(items, layoutConfig.groups ?? {})
|
||||
const key = position as keyof Pick<
|
||||
FullPageLayout,
|
||||
"left" | "right" | "beforeBody" | "afterBody"
|
||||
"header" | "left" | "right" | "beforeBody" | "afterBody" | "footer"
|
||||
>
|
||||
;(result as Record<string, QuartzComponent[]>)[key] = resolved
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { BuildCtx } from "../../util/ctx"
|
||||
|
||||
export type PluginCategory = "transformer" | "filter" | "emitter" | "pageType" | "component"
|
||||
|
||||
export type LayoutPosition = "left" | "right" | "beforeBody" | "afterBody"
|
||||
export type LayoutPosition = "left" | "right" | "beforeBody" | "afterBody" | "header" | "footer"
|
||||
|
||||
export type LayoutDisplay = "all" | "mobile-only" | "desktop-only"
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import test, { describe } from "node:test"
|
||||
import assert from "node:assert"
|
||||
import { 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, [])
|
||||
})
|
||||
})
|
||||
|
||||
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")
|
||||
})
|
||||
})
|
||||
@@ -15,7 +15,8 @@ function getPageTypes(ctx: BuildCtx): QuartzPageTypePluginInstance[] {
|
||||
return (ctx.cfg.plugins.pageTypes ?? []) as unknown as QuartzPageTypePluginInstance[]
|
||||
}
|
||||
|
||||
function resolveLayout(
|
||||
/** @internal Exported for testing only. */
|
||||
export function resolveLayout(
|
||||
pageType: QuartzPageTypePluginInstance,
|
||||
sharedDefaults: Partial<FullPageLayout>,
|
||||
byPageType: Record<string, Partial<FullPageLayout>>,
|
||||
@@ -31,7 +32,7 @@ function resolveLayout(
|
||||
afterBody: overrides.afterBody ?? sharedDefaults.afterBody ?? [],
|
||||
left: overrides.left ?? sharedDefaults.left ?? [],
|
||||
right: overrides.right ?? sharedDefaults.right ?? [],
|
||||
footer: overrides.footer ?? sharedDefaults.footer!,
|
||||
footer: overrides.footer ?? sharedDefaults.footer ?? [],
|
||||
frame,
|
||||
}
|
||||
}
|
||||
@@ -52,7 +53,7 @@ function collectComponents(
|
||||
...layout.afterBody,
|
||||
...layout.left,
|
||||
...layout.right,
|
||||
layout.footer,
|
||||
...layout.footer,
|
||||
]
|
||||
for (const c of all) {
|
||||
if (c) seen.add(c)
|
||||
|
||||
Reference in New Issue
Block a user