diff --git a/docs/generation/README.md b/docs/generation/README.md index 4be316bb..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 @@ -247,6 +248,100 @@ 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. +`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. + +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 1d0c7752..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. @@ -326,6 +341,201 @@ 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, 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. | +| `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. | + +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. +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. + +### 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 bff9a3bf..6ed2964a 100644 --- a/examples/basic/README.md +++ b/examples/basic/README.md @@ -71,6 +71,110 @@ 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' + * @import { default as globalVars } from '../global.vars.ts' + */ + +/** @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', 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` 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. +- `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. + +#### 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: + +- `/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. +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: + +```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/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/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 5493a85b..36100c3d 100644 --- a/examples/basic/src/js-page/loose-assets/page.ts +++ b/examples/basic/src/js-page/loose-assets/page.ts @@ -1,11 +1,17 @@ import { html } from 'fragtml' -import type { HtmlResult } from 'fragtml/types.js' -import type { PageFunction } 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' -import type { PageVars } from '../../layouts/root.layout.ts' -const JSPage: PageFunction = async () => { +type AssetPage = PageForLayout< + 'root', + typeof vars, + Record, + Awaited> +> + +const JSPage: AssetPage = async ({ vars }) => { return html`

@@ -18,16 +24,44 @@ const JSPage: PageFunction = 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}
  • `)} +
+

Download asset metadata as JSON

+
` } export default JSPage -interface PageVariables extends PageVars { - title: string; -} +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), +}) -export const vars: Partial = { +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 21e1702c..4146c2db 100644 --- a/examples/basic/src/js-page/page.js +++ b/examples/basic/src/js-page/page.js @@ -1,19 +1,23 @@ /** - * @import { PageFunction } from '@domstack/static/types.js' - * @import { PageVars } from '../layouts/root.layout.ts' - * @import { HtmlResult } from 'fragtml/types.js' + * @import { PageForLayout, ValidatePageVars } from '@domstack/static/types.js' + * @import { default as globalVars } from '../global.vars.ts' */ import { html } from 'fragtml' -/** -* @type { PageFunction } -*/ -export default async function JSPage ({ +/** @satisfies {PageForLayout<'child', typeof vars, Record, Awaited>>} */ +const JSPage = async ({ vars: { siteName, title, + locale, + theme, + footer, + readingMinutes, + badge, + navigation, + topics, } -}) { +}) => { return html`

JavaScript Page Example

@@ -48,10 +52,19 @@ export default async function JSPage ({

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(', ')}
+
@@ -71,8 +84,16 @@ export default async function JSPage ({ ` } +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' + 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/src/layouts/child.layout.ts b/examples/basic/src/layouts/child.layout.ts index bbba853a..97170c80 100644 --- a/examples/basic/src/layouts/child.layout.ts +++ b/examples/basic/src/layouts/child.layout.ts @@ -6,11 +6,43 @@ 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' + } +} + +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 + } + } +} + +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' diff --git a/examples/basic/src/layouts/root.layout.ts b/examples/basic/src/layouts/root.layout.ts index c7da72fe..c3f028d8 100644 --- a/examples/basic/src/layouts/root.layout.ts +++ b/examples/basic/src/layouts/root.layout.ts @@ -15,12 +15,38 @@ export interface PageVars { title: string; siteName: string; basePath?: string; + locale: 'en' | 'fr'; + theme: 'light' | 'dark'; + footer: { + label: string; + showYear: boolean; + }; } +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 + } + } +} + +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 +55,7 @@ const RootLayout: LayoutFunction = async }) => { return render(html` - + ${siteName}${title ? ` | ${title}` : ''} @@ -44,6 +70,7 @@ const RootLayout: LayoutFunction = async
${typeof children === 'string' ? raw(children) : children}
+
${footer.label}${footer.showYear ? ` · ${new Date().getFullYear()}` : ''}
`) diff --git a/examples/basic/tsconfig.json b/examples/basic/tsconfig.json index a5cb75c5..098b4ed5 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", "type-checks.ts"] } diff --git a/examples/basic/type-checks.ts b/examples/basic/type-checks.ts new file mode 100644 index 00000000..cb3eec9b --- /dev/null +++ b/examples/basic/type-checks.ts @@ -0,0 +1,56 @@ +import type { + LayoutChain, + LayoutChainVars, + 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' +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 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> +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' }) diff --git a/scripts/test-packed-types.js b/scripts/test-packed-types.js index b157dbc6..9f194186 100644 --- a/scripts/test-packed-types.js +++ b/scripts/test-packed-types.js @@ -45,17 +45,190 @@ 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, + GeneratedPageForLayout, + LayoutChain, + LayoutChainVars, + LayoutFunction, + LayoutPageOutput, + LayoutProvidedVars, + LayoutRequiredVars, + LayoutResult, + PageForLayout, PageFunction, + PagesForLayout, Results, + ValidatePageVars, } from '@domstack/static/types.js' 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, 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 + vars: typeof articleVars + render: typeof articleLayout + } + } +} + +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>> +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 }> +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 + 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' +} +// @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' }) } @@ -71,12 +244,193 @@ 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, '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' */ + +/** @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, dataDeps: ['related'] }) + +/** @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 { GeneratedPageForLayout, PageForLayout, PageOutputsForRenderer, PagesForLayout } 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 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. + data.related + return data.body +} + +/** @type {PageForLayout} */ +// @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() + }, + })) +} + +/** @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 `), writeFile(path.join(consumerPath, 'tsconfig.json'), `${JSON.stringify({ compilerOptions: { @@ -94,6 +448,25 @@ void ({} as Results) 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: { + 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 +484,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', '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/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 new file mode 100644 index 00000000..aa1313e7 --- /dev/null +++ b/test-cases/type-exports/layout-registry.test.ts @@ -0,0 +1,312 @@ +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<( + | 'root' | 'article' | 'badChild' | 'orphan' | 'cycleA' | 'cycleB' | 'badVars' | 'invalidVars' | 'dynamicParent' + | 'undefinedParent' | 'optionalUndefinedParent' | 'possiblyRootParent' | 'optionalRootParent' | 'unionParent' + | 'possiblyUndefinedVars' | 'optionalVars' | 'narrowOuter' | 'wideInner' | 'wideOuter' | 'narrowInner' + | 'requiredDefault' | 'optionalOverride' | 'unionDefaults' | 'unionDefaultsChild' | 'incompatibleRequirements' + | 'unionRequirements' | 'unionOverlap' | 'narrowedUnion' +) extends LayoutRegistryName ? true : false> +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/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/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 32f40804..0827934e 100644 --- a/types.ts +++ b/types.ts @@ -4,6 +4,13 @@ // 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' +import type { PageOutputsFunction as PageOutputsFunctionExport } from './lib/build-pages/page-outputs.js' export type { DataDeps } from './lib/build-pages/data-deps.js' export type { @@ -70,3 +77,528 @@ 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. + * + * 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. + */ +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 + +/** + * 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 defaults merged outermost to innermost, excluding dataDeps metadata. */ +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[]] + ? RequiredVarsAcrossDefaults, MergeLayoutDefaults> + : 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 + +/** + * 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 + +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[] = [] +> = undefined extends string + ? never + : 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> + : 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>>> + : Simplify + +type CandidateLayoutVars< + Chain extends readonly string[], + GlobalVars extends AnyVars, + PageVars extends AnyVars +> = 'dataDeps' extends KeysOfUnion + ? never + : MergeRight< + MergeRight< + MergeRight, GlobalVars>, + MergeLayoutDefaults + >, + WithoutSubscriptions + > + +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 + +// 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 +}[keyof Value] + +type Last = + Values extends readonly [...string[], infer Value extends string] ? Value : never