From def629590fdf8f00a938c4b450eb6fd972f79da2 Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Sun, 13 Sep 2026 09:16:45 -0700 Subject: [PATCH 1/8] Add type-only layout registry and inferred chain contracts --- docs/layouts/README.md | 146 +++++++ scripts/test-packed-types.js | 140 ++++++- .../type-exports/layout-registry.test.ts | 313 +++++++++++++++ types.ts | 365 ++++++++++++++++++ 4 files changed, 963 insertions(+), 1 deletion(-) create mode 100644 test-cases/type-exports/layout-registry.test.ts diff --git a/docs/layouts/README.md b/docs/layouts/README.md index 1d0c7752..41b422cd 100644 --- a/docs/layouts/README.md +++ b/docs/layouts/README.md @@ -326,6 +326,152 @@ const articleLayout: LayoutFunction = ({ children }) => { + return `${children.html}` +} + +export default rootLayout + +declare module '@domstack/static/types.js' { + interface LayoutRegistry { + root: { + render: typeof rootLayout + } + } +} +``` + +```ts +// article.layout.ts +import type { LayoutFunction } from '@domstack/static/types.js' +import type { Frame, RootVars } from './root.layout.ts' + +export const parentLayout = 'root' +export const vars = { showSidebar: true } + +export type ArticleVars = RootVars & { + showSidebar: boolean +} + +const articleLayout: LayoutFunction = ({ children }) => ({ + html: `
${children}
`, +}) + +export default articleLayout + +declare module '@domstack/static/types.js' { + interface LayoutRegistry { + article: { + parentLayout: typeof parentLayout + vars: typeof vars + render: typeof articleLayout + } + } +} +``` + +A page can then derive its renderer contract from the selected innermost layout: + +```ts +import type { DataDeps, PageForLayout } from '@domstack/static/types.js' + +type PageData = { articleBody: string } + +export const vars = { + layout: 'article', + title: 'My article', + dataDeps: ['articleBody'] satisfies DataDeps, +} + +type ArticlePage = PageForLayout< + 'article', + typeof vars, // page/frontmatter/builder vars known here + PageData, // this page's data only + { siteName: string } // global vars known here +> + +const page: ArticlePage = ({ vars, data }) => { + vars.siteName + vars.title + vars.showSidebar + data.articleBody + return '

Article body

' +} + +export default page +``` + +The registry helpers are: + +| Type | Result | +| --- | --- | +| `LayoutRegistryName` | Registered names in the current TypeScript program. | +| `LayoutChain` | Names from the outermost to innermost layout. | +| `LayoutProvidedVars` | Layout defaults merged outer-to-inner with shallow override semantics. | +| `LayoutRequiredVars` | Required renderer vars not definitely supplied by layout defaults. | +| `LayoutChainVars` | Final known vars after global, layout, and page override precedence. | +| `LayoutPageOutput` | Children type accepted by the innermost layout. | +| `LayoutResult` | Awaited output of the outermost renderer, before DOMStack converts it to the final HTML string. | +| `PageForLayout` | A `PageFunction` with inferred vars and page output. `Data` remains the page's own data contract and never includes layout data. | + +These types describe contracts; they do not supply missing values or select a layout at runtime. +Required vars without registered defaults still need a global, page, or builder source. +Use `LayoutRequiredVars` to inspect those obligations; the global-vars type above assumes a matching global vars export. +`LayoutVars` retains its existing meaning as the type of a layout vars export. + +The helpers await each layout's return type and verify it is accepted by the immediate parent. +They also reject statically known incompatible vars overrides. +Unknown names, missing parents, cycles, widened or union parent names, malformed entries, incompatible renderer boundaries, and chains deeper than 32 layouts resolve to `never`. +DOMStack still performs runtime checks because Markdown frontmatter, dynamic modules, and JavaScript values are not guaranteed by TypeScript. +The selected name must be a single string literal; a union of selected names also resolves to `never` rather than accepting a page that only works for one alternative. +Use the existing `LayoutFunction` and `PageFunction` APIs for unregistered layouts or dynamic/union layout selections. +Explicit `any` contracts, generic renderers, and overloaded functions can reduce inference precision; prefer concrete `LayoutFunction` annotations for registered renderers. + +Registry declarations are global to one TypeScript program. +Use one program per site or site-specific layout names when several sites share a program, otherwise common names such as `root` can collide. +Every file containing an augmentation must be included by that site's `tsconfig.json`. + +JavaScript/JSDoc projects can opt in with an included companion declaration file: + +```ts +// src/layout-registry.d.ts +import type rootLayout from './layouts/root.layout.js' +import type articleLayout from './layouts/article.layout.js' +import type { parentLayout, vars } from './layouts/article.layout.js' + +declare module '@domstack/static/types.js' { + interface LayoutRegistry { + root: { + render: typeof rootLayout + } + article: { + parentLayout: typeof parentLayout + vars: typeof vars + render: typeof articleLayout + } + } +} +``` + +These imports are erased and do not become runtime or watch dependencies. + ## Custom layout renderers DOMStack's bundled default layout uses [`fragtml`][fragtml] because the default template only needs safe string manipulation. diff --git a/scripts/test-packed-types.js b/scripts/test-packed-types.js index b157dbc6..9d29975f 100644 --- a/scripts/test-packed-types.js +++ b/scripts/test-packed-types.js @@ -48,6 +48,12 @@ void stack import type { WorkerOptions } from 'node:worker_threads' import type { DomStackOpts, + LayoutChain, + LayoutChainVars, + LayoutFunction, + LayoutPageOutput, + LayoutResult, + PageForLayout, PageFunction, Results, } from '@domstack/static/types.js' @@ -56,6 +62,59 @@ const logger = pino({ level: 'silent' }) const options: DomStackOpts = { buildDrafts: true, logger } const render: PageFunction, string> = ({ vars }) => String(vars) +type Equal = + (() => Value extends Left ? 1 : 2) extends + (() => Value extends Right ? 1 : 2) + ? true + : false +type Expect = Value + +type Frame = { html: string } +const rootLayout: LayoutFunction<{ siteName: string }, Frame, Uint8Array> = ({ children }) => new TextEncoder().encode(children.html) +const parentLayout = 'root' +const articleVars = async () => ({ showSidebar: true }) +const articleLayout: LayoutFunction<{ siteName: string, showSidebar: boolean }, string, Frame> = ({ children }) => ({ html: children }) + +declare module '@domstack/static/types.js' { + interface LayoutRegistry { + root: { + render: typeof rootLayout + } + article: { + parentLayout: typeof parentLayout + vars: typeof articleVars + render: typeof articleLayout + } + } +} + +type _Chain = Expect, readonly ['root', 'article']>> +type _PageOutput = Expect, string>> +type _LayoutResult = Expect, Uint8Array>> +type _Vars = Expect['slug'], + string +>> + +// With exactOptionalPropertyTypes disabled, an optional override can supply undefined. +type _OptionalOverride = Expect['x'], + string | number | undefined +>> + +type ArticlePage = PageForLayout<'article', { slug: string }, { body: string }, { siteName: string }> +const articlePage: ArticlePage = ({ vars, data }) => { + vars.siteName + vars.showSidebar + vars.slug + data.body + // @ts-expect-error Layout data is not merged into page data. + data.navigation + return 'article' +} +// @ts-expect-error The article layout accepts string page output. +const invalidArticlePage: ArticlePage = () => ({ html: 'invalid' }) + // The logger option retains Pino's full contract. const configuredLogger: pino.Logger | undefined = options.logger const childOptions: DomStackOpts = { logger: logger.child({ component: 'consumer' }) } @@ -71,12 +130,81 @@ const actual: TransferItem = expected const invalidTransfer: TransferItem = 123 void render +void articlePage +void invalidArticlePage void configuredLogger void childOptions void invalidOptions void actual void invalidTransfer void ({} as Results) +`), + writeFile(path.join(consumerPath, 'js-root.layout.js'), `/** @import { LayoutFunction } from '@domstack/static/types.js' */ + +/** @type {LayoutFunction<{ siteName: string }, { html: string }, Uint8Array, { navigation: string[] }>} */ +const rootLayout = ({ vars, children, data }) => { + vars.siteName.toUpperCase() + data.navigation.map(item => item.toUpperCase()) + return new TextEncoder().encode(children.html) +} + +export default rootLayout +`), + writeFile(path.join(consumerPath, 'js-article.layout.js'), `/** @import { LayoutFunction } from '@domstack/static/types.js' */ + +export const parentLayout = 'js-root' +export const vars = async () => ({ showSidebar: true }) + +/** @type {LayoutFunction<{ siteName: string, showSidebar: boolean }, string, { html: string }, { related: string[] }>} */ +const articleLayout = ({ vars, children, data }) => { + vars.siteName.toUpperCase() + vars.showSidebar.valueOf() + data.related.map(item => item.toUpperCase()) + return { html: children.toUpperCase() } +} + +export default articleLayout +`), + writeFile(path.join(consumerPath, 'js-layout-registry.d.ts'), `import type rootLayout from './js-root.layout.js' +import type articleLayout from './js-article.layout.js' +import type { parentLayout, vars } from './js-article.layout.js' + +declare module '@domstack/static/types.js' { + interface LayoutRegistry { + 'js-root': { + render: typeof rootLayout + } + 'js-article': { + parentLayout: typeof parentLayout + vars: typeof vars + render: typeof articleLayout + } + } +} +`), + writeFile(path.join(consumerPath, 'js-page.js'), `/** @import { PageForLayout } from '@domstack/static/types.js' */ + +export const layout = 'js-article' + +/** @type {PageForLayout} */ +const articlePage = ({ vars, data }) => { + vars.siteName.toUpperCase() + vars.showSidebar.valueOf() + vars.slug.toUpperCase() + data.body.toUpperCase() + // @ts-expect-error Ancestor layout data is not merged into page data. + data.navigation + // @ts-expect-error Immediate layout data is not merged into page data. + data.related + return data.body +} + +/** @type {PageForLayout} */ +// @ts-expect-error The article layout accepts string page output, not a frame. +const invalidArticlePage = () => ({ html: 'invalid' }) + +void invalidArticlePage +export default articlePage `), writeFile(path.join(consumerPath, 'tsconfig.json'), `${JSON.stringify({ compilerOptions: { @@ -94,6 +222,16 @@ void ({} as Results) extends: './tsconfig.json', include: ['types.ts'], }, null, 2)}\n`), + writeFile(path.join(consumerPath, 'tsconfig-js.json'), `${JSON.stringify({ + extends: './tsconfig.json', + compilerOptions: { + allowJs: true, + checkJs: true, + strict: true, + skipLibCheck: false, + }, + include: ['js-root.layout.js', 'js-article.layout.js', 'js-page.js', 'js-layout-registry.d.ts'], + }, null, 2)}\n`), ]) await run( @@ -111,7 +249,7 @@ void ({} as Results) consumerPath ) } - for (const config of ['tsconfig.json', 'tsconfig-types.json']) { + for (const config of ['tsconfig.json', 'tsconfig-types.json', 'tsconfig-js.json']) { console.log(`Checking TypeScript ${devDependencies.typescript}, @types/node ${nodeVersion}, ${config}`) await run( process.execPath, diff --git a/test-cases/type-exports/layout-registry.test.ts b/test-cases/type-exports/layout-registry.test.ts new file mode 100644 index 00000000..3862ab72 --- /dev/null +++ b/test-cases/type-exports/layout-registry.test.ts @@ -0,0 +1,313 @@ +import type { + AsyncLayoutFunction, + LayoutChain, + LayoutChainVars, + LayoutFunction, + LayoutPageOutput, + LayoutProvidedVars, + LayoutRegistryName, + LayoutRequiredVars, + LayoutResult, + PageForLayout, +} from '#types' + +type Equal = + (() => Value extends Left ? 1 : 2) extends + (() => Value extends Right ? 1 : 2) + ? true + : false + +// Conditional providers can produce distinct but structurally identical branches. +type Equivalent = [Left] extends [Right] + ? [Right] extends [Left] ? true : false + : false + +type Expect = Value + +type Frame = { html: string } +type RootData = { navigation: string[] } +type ArticleData = { recentPosts: string[] } +type PageData = { postBody: string } + +type RootVars = { + siteName: string + theme: 'light' | 'dark' + rootDefault: number +} + +type ArticleVars = { + theme: 'light' | 'dark' + articleDefault: boolean +} + +export const rootVars = { + theme: 'light' as const, + rootDefault: 1, +} + +export const rootLayout: LayoutFunction = ({ children }) => { + return new TextEncoder().encode(children.html) +} + +export const parentLayout = 'root' +export const articleVars = async () => ({ + theme: 'dark' as const, + articleDefault: true, +}) +export const articleLayout: AsyncLayoutFunction = async ({ children }) => ({ + html: `
${children}
`, +}) + +export const badChildLayout: LayoutFunction<{}, string, number> = () => 42 +export const cyclicLayout: LayoutFunction<{}, string, string> = ({ children }) => children +export const incompatibleVars = { mode: 42 } +export const incompatibleVarsLayout: LayoutFunction<{ mode: string }, string, string> = ({ children }) => children +export const invalidVars = async () => 'not an object' +export const invalidVarsLayout: LayoutFunction<{}, string, string> = ({ children }) => children +export const dynamicParent: string = 'root' + +export const narrowVarsLayout: LayoutFunction<{ x: 'narrow' }, string, string> = ({ children }) => children +export const wideVarsLayout: LayoutFunction<{ x: string }, string, string> = ({ children }) => children +export const numericVarsLayout: LayoutFunction<{ x: number }, string, string> = ({ children }) => children +export declare const possiblyUndefinedVars: { x: string } | undefined +export declare const optionalOverrideVars: { x?: number } +export declare const unionDefaults: { kind: 'text', text: string } | { kind: 'count', count: number } +export declare const unionRequirements: LayoutFunction<{ a: string } | { b: number }, string, string> +export declare const unionOverlap: LayoutFunction<{ x: string } | { x: number }, string, string> + +// Source fixtures resolve through #types even when packed tests clean declarations. +declare module '#types' { + interface LayoutRegistry { + root: { + vars: typeof rootVars + render: typeof rootLayout + } + article: { + parentLayout: typeof parentLayout + vars: typeof articleVars + render: typeof articleLayout + } + badChild: { + parentLayout: 'root' + render: typeof badChildLayout + } + orphan: { + parentLayout: 'not-registered' + render: typeof cyclicLayout + } + cycleA: { + parentLayout: 'cycleB' + render: typeof cyclicLayout + } + cycleB: { + parentLayout: 'cycleA' + render: typeof cyclicLayout + } + badVars: { + vars: typeof incompatibleVars + render: typeof incompatibleVarsLayout + } + invalidVars: { + vars: typeof invalidVars + render: typeof invalidVarsLayout + } + dynamicParent: { + parentLayout: typeof dynamicParent + render: typeof cyclicLayout + } + undefinedParent: { + parentLayout: undefined + render: typeof cyclicLayout + } + optionalUndefinedParent: { + parentLayout?: undefined + render: typeof cyclicLayout + } + possiblyRootParent: { + parentLayout: 'root' | undefined + render: typeof articleLayout + } + optionalRootParent: { + parentLayout?: 'root' + render: typeof articleLayout + } + unionParent: { + parentLayout: 'undefinedParent' | 'optionalUndefinedParent' + render: typeof cyclicLayout + } + possiblyUndefinedVars: { + vars: typeof possiblyUndefinedVars + render: typeof wideVarsLayout + } + optionalVars: { + vars?: { x: string } + render: typeof wideVarsLayout + } + narrowOuter: { + render: typeof narrowVarsLayout + } + wideInner: { + parentLayout: 'narrowOuter' + render: typeof wideVarsLayout + } + wideOuter: { + render: typeof wideVarsLayout + } + narrowInner: { + parentLayout: 'wideOuter' + render: typeof narrowVarsLayout + } + requiredDefault: { + vars: { x: string } + render: typeof cyclicLayout + } + optionalOverride: { + parentLayout: 'requiredDefault' + vars: typeof optionalOverrideVars + render: typeof cyclicLayout + } + unionDefaults: { + vars: typeof unionDefaults + render: typeof cyclicLayout + } + unionDefaultsChild: { + parentLayout: 'unionDefaults' + vars: { child: boolean } + render: typeof cyclicLayout + } + unionRequirements: { + render: typeof unionRequirements + } + unionOverlap: { + render: typeof unionOverlap + } + narrowedUnion: { + parentLayout: 'unionOverlap' + render: typeof wideVarsLayout + } + incompatibleRequirements: { + parentLayout: 'wideOuter' + render: typeof numericVarsLayout + } + } +} + +export type _RegistryNames = Expect> +export type _Chain = Expect, readonly ['root', 'article']>> +export type _PageOutput = Expect, string>> +export type _OuterResult = Expect, Uint8Array>> + +type Provided = LayoutProvidedVars<'article'> +export type _InnerDefaultWins = Expect> +export type _RootDefaultRemains = Expect> +export type _ArticleDefaultRemains = Expect> +export type _RequiredExternally = Expect, { siteName: string }>> + +type FinalVars = LayoutChainVars< + 'article', + { siteName: string, theme: 'light' }, + { slug: string, theme: 'dark' } +> +export type _PageOverrideWins = Expect> +export type _GlobalVarRemains = Expect> +export type _PageVarRemains = Expect> + +type ArticlePage = PageForLayout< + 'article', + { slug: string }, + PageData, + { siteName: string } +> +export type _PageDataOnly = Expect[0]['data'], PageData>> + +export const page: ArticlePage = ({ vars, data }) => { + const values: [string, number, boolean, string] = [ + vars.siteName, + vars.rootDefault, + vars.articleDefault, + vars.slug, + ] + const ownData: string = data.postBody + // @ts-expect-error Ancestor layout data is not visible to the page. + const ancestorData = data.navigation + // @ts-expect-error Innermost layout data is not visible to the page. + const layoutData = data.recentPosts + return `${values.length}:${ownData}:${String(ancestorData)}:${String(layoutData)}` +} + +// @ts-expect-error The innermost article layout requires string children. +export const wrongPageOutput: ArticlePage = () => ({ html: 'wrong level' }) + +export type _UndefinedParentIsRoot = Expect, readonly ['undefinedParent']>> +export type _OptionalUndefinedParentIsRoot = Expect, + readonly ['optionalUndefinedParent'] +>> +export type _PossiblyRootParentRejected = Expect, never>> +export type _OptionalRootParentRejected = Expect, never>> +export type _UnionParentRejected = Expect, never>> + +export type _UndefinedDefaultsRemainPossible = Expect, + {} | { x: string } +>> +export type _UndefinedDefaultsNotDefinitelySupplied = Expect, + { x: string } +>> +export type _OptionalDefaultsNotDefinitelySupplied = Expect, { x: string }>> +export type _NarrowOuterWideInner = Expect, { x: 'narrow' }>> +export type _WideOuterNarrowInner = Expect, { x: 'narrow' }>> +export type _NarrowOuterRequired = Expect, { x: 'narrow' }>> +export type _NarrowInnerRequired = Expect, { x: 'narrow' }>> + +export type _OptionalDefaultRetainsRequiredLeft = Expect, + { x: string | number } +>> +export type _OptionalPageOverrideRetainsRequiredLeft = Expect, + { x: string | number } +>> +export type _UnionDefaultsPreserveRightBranchKeys = Expect, + typeof unionDefaults +>> +export type _UnionDefaultsPreserveLeftBranchKeys = Expect, + { kind: 'text', text: string, child: boolean } | { kind: 'count', count: number, child: boolean } +>> +export type _UnionDefaultsPreserveChainBranchKeys = Expect, + | { kind: 'text', text: string, child: boolean, page: string } + | { kind: 'count', count: number, child: boolean, page: string } +>> +export type _IncompatibleRequirementsVars = Expect, never>> +export type _IncompatibleRequirementsRequired = Expect, never>> +export type _IncompatibleRequirementsPage = Expect, never>> + +export type _UnionSelectedNames = Expect, never>> +export type _PartiallyMissingSelectedName = Expect, never>> +export type _DynamicSelectedName = Expect, never>> +export type _UnionRequiredVars = Expect, { a: string } | { b: number }>> +export type _NarrowedUnionRequirements = Expect, { x: string }>> + +export type _BadEdge = Expect, never>> +export type _BadPage = Expect, never>> +export type _MissingParent = Expect, never>> +export type _Cycle = Expect, never>> +export type _UnknownLayout = Expect, never>> +export type _IncompatibleDefaults = Expect, never>> +export type _InvalidVars = Expect, never>> +export type _DynamicParent = Expect, never>> +export type _IncompatiblePageOverride = Expect, + never +>> diff --git a/types.ts b/types.ts index 32f40804..7e377f06 100644 --- a/types.ts +++ b/types.ts @@ -5,6 +5,8 @@ // and `types.js` is reserved for a future runtime/type companion entry if needed. import type { Results } from './lib/builder.js' +import type { PageFunction as PageFunctionExport } from './lib/build-pages/page-builders/page-writer.js' + export type { DataDeps } from './lib/build-pages/data-deps.js' export type { PageOutput, @@ -70,3 +72,366 @@ export type TestBuildResult = { readOutput: (path: string) => Promise cleanup: () => Promise } + +/** + * Compile-time registry for layouts known to an application's TypeScript + * program. Layout modules opt in through module augmentation; DOMStack does not + * create or read this registry at runtime. + * + * This interface intentionally has no index signature so unknown layout and + * parent names can be detected. + */ +export interface LayoutRegistry {} + +/** Names registered in the current TypeScript program. */ +export type LayoutRegistryName = Extract + +/** + * Registered layout names from the outermost layout to the selected innermost + * layout. Invalid entries, missing parents, cycles, incompatible render values, + * and chains deeper than 32 layouts resolve to `never`. + */ +export type LayoutChain = ResolveLayoutChain + +/** Layout `vars` exports merged in runtime order, outermost to innermost. */ +export type LayoutProvidedVars = + ResolveLayoutChain extends infer Chain + ? [Chain] extends [never] + ? never + : Chain extends readonly [string, ...string[]] + ? MergeLayoutDefaults + : never + : never + +/** + * Renderer variables that are not definitely supplied by a registered layout's + * `vars` export. They must be supplied by another source such as global vars, + * page vars/frontmatter, or builder vars. + */ +export type LayoutRequiredVars = + ResolveLayoutChain extends infer Chain + ? [Chain] extends [never] + ? never + : Chain extends readonly [string, ...string[]] + ? CheckedLayoutVars extends infer Vars + ? [Vars] extends [never] + ? never + : Vars extends AnyVars + ? Pick< + Vars, + Extract< + Exclude< + RequiredKeys, + DefinitelyRequiredKeys> + >, + keyof Vars + > + > + : never + : never + : never + : never + +/** + * Variables visible to the page after known sources are shallow-merged in + * runtime precedence order: global vars, outer-to-inner layout vars, then page + * vars/frontmatter/builder vars. Renderer declarations provide the baseline + * contract and every known override must remain compatible with every renderer + * in the chain. + */ +export type LayoutChainVars< + Name extends string, + GlobalVars extends AnyVars = {}, + PageVars extends AnyVars = {} +> = ResolveLayoutChain extends infer Chain + ? [Chain] extends [never] + ? never + : Chain extends readonly [string, ...string[]] + ? CheckedLayoutVars + : never + : never + +/** The value a page must return for the selected innermost layout. */ +export type LayoutPageOutput = + ResolveLayoutChain extends infer Chain + ? [Chain] extends [never] + ? never + : Chain extends readonly [string, ...string[]] + ? RendererChildren> + : never + : never + +/** + * Awaited result of the outermost layout. This is distinct from DOMStack's final + * serialized HTML string. + */ +export type LayoutResult = + ResolveLayoutChain extends infer Chain + ? [Chain] extends [never] + ? never + : Chain extends readonly [infer Outer extends string, ...string[]] + ? RendererResult + : never + : never + +/** + * Page render function inferred from a selected registered layout. + * + * `Data` is the page's own subscribed data contract. Layout data contracts are + * intentionally not merged into it. + */ +export type PageForLayout< + Name extends string, + PageVars extends AnyVars = {}, + Data extends object = Record, + GlobalVars extends AnyVars = {} +> = ResolveLayoutChain extends infer Chain + ? [Chain] extends [never] + ? never + : Chain extends readonly [string, ...string[]] + ? CheckedLayoutVars extends infer Vars + ? [Vars] extends [never] + ? never + : Vars extends AnyVars + ? PageFunctionExport>, Data> + : never + : never + : never + : never + +type AnyVars = Record +type AnyFunction = (...args: any[]) => any + +type RegistryEntry = + Name extends LayoutRegistryName ? LayoutRegistry[Name] : never + +type EntryProperty = + Key extends keyof Entry ? Entry[Key] : never + +type Renderer = + RegistryEntry extends { readonly render: infer Render extends AnyFunction } + ? Render + : never + +type RendererParams = + Renderer extends (params: infer Params, ...rest: any[]) => any + ? Params + : never + +type RendererVars = + RendererParams extends { readonly vars: infer Vars extends AnyVars } + ? Vars + : never + +type RendererChildren = + RendererParams extends { readonly children: infer Children } + ? Children + : never + +type RendererResult = + Renderer extends AnyFunction ? Awaited>> : never + +type RendererIsValid = + [Renderer] extends [never] + ? false + : RendererParams extends { + readonly vars: AnyVars + readonly children: unknown + } + ? true + : false + +type ResolvedVarsValue = + Value extends AnyVars ? Value : false + +type ResolveVarsExport = + Value extends undefined + ? {} + : Value extends () => infer Result + ? ResolvedVarsValue> + : ResolvedVarsValue + +type EntryDefaults = + 'vars' extends keyof RegistryEntry + ? ResolveVarsExport, 'vars'>> extends infer Defaults + ? [Defaults] extends [AnyVars] + ? Defaults + : never + : never + : {} + +type RootReference = { readonly kind: 'root' } +type ParentReference = { readonly kind: 'parent', readonly name: Name } +type InvalidParentReference = { readonly kind: 'invalid' } + +type IsUnion = + Value extends unknown ? ([Whole] extends [Value] ? false : true) : never + +type EntryParent = + 'parentLayout' extends keyof RegistryEntry + ? EntryProperty, 'parentLayout'> extends infer Parent + ? [Parent] extends [never] + ? InvalidParentReference + : [Parent] extends [undefined] + ? RootReference + : undefined extends Parent + ? InvalidParentReference + : [Parent] extends [string] + ? string extends Parent + ? InvalidParentReference + : true extends IsUnion + ? InvalidParentReference + : ParentReference + : InvalidParentReference + : InvalidParentReference + : RootReference + +type ResolveLayoutChain< + Name extends string, + Seen extends string = never, + Depth extends readonly unknown[] = [] +> = true extends IsUnion + ? never + : string extends Name + ? never + : ResolveLiteralLayoutChain + +type ResolveLiteralLayoutChain< + Name extends string, + Seen extends string, + Depth extends readonly unknown[] +> = Depth['length'] extends 32 + ? never + : Name extends LayoutRegistryName + ? Name extends Seen + ? never + : RendererIsValid extends true + ? [EntryDefaults] extends [never] + ? never + : EntryParent extends RootReference + ? readonly [Name] + : EntryParent extends ParentReference + ? Parent extends LayoutRegistryName + ? [RendererResult] extends [RendererChildren] + ? ResolveLayoutChain< + Parent, + Seen | Name, + readonly [...Depth, unknown] + > extends infer Parents + ? Parents extends readonly [string, ...string[]] + ? readonly [...Parents, Name] + : never + : never + : never + : never + : never + : never + : never + +type Simplify = { -readonly [Key in keyof Value]: Value[Key] } + +type OptionalKeys = Exclude> + +// Without exactOptionalPropertyTypes, an explicitly supplied undefined is a +// legal override and must remain in the result even when the left key exists. +type PresentProperty = + { value: undefined } extends { value?: never } ? Value[Key] : Required[Key] + +type SpreadPair = Simplify< + Omit + & Pick> + & { + [Key in Extract, RequiredKeys>]-?: + Key extends keyof Left + ? Left[Key] | PresentProperty + : PresentProperty + } + & { + [Key in Exclude, RequiredKeys>]?: + (Key extends keyof Left ? Left[Key] : never) + | PresentProperty + } +> + +/** Distributive, right-biased shallow merge matching object spread semantics. */ +type MergeRight = + Left extends object + ? Right extends object + ? SpreadPair + : never + : never + +/** Renderer declarations are simultaneous requirements, not override sources. */ +type MergeRendererVars< + Chain extends readonly string[], + Accumulated = {} +> = Chain extends readonly [infer Current extends string, ...infer Rest extends string[]] + ? MergeRendererVars> + : SatisfiableRequirements + +type MergeLayoutDefaults< + Chain extends readonly string[], + Accumulated = {} +> = Chain extends readonly [infer Current extends string, ...infer Rest extends string[]] + ? MergeLayoutDefaults>> + : Simplify + +type CandidateLayoutVars< + Chain extends readonly string[], + GlobalVars extends AnyVars, + PageVars extends AnyVars +> = MergeRight< + MergeRight< + MergeRight, GlobalVars>, + MergeLayoutDefaults + >, + PageVars +> + +type EveryRendererAccepts = + Chain extends readonly [infer Current extends string, ...infer Rest extends string[]] + ? [Vars] extends [RendererVars] + ? EveryRendererAccepts + : false + : true + +type NeverRequiredKeys = { + [Key in RequiredKeys]: [Value[Key]] extends [never] ? Key : never +}[RequiredKeys] + +// Intersections of union contracts can contain impossible alternatives; those +// are not valid requirements and should not invalidate the remaining choices. +type SatisfiableRequirements = + Value extends unknown + ? [NeverRequiredKeys] extends [never] ? Value : never + : never + +type HasNeverRequired = + Value extends unknown + ? [NeverRequiredKeys] extends [never] ? false : true + : never + +type CheckedLayoutVars< + Chain extends readonly string[], + GlobalVars extends AnyVars, + PageVars extends AnyVars +> = CandidateLayoutVars extends infer Vars + ? [Vars] extends [never] + ? never + : true extends HasNeverRequired + ? never + : EveryRendererAccepts extends true + ? Simplify + : never + : never + +type DefinitelyRequiredKeys = { + [Key in Keys]: [Value] extends [Record] ? Key : never +}[Keys] + +type RequiredKeys = { + [Key in keyof Value]-?: {} extends Pick ? never : Key +}[keyof Value] + +type Last = + Values extends readonly [...string[], infer Value extends string] ? Value : never From 686fcdd5f640888c56537fa4b8f193981f67d3a4 Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Sun, 13 Sep 2026 09:29:27 -0700 Subject: [PATCH 2/8] Demonstrate inferred layout contracts in the basic example --- examples/basic/README.md | 38 +++++++++++++++++++ .../basic/src/js-page/loose-assets/page.ts | 12 ++---- examples/basic/src/js-page/page.js | 14 +++---- examples/basic/src/layouts/child.layout.ts | 9 +++++ examples/basic/src/layouts/root.layout.ts | 8 ++++ examples/basic/tsconfig.json | 2 +- 6 files changed, 65 insertions(+), 18 deletions(-) diff --git a/examples/basic/README.md b/examples/basic/README.md index bff9a3bf..f98ff45e 100644 --- a/examples/basic/README.md +++ b/examples/basic/README.md @@ -71,6 +71,44 @@ src/ ### Layouts The example demonstrates DOMStack's layout system with nested layouts that wrap page content, fully typed with TypeScript interfaces. +### Inferred layout contracts + +The [root layout](src/layouts/root.layout.ts) and [child layout](src/layouts/child.layout.ts) register their actual renderer types through the type-only `LayoutRegistry` interface. +The child registration also references `typeof parentLayout`, linking it to root without repeating the parent name in the type declaration. +Both renderers keep explicit `LayoutFunction` annotations so their signatures do not depend recursively on their own registrations. + +The [JavaScript page](src/js-page/page.js) uses JSDoc to derive its vars and accepted output from the registered child layout: + +```js +/** + * @import { PageForLayout } from '@domstack/static/types.js' + */ + +/** @satisfies {PageForLayout<'child'>} */ +``` + +The `@satisfies` annotation checks the inferred contract while preserving the async function's own Promise return type. +Its [page.vars.js](src/js-page/page.vars.js) still selects `child` at runtime. +The page returns a `HtmlResult`, child converts that into a string, and root wraps the result into the final document. +The registry checks this chain without requiring the page to import `PageVars` or repeat `HtmlResult` in its annotation. +The [loose-assets TypeScript page](src/js-page/loose-assets/page.ts) similarly uses `PageForLayout<'root'>` for its default root layout. + +Registration does not supply missing vars or replace runtime layout selection. +`siteName` still comes from global vars, and each page supplies its title. +The example's TypeScript program includes both `.ts` and `.js` files, so `npm test` checks the JSDoc consumer as well as the TypeScript consumer. +Keep each site's registry in its own TypeScript program to avoid name collisions with other sites. + +When working from this repository checkout, build the package declarations before checking the example, and clean them afterward: + +```sh +# Run from the repository root +npm run build:declaration +npm --workspace @domstack/basic-example test +npm --workspace @domstack/basic-example run build +npm run clean:declarations-top +npm run clean:declarations-lib +``` + ### Assets Static assets like images are co-located with content and automatically copied to the output directory. diff --git a/examples/basic/src/js-page/loose-assets/page.ts b/examples/basic/src/js-page/loose-assets/page.ts index 5493a85b..e2049421 100644 --- a/examples/basic/src/js-page/loose-assets/page.ts +++ b/examples/basic/src/js-page/loose-assets/page.ts @@ -1,11 +1,9 @@ import { html } from 'fragtml' -import type { HtmlResult } from 'fragtml/types.js' -import type { PageFunction } from '@domstack/static/types.js' +import type { PageForLayout } from '@domstack/static/types.js' import sharedData from './shared-lib.ts' -import type { PageVars } from '../../layouts/root.layout.ts' -const JSPage: PageFunction = async () => { +const JSPage: PageForLayout<'root'> = async () => { return html`

@@ -24,10 +22,6 @@ const JSPage: PageFunction = async () => { export default JSPage -interface PageVariables extends PageVars { - title: string; -} - -export const vars: Partial = { +export const vars = { title: 'JS Page with loose assets', } diff --git a/examples/basic/src/js-page/page.js b/examples/basic/src/js-page/page.js index 21e1702c..4febdfe8 100644 --- a/examples/basic/src/js-page/page.js +++ b/examples/basic/src/js-page/page.js @@ -1,19 +1,15 @@ /** - * @import { PageFunction } from '@domstack/static/types.js' - * @import { PageVars } from '../layouts/root.layout.ts' - * @import { HtmlResult } from 'fragtml/types.js' + * @import { PageForLayout } from '@domstack/static/types.js' */ import { html } from 'fragtml' -/** -* @type { PageFunction } -*/ -export default async function JSPage ({ +/** @satisfies {PageForLayout<'child'>} */ +const JSPage = async ({ vars: { siteName, title, } -}) { +}) => { return html`

JavaScript Page Example

@@ -71,6 +67,8 @@ export default async function JSPage ({ ` } +export default JSPage + // Define page-specific variables export const vars = { title: 'JavaScript Page Example', diff --git a/examples/basic/src/layouts/child.layout.ts b/examples/basic/src/layouts/child.layout.ts index bbba853a..763caac1 100644 --- a/examples/basic/src/layouts/child.layout.ts +++ b/examples/basic/src/layouts/child.layout.ts @@ -23,3 +23,12 @@ const articleLayout: LayoutFunction = ({ } export default articleLayout + +declare module '@domstack/static/types.js' { + interface LayoutRegistry { + child: { + parentLayout: typeof parentLayout + render: typeof articleLayout + } + } +} diff --git a/examples/basic/src/layouts/root.layout.ts b/examples/basic/src/layouts/root.layout.ts index c7da72fe..ac4ee28a 100644 --- a/examples/basic/src/layouts/root.layout.ts +++ b/examples/basic/src/layouts/root.layout.ts @@ -50,3 +50,11 @@ const RootLayout: LayoutFunction = async } export default RootLayout + +declare module '@domstack/static/types.js' { + interface LayoutRegistry { + root: { + render: typeof RootLayout + } + } +} diff --git a/examples/basic/tsconfig.json b/examples/basic/tsconfig.json index a5cb75c5..1364c212 100644 --- a/examples/basic/tsconfig.json +++ b/examples/basic/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "../../tsconfig.json", - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "src/**/*.js"] } From bfd59a05a7dbbd01735691f04db27be8b12edcb2 Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Sun, 13 Sep 2026 09:37:13 -0700 Subject: [PATCH 3/8] Show typed variable precedence in the basic registry example --- examples/basic/README.md | 41 ++++++++++++++-- examples/basic/src/global.vars.ts | 13 ++--- .../basic/src/js-page/loose-assets/page.ts | 23 ++++++++- examples/basic/src/js-page/page.js | 26 ++++++++-- examples/basic/src/layouts/child.layout.ts | 21 +++++++- examples/basic/src/layouts/root.layout.ts | 18 ++++++- examples/basic/tsconfig.json | 2 +- examples/basic/type-checks.ts | 49 +++++++++++++++++++ 8 files changed, 177 insertions(+), 16 deletions(-) create mode 100644 examples/basic/type-checks.ts diff --git a/examples/basic/README.md b/examples/basic/README.md index f98ff45e..828c8c8f 100644 --- a/examples/basic/README.md +++ b/examples/basic/README.md @@ -82,22 +82,57 @@ The [JavaScript page](src/js-page/page.js) uses JSDoc to derive its vars and acc ```js /** * @import { PageForLayout } from '@domstack/static/types.js' + * @import { default as globalVars } from '../global.vars.ts' */ -/** @satisfies {PageForLayout<'child'>} */ +/** @satisfies {PageForLayout<'child', typeof vars, Record, Awaited>>} */ ``` The `@satisfies` annotation checks the inferred contract while preserving the async function's own Promise return type. Its [page.vars.js](src/js-page/page.vars.js) still selects `child` at runtime. The page returns a `HtmlResult`, child converts that into a string, and root wraps the result into the final document. The registry checks this chain without requiring the page to import `PageVars` or repeat `HtmlResult` in its annotation. -The [loose-assets TypeScript page](src/js-page/loose-assets/page.ts) similarly uses `PageForLayout<'root'>` for its default root layout. +The [loose-assets TypeScript page](src/js-page/loose-assets/page.ts) similarly uses `PageForLayout<'root', typeof vars, Record, Awaited>>` for its default root layout. +Both pages reference actual exports for their own vars and resolved async global vars, while the registry supplies layout defaults and renderer requirements. +`Record` explicitly states that these pages subscribe to no global data; global vars and subscribed data are separate concepts. Registration does not supply missing vars or replace runtime layout selection. -`siteName` still comes from global vars, and each page supplies its title. +`siteName` and `locale` still come from global vars, and each page supplies its title. The example's TypeScript program includes both `.ts` and `.js` files, so `npm test` checks the JSDoc consumer as well as the TypeScript consumer. Keep each site's registry in its own TypeScript program to avoid name collisions with other sites. +#### A visible variable cascade + +The two pages render their inferred variables so the type-level composition can be compared with the built HTML: + +| Variable | Global vars | Root defaults | Async child defaults | JavaScript page | Final JavaScript page type/value | +| --- | --- | --- | --- | --- | --- | +| `theme` | `'dark'` | `'light'` | `'dark'` | `'light'` | `'light'` | +| `locale` | `'en'` | — | — | — | `'en'` | +| `navigation` | Array of `{ label, href }` | — | — | — | Typed navigation entries | +| `footer` | — | `{ label: 'Built with DOMStack', showYear: false }` | — | — | Inherited typed object | +| `readingMinutes` | — | — | `4` | — | `number`, value `4` | +| `badge` | — | — | `{ label: 'Guide', tone: 'info' }` | `{ label: 'Hands-on example', tone: 'tip' }` | Page object, with tone `'tip'` | +| `topics` | — | — | — | String array | Page-only `string[]` | + +The TypeScript loose-assets page selects root, so it gets root's `'light'` theme instead of the global `'dark'` theme and has no child-only reading time or badge. +Its own `assets` array infers the item kind as `'module' | 'stylesheet'` without a separate page vars interface. + +Layout renderer requirements remain deliberately broader than supplied defaults. +Root accepts either `'light'` or `'dark'`, and child accepts badges with either `'info'` or `'tip'` tone, so the page can supply a compatible override without being restricted to the default literal. +The helpers infer the winning source's narrower type rather than intersecting `'light'` and `'dark'` into `never`. +Vars merge shallowly: overriding `badge` must provide the complete required object, not just a new label. + +[Compile-time checks](type-checks.ts) exercise the actual example registrations and exports: + +- `LayoutProvidedVars<'child'>` includes root defaults plus the awaited child defaults. +- `LayoutRequiredVars<'child'>` identifies `title`, `siteName`, and `locale` as required values not supplied by the layouts. +- `LayoutChainVars` verifies every step of theme precedence, page-only arrays, and global navigation inference. +- Invalid themes, string reading times, incomplete badge overrides, and incompatible page output are rejected. + +The checks live outside `src` so they are not copied into the built site. +Run them with the example's regular `npm test` command. + When working from this repository checkout, build the package declarations before checking the example, and clean them afterward: ```sh diff --git a/examples/basic/src/global.vars.ts b/examples/basic/src/global.vars.ts index 92bdeedd..1fa99553 100644 --- a/examples/basic/src/global.vars.ts +++ b/examples/basic/src/global.vars.ts @@ -3,13 +3,14 @@ // // These variables are available to every page, and have the lowest precedence. -interface GlobalVars { - siteName: string; - [key: string]: unknown; -} - -export default async function (): Promise { +export default async function globalVars () { return { siteName: 'domstack basic', + locale: 'en' as const, + theme: 'dark' as const, + navigation: [ + { label: 'Home', href: '/' }, + { label: 'JavaScript page', href: '/js-page/' }, + ], } } diff --git a/examples/basic/src/js-page/loose-assets/page.ts b/examples/basic/src/js-page/loose-assets/page.ts index e2049421..df3aeff1 100644 --- a/examples/basic/src/js-page/loose-assets/page.ts +++ b/examples/basic/src/js-page/loose-assets/page.ts @@ -1,9 +1,17 @@ import { html } from 'fragtml' import type { PageForLayout } from '@domstack/static/types.js' +import type globalVars from '../../global.vars.ts' import sharedData from './shared-lib.ts' -const JSPage: PageForLayout<'root'> = async () => { +type AssetPage = PageForLayout< + 'root', + typeof vars, + Record, + Awaited> +> + +const JSPage: AssetPage = async ({ vars }) => { return html`

@@ -16,6 +24,15 @@ const JSPage: PageForLayout<'root'> = async () => { that get imported into the page.js, client.js and style.css files for this page.

${sharedData.shared}

+
+

Inferred root-layout variables

+

${vars.siteName} · ${vars.locale} · ${vars.theme} theme

+

Root footer default: ${vars.footer.label}

+

Page-only asset metadata

+
    + ${vars.assets.map(asset => html`
  • ${asset.label}: ${asset.kind}
  • `)} +
+
` } @@ -24,4 +41,8 @@ export default JSPage export const vars = { title: 'JS Page with loose assets', + assets: [ + { label: 'Shared data', kind: 'module' as const }, + { label: 'Local styles', kind: 'stylesheet' as const }, + ], } diff --git a/examples/basic/src/js-page/page.js b/examples/basic/src/js-page/page.js index 4febdfe8..31175eca 100644 --- a/examples/basic/src/js-page/page.js +++ b/examples/basic/src/js-page/page.js @@ -1,13 +1,21 @@ /** * @import { PageForLayout } from '@domstack/static/types.js' + * @import { default as globalVars } from '../global.vars.ts' */ import { html } from 'fragtml' -/** @satisfies {PageForLayout<'child'>} */ +/** @satisfies {PageForLayout<'child', typeof vars, Record, Awaited>>} */ const JSPage = async ({ vars: { siteName, title, + locale, + theme, + footer, + readingMinutes, + badge, + navigation, + topics, } }) => { return html` @@ -44,10 +52,19 @@ const JSPage = async ({

Using Variables

-

Here we access the siteName and title variables inside the page:

+

These values are inferred from global vars, both layouts, and this page's overrides:

Site Name: ${siteName}
Page Title: ${title}
+
Global locale: ${locale}
+
Page theme override: ${theme}
+
Inherited root footer: ${footer.label}
+
Inherited child reading time: ${readingMinutes} minutes
+
Page badge override: ${badge.label} (${badge.tone})
+
Page-only topics: ${topics.join(', ')}
+
@@ -72,5 +89,8 @@ export default JSPage // Define page-specific variables export const vars = { title: 'JavaScript Page Example', - description: 'Learn how to use JavaScript pages in DOMStack for dynamic content generation' + description: 'Learn how to use JavaScript pages in DOMStack for dynamic content generation', + theme: /** @type {const} */ ('light'), + badge: { label: 'Hands-on example', tone: /** @type {const} */ ('tip') }, + topics: ['TypeScript', 'JSDoc', 'Nested layouts'], } diff --git a/examples/basic/src/layouts/child.layout.ts b/examples/basic/src/layouts/child.layout.ts index 763caac1..62de8699 100644 --- a/examples/basic/src/layouts/child.layout.ts +++ b/examples/basic/src/layouts/child.layout.ts @@ -6,11 +6,29 @@ import type { PageVars } from './root.layout.ts' export const parentLayout = 'root' -const articleLayout: LayoutFunction = ({ children, vars }) => { +export type ArticleVars = PageVars & { + readingMinutes: number + badge: { + label: string + tone: 'info' | 'tip' + } +} + +export const vars = async () => ({ + theme: 'dark', + readingMinutes: 4, + badge: { label: 'Guide', tone: 'info' }, +} satisfies Pick) + +const articleLayout: LayoutFunction = ({ children, vars }) => { return render(html`

${vars.title}

+
${typeof children === 'string' @@ -28,6 +46,7 @@ declare module '@domstack/static/types.js' { interface LayoutRegistry { child: { parentLayout: typeof parentLayout + vars: typeof vars render: typeof articleLayout } } diff --git a/examples/basic/src/layouts/root.layout.ts b/examples/basic/src/layouts/root.layout.ts index ac4ee28a..869b1777 100644 --- a/examples/basic/src/layouts/root.layout.ts +++ b/examples/basic/src/layouts/root.layout.ts @@ -15,12 +15,26 @@ export interface PageVars { title: string; siteName: string; basePath?: string; + locale: 'en' | 'fr'; + theme: 'light' | 'dark'; + footer: { + label: string; + showYear: boolean; + }; } +export const vars = { + theme: 'light', + footer: { label: 'Built with DOMStack', showYear: false }, +} satisfies Pick + const RootLayout: LayoutFunction = async ({ vars: { title, siteName, + locale, + theme, + footer, basePath }, scripts, @@ -29,7 +43,7 @@ const RootLayout: LayoutFunction = async }) => { return render(html` - + ${siteName}${title ? ` | ${title}` : ''} @@ -44,6 +58,7 @@ const RootLayout: LayoutFunction = async
${typeof children === 'string' ? raw(children) : children}
+
${footer.label}${footer.showYear ? ` · ${new Date().getFullYear()}` : ''}
`) @@ -54,6 +69,7 @@ export default RootLayout declare module '@domstack/static/types.js' { interface LayoutRegistry { root: { + vars: typeof vars render: typeof RootLayout } } diff --git a/examples/basic/tsconfig.json b/examples/basic/tsconfig.json index 1364c212..098b4ed5 100644 --- a/examples/basic/tsconfig.json +++ b/examples/basic/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "../../tsconfig.json", - "include": ["src/**/*.ts", "src/**/*.js"] + "include": ["src/**/*.ts", "src/**/*.js", "type-checks.ts"] } diff --git a/examples/basic/type-checks.ts b/examples/basic/type-checks.ts new file mode 100644 index 00000000..a7d459f4 --- /dev/null +++ b/examples/basic/type-checks.ts @@ -0,0 +1,49 @@ +import type { + LayoutChain, + LayoutChainVars, + LayoutProvidedVars, + LayoutRequiredVars, + PageForLayout, +} from '@domstack/static/types.js' +import type globalVars from './src/global.vars.ts' +import type { vars as pageVars } from './src/js-page/page.js' +import type { vars as assetVars } from './src/js-page/loose-assets/page.ts' + +type Equal = + (() => Value extends Left ? 1 : 2) extends + (() => Value extends Right ? 1 : 2) ? true : false + +type Expect = Value + +type GlobalVars = Awaited> +type ArticleDefaults = LayoutProvidedVars<'child'> +type ArticleResolved = LayoutChainVars<'child', GlobalVars, typeof pageVars> +type AssetResolved = LayoutChainVars<'root', GlobalVars, typeof assetVars> + +export type Chain = Expect, readonly ['root', 'child']>> +export type GlobalTheme = Expect> +export type RootOverridesGlobal = Expect> +export type ChildOverridesRoot = Expect> +export type PageOverridesChild = Expect> +export type ChildReadingTime = Expect> +export type PageBadge = Expect> +export type GlobalLocale = Expect> +export type GlobalNavigation = Expect> +export type PageTopics = Expect> +export type AssetKinds = Expect> +export type RequiredOutsideLayouts = Expect, + { title: string, siteName: string, locale: 'en' | 'fr' } +>> + +export type BadTheme = Expect, never>> +export type BadReadingTime = Expect, never>> +// Vars merge shallowly: a page badge replaces the whole object, not just its label. +export type IncompleteBadge = Expect, never>> +export type NoChildVarsOnRoot = Expect> + +export type ArticlePage = PageForLayout<'child', typeof pageVars, Record, GlobalVars> +export type NoImplicitData = Expect[0]['data'], Record>> + +// @ts-expect-error Pages must return a string or HtmlResult, not arbitrary objects. +export const invalidContent: ArticlePage = () => ({ html: 'not a HtmlResult' }) From 0367780e6a7997c2dd1edc50b0088cc34635b16e Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Sun, 13 Sep 2026 15:32:22 -0700 Subject: [PATCH 4/8] Validate supplied vars and infer generated-page layout contracts --- docs/generation/README.md | 91 +++++++ docs/layouts/README.md | 40 +++ examples/basic/README.md | 20 ++ examples/basic/src/guides.pages.ts | 49 ++++ .../basic/src/js-page/loose-assets/page.ts | 10 +- examples/basic/src/js-page/page.js | 7 +- examples/basic/type-checks.ts | 7 + scripts/test-packed-types.js | 120 ++++++++- .../generated-layout-types.test.ts | 233 ++++++++++++++++++ .../type-exports/layout-registry.test.ts | 5 +- types.ts | 122 +++++++++ 11 files changed, 696 insertions(+), 8 deletions(-) create mode 100644 examples/basic/src/guides.pages.ts create mode 100644 test-cases/type-exports/generated-layout-types.test.ts diff --git a/docs/generation/README.md b/docs/generation/README.md index 4be316bb..b7f12693 100644 --- a/docs/generation/README.md +++ b/docs/generation/README.md @@ -247,6 +247,97 @@ export default archivePages For metadata-driven redirects, see the cookbook recipe [Generate redirect pages from page metadata](../cookbook/redirect-pages/). +### Registered layouts for generated pages + +For a [registered layout chain](../layouts/#inferring-registered-layout-chains), use `GeneratedPageForLayout` for a definition or `PagesForLayout` for a factory. +These helpers validate required vars from actual known sources, rather than assuming values exist because a renderer requires them. +`PageVars` describes only what each definition supplies; inline renderers receive the merged globals, layout defaults, and page vars. + +For the registered `article` layout in the layout guide, which accepts string content and defaults `showSidebar`, a single definition can be typed as follows: + +```ts +import type { GeneratedPageForLayout } from '@domstack/static/types.js' +import type globalVars from './global.vars.ts' + +type GlobalVars = Awaited> + +type ArticleDefinition = GeneratedPageForLayout< + 'article', + { title: string }, + Record, + GlobalVars +> + +const article: ArticleDefinition = { + outputName: 'article/index.html', + vars: { layout: 'article', title: 'Generated article' }, + children: ({ vars }) => vars.showSidebar ? '

With sidebar

' : '

No sidebar

', +} + +export default article +``` + +The actual global provider must supply `siteName`, as required by the registered root renderer. +The definition supplies neither `siteName` nor `showSidebar`, but its inline renderer can access both. +Its `vars` property is required and must contain the literal `layout: 'article'`, even when a global default would select the same layout. +A missing title, incompatible override, wrong selector, or incompatible children produces a type error. + +Factories keep their own inputs separate from each generated page: + +```ts +import type { DataDeps, PagesForLayout } from '@domstack/static/types.js' +import type globalVars from './global.vars.ts' + +type GlobalVars = Awaited> +type FactoryData = { articleTitles: string[] } +type InlineData = { announcement: string } +type SuppliedVars = { + title: string + dataDeps: DataDeps +} + +export const dataDeps = ['articleTitles'] satisfies DataDeps + +const articles: PagesForLayout< + 'article', SuppliedVars, GlobalVars, FactoryData, InlineData +> = async function * ({ vars: globals, data: factoryData }) { + for (const [index, title] of factoryData.articleTitles.entries()) { + yield { + outputName: `articles/${index}/index.html`, + vars: { layout: 'article', title, dataDeps: ['announcement'] }, + children: ({ vars, data }) => { + // vars contains siteName, title, and showSidebar, but not dataDeps. + // data contains announcement, not articleTitles or layout subscriptions. + return `${globals.siteName}: ${vars.title} — ${data.announcement}` + }, + } + } +} + +export default articles +``` + +Here the factory sees only effective global vars and its own subscribed data, not layout defaults or page vars. +The inline page receives its own data contract; `FactoryData` and `PageData` default independently and do not inherit from each other. +The global-data provider must produce both declared data keys; supplying type arguments does not create subscriptions. +Escape interpolated content with your template library when producing HTML from untrusted values. + +Factories can return one definition, an array, an async iterable, `null`, or `undefined`, directly or through a promise. +Arrays and iterables contain definitions, not promises or nullish placeholders. +Use a union of individually checked `GeneratedPageForLayout` types for heterogeneous collections; a single `PagesForLayout` checks one literal selected layout. +The existing explicit `GeneratedPageDefinition` and `PagesFunction` APIs remain available for dynamic or unregistered layouts. + +Children follow the runtime boundary: + +- Static content must match the innermost layout's accepted children type. +- Omitted, `undefined`, or static `null` content becomes an empty string, so these forms are allowed only when that layout accepts `''`. +- Inline renderer results are awaited and then passed through unchanged, including nullish values; a layout accepting only `null` therefore needs `children: () => null`, not static `null`. +- Static callable and thenable objects are not supported by this strict helper; use an inline renderer so invocation and awaiting are explicit. +- Static objects reserve `call`, `apply`, `bind`, and `then` properties to avoid accidentally accepting functions or promises through structural object compatibility. +- To pass a function as a child's render value, wrap it in an inline renderer that returns the function. + +See the [basic example's generated guides](../../examples/basic/src/guides.pages.ts) for static and async inline content using the same registered chain. + ## Templates Template files let you write any kind of file type to the `dest` folder while customizing the contents with global vars and explicitly subscribed global data. diff --git a/docs/layouts/README.md b/docs/layouts/README.md index 41b422cd..28b10ea5 100644 --- a/docs/layouts/README.md +++ b/docs/layouts/README.md @@ -430,6 +430,9 @@ The registry helpers are: | `LayoutPageOutput` | Children type accepted by the innermost layout. | | `LayoutResult` | Awaited output of the outermost renderer, before DOMStack converts it to the final HTML string. | | `PageForLayout` | A `PageFunction` with inferred vars and page output. `Data` remains the page's own data contract and never includes layout data. | +| `ValidatePageVars` | The original supplied `PageVars` if actual known sources satisfy every renderer, otherwise `never`. | +| `GeneratedPageForLayout` | A strictly checked generated definition with supplied vars, a required literal layout selector, and correctly typed static or inline children. | +| `PagesForLayout` | A factory or async generator producing checked definitions, with separate factory and inline-page data contracts. | These types describe contracts; they do not supply missing values or select a layout at runtime. Required vars without registered defaults still need a global, page, or builder source. @@ -472,6 +475,43 @@ declare module '@domstack/static/types.js' { These imports are erased and do not become runtime or watch dependencies. +### Validating supplied page vars + +`PageForLayout` describes a renderer contract but does not prove that your exports supply all required vars. +Use `ValidatePageVars` at the export boundary for that stronger check: + +```ts +import type { ValidatePageVars } from '@domstack/static/types.js' +import type globalVars from './global.vars.ts' + +const supplied = { + layout: 'article' as const, + title: 'My article', +} + +export const vars = supplied satisfies ValidatePageVars< + 'article', + typeof supplied, + Awaited> +> +``` + +For the registered layouts above, this succeeds if the global provider supplies `siteName`; the article layout supplies `showSidebar`. +Removing `title` from `supplied`, or omitting `siteName` from the globals, makes the validation type `never` and the export fails to type-check. +The local `supplied` binding avoids circular inference from checking `typeof vars` within its own initializer. +JavaScript can use the equivalent `@satisfies {ValidatePageVars<...>}` annotation on `export const vars = supplied`. + +Validation uses only actual known sources, without adding renderer requirements as assumed values. +It merges global vars, outer-to-inner layout defaults, and the supplied page vars, then checks the final object against every renderer. +`dataDeps` stays in the original page export but is stripped from page/layout vars during validation, matching runtime subscription handling; global vars must not contain `dataDeps`. +The generic inputs are resolved object types, so use `Awaited>` for function exports. +If page vars and frontmatter are separate sources, pass their final shallow-merged shape; TypeScript does not inspect frontmatter automatically. +Built-in defaults are not inferred automatically: include any relied-on built-ins in the known effective global-vars type. + +The name selects the chain to validate, not the runtime layout: keep the actual `layout` export consistent with it. +These checks cannot prove the accuracy of manually asserted types or validate arbitrary dynamic modules. +[Registry-aware generated definitions](../generation/#registered-layouts-for-generated-pages) additionally require a matching literal `vars.layout` and validate supplied vars automatically. + ## Custom layout renderers DOMStack's bundled default layout uses [`fragtml`][fragtml] because the default template only needs safe string manipulation. diff --git a/examples/basic/README.md b/examples/basic/README.md index 828c8c8f..982da832 100644 --- a/examples/basic/README.md +++ b/examples/basic/README.md @@ -129,10 +129,30 @@ Vars merge shallowly: overriding `badge` must provide the complete required obje - `LayoutRequiredVars<'child'>` identifies `title`, `siteName`, and `locale` as required values not supplied by the layouts. - `LayoutChainVars` verifies every step of theme precedence, page-only arrays, and global navigation inference. - Invalid themes, string reading times, incomplete badge overrides, and incompatible page output are rejected. +- `ValidatePageVars` checks actual supplied sources and rejects a missing page title or missing global locale, even though those properties are declared in the renderer contract. The checks live outside `src` so they are not copied into the built site. Run them with the example's regular `npm test` command. +#### Checking the exports, not just renderer parameters + +The JavaScript and TypeScript source pages now validate their exported vars separately from their renderer signatures. +Each defines a local `pageVars` object and checks it with `ValidatePageVars` when exporting `vars`. +This avoids circular inference while ensuring globals, layout defaults, and the actual page export supply every required renderer property. +`PageForLayout` alone still describes a renderer contract rather than proving that all required values exist. + +#### Generated pages with the same registry + +[guides.pages.ts](src/guides.pages.ts) uses `PagesForLayout` to generate two child-layout pages: + +- `/guides/layout-defaults/` renders async inline content that reads the merged global, root, child, and supplied page vars. +- `/guides/static-content/` supplies static string children checked against the same layout. + +The factory receives only global vars, while the inline renderer receives defaults such as `readingMinutes`, `badge`, and `footer`. +Each definition must supply its title and an explicit `layout: 'child'`; it does not need to repeat inherited defaults. +Both factory and inline page declare `Record` data contracts, so neither implicitly inherits subscriptions. +Unlike the JavaScript page's light-theme override, these generated pages inherit the child's dark theme. + When working from this repository checkout, build the package declarations before checking the example, and clean them afterward: ```sh diff --git a/examples/basic/src/guides.pages.ts b/examples/basic/src/guides.pages.ts new file mode 100644 index 00000000..4f497be9 --- /dev/null +++ b/examples/basic/src/guides.pages.ts @@ -0,0 +1,49 @@ +import { html } from 'fragtml' +import type { PagesForLayout } from '@domstack/static/types.js' +import type globalVars from './global.vars.ts' + +type GuideVars = { + title: string + topics: string[] +} + +type GuidePages = PagesForLayout< + 'child', + GuideVars, + Awaited>, + Record, + Record +> + +// The factory sees globals, not the defaults belonging to each generated page. +const guides: GuidePages = ({ vars: globals }) => [ + { + outputName: 'guides/layout-defaults/index.html', + vars: { + layout: 'child', + title: `${globals.siteName}: inherited defaults`, + topics: ['Global vars', 'Layout defaults'], + }, + children: async ({ vars, page }) => html` +
+

Generated page with inferred vars

+

${vars.siteName} · ${vars.locale} · ${vars.theme} theme

+

${vars.readingMinutes} min read · ${vars.badge.label}

+

Root footer: ${vars.footer.label}

+
    ${vars.topics.map(topic => html`
  • ${topic}
  • `)}
+

Generated URL: ${page.url}

+
+ `, + }, + { + outputName: 'guides/static-content/index.html', + vars: { + layout: 'child', + title: 'Generated static content', + topics: ['Static children'], + }, + children: '

Static children use the same checked layout contract.

', + }, +] + +export default guides diff --git a/examples/basic/src/js-page/loose-assets/page.ts b/examples/basic/src/js-page/loose-assets/page.ts index df3aeff1..0d7422de 100644 --- a/examples/basic/src/js-page/loose-assets/page.ts +++ b/examples/basic/src/js-page/loose-assets/page.ts @@ -1,5 +1,5 @@ import { html } from 'fragtml' -import type { PageForLayout } from '@domstack/static/types.js' +import type { PageForLayout, ValidatePageVars } from '@domstack/static/types.js' import type globalVars from '../../global.vars.ts' import sharedData from './shared-lib.ts' @@ -39,10 +39,16 @@ const JSPage: AssetPage = async ({ vars }) => { export default JSPage -export const vars = { +const pageVars = { title: 'JS Page with loose assets', assets: [ { label: 'Shared data', kind: 'module' as const }, { label: 'Local styles', kind: 'stylesheet' as const }, ], } + +export const vars = pageVars satisfies ValidatePageVars< + 'root', + typeof pageVars, + Awaited> +> diff --git a/examples/basic/src/js-page/page.js b/examples/basic/src/js-page/page.js index 31175eca..4146c2db 100644 --- a/examples/basic/src/js-page/page.js +++ b/examples/basic/src/js-page/page.js @@ -1,5 +1,5 @@ /** - * @import { PageForLayout } from '@domstack/static/types.js' + * @import { PageForLayout, ValidatePageVars } from '@domstack/static/types.js' * @import { default as globalVars } from '../global.vars.ts' */ import { html } from 'fragtml' @@ -87,10 +87,13 @@ const JSPage = async ({ export default JSPage // Define page-specific variables -export const vars = { +const pageVars = { title: 'JavaScript Page Example', description: 'Learn how to use JavaScript pages in DOMStack for dynamic content generation', theme: /** @type {const} */ ('light'), badge: { label: 'Hands-on example', tone: /** @type {const} */ ('tip') }, topics: ['TypeScript', 'JSDoc', 'Nested layouts'], } + +/** @satisfies {ValidatePageVars<'child', typeof pageVars, Awaited>>} */ +export const vars = pageVars diff --git a/examples/basic/type-checks.ts b/examples/basic/type-checks.ts index a7d459f4..cb3eec9b 100644 --- a/examples/basic/type-checks.ts +++ b/examples/basic/type-checks.ts @@ -4,6 +4,8 @@ import type { LayoutProvidedVars, LayoutRequiredVars, PageForLayout, + ValidatePageVars, + GeneratedPageForLayout, } from '@domstack/static/types.js' import type globalVars from './src/global.vars.ts' import type { vars as pageVars } from './src/js-page/page.js' @@ -20,6 +22,11 @@ type ArticleDefaults = LayoutProvidedVars<'child'> type ArticleResolved = LayoutChainVars<'child', GlobalVars, typeof pageVars> type AssetResolved = LayoutChainVars<'root', GlobalVars, typeof assetVars> +export type SuppliedPageVars = Expect, typeof pageVars>> +export type MissingTitle = Expect, never>> +export type MissingGlobalLocale = Expect, never>> +export type GeneratedMissingTitle = Expect, GlobalVars>, never>> + export type Chain = Expect, readonly ['root', 'child']>> export type GlobalTheme = Expect> export type RootOverridesGlobal = Expect> diff --git a/scripts/test-packed-types.js b/scripts/test-packed-types.js index 9d29975f..c6e10737 100644 --- a/scripts/test-packed-types.js +++ b/scripts/test-packed-types.js @@ -48,6 +48,7 @@ void stack import type { WorkerOptions } from 'node:worker_threads' import type { DomStackOpts, + GeneratedPageForLayout, LayoutChain, LayoutChainVars, LayoutFunction, @@ -55,7 +56,9 @@ import type { LayoutResult, PageForLayout, PageFunction, + PagesForLayout, Results, + ValidatePageVars, } from '@domstack/static/types.js' const logger = pino({ level: 'silent' }) @@ -115,6 +118,88 @@ const articlePage: ArticlePage = ({ vars, data }) => { // @ts-expect-error The article layout accepts string page output. const invalidArticlePage: ArticlePage = () => ({ html: 'invalid' }) +type _MissingSiteName = Expect, never>> +type _ValidatedPageVars = Expect, + { slug: string } +>> +type _MissingGeneratedGlobals = Expect, never>> +type _MissingFactoryGlobals = Expect, never>> + +type GeneratedArticle = GeneratedPageForLayout<'article', { slug: string }, { body: string }, { siteName: string }> +type _SuppliedGeneratedVars = Expect> +const generatedArticle: GeneratedArticle = { + vars: { layout: 'article', slug: 'generated' }, + children: ({ vars, data }) => { + vars.layout satisfies 'article' + vars.siteName.toUpperCase() + vars.showSidebar.valueOf() + vars.slug.toUpperCase() + data.body.toUpperCase() + // @ts-expect-error Inline page data does not include factory data. + data.slugs + return data.body + }, +} +// Layout defaults and globals need not be repeated in the supplied vars. +const emptyGeneratedArticle: GeneratedArticle = { vars: { layout: 'article', slug: 'empty' } } +// @ts-expect-error Generated definitions must supply vars. +const missingGeneratedVars: GeneratedArticle = { children: 'article' } +// @ts-expect-error The runtime layout selector is required. +const missingGeneratedLayout: GeneratedArticle = { vars: { slug: 'article' } } +// @ts-expect-error Supplied page vars are required independently of defaults. +const missingGeneratedSlug: GeneratedArticle = { vars: { layout: 'article' } } +// @ts-expect-error The layout selector must be the registered literal. +const wrongGeneratedLayout: GeneratedArticle = { vars: { layout: 'root', slug: 'article' } } +// @ts-expect-error Static children must match the article layout input. +const wrongGeneratedChildren: GeneratedArticle = { vars: { layout: 'article', slug: 'article' }, children: { html: 'invalid' } } +// @ts-expect-error Inline renderer output must match the article layout input. +const wrongGeneratedRenderer: GeneratedArticle = { vars: { layout: 'article', slug: 'article' }, children: () => ({ html: 'invalid' }) } + +type ArticlePages = PagesForLayout<'article', { slug: string }, { siteName: string }, { slugs: string[] }, { body: string }> +const articlePages: ArticlePages = ({ vars, data }) => { + vars.siteName.toUpperCase() + // @ts-expect-error Layout defaults are not available to the factory. + vars.showSidebar + // @ts-expect-error Generated page vars are not available to the factory. + vars.slug + // @ts-expect-error Inline page data is not factory data. + data.body + return data.slugs.map(slug => ({ + vars: { layout: 'article', slug }, + children: ({ vars, data }) => { + vars.siteName.toUpperCase() + vars.showSidebar.valueOf() + vars.slug.toUpperCase() + // @ts-expect-error Factory data is not merged into inline page data. + data.slugs + return data.body.toUpperCase() + }, + })) +} +const asyncArticlePages: ArticlePages = async ({ data }) => ({ + vars: { layout: 'article', slug: data.slugs[0] }, + children: ({ data }) => data.body, +}) +const iterableArticlePages: ArticlePages = async function * ({ vars, data }) { + vars.siteName.toUpperCase() + for (const slug of data.slugs) { + yield { vars: { layout: 'article', slug }, children: 'article' } satisfies GeneratedArticle + } +} + +void generatedArticle +void emptyGeneratedArticle +void missingGeneratedVars +void missingGeneratedLayout +void missingGeneratedSlug +void wrongGeneratedLayout +void wrongGeneratedChildren +void wrongGeneratedRenderer +void articlePages +void asyncArticlePages +void iterableArticlePages + // The logger option retains Pino's full contract. const configuredLogger: pino.Logger | undefined = options.logger const childOptions: DomStackOpts = { logger: logger.child({ component: 'consumer' }) } @@ -182,7 +267,7 @@ declare module '@domstack/static/types.js' { } } `), - writeFile(path.join(consumerPath, 'js-page.js'), `/** @import { PageForLayout } from '@domstack/static/types.js' */ + writeFile(path.join(consumerPath, 'js-page.js'), `/** @import { GeneratedPageForLayout, PageForLayout, PagesForLayout } from '@domstack/static/types.js' */ export const layout = 'js-article' @@ -203,6 +288,39 @@ const articlePage = ({ vars, data }) => { // @ts-expect-error The article layout accepts string page output, not a frame. const invalidArticlePage = () => ({ html: 'invalid' }) +/** @type {GeneratedPageForLayout} */ +export const generatedArticle = { + vars: { layout: 'js-article', slug: 'generated' }, + children: ({ vars, data }) => { + vars.siteName.toUpperCase() + vars.showSidebar.valueOf() + vars.slug.toUpperCase() + // @ts-expect-error Layout data is not merged into inline page data. + data.related + return data.body.toUpperCase() + }, +} + +/** @type {PagesForLayout} */ +export const articlePages = ({ vars, data }) => { + vars.siteName.toUpperCase() + // @ts-expect-error Layout defaults are not available to the factory. + vars.showSidebar + // @ts-expect-error Inline page data is not factory data. + data.body + return data.slugs.map(slug => ({ + vars: { layout: 'js-article', slug }, + children: ({ vars, data }) => { + vars.siteName.toUpperCase() + vars.showSidebar.valueOf() + vars.slug.toUpperCase() + // @ts-expect-error Factory data is not merged into inline page data. + data.slugs + return data.body.toUpperCase() + }, + })) +} + void invalidArticlePage export default articlePage `), diff --git a/test-cases/type-exports/generated-layout-types.test.ts b/test-cases/type-exports/generated-layout-types.test.ts new file mode 100644 index 00000000..9d1f41e5 --- /dev/null +++ b/test-cases/type-exports/generated-layout-types.test.ts @@ -0,0 +1,233 @@ +import type { + GeneratedPageForLayout, + LayoutFunction, + PageForLayout, + PagesForLayout, + ValidatePageVars, +} from '#types' + +type Equal = (() => T extends A ? 1 : 2) extends +(() => T extends B ? 1 : 2) ? true : false +type Expect = T + +export type Frame = { html: string } +export type Globals = { siteName: string, theme: 'light', globalOnly: number } +export type Supplied = { slug: string, theme: 'light', dataDeps: ['body'] } +export type FactoryData = { posts: string[] } +export type InlineData = { body: string } +export declare const genRoot: LayoutFunction<{ + siteName: string + theme: 'light' | 'dark' + rootDefault: number +}, Frame, string, { navigation: string[] }> +export declare const genArticle: LayoutFunction<{ + articleDefault: boolean +}, string, Frame, { recentPosts: string[] }> +export declare const genDefaults: () => Promise<{ + theme: 'dark' + articleDefault: true + dataDeps: ['recentPosts'] +}> +export declare const genBadDefault: LayoutFunction<{ mode: string }, string, string> +export declare const genNull: LayoutFunction<{}, null, string> +export declare const genCallable: LayoutFunction<{}, (value: number) => string, string> +export declare const genPromise: LayoutFunction<{}, Promise, string> +export declare const genAwaitable: LayoutFunction<{}, string | Promise, string> +export declare const genObject: LayoutFunction<{}, Frame, string> +export declare const genMixedAwaitable: LayoutFunction<{}, number | Promise, string> +export declare const genReserved: LayoutFunction<{ dataDeps: string[] }, string, string> + +declare module '#types' { + interface LayoutRegistry { + genRoot: { render: typeof genRoot, vars: { rootDefault: number, theme: 'light' } } + genArticle: { parentLayout: 'genRoot', render: typeof genArticle, vars: typeof genDefaults } + genBadDefault: { render: typeof genBadDefault, vars: { mode: number } } + genNull: { render: typeof genNull } + genCallable: { render: typeof genCallable } + genPromise: { render: typeof genPromise } + genAwaitable: { render: typeof genAwaitable } + genObject: { render: typeof genObject } + genMixedAwaitable: { render: typeof genMixedAwaitable } + genReserved: { render: typeof genReserved, vars: { dataDeps: string[] } } + } +} + +// Renderer requirements describe a baseline, not actual supplied values. +export type _BaselineExists = Expect>[0]['vars']['siteName'], string +>> +export type _MissingRequired = Expect, never>> +export type _OptionalRequired = Expect, never>> +export type _BadUnion = Expect, never +>> +export type _MissingUnionBranch = Expect, never +>> +export type _AsyncDefaults = Expect, { siteName: string } +>> +export type _BadDefault = Expect, never>> +export type _RepairedDefault = Expect, { mode: string } +>> +export type _ExportPreserved = Expect, Supplied>> +export type _ReservedCannotSupplyRenderer = Expect, never +>> +export type _GlobalSubscriptionsRejected = Expect, never +>> +export type _GlobalUnionSubscriptionsRejected = Expect, never +>> +export type _UnknownLayout = Expect, never>> +export type _MissingGeneratedVars = Expect, never>> +export type _MissingFactoryVars = Expect, never>> +export type _GeneratedGlobalSubscriptions = Expect, never +>> + +export type Article = GeneratedPageForLayout<'genArticle', Supplied, InlineData, Globals> +export type Factory = PagesForLayout<'genArticle', Supplied, Globals, FactoryData, InlineData> +export type Inline = Extract, (...args: never[]) => unknown> +export type InlineVars = Parameters[0]['vars'] +export type _LayoutLiteral = Expect> +export type _ExportSubscriptions = Expect> +export type _RenderSubscriptionsStripped = Expect, never>> +export type _PageOverride = Expect> +export type _AsyncDefaultRetained = Expect> +export type _RootDefaultRetained = Expect> +export type _GlobalRetained = Expect> +export type _PageRetained = Expect> +export type _FactoryVarsOnlyGlobals = Expect[0]['vars'], Globals>> +export type _FactoryData = Expect[0]['data'], FactoryData>> +export type _InlineData = Expect[0]['data'], InlineData>> +export type DefaultFactory = PagesForLayout<'genArticle', Supplied, Globals, FactoryData> +export type DefaultDefinition = Extract>, { vars: unknown }> +export type DefaultInline = Extract, (...args: never[]) => unknown> +export type _NoFactoryDataInheritance = Expect[0]['data'], Record +>> + +export const supplied: Article['vars'] = { layout: 'genArticle', slug: 'hello', theme: 'light', dataDeps: ['body'] } +export const staticPage: Article = { outputName: 'hello.html', draft: false, vars: supplied, children: 'Hello' } +export const omittedPage: Article = { vars: supplied } +export const nullPage: Article = { vars: supplied, children: null } +export const undefinedPage: Article = { vars: supplied, children: undefined } +export const inlinePage: Article = { + vars: supplied, + children: ({ vars, data }) => { + // @ts-expect-error Subscription metadata is not a render variable. + const subscriptions = vars.dataDeps + // @ts-expect-error Factory subscriptions do not reach inline pages. + const posts = data.posts + // @ts-expect-error Root layout subscriptions do not reach inline pages. + const navigation = data.navigation + // @ts-expect-error Inner layout subscriptions do not reach inline pages. + const recent = data.recentPosts + return `${vars.siteName}:${vars.slug}:${data.body}:${subscriptions}:${posts}:${navigation}:${recent}` + }, +} +export const asyncInlinePage: Article = { vars: supplied, children: async ({ data }) => data.body } +export const repairedPage: GeneratedPageForLayout<'genBadDefault', { mode: string }> = { + vars: { layout: 'genBadDefault', mode: 'fixed' }, children: 'ok', +} +// @ts-expect-error Generated pages always require vars, even when content may be omitted. +export const noVars: Article = { children: 'hello' } +// @ts-expect-error Runtime layout selection must be explicit. +export const noLayout: Article = { vars: { slug: 'hello', theme: 'light', dataDeps: ['body'] } } +// @ts-expect-error The selector must be the selected innermost layout literal. +export const wrongLayout: Article = { vars: { ...supplied, layout: 'genRoot' } } +// @ts-expect-error Supplied page vars remain required in each generated definition. +export const noSlug: Article = { vars: { layout: 'genArticle', theme: 'light', dataDeps: ['body'] } } +// @ts-expect-error Article children are strings, not outer-layout frames. +export const wrongChildren: Article = { vars: supplied, children: { html: 'wrong' } } +// @ts-expect-error Inline renderers must also satisfy the innermost children contract. +export const wrongInline: Article = { vars: supplied, children: () => 42 } +// @ts-expect-error Async inline results obey the same contract. +export const wrongAsyncInline: Article = { vars: supplied, children: async () => 42 } +// @ts-expect-error Static thenables require an inline renderer boundary. +export const staticPromise: Article = { vars: supplied, children: Promise.resolve('hello') } +// @ts-expect-error Output filenames are strings. +export const wrongOutputName: Article = { vars: supplied, outputName: 42 } + +export type NullPage = GeneratedPageForLayout<'genNull'> +export const renderedNull: NullPage = { vars: { layout: 'genNull' }, children: () => null } +// Static null/undefined normalize to '', which a null-only layout cannot accept. +// @ts-expect-error Null-only layouts require explicit inline content. +export const omittedNull: NullPage = { vars: { layout: 'genNull' } } +// @ts-expect-error Static null means empty string, not a null render result. +export const staticNull: NullPage = { vars: { layout: 'genNull' }, children: null } +// @ts-expect-error Static undefined also means empty string. +export const staticUndefined: NullPage = { vars: { layout: 'genNull' }, children: undefined } +export const callableChild = (value: number): string => String(value) +export const wrappedCallable: GeneratedPageForLayout<'genCallable'> = { + vars: { layout: 'genCallable' }, children: () => callableChild, +} +// @ts-expect-error A callable child must be wrapped so it is not invoked as a renderer. +export const bareCallable: GeneratedPageForLayout<'genCallable'> = { vars: { layout: 'genCallable' }, children: callableChild } +export type _AwaitedBoundary = Expect, never>> +export const awaitablePage: GeneratedPageForLayout<'genAwaitable'> = { + vars: { layout: 'genAwaitable' }, children: async () => 'awaited', +} +// @ts-expect-error Even promise-accepting layouts must use an inline renderer for promises. +export const awaitableStatic: GeneratedPageForLayout<'genAwaitable'> = { vars: { layout: 'genAwaitable' }, children: Promise.resolve('hello') } + +// Structural object matches must not bypass runtime invocation or awaiting. +export type ObjectPage = GeneratedPageForLayout<'genObject'> +export const plainObjectPage: ObjectPage = { vars: { layout: 'genObject' }, children: { html: 'ok' } } +export const inlineObjectPage: ObjectPage = { vars: { layout: 'genObject' }, children: () => ({ html: 'ok' }) } +export const callableObject = Object.assign(() => 42, { html: 'ok' }) +export const thenableObject = Object.assign(Promise.resolve(42), { html: 'ok' }) +// @ts-expect-error Runtime invokes this callable and gets a number, not a Frame. +export const staticCallableObjectPage: ObjectPage = { vars: { layout: 'genObject' }, children: callableObject } +// @ts-expect-error Runtime awaits this thenable and gets a number, not a Frame. +export const staticThenableObjectPage: ObjectPage = { vars: { layout: 'genObject' }, children: thenableObject } +// @ts-expect-error Returning a structural thenable still awaits to the wrong type. +export const inlineThenableObjectPage: ObjectPage = { vars: { layout: 'genObject' }, children: () => thenableObject } + +// Filter unsafe awaited branches without discarding safe siblings in the union. +export type MixedAwaitablePage = GeneratedPageForLayout<'genMixedAwaitable'> +export const mixedStaticPage: MixedAwaitablePage = { vars: { layout: 'genMixedAwaitable' }, children: 42 } +export const mixedInlinePage: MixedAwaitablePage = { vars: { layout: 'genMixedAwaitable' }, children: () => 42 } +// @ts-expect-error Awaiting Promise produces a string outside the children contract. +export const mixedPromisePage: MixedAwaitablePage = { vars: { layout: 'genMixedAwaitable' }, children: Promise.resolve('unsafe') } +// @ts-expect-error An inline renderer cannot restore the unsafe promise branch. +export const mixedAsyncPage: MixedAwaitablePage = { vars: { layout: 'genMixedAwaitable' }, children: async () => 'unsafe' } + +export const syncFactory: Factory = ({ vars, data }) => { + // @ts-expect-error Factory vars do not contain layout defaults. + const defaultValue = vars.articleDefault + // @ts-expect-error Factory vars do not contain supplied inline-page vars. + const slug = vars.slug + // @ts-expect-error Inline subscriptions do not reach the factory. + const body = data.body + // @ts-expect-error Layout subscriptions do not reach the factory. + const navigation = data.navigation + return { ...staticPage, children: `${vars.siteName}:${data.posts.length}:${defaultValue}:${slug}:${body}:${navigation}` } +} +export const arrayFactory: Factory = () => [staticPage, inlinePage] +export const asyncFactory: Factory = async () => [staticPage, asyncInlinePage] +export const asyncSingleFactory: Factory = async () => staticPage +export const generatorFactory: Factory = async function * () { yield staticPage; yield inlinePage } +export const iterableFactory: Factory = params => generatorFactory(params) +export const nullFactory: Factory = () => null +export const undefinedFactory: Factory = () => undefined +export const asyncNullFactory: Factory = async () => null +export const asyncUndefinedFactory: Factory = async () => undefined +export type _ResultEnvelope = Expect>, Article | Article[] | AsyncIterable
| null | undefined +>> +// @ts-expect-error A result is a definition or collection, not raw rendered content. +export const rawFactory: Factory = () => 'hello' +// @ts-expect-error Every array member must be a valid definition. +export const badArrayFactory: Factory = () => [staticPage, { children: 'missing vars' }] +// @ts-expect-error Async results must retain the literal layout selector. +export const badAsyncFactory: Factory = async () => ({ vars: { ...supplied, layout: 'genRoot' } }) +// @ts-expect-error Async generators must yield valid definitions. +export const badGeneratorFactory: Factory = async function * () { yield { vars: supplied, children: 42 } } +// @ts-expect-error Synchronous iterators are not supported factory result envelopes. +export const syncGeneratorFactory: Factory = function * () { yield staticPage } +// @ts-expect-error A pages property is not a supported result envelope. +export const objectEnvelopeFactory: Factory = () => ({ pages: [staticPage] }) diff --git a/test-cases/type-exports/layout-registry.test.ts b/test-cases/type-exports/layout-registry.test.ts index 3862ab72..aa1313e7 100644 --- a/test-cases/type-exports/layout-registry.test.ts +++ b/test-cases/type-exports/layout-registry.test.ts @@ -192,14 +192,13 @@ declare module '#types' { } } -export type _RegistryNames = Expect> +) extends LayoutRegistryName ? true : false> export type _Chain = Expect, readonly ['root', 'article']>> export type _PageOutput = Expect, string>> export type _OuterResult = Expect, Uint8Array>> diff --git a/types.ts b/types.ts index 7e377f06..6115ffce 100644 --- a/types.ts +++ b/types.ts @@ -4,6 +4,10 @@ // There is intentionally no runtime `types.js` today; this source emits `types.d.ts`, // and `types.js` is reserved for a future runtime/type companion entry if needed. import type { Results } from './lib/builder.js' +import type { + GeneratedPageDefinition as GeneratedPageDefinitionExport, + PagesFunctionParams as PagesFunctionParamsExport, +} from './lib/build-pages/index.js' import type { PageFunction as PageFunctionExport } from './lib/build-pages/page-builders/page-writer.js' @@ -199,6 +203,124 @@ export type PageForLayout< : never : never +/** + * Validate resolved page/frontmatter vars against a registered chain using only + * actual known sources: global vars, layout defaults, then page vars. Returns + * the original supplied PageVars on success, or never for missing requirements + * or incompatible overrides. Unlike PageForLayout, renderer requirements do not + * supply a baseline. Name identifies the chain to check, not runtime selection. + * dataDeps stays in the export but is removed before checking renderer vars. + */ +export type ValidatePageVars< + Name extends string, + PageVars extends AnyVars, + GlobalVars extends AnyVars = {} +> = [SuppliedLayoutVars] extends [never] ? never : PageVars + +/** + * One generated page for a registered layout. vars contains only supplied page + * vars plus a required literal layout selector. Inline children receive the + * validated final merged vars and their own Data, never the factory's data. + * Empty content may be omitted only when the layout accepts an empty string. + * Static null/undefined also normalize to empty content; functions are renderers. + */ +export type GeneratedPageForLayout< + Name extends string, + PageVars extends AnyVars = {}, + Data extends object = Record, + GlobalVars extends AnyVars = {} +> = SuppliedLayoutVars extends infer ResolvedVars + ? [ResolvedVars] extends [never] + ? never + : [ResolvedVars] extends [AnyVars] + ? GeneratedLayoutDefinition, Data> + : never + : never + +/** + * A sync/async factory or async generator producing pages for one layout. + * GlobalVars describes only effective globals available before layout defaults; + * FactoryData and PageData are independent subscription contracts. Module + * dataDeps subscribes the factory; each definition's vars.dataDeps subscribes + * its inline renderer. Type arguments alone do not create subscriptions. + */ +export type PagesForLayout< + Name extends string, + PageVars extends AnyVars = {}, + GlobalVars extends AnyVars = {}, + FactoryData extends object = Record, + PageData extends object = Record +> = GeneratedPageForLayout extends infer Definition + ? [Definition] extends [never] + ? never + : (params: PagesFunctionParamsExport) => + GeneratedLayoutResults | Promise> + : never + +type GeneratedLayoutResults = + Definition | Definition[] | AsyncIterable | null | undefined + +type GeneratedLayoutDefinition< + Name extends string, + SuppliedVars, + ResolvedVars extends AnyVars, + Data extends object +> = Pick + & { vars: SuppliedVars } + & GeneratedLayoutChildren, ResolvedVars, Data> + +type AwaitCompatible = + Children extends unknown ? [Awaited] extends [Whole] ? Children : never : never + +type GeneratedLayoutChildren = + [AwaitCompatible] extends [never] + ? never + : '' extends Children + ? { children?: GeneratedLayoutContent, Vars, Data> | null | undefined } + : { children: GeneratedLayoutContent, Vars, Data> } + +// Callable objects can structurally match a plain object children contract but +// runtime calls them as renderers. Reserve callable/thenable members on static +// objects; an inline renderer can return objects with ordinary call/apply keys. +type StaticLayoutContent = Value extends object + ? Value & { call?: never, apply?: never, bind?: never, then?: never } + : Value + +type RenderedLayoutContent = Value extends object ? Value & { then?: never } : Value + +type GeneratedLayoutContent = + StaticLayoutContent | null | undefined>> + | PageFunctionExport>>, Data> + +type WithoutSubscriptions = Vars extends unknown ? Omit : never + +type SuppliedLayoutVars< + Name extends string, + GlobalVars extends AnyVars, + PageVars extends AnyVars +> = ResolveLayoutChain extends infer Chain + ? [Chain] extends [never] + ? never + : Chain extends readonly [string, ...string[]] + ? 'dataDeps' extends KeysOfUnion + ? never + : MergeRight< + MergeRight>>, + WithoutSubscriptions + > extends infer Vars + ? [Vars] extends [never] + ? never + : true extends HasNeverRequired + ? never + : EveryRendererAccepts extends true + ? Vars + : never + : never + : never + : never + +type KeysOfUnion = Value extends unknown ? keyof Value : never + type AnyVars = Record type AnyFunction = (...args: any[]) => any From 1c8a215f52cd6ce619d51e17e0a9197f2bf4acb8 Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Sun, 13 Sep 2026 16:24:06 -0700 Subject: [PATCH 5/8] Address registry nullability, union defaults, and subscription reviews --- docs/layouts/README.md | 10 +- scripts/test-packed-types.js | 106 +++++++++++++++++- .../registry-required-vars.test.ts | 102 +++++++++++++++++ .../registry-subscriptions.test.ts | 76 +++++++++++++ types.ts | 89 +++++++++------ 5 files changed, 345 insertions(+), 38 deletions(-) create mode 100644 test-cases/type-exports/registry-required-vars.test.ts create mode 100644 test-cases/type-exports/registry-subscriptions.test.ts diff --git a/docs/layouts/README.md b/docs/layouts/README.md index 28b10ea5..f7f45ff1 100644 --- a/docs/layouts/README.md +++ b/docs/layouts/README.md @@ -329,6 +329,9 @@ export default articleLayout ### Inferring registered layout chains TypeScript projects can optionally register layouts through module augmentation. +Registry helpers require `strictNullChecks: true` in the site's TypeScript configuration (`strict: true` enables it unless explicitly overridden). +Without it, TypeScript cannot distinguish optional or nullish contracts reliably, so chain-dependent registry helpers consistently resolve to `never`. +The existing explicit `LayoutFunction`, `PageFunction`, `GeneratedPageDefinition`, and `PagesFunction` APIs remain available without this setting. The registry is type-only: it does not replace filesystem discovery, create runtime imports, or change DOMStack's runtime validation. Annotate each renderer with the existing explicit `LayoutFunction` API first, then register the actual exports with `typeof` so the renderer does not recursively depend on its own registry entry. @@ -424,7 +427,7 @@ The registry helpers are: | --- | --- | | `LayoutRegistryName` | Registered names in the current TypeScript program. | | `LayoutChain` | Names from the outermost to innermost layout. | -| `LayoutProvidedVars` | Layout defaults merged outer-to-inner with shallow override semantics. | +| `LayoutProvidedVars` | Layout defaults merged outer-to-inner with shallow override semantics, excluding `dataDeps` metadata. | | `LayoutRequiredVars` | Required renderer vars not definitely supplied by layout defaults. | | `LayoutChainVars` | Final known vars after global, layout, and page override precedence. | | `LayoutPageOutput` | Children type accepted by the innermost layout. | @@ -437,7 +440,12 @@ The registry helpers are: These types describe contracts; they do not supply missing values or select a layout at runtime. Required vars without registered defaults still need a global, page, or builder source. Use `LayoutRequiredVars` to inspect those obligations; the global-vars type above assumes a matching global vars export. +For union-shaped defaults, a renderer alternative may be satisfied differently by each defaults branch; the helper reports only obligations needed across all possible branches. +For example, when both renderer vars and defaults are `{ a: string } | { b: number }`, nothing remains required externally, so `LayoutRequiredVars` is `{}`. +If different defaults branches leave different fields missing, the external vars must cover every branch rather than just one. `LayoutVars` retains its existing meaning as the type of a layout vars export. +Registry-provided, required, and renderer vars exclude the reserved `dataDeps` property; it stays in raw exports for subscription handling but is not passed to renderers. +A renderer that requires `vars.dataDeps` is incompatible with that runtime boundary, and global vars containing `dataDeps` are rejected just as they are at runtime. The helpers await each layout's return type and verify it is accepted by the immediate parent. They also reject statically known incompatible vars overrides. diff --git a/scripts/test-packed-types.js b/scripts/test-packed-types.js index c6e10737..f7820605 100644 --- a/scripts/test-packed-types.js +++ b/scripts/test-packed-types.js @@ -53,6 +53,8 @@ import type { LayoutChainVars, LayoutFunction, LayoutPageOutput, + LayoutProvidedVars, + LayoutRequiredVars, LayoutResult, PageForLayout, PageFunction, @@ -75,13 +77,22 @@ type Expect = Value type Frame = { html: string } const rootLayout: LayoutFunction<{ siteName: string }, Frame, Uint8Array> = ({ children }) => new TextEncoder().encode(children.html) const parentLayout = 'root' -const articleVars = async () => ({ showSidebar: true }) +const articleVars = async () => ({ showSidebar: true, dataDeps: ['related'] }) const articleLayout: LayoutFunction<{ siteName: string, showSidebar: boolean }, string, Frame> = ({ children }) => ({ html: children }) declare module '@domstack/static/types.js' { interface LayoutRegistry { root: { render: typeof rootLayout + vars: { dataDeps: ['navigation'] } + } + packedUnionDefaults: { + render: LayoutFunction<{ a: string } | { b: number }, string, string> + vars: { a: string } | { b: number } + } + packedSharedMissing: { + render: LayoutFunction<({ a: string } | { b: number }) & { shared: boolean }, string, string> + vars: { a: string } | { b: number } } article: { parentLayout: typeof parentLayout @@ -91,6 +102,13 @@ declare module '@domstack/static/types.js' { } } +type _NoRequiredUnionDefaults = Expect, {}>> +type _SharedMissing = Expect, { shared: boolean }>> +type _ProvidedExcludesMetadata = Expect, { showSidebar: boolean }>> +type _RequiredExcludesMetadata = Expect, { siteName: string }>> +type _PageMetadata = PageForLayout<'article', { slug: string, dataDeps: ['body'] }> +type _PageExcludesMetadata = Expect[0]['vars'] ? true : false, false>> + type _Chain = Expect, readonly ['root', 'article']>> type _PageOutput = Expect, string>> type _LayoutResult = Expect, Uint8Array>> @@ -111,6 +129,8 @@ const articlePage: ArticlePage = ({ vars, data }) => { vars.showSidebar vars.slug data.body + // @ts-expect-error Layout subscription metadata is not a renderer variable. + vars.dataDeps // @ts-expect-error Layout data is not merged into page data. data.navigation return 'article' @@ -223,6 +243,75 @@ void invalidOptions void actual void invalidTransfer void ({} as Results) +`), + writeFile(path.join(consumerPath, 'null-checks.ts'), `import type { + GeneratedPageDefinition, + GeneratedPageForLayout, + LayoutChain, + LayoutChainVars, + LayoutFunction, + LayoutPageOutput, + LayoutProvidedVars, + LayoutRequiredVars, + LayoutResult, + PageForLayout, + PageFunction, + PagesForLayout, + PagesFunction, + ValidatePageVars, +} from '@domstack/static/types.js' + +type Equal = + (() => Value extends Left ? 1 : 2) extends + (() => Value extends Right ? 1 : 2) + ? true + : false +type Expect = Value + +const render: LayoutFunction<{ title: string }, string, string> = ({ vars, children }) => vars.title + children + +declare module '@domstack/static/types.js' { + interface LayoutRegistry { + nullRoot: { + vars: { title: string } + render: typeof render + } + nullChild: { + parentLayout: 'nullRoot' + render: typeof render + } + } +} + +// Both root-only and nested registry contracts require strictNullChecks. +type _NullChecksDisabled = Expect> +type _RootChain = Expect, never>> +type _ChildChain = Expect, never>> +type _ProvidedVars = Expect | LayoutProvidedVars<'nullChild'>, never>> +type _RequiredVars = Expect | LayoutRequiredVars<'nullChild'>, never>> +type _ChainVars = Expect | LayoutChainVars<'nullChild'>, never>> +type _PageOutput = Expect | LayoutPageOutput<'nullChild'>, never>> +type _Result = Expect | LayoutResult<'nullChild'>, never>> +type _Page = Expect | PageForLayout<'nullChild'>, never>> +type _ValidatedVars = Expect | ValidatePageVars<'nullChild', {}>, never>> +type _GeneratedPage = Expect | GeneratedPageForLayout<'nullChild'>, never>> +type _Pages = Expect | PagesForLayout<'nullChild'>, never>> + +// Explicit APIs remain usable without registry inference or strictNullChecks. +const page: PageFunction<{ title: string }, string> = ({ vars }) => vars.title.toUpperCase() +const generatedPage: GeneratedPageDefinition<{ title: string }, string> = { + vars: { title: 'Explicit page' }, + children: page, +} +const pages: PagesFunction<{ title: string }, string, { title: string }> = ({ vars }) => ({ + ...generatedPage, + vars: { title: vars.title.toUpperCase() }, +}) + +void render +void page +void generatedPage +void pages `), writeFile(path.join(consumerPath, 'js-root.layout.js'), `/** @import { LayoutFunction } from '@domstack/static/types.js' */ @@ -238,7 +327,7 @@ export default rootLayout writeFile(path.join(consumerPath, 'js-article.layout.js'), `/** @import { LayoutFunction } from '@domstack/static/types.js' */ export const parentLayout = 'js-root' -export const vars = async () => ({ showSidebar: true }) +export const vars = async () => ({ showSidebar: true, dataDeps: ['related'] }) /** @type {LayoutFunction<{ siteName: string, showSidebar: boolean }, string, { html: string }, { related: string[] }>} */ const articleLayout = ({ vars, children, data }) => { @@ -277,6 +366,8 @@ const articlePage = ({ vars, data }) => { vars.showSidebar.valueOf() vars.slug.toUpperCase() data.body.toUpperCase() + // @ts-expect-error Subscription metadata is absent from JSDoc renderer vars. + vars.dataDeps // @ts-expect-error Ancestor layout data is not merged into page data. data.navigation // @ts-expect-error Immediate layout data is not merged into page data. @@ -340,6 +431,15 @@ export default articlePage extends: './tsconfig.json', include: ['types.ts'], }, null, 2)}\n`), + writeFile(path.join(consumerPath, 'tsconfig-null-checks.json'), `${JSON.stringify({ + extends: './tsconfig.json', + compilerOptions: { + strict: true, + strictNullChecks: false, + skipLibCheck: true, + }, + include: ['null-checks.ts'], + }, null, 2)}\n`), writeFile(path.join(consumerPath, 'tsconfig-js.json'), `${JSON.stringify({ extends: './tsconfig.json', compilerOptions: { @@ -367,7 +467,7 @@ export default articlePage consumerPath ) } - for (const config of ['tsconfig.json', 'tsconfig-types.json', 'tsconfig-js.json']) { + for (const config of ['tsconfig.json', 'tsconfig-types.json', 'tsconfig-js.json', 'tsconfig-null-checks.json']) { console.log(`Checking TypeScript ${devDependencies.typescript}, @types/node ${nodeVersion}, ${config}`) await run( process.execPath, diff --git a/test-cases/type-exports/registry-required-vars.test.ts b/test-cases/type-exports/registry-required-vars.test.ts new file mode 100644 index 00000000..489633d4 --- /dev/null +++ b/test-cases/type-exports/registry-required-vars.test.ts @@ -0,0 +1,102 @@ +import type { LayoutFunction, LayoutRequiredVars } from '#types' + +type reqEqual = + (() => Value extends Left ? 1 : 2) extends + (() => Value extends Right ? 1 : 2) ? true : false + +type reqEquivalent = [Left] extends [Right] + ? [Right] extends [Left] ? true : false + : false + +type reqExpect = Value + +type reqUnion = { a: string } | { b: number } +type reqTagged = { kind: 'a', a: string } | { kind: 'b', b: number } + +declare module '#types' { + interface LayoutRegistry { + reqCompleteUnion: { + render: LayoutFunction + vars: reqUnion + } + reqSharedMissing: { + render: LayoutFunction + vars: reqUnion + } + reqEmptyBranch: { + render: LayoutFunction<{ x: string }, string, string> + vars: {} | { x: string } + } + reqDifferentBranches: { + render: LayoutFunction + vars: { kind: 'a' } | { kind: 'b' } + } + reqRendererAlternatives: { + render: LayoutFunction + } + reqIncompatibleBranch: { + render: LayoutFunction + vars: { kind: 'a', a: string } | { kind: 'c' } + } + reqOptionalExport: { + render: LayoutFunction<{ x: string }, string, string> + vars?: { x: string } + } + reqUndefinedExport: { + render: LayoutFunction<{ x: string }, string, string> + vars: { x: string } | undefined + } + reqOptionalProperty: { + render: LayoutFunction<{ x: string }, string, string> + vars: { x?: string } + } + reqConflictingObligations: { + render: LayoutFunction<{ kind: 'a', x: string } | { kind: 'b', x: number }, string, string> + vars: { kind: 'a' } | { kind: 'b' } + } + reqChoicesPerBranch: { + render: LayoutFunction< + | { kind: 'a', a: string } + | { kind: 'a', c: boolean } + | { kind: 'b', b: number } + | { kind: 'b', d: Date }, + string, + string + > + vars: { kind: 'a' } | { kind: 'b' } + } + reqMetadata: { + render: LayoutFunction<{ x: string }, string, string> + vars: { dataDeps: ['navigation'] } + } + } +} + +export type reqComplete = reqExpect, {}>> +export type reqShared = reqExpect, { shared: boolean }>> +export type reqEmpty = reqExpect, { x: string }>> +export type reqDifferent = reqExpect, { a: string, b: number }>> +export type reqAlternatives = reqExpect, reqUnion>> +export type reqIncompatible = reqExpect, never>> +export type reqOptional = reqExpect, { x: string }>> +export type reqUndefined = reqExpect, { x: string }>> +export type reqOptionalKey = reqExpect, { x: string }>> +export type reqConflicting = reqExpect, never>> +export type reqNoMetadata = reqExpect, { x: string }>> +export type reqChoices = reqExpect, + ({ a: string } | { c: boolean }) & ({ b: number } | { d: Date }) +>> + +export const reqCompleteDefaultsNeedNothing: LayoutRequiredVars<'reqCompleteUnion'> = {} +// @ts-expect-error Every defaults branch must be covered, not just the 'a' branch. +export const reqMissingB: LayoutRequiredVars<'reqDifferentBranches'> = { a: 'supplied' } +// @ts-expect-error Every defaults branch must be covered, not just the 'b' branch. +export const reqMissingA: LayoutRequiredVars<'reqDifferentBranches'> = { b: 1 } +// @ts-expect-error The empty defaults branch still requires x. +export const reqMissingX: LayoutRequiredVars<'reqEmptyBranch'> = {} + +export const reqChoiceAB: LayoutRequiredVars<'reqChoicesPerBranch'> = { a: 'supplied', b: 1 } +export const reqChoiceCD: LayoutRequiredVars<'reqChoicesPerBranch'> = { c: true, d: new Date() } +// @ts-expect-error Satisfying both choices for one defaults branch is not enough. +export const reqOnlyFirstBranch: LayoutRequiredVars<'reqChoicesPerBranch'> = { a: 'supplied', c: true } diff --git a/test-cases/type-exports/registry-subscriptions.test.ts b/test-cases/type-exports/registry-subscriptions.test.ts new file mode 100644 index 00000000..d15be22a --- /dev/null +++ b/test-cases/type-exports/registry-subscriptions.test.ts @@ -0,0 +1,76 @@ +import type { + LayoutChainVars, + LayoutFunction, + LayoutProvidedVars, + LayoutRequiredVars, + PageForLayout, + ValidatePageVars, +} from '#types' + +type Equal = + (() => Value extends Left ? 1 : 2) extends + (() => Value extends Right ? 1 : 2) ? true : false + +type Expect = Value + +declare module '#types' { + interface LayoutRegistry { + subscriptionsRoot: { + render: LayoutFunction<{ title: string }, string, string> + vars: { rootOnly: number, dataDeps: ['navigation'] } + } + subscriptionsChild: { + parentLayout: 'subscriptionsRoot' + render: LayoutFunction<{ title: string }, string, string> + vars: () => Promise<{ showSidebar: boolean, dataDeps: ['related'] }> + } + subscriptionsUnion: { + render: LayoutFunction<{}, string, string> + vars: { kind: 'a', a: string, dataDeps: ['a'] } | { kind: 'b', b: number, dataDeps: ['b'] } + } + subscriptionsRequiredMetadata: { + render: LayoutFunction<{ dataDeps: string[] }, string, string> + vars: { dataDeps: ['navigation'] } + } + subscriptionsOptionalMetadata: { + render: LayoutFunction<{ title: string, dataDeps?: string[] }, string, string> + } + } +} + +type Supplied = { title: string, dataDeps: ['body'] } +type Article = PageForLayout<'subscriptionsChild', Supplied, { body: string }> +type FinalVars = Parameters
[0]['vars'] + +export type NoPageMetadata = Expect> +export type NoProvidedMetadata = Expect, + { rootOnly: number, showSidebar: boolean } +>> +export type RequiredTitleOnly = Expect, { title: string }>> +export type PageTitle = Expect> +export type OwnData = Expect[0]['data'], { body: string }>> +export type ExportMetadataRetained = Expect, Supplied>> +export type UnionDefaultsPreserved = Expect, + { kind: 'a', a: string } | { kind: 'b', b: number } +>> +export type UnionPageVarsPreserved = Expect, + { kind: 'a', a: string, page: number } | { kind: 'b', b: number, page: number } +>> +export type NoRequiredMetadata = Expect, never>> +export type ImpossibleRenderer = Expect, never>> +export type NoOptionalMetadata = Expect, + { title: string } +>> +export type GlobalMetadataRejected = Expect, never +>> + +export const render: Article = ({ vars, data }) => { + // @ts-expect-error Runtime strips page and layout subscription metadata. + const subscriptions = vars.dataDeps + return `${vars.title}:${data.body}:${subscriptions}` +} diff --git a/types.ts b/types.ts index 6115ffce..7e2a79fe 100644 --- a/types.ts +++ b/types.ts @@ -83,7 +83,8 @@ export type TestBuildResult = { * create or read this registry at runtime. * * This interface intentionally has no index signature so unknown layout and - * parent names can be detected. + * parent names can be detected. Registry helpers require strictNullChecks; + * without it they resolve to never. Existing explicit renderer APIs are unchanged. */ export interface LayoutRegistry {} @@ -97,7 +98,7 @@ export type LayoutRegistryName = Extract */ export type LayoutChain = ResolveLayoutChain -/** Layout `vars` exports merged in runtime order, outermost to innermost. */ +/** Layout defaults merged outermost to innermost, excluding dataDeps metadata. */ export type LayoutProvidedVars = ResolveLayoutChain extends infer Chain ? [Chain] extends [never] @@ -117,22 +118,7 @@ export type LayoutRequiredVars = ? [Chain] extends [never] ? never : Chain extends readonly [string, ...string[]] - ? CheckedLayoutVars extends infer Vars - ? [Vars] extends [never] - ? never - : Vars extends AnyVars - ? Pick< - Vars, - Extract< - Exclude< - RequiredKeys, - DefinitelyRequiredKeys> - >, - keyof Vars - > - > - : never - : never + ? RequiredVarsAcrossDefaults, MergeLayoutDefaults> : never : never @@ -305,7 +291,7 @@ type SuppliedLayoutVars< ? 'dataDeps' extends KeysOfUnion ? never : MergeRight< - MergeRight>>, + MergeRight>, WithoutSubscriptions > extends infer Vars ? [Vars] extends [never] @@ -412,11 +398,13 @@ type ResolveLayoutChain< Name extends string, Seen extends string = never, Depth extends readonly unknown[] = [] -> = true extends IsUnion +> = undefined extends string ? never - : string extends Name + : true extends IsUnion ? never - : ResolveLiteralLayoutChain + : string extends Name + ? never + : ResolveLiteralLayoutChain type ResolveLiteralLayoutChain< Name extends string, @@ -489,26 +477,36 @@ type MergeRendererVars< Accumulated = {} > = Chain extends readonly [infer Current extends string, ...infer Rest extends string[]] ? MergeRendererVars> - : SatisfiableRequirements + : RenderableRequirements> + +// Subscription metadata is never a renderer variable. Drop optional metadata, +// but reject alternatives that require it rather than inventing a runtime value. +type RenderableRequirements = Requirements extends unknown + ? WithoutSubscriptions extends Requirements + ? WithoutSubscriptions + : never + : never type MergeLayoutDefaults< Chain extends readonly string[], Accumulated = {} > = Chain extends readonly [infer Current extends string, ...infer Rest extends string[]] - ? MergeLayoutDefaults>> + ? MergeLayoutDefaults>>> : Simplify type CandidateLayoutVars< Chain extends readonly string[], GlobalVars extends AnyVars, PageVars extends AnyVars -> = MergeRight< - MergeRight< - MergeRight, GlobalVars>, - MergeLayoutDefaults - >, - PageVars -> +> = 'dataDeps' extends KeysOfUnion + ? never + : MergeRight< + MergeRight< + MergeRight, GlobalVars>, + MergeLayoutDefaults + >, + WithoutSubscriptions + > type EveryRendererAccepts = Chain extends readonly [infer Current extends string, ...infer Rest extends string[]] @@ -547,9 +545,32 @@ type CheckedLayoutVars< : never : never -type DefinitelyRequiredKeys = { - [Key in Keys]: [Value] extends [Record] ? Key : never -}[Keys] +// Each defaults branch can choose any compatible renderer alternative (OR). +// Drop stronger alternatives when a weaker obligation already covers them. +type MinimalRequiredVars = + Choices extends unknown + ? Choices extends Exclude ? never : Choices + : never + +type RequiredVarsForDefault = + Requirements extends unknown + ? [MergeRight] extends [Requirements] + ? Pick, RequiredKeys>> + : never + : never + +// Wrap before distributing so {} cannot absorb another branch's obligations, +// and never cannot silently disappear. Contravariant inference combines the +// branch obligations with AND without intersecting their renderer choices. +type RequiredVarsDefaultFunctions = + Defaults extends unknown + ? (vars: MinimalRequiredVars>) => void + : never + +type RequiredVarsAcrossDefaults = + RequiredVarsDefaultFunctions extends (vars: infer Vars) => void + ? Simplify> + : never type RequiredKeys = { [Key in keyof Value]-?: {} extends Pick ? never : Key From e528fb071fda7190b6ae3eb7dbba493fed17b95a Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Mon, 14 Sep 2026 15:30:13 -0700 Subject: [PATCH 6/8] Derive page-output hooks from renderer contracts --- docs/generation/README.md | 8 +- docs/layouts/README.md | 16 ++++ examples/basic/README.md | 11 +++ .../basic/src/js-page/loose-assets/page.ts | 15 +++- scripts/test-packed-types.js | 19 ++++- .../registry-page-outputs.test.ts | 76 +++++++++++++++++++ types.ts | 13 ++++ 7 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 test-cases/type-exports/registry-page-outputs.test.ts diff --git a/docs/generation/README.md b/docs/generation/README.md index b7f12693..6f4963b6 100644 --- a/docs/generation/README.md +++ b/docs/generation/README.md @@ -50,7 +50,8 @@ A generated-pages module can default-export: Static objects and arrays do not receive factory parameters. A `null` or `undefined` default export or factory result produces no pages, as does an empty array or async iterable. -Each array entry or yielded value must still be a page-definition object; `null` entries are not skipped. +Each consumed entry must resolve to a page-definition object; `null` entries are not skipped. +The runtime consumes arrays with `for await`, so promise entries are awaited before definition validation. #### One page definition @@ -323,7 +324,10 @@ The global-data provider must produce both declared data keys; supplying type ar Escape interpolated content with your template library when producing HTML from untrusted values. Factories can return one definition, an array, an async iterable, `null`, or `undefined`, directly or through a promise. -Arrays and iterables contain definitions, not promises or nullish placeholders. +`PagesForLayout` intentionally uses a conservative definition-only array envelope: its array entries must be definitions, not promises. +This is a type-level restriction, not a runtime limitation: `iterateGeneratedPageDefinitions` consumes arrays with `for await`, awaiting promise entries before validation. +Resolve promised definitions before returning an array to satisfy the helper, or use an async generator to yield resolved definitions incrementally. +Nullish placeholders are invalid entries even though a nullish top-level result produces no pages. Use a union of individually checked `GeneratedPageForLayout` types for heterogeneous collections; a single `PagesForLayout` checks one literal selected layout. The existing explicit `GeneratedPageDefinition` and `PagesFunction` APIs remain available for dynamic or unregistered layouts. diff --git a/docs/layouts/README.md b/docs/layouts/README.md index f7f45ff1..5e7917f7 100644 --- a/docs/layouts/README.md +++ b/docs/layouts/README.md @@ -135,6 +135,21 @@ Nested hooks run outermost layout → innermost layout → selected page-level h If a JS/TS page module and its vars companion both export `pageOutputs`, the page module's hook wins with a warning; layout hooks still run. Each layout hook receives the fully resolved page `vars` and only that layout renderer's `vars.dataDeps` subscriptions in `data`. Declare data needed by the hook in the same subscriptions used by the layout render function. +The hook's `page` is a restricted read-only `PageOutputsPage` handle with source metadata and `readMarkdownContent()`, not the renderer's full page object or its rendering methods. + +Use `PageOutputsForRenderer` to derive a hook's resolved vars and own subscribed data contract from an explicitly typed layout renderer, or `PageOutputsForRenderer` for a page renderer type such as the one below. +This helper reuses the renderer's vars and data types but keeps the hook's restricted page handle; it does not combine data from other renderers or create runtime subscriptions. +When a layout renderer declares only a subset of the fully resolved page vars, the derived hook type exposes only that declared contract. +For example, alongside the `ArticlePage` renderer below: + +```ts +import type { PageOutputsForRenderer } from '@domstack/static/types.js' + +export const pageOutputs: PageOutputsForRenderer = ({ page, vars, data }) => ({ + outputName: './article.json', + content: JSON.stringify({ title: vars.title, url: page.url, body: data.articleBody }), +}) +``` Hooks may return a `{ outputName, content }` record, an array of records, or an async iterable of records, directly or through a promise. DOMStack validates and processes each file before requesting the next record, writing it or retaining an unchanged file during watch rebuilds. @@ -433,6 +448,7 @@ The registry helpers are: | `LayoutPageOutput` | Children type accepted by the innermost layout. | | `LayoutResult` | Awaited output of the outermost renderer, before DOMStack converts it to the final HTML string. | | `PageForLayout` | A `PageFunction` with inferred vars and page output. `Data` remains the page's own data contract and never includes layout data. | +| `PageOutputsForRenderer` | A page-output hook deriving vars and the renderer's own data contract, with a restricted read-only page handle. | | `ValidatePageVars` | The original supplied `PageVars` if actual known sources satisfy every renderer, otherwise `never`. | | `GeneratedPageForLayout` | A strictly checked generated definition with supplied vars, a required literal layout selector, and correctly typed static or inline children. | | `PagesForLayout` | A factory or async generator producing checked definitions, with separate factory and inline-page data contracts. | diff --git a/examples/basic/README.md b/examples/basic/README.md index 982da832..6ed2964a 100644 --- a/examples/basic/README.md +++ b/examples/basic/README.md @@ -141,6 +141,14 @@ Each defines a local `pageVars` object and checks it with `ValidatePageVars` whe This avoids circular inference while ensuring globals, layout defaults, and the actual page export supply every required renderer property. `PageForLayout` alone still describes a renderer contract rather than proving that all required values exist. +#### Page outputs with the same renderer contract + +The [loose-assets TypeScript page](src/js-page/loose-assets/page.ts) exports `pageOutputs: PageOutputsForRenderer` and renders a link to its `./assets.json` output. +The JSON includes `title`, `siteName`, `locale`, `theme`, `assets`, and `url` from `page.url`. +`PageOutputsForRenderer` derives resolved vars and the declaring renderer's own data contract from the renderer type, without repeating those types or including other renderers' subscriptions. +The hook receives a restricted read-only `PageOutputsPage` handle rather than the renderer's full page object, so metadata such as `page.url` is available but rendering methods are not. +The helper does not create runtime data subscriptions; this page still declares no subscribed data. + #### Generated pages with the same registry [guides.pages.ts](src/guides.pages.ts) uses `PagesForLayout` to generate two child-layout pages: @@ -152,6 +160,9 @@ The factory receives only global vars, while the inline renderer receives defaul Each definition must supply its title and an explicit `layout: 'child'`; it does not need to repeat inherited defaults. Both factory and inline page declare `Record` data contracts, so neither implicitly inherits subscriptions. Unlike the JavaScript page's light-theme override, these generated pages inherit the child's dark theme. +Generated pages skip all `pageOutputs` hooks, including inherited layout hooks. +`PagesForLayout` conservatively types array entries as definitions only, even though the runtime consumes arrays with `for await` and awaits promise entries before validating them. +Resolve promised definitions before returning an array to stay within that helper's type contract. When working from this repository checkout, build the package declarations before checking the example, and clean them afterward: diff --git a/examples/basic/src/js-page/loose-assets/page.ts b/examples/basic/src/js-page/loose-assets/page.ts index 0d7422de..36100c3d 100644 --- a/examples/basic/src/js-page/loose-assets/page.ts +++ b/examples/basic/src/js-page/loose-assets/page.ts @@ -1,5 +1,5 @@ import { html } from 'fragtml' -import type { PageForLayout, ValidatePageVars } from '@domstack/static/types.js' +import type { PageForLayout, PageOutputsForRenderer, ValidatePageVars } from '@domstack/static/types.js' import type globalVars from '../../global.vars.ts' import sharedData from './shared-lib.ts' @@ -32,6 +32,7 @@ const JSPage: AssetPage = async ({ vars }) => {
    ${vars.assets.map(asset => html`
  • ${asset.label}: ${asset.kind}
  • `)}
+

Download asset metadata as JSON

` @@ -39,6 +40,18 @@ const JSPage: AssetPage = async ({ vars }) => { export default JSPage +export const pageOutputs: PageOutputsForRenderer = ({ page, vars }) => ({ + outputName: './assets.json', + content: JSON.stringify({ + title: vars.title, + siteName: vars.siteName, + locale: vars.locale, + theme: vars.theme, + assets: vars.assets, + url: page.url, + }, null, 2), +}) + const pageVars = { title: 'JS Page with loose assets', assets: [ diff --git a/scripts/test-packed-types.js b/scripts/test-packed-types.js index f7820605..9f194186 100644 --- a/scripts/test-packed-types.js +++ b/scripts/test-packed-types.js @@ -45,6 +45,7 @@ void PageData void stack `), writeFile(path.join(consumerPath, 'types.ts'), `import pino from 'pino' +import type { PageOutputsForRenderer } from '@domstack/static/types.js' import type { WorkerOptions } from 'node:worker_threads' import type { DomStackOpts, @@ -124,6 +125,14 @@ type _OptionalOverride = Expect> type ArticlePage = PageForLayout<'article', { slug: string }, { body: string }, { siteName: string }> +export const articleOutputs: PageOutputsForRenderer = ({ vars, data, page }) => { + vars.showSidebar satisfies boolean + // @ts-expect-error Layout data is not page hook data. + data.navigation + // @ts-expect-error Hook metadata does not allow rendering. + page.render() + return { outputName: './article.json', content: JSON.stringify({ slug: vars.slug, body: data.body, url: page.url }) } +} const articlePage: ArticlePage = ({ vars, data }) => { vars.siteName vars.showSidebar @@ -356,7 +365,7 @@ declare module '@domstack/static/types.js' { } } `), - writeFile(path.join(consumerPath, 'js-page.js'), `/** @import { GeneratedPageForLayout, PageForLayout, PagesForLayout } from '@domstack/static/types.js' */ + writeFile(path.join(consumerPath, 'js-page.js'), `/** @import { GeneratedPageForLayout, PageForLayout, PageOutputsForRenderer, PagesForLayout } from '@domstack/static/types.js' */ export const layout = 'js-article' @@ -412,6 +421,14 @@ export const articlePages = ({ vars, data }) => { })) } +/** @type {PageOutputsForRenderer} */ +export const pageOutputs = ({ vars, data, page }) => { + vars.showSidebar.valueOf() + // @ts-expect-error Layout data does not leak into the page hook. + data.navigation + return { outputName: './article.json', content: JSON.stringify({ slug: vars.slug, body: data.body, url: page.url }) } +} + void invalidArticlePage export default articlePage `), diff --git a/test-cases/type-exports/registry-page-outputs.test.ts b/test-cases/type-exports/registry-page-outputs.test.ts new file mode 100644 index 00000000..95a5e152 --- /dev/null +++ b/test-cases/type-exports/registry-page-outputs.test.ts @@ -0,0 +1,76 @@ +import type { + GeneratedPageForLayout, + LayoutFunction, + PageForLayout, + PageFunction, + PageOutputsForRenderer, + PageOutputsFunction, +} from '#types' + +type Equal = + (() => Value extends Left ? 1 : 2) extends + (() => Value extends Right ? 1 : 2) ? true : false +type Expect = Value + +type Root = LayoutFunction<{ title: string }, string, string, { navigation: string[] }> +declare module '#types' { + interface LayoutRegistry { + outputsRoot: { render: Root, vars: { theme: 'dark', dataDeps: ['navigation'] } } + } +} + +type Article = PageForLayout<'outputsRoot', { title: string, slug: string }, { body: string }> +type Hook = PageOutputsForRenderer
+export type SameContract = Expect[0]['vars'], { body: string }>>> +export type InvalidRenderer = Expect, never>> +export type InvalidParams = Expect string>, never>> +export type NeverRenderer = Expect, never>> +export type NoGeneratedHook = Expect ? true : false, false>> + +export const outputs: Hook = ({ vars, data, page }) => { + vars.theme satisfies 'dark' + vars.slug.toUpperCase() + data.body.toUpperCase() + page.readMarkdownContent() satisfies Promise + // @ts-expect-error Vars are readonly. + vars.title = 'changed' + // @ts-expect-error Metadata is readonly. + page.url = '/changed/' + // @ts-expect-error Layout data is not page data. + String(data.navigation) + // @ts-expect-error Subscription metadata is not a variable. + String(vars.dataDeps) + // @ts-expect-error No rendering access. + page.render() + // @ts-expect-error No global data access. + page.getData() + // @ts-expect-error No client asset context. + String(page.scripts) + return { outputName: './article.json', content: JSON.stringify({ title: vars.title, body: data.body, url: page.url }) } +} + +export const layoutOutputs: PageOutputsForRenderer = ({ vars, data }) => { + data.navigation.map(String) + // @ts-expect-error Page subscriptions are not layout subscriptions. + String(data.body) + return { outputName: './navigation.txt', content: vars.title } +} + +export const explicitOutputs: PageOutputsForRenderer> = ({ vars, data }) => ({ + outputName: './count.txt', content: `${vars.count}:${data.labels.join(',')}`, +}) +export const asyncOutputs: Hook = async () => [] +export const streamedOutputs: Hook = async function * ({ vars }) { + yield { outputName: './title.txt', content: vars.title } +} +// @ts-expect-error Additional outputs are strings regardless of the renderer output type. +export const invalidContent: Hook = () => ({ outputName: './binary', content: new Uint8Array() }) +// @ts-expect-error Return [] instead of undefined to emit nothing. +export const invalidEmpty: Hook = () => undefined +export const restrictedContext: Hook = (params) => { + // @ts-expect-error No layout children are passed to hooks. + String(params.children) + // @ts-expect-error No renderer styles are passed to hooks. + String(params.styles) + return [] +} diff --git a/types.ts b/types.ts index 7e2a79fe..314668cb 100644 --- a/types.ts +++ b/types.ts @@ -10,6 +10,7 @@ import type { } from './lib/build-pages/index.js' import type { PageFunction as PageFunctionExport } from './lib/build-pages/page-builders/page-writer.js' +import type { PageOutputsFunction as PageOutputsFunctionExport } from './lib/build-pages/page-outputs.js' export type { DataDeps } from './lib/build-pages/data-deps.js' export type { @@ -88,6 +89,18 @@ export type TestBuildResult = { */ export interface LayoutRegistry {} +/** + * Additional-output hook using an existing page or layout renderer's vars and + * own data subscriptions, with the restricted page-output metadata handle. + * Generated pages do not run these hooks, including inherited layout hooks. + */ +export type PageOutputsForRenderer = + Renderer extends (params: infer Params, ...rest: any[]) => any + ? Params extends { vars: infer Vars extends AnyVars, data: infer Data extends object } + ? PageOutputsFunctionExport + : never + : never + /** Names registered in the current TypeScript program. */ export type LayoutRegistryName = Extract From dfaac2e867274fd72d0cc436867c18704dd8fa58 Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Mon, 14 Sep 2026 15:37:53 -0700 Subject: [PATCH 7/8] Place example layout registrations beside their types --- examples/basic/src/layouts/child.layout.ts | 20 ++++++++++---------- examples/basic/src/layouts/root.layout.ts | 18 +++++++++--------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/examples/basic/src/layouts/child.layout.ts b/examples/basic/src/layouts/child.layout.ts index 62de8699..04086600 100644 --- a/examples/basic/src/layouts/child.layout.ts +++ b/examples/basic/src/layouts/child.layout.ts @@ -14,6 +14,16 @@ export type ArticleVars = PageVars & { } } +declare module '@domstack/static/types.js' { + interface LayoutRegistry { + child: { + parentLayout: typeof parentLayout + vars: typeof vars + render: typeof articleLayout + } + } +} + export const vars = async () => ({ theme: 'dark', readingMinutes: 4, @@ -41,13 +51,3 @@ const articleLayout: LayoutFunction = } export default articleLayout - -declare module '@domstack/static/types.js' { - interface LayoutRegistry { - child: { - parentLayout: typeof parentLayout - vars: typeof vars - render: typeof articleLayout - } - } -} diff --git a/examples/basic/src/layouts/root.layout.ts b/examples/basic/src/layouts/root.layout.ts index 869b1777..24d62ac3 100644 --- a/examples/basic/src/layouts/root.layout.ts +++ b/examples/basic/src/layouts/root.layout.ts @@ -23,6 +23,15 @@ export interface PageVars { }; } +declare module '@domstack/static/types.js' { + interface LayoutRegistry { + root: { + vars: typeof vars + render: typeof RootLayout + } + } +} + export const vars = { theme: 'light', footer: { label: 'Built with DOMStack', showYear: false }, @@ -65,12 +74,3 @@ const RootLayout: LayoutFunction = async } export default RootLayout - -declare module '@domstack/static/types.js' { - interface LayoutRegistry { - root: { - vars: typeof vars - render: typeof RootLayout - } - } -} From 4e8261ab269b8d3928b3bb9eee8c58a5ba6c8045 Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Mon, 14 Sep 2026 17:50:44 -0700 Subject: [PATCH 8/8] Document layout registry entries with JSDoc --- examples/basic/src/layouts/child.layout.ts | 4 ++++ examples/basic/src/layouts/root.layout.ts | 3 +++ types.ts | 11 +++++++++++ 3 files changed, 18 insertions(+) diff --git a/examples/basic/src/layouts/child.layout.ts b/examples/basic/src/layouts/child.layout.ts index 04086600..97170c80 100644 --- a/examples/basic/src/layouts/child.layout.ts +++ b/examples/basic/src/layouts/child.layout.ts @@ -16,9 +16,13 @@ export type ArticleVars = PageVars & { declare module '@domstack/static/types.js' { interface LayoutRegistry { + /** Article layout nested inside root; pages can derive its full chain contract. */ child: { + /** Literal registered parent name; the child's awaited output must fit its accepted children. */ parentLayout: typeof parentLayout + /** Async defaults are awaited, then shallowly override root defaults before page overrides. */ vars: typeof vars + /** Explicit renderer contract; keep it independent of this entry to avoid circular inference. */ render: typeof articleLayout } } diff --git a/examples/basic/src/layouts/root.layout.ts b/examples/basic/src/layouts/root.layout.ts index 24d62ac3..c3f028d8 100644 --- a/examples/basic/src/layouts/root.layout.ts +++ b/examples/basic/src/layouts/root.layout.ts @@ -25,8 +25,11 @@ export interface PageVars { declare module '@domstack/static/types.js' { interface LayoutRegistry { + /** Outermost document layout; omitting parentLayout ends the chain. */ root: { + /** Defaults supplied by root, shallowly overridden by child and page vars. */ vars: typeof vars + /** Explicit renderer contract supplying required vars, accepted children, and output types. */ render: typeof RootLayout } } diff --git a/types.ts b/types.ts index 314668cb..0827934e 100644 --- a/types.ts +++ b/types.ts @@ -83,6 +83,17 @@ export type TestBuildResult = { * program. Layout modules opt in through module augmentation; DOMStack does not * create or read this registry at runtime. * + * Each property key is a layout's runtime name, with an entry describing its exports: + * - `render`: Required renderer type, normally `typeof layoutRenderer`. + * Supplies the accepted vars and children types and the layout's return type. + * Keep the renderer explicitly typed rather than deriving it from its own entry. + * - `vars`: Optional defaults export type, normally `typeof vars`. + * Object defaults or sync/async provider results are shallow-merged outer-to-inner, + * after global vars and before page overrides. `dataDeps` is not a renderer var. + * - `parentLayout`: Optional single literal registered parent name, normally + * `typeof parentLayout`. Omit it for an outermost layout. Helpers check that + * this renderer's awaited output is accepted by the parent's children type. + * * This interface intentionally has no index signature so unknown layout and * parent names can be detected. Registry helpers require strictNullChecks; * without it they resolve to never. Existing explicit renderer APIs are unchanged.