Skip to content

Add type-only layout registry for inferred chain contracts - #318

Open
bcomnes wants to merge 7 commits into
masterfrom
issue-313-layout-registry
Open

bcomnes wants to merge 7 commits into
masterfrom
issue-313-layout-registry

Conversation

@bcomnes

@bcomnes bcomnes commented Sep 13, 2026

Copy link
Copy Markdown
Owner

Summary

Implements the optional type-only registry proposed in #313 for review, without changing filesystem discovery, rendering, subscriptions, or runtime validation.

  • Add an augmentable LayoutRegistry to the public type entry, with registrations referencing actual layout exports through typeof.
  • Infer outer-to-inner layout chains, provided and required vars, final known vars, innermost page content, and the awaited outermost render result.
  • Add PageForLayout to derive page renderer contracts while keeping page data separate from every layout data contract.
  • Model shallow override precedence, including optional and union-shaped sources, and validate final vars against renderer requirements.
  • Check awaited child results against parent children types and reject invalid names, parents, cycles, and render boundaries.
  • Document TypeScript registration and JavaScript/JSDoc opt-in through companion declarations.

Before and after: typing a page through nested layouts

Consider a site that renders root(article(page())). The page produces a string; article transforms it into a Frame; root accepts that Frame and produces the document string. Both layouts receive the same resolved vars, but each renderer has its own data contract.

Before: explicit types, but no connection through the selected layout name

The existing APIs correctly describe each renderer in isolation:

// root.layout.ts
import type { LayoutFunction } from "@domstack/static/types.js"

export type Frame = { html: string }
export type RootVars = {
  siteName: string
  title: string
  theme: "light" | "dark"
}

export const vars = { theme: "light" as const }

const rootLayout: LayoutFunction<RootVars, Frame, string> = ({ children }) =>
  `<!doctype html><html><body>${children.html}</body></html>`

export default rootLayout
// 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 = { theme: "dark" as const, showSidebar: true }

export type ArticleVars = RootVars & { showSidebar: boolean }

const articleLayout: LayoutFunction<ArticleVars, string, Frame> =
  async ({ children }) => ({ html: `<article>${children}</article>` })

export default articleLayout
// global.vars.ts
export default { siteName: "Example" }

A page author then describes the resolved vars and repeats the expected inner content type:

// index.page.ts — before
import type { PageFunction } from "@domstack/static/types.js"
import type { ArticleVars } from "./article.layout.ts"

export const vars = {
  layout: "article" as const,
  title: "Hello",
  slug: "hello",
}

type PageVars = ArticleVars & typeof vars

const page: PageFunction<PageVars, string> = ({ vars }) =>
  `<p>${vars.slug}</p>`

export default page

Here, the page annotation manually connects ArticleVars and string to the runtime choice layout: "article". It does not derive either from that name. If the selected layout changes its children type, this page annotation can become stale without a type error at the page. Likewise, changing an article renderer to return number instead of Frame can satisfy its own revised annotation without TypeScript discovering that its declared parent still accepts only Frame. Authors can manually construct more elaborate types today, but each consumer must reconstruct those relationships.

After: register each layout once, then derive the page contract

Keep the explicit renderer annotations above. Add a type-only registration alongside each layout, referencing the actual exports rather than copying their contracts:

// Add to root.layout.ts
declare module "@domstack/static/types.js" {
  interface LayoutRegistry {
    root: {
      vars: typeof vars
      render: typeof rootLayout
    }
  }
}
// Add to article.layout.ts
declare module "@domstack/static/types.js" {
  interface LayoutRegistry {
    article: {
      parentLayout: typeof parentLayout
      vars: typeof vars
      render: typeof articleLayout
    }
  }
}

The page no longer imports the layout vars contract or repeats string as its output type:

// index.page.ts — after
import type { PageForLayout } from "@domstack/static/types.js"
import type globalVars from "./global.vars.ts"

export const vars = {
  layout: "article" as const,
  title: "Hello",
  slug: "hello",
}

type ArticlePage = PageForLayout<
  typeof vars.layout,
  typeof vars,
  Record<string, never>, // this page declares no data subscriptions
  typeof globalVars
>

const page: ArticlePage = ({ vars }) => {
  // siteName: string; title: string; slug: string
  // showSidebar: boolean; theme: "dark" (inner default wins)
  return `<p>${vars.slug}</p>`
}

export default page

PageForLayout<"article"> is also sufficient when a page only needs the inferred layout contract. The extra parameters above account for this page’s own vars, its own data contract, and the known global vars without inventing another source of truth. As with the existing API, these types do not create exports, provide missing values, or declare runtime subscriptions.

The same registry exposes the individual contracts for tooling or other typed consumers:

import type {
  LayoutChain,
  LayoutPageOutput,
  LayoutProvidedVars,
  LayoutRequiredVars,
  LayoutResult,
} from "@domstack/static/types.js"

type Chain = LayoutChain<"article"> // readonly ["root", "article"]
type Content = LayoutPageOutput<"article"> // string, not string & Frame
type Defaults = LayoutProvidedVars<"article">
// { theme: "dark"; showSidebar: boolean }
type Required = LayoutRequiredVars<"article">
// { siteName: string; title: string } — supplied by global/page vars here
type Result = LayoutResult<"article"> // string, from the outermost renderer

Why prefer this for statically known layout chains?

  • Less repeated page typing: each layout registers once, while every page selecting it can derive the vars and accepted output from that registration. Changing the selected name or a renderer signature updates the inferred contract rather than leaving a hand-written page annotation behind.
  • Checks between renderers, not just within them: the helpers await each child result and check it against the immediate parent. In the example, returning a Frame from the page is rejected because the article layout expects a string; changing the article result to number makes the registered chain invalid because root expects a Frame.
  • Vars follow runtime precedence: root supplies theme: "light", article overrides it with "dark", and the result is "dark", not an impossible intersection. A known override outside the renderers’ accepted "light" | "dark" contract is rejected. Required renderer vars remain distinct from defaults that layouts actually provide.
  • No accidental data inheritance: a page’s data parameter is its own contract, not the union of its layouts’ subscriptions. The registry does not change runtime invalidation or subscription behavior.
  • No runtime machinery: registration and helper imports are type-only. Filesystem discovery and runtime checks stay intact.

The tradeoff is a one-time registration per layout and more advanced compile-time machinery. This is most useful when many pages share statically named layouts, especially nested layouts with different intermediate render values. Small sites, unregistered layouts, and dynamic/union selections can keep using the simpler existing explicit APIs. Registrations must be included in the site TypeScript program; separate sites sharing one program must avoid registry name collisions. Layout signatures should remain explicitly annotated rather than deriving themselves recursively from their own registry entry.

Design choices

The existing LayoutVars<T> API is unchanged; the inferred helper is named LayoutChainVars. Existing explicit LayoutFunction and PageFunction APIs remain available for unregistered layouts and dynamic selections.

Registry helpers require a single literal selected name and literal parent names. Invalid or ambiguous chains resolve to never, with a 32-layout recursion limit. Registration is scoped to the site TypeScript program and does not introduce a runtime registry or watch dependencies.

Renderer declarations remain independent of the registry to avoid circular inference. Renderer requirements compose as simultaneous constraints, while actual vars sources merge in runtime precedence order. Required vars still need real global, page, or builder sources; these helpers describe contracts rather than supplying values.

Working example: basic site

The existing basic example now demonstrates inferred contracts in both JavaScript/JSDoc and TypeScript, with visible global, root, child, and page variable contributions.

  • Global async vars supply a literal locale, theme, and typed navigation array, without an open-ended index signature.
  • Root registers object defaults for theme and a structured footer.
  • Child registers an async vars provider with its own theme, numeric reading time, and structured badge defaults, alongside typeof parentLayout and its explicit renderer type.
  • The JavaScript page derives its contract with PageForLayout using typeof vars and Awaited<ReturnType<typeof globalVars>>, overrides the theme and whole badge object, and adds its own topics array. JSDoc @satisfies preserves the async function’s Promise return type.
  • The root-only TypeScript page infers its own asset metadata array, including a literal union of asset kinds, without inheriting child-only vars.
  • Both pages explicitly use Record<string, never> for their own data contracts; global vars are not subscribed global data.

Variable precedence shown in the built page

Variable Global Root Child JavaScript page Final
theme dark light dark light Literal light
locale en Literal en
footer Structured footer object Root object
readingMinutes 4 number, value 4
badge Guide/info Hands-on example/tip Page object, literal tip tone
topics String array Page-only array

Renderer requirements accept compatible alternatives such as light/dark themes and info/tip badges, while inferred page vars retain the winning source’s narrower type. Nested objects are replaced shallowly, not deep-merged. LayoutRequiredVars identifies title, site name, and locale as values still needed from outside the layouts.

The example includes compile-time checks against its actual exports for precedence, async defaults, required vars, literal unions, page-only fields, and rejection of invalid themes, string reading times, incomplete badge overrides, and incompatible page content. The checks live outside src and are not included in the built site. Its TypeScript program checks both JS and TS consumers.

The render chain remains unchanged: the JavaScript page produces a HtmlResult, child renders it to a string, and root wraps that string into the document. The pages now display their inferred vars, and root emits locale/theme attributes and its footer so runtime composition can be inspected.

Additional validation:

  • npm run build:declaration
  • npm --workspace @domstack/basic-example test
  • npm --workspace @domstack/basic-example run build
  • Repository ESLint and TypeScript checks
  • Assertions on generated HTML for locale, theme precedence, badge, reading time, footer, navigation, topics, and root-only asset metadata
  • Generated declaration cleanup and git diff --check

Supplied-vars validation and generated-page support

This PR also closes the two immediate coverage gaps identified during review: checking actual supplied vars and connecting generated definitions/factories to registered layouts. It remains a layout-chain registry, not a site-wide registry for manifests, templates, build configuration, or page introspection.

Validate actual exports

ValidatePageVars<Name, PageVars, GlobalVars> returns the original supplied page-vars type only if the actual known global vars, layout defaults, and page vars satisfy every renderer. It resolves to never for missing required values or incompatible final overrides. Unlike PageForLayout, it does not treat renderer requirements as assumed values.

import type { ValidatePageVars } from "@domstack/static/types.js"
import type globalVars from "./global.vars.ts"

const supplied = { layout: "article" as const, title: "Hello" }

export const vars = supplied satisfies ValidatePageVars<
  "article",
  typeof supplied,
  Awaited<ReturnType<typeof globalVars>>
>

For the registered example earlier in this description, omitting the title or failing to supply the required global site name now fails at this export boundary. The name specifies the chain to validate; the actual layout export must still select that chain. Reserved dataDeps remains in the supplied export but is excluded from the vars passed to renderers, matching runtime behavior.

Generate checked definitions

GeneratedPageForLayout<Name, PageVars, PageData, GlobalVars> separates supplied definition vars from the fully merged vars available to inline children. It requires an explicit literal vars.layout, validates supplied requirements automatically, and checks static or inline content against the innermost layout.

import type { GeneratedPageForLayout } from "@domstack/static/types.js"
import type globalVars from "./global.vars.ts"

type ArticleDefinition = GeneratedPageForLayout<
  "article",
  { title: string },
  Record<string, never>,
  Awaited<ReturnType<typeof globalVars>>
>

const article: ArticleDefinition = {
  outputName: "article/index.html",
  vars: { layout: "article", title: "Generated article" },
  children: ({ vars }) => vars.showSidebar ? "With sidebar" : "No sidebar",
}

PagesForLayout<Name, PageVars, GlobalVars, FactoryData, PageData> adds the corresponding factory contract for sync functions, async functions, and async generators. Factory vars contain only effective globals; inline-page vars include layout and page contributions. Factory and page data contracts are independent, and declaring a type does not create subscriptions.

Generated-content validation models omitted/static-nullish content becoming an empty string, awaited inline results, and function-valued children requiring a wrapper. Static objects conservatively reserve callable/thenable member names to prevent structural function or promise values from bypassing the runtime boundary. Existing explicit generated-page APIs remain unchanged.

Working examples and coverage

The basic example now validates both existing source-page exports and adds src/guides.pages.ts, producing one async inline guide and one static-content guide through the registered child/root chain. Their HTML confirms inherited dark theme, locale, reading time, badge, footer, and generated URL. Source compile-time tests and packed TypeScript/JSDoc tests cover missing required vars, incompatible unions/overrides, subscription metadata, selector enforcement, factory/page isolation, nullish content, mixed awaited unions, and structural callable/thenable cases.

Latest validation passed:

  • Repository ESLint and TypeScript checks
  • Packed TypeScript/JSDoc consumers with Node typings 22, 24, and 26
  • Basic example type checks and build
  • Generated-guide HTML assertions
  • Full Node suite with node --test --test-concurrency=1 --test-reporter=dot
  • Declaration cleanup and git diff --check

The default concurrent Node test run encountered intermittent watch failures: the JSX-client update assertion on one run and generated-page layout-asset watching on another. The full suite passed with file concurrency disabled; no runtime code or unrelated watch tests were changed. An initial concurrently run ESLint process also encountered a temporary watch-fixture file being deleted; standalone lint passed.

Validation

  • npm run test:tsc
  • npm run test:neostandard
  • npm run test:node-test passed earlier in the PR; see the latest sequential-run results and watch-test caveat above.
  • npm run test:packed-types — TypeScript and JSDoc consumers with Node typings 22, 24, and 26
  • git diff --check

Compile-time regressions cover mixed and asynchronous render values, vars precedence, optional and union sources, required/default distinctions, incompatible overrides, missing names, cycles, ambiguous selections, renderer boundaries, and data isolation. Generated declarations were cleaned up after validation.

Rebased page-outputs integration

Rebased onto master at 6cf1c4c, including #323, and added PageOutputsForRenderer<Renderer> in e528fb0. This type-only helper derives a hook’s vars and its own renderer’s data contract while reusing the upstream restricted PageOutputsPage handle and string-only output envelope. No library runtime behavior changes.

Before, a hook alongside a registry-derived page had to repeat its vars/data types or manually extract them:

import type { PageOutputsFunction } from "@domstack/static/types.js"

type Hook = PageOutputsFunction<
  Parameters<ArticlePage>[0]["vars"],
  Parameters<ArticlePage>[0]["data"]
>

After, the existing renderer contract is the single source of truth:

import type { PageOutputsForRenderer } from "@domstack/static/types.js"

type Hook = PageOutputsForRenderer<ArticlePage>
// Layout hooks can use PageOutputsForRenderer<typeof layoutRenderer>.

This avoids repeated extraction and keeps hook typings synchronized with the page or layout without accidentally exposing renderer methods, children, client assets, or another renderer’s subscriptions. Generated pages still skip all page-output hooks, including inherited layout hooks.

The basic loose-assets page now emits assets.json using its inferred title, global site name/locale, winning root theme, asset-kind union, and page URL, with a visible download link. Generated-page docs also distinguish upstream runtime support for promised array entries from the existing conservative definition-only array types.

Validation after rebase: repository lint and TypeScript checks; packed TypeScript/JSDoc consumers with Node typings 22/24/26; basic example type checks/build; assertions on emitted JSON values and its HTML link; full Node suite with file concurrency disabled; declaration cleanup; and git diff --check all passed. The three existing review threads remain unresolved as requested.

Closes #313

@coveralls

coveralls commented Sep 13, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 34905065268

Coverage decreased (-0.2%) to 94.689%

Details

  • Coverage decreased (-0.2%) from the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 9583
Covered Lines: 9223
Line Coverage: 96.24%
Relevant Branches: 2807
Covered Branches: 2509
Branch Coverage: 89.38%
Branches in Coverage %: Yes
Coverage Strength: 403.56 hits per line

💛 - Coveralls

@bcomnes
bcomnes marked this pull request as ready for review September 13, 2026 17:09
@bcomnes
bcomnes requested a lite review from Copilot September 13, 2026 17:09

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Three unresolved findings remain, including one critical strictNullChecks compatibility issue and two moderate inference issues.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds an optional type-only layout registry for inferring nested layout contracts, page types, variables, and render results without runtime changes.

Changes:

  • Adds registry and recursive type-inference helpers.
  • Adds TypeScript/JSDoc compile-time coverage.
  • Updates the basic example and layout documentation.
File summaries
File Summary
types.ts Adds registry helpers. Findings: moderate (3 votes) dataDeps must be excluded; moderate (2 votes) union defaults can be over-reported as required; critical (1 vote) parent detection breaks without strictNullChecks.
test-cases/type-exports/layout-registry.test.ts Adds registry inference and invalid-contract tests.
scripts/test-packed-types.js Validates packaged TypeScript and JSDoc consumers.
examples/basic/type-checks.ts Adds compile-time example assertions.
examples/basic/tsconfig.json Includes JavaScript and type-check sources.
examples/basic/src/layouts/root.layout.ts Registers the root layout and defaults.
examples/basic/src/layouts/child.layout.ts Registers the nested layout and defaults.
examples/basic/src/js-page/page.js Uses inferred JSDoc page contracts.
examples/basic/src/js-page/loose-assets/page.ts Uses inferred root-layout contracts.
examples/basic/src/global.vars.ts Adds typed global variables.
examples/basic/README.md Documents the working example.
docs/layouts/README.md Documents registry usage and constraints.
Review details
  • Files reviewed: 12/12 changed files
  • Comments generated: 3
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread types.ts
Comment on lines +268 to +270
: undefined extends Parent
? InvalidParentReference
: [Parent] extends [string]

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed with a compiler probe: with strictNullChecks disabled, the root chain resolved but the literal child-to-root chain became never. Addressed in 359a74e using the explicit-requirement option: registry helpers now require strictNullChecks, documented on LayoutRegistry and in the layout guide, with a central guard that consistently returns never for chain-dependent helpers when it is disabled. The existing explicit LayoutFunction, PageFunction, GeneratedPageDefinition, and PagesFunction APIs remain unchanged.

Added an isolated packed consumer with strictNullChecks: false that verifies the behavior for root and child across all registry helpers and demonstrates that the explicit APIs remain usable. This passed with Node typings 22, 24, and 26; the normal strict TS/JSDoc matrix also passes. Leaving this thread unresolved for your review.

Comment thread types.ts Outdated
Comment on lines +115 to +117
RequiredKeys<Vars>,
DefinitelyRequiredKeys<MergeLayoutDefaults<Chain>>
>,

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed with a failing regression: a layout whose renderer vars and defaults are both { a: string } | { b: number } rejected {} as its external requirements. Fixed in 359a74e by calculating compatible renderer choices separately for each defaults branch, then combining obligations across all possible defaults branches. Fully supplied alternatives now require {}, while shared missing keys and branch-specific obligations are retained; an empty, optional, or incompatible defaults branch is not silently discarded.

Added registry-required-vars.test.ts covering the reported case, shared missing fields, optional/undefined defaults, differing obligations, conflicting obligations, and multiple renderer choices per branch, plus packed-consumer assertions. Source type checks, lint, packed tests with Node typings 22/24/26, the basic example checks/build, and the full serial Node suite passed. Leaving this thread unresolved for your review.

Comment thread types.ts Outdated
Comment on lines +376 to +379
MergeRight<MergeRendererVars<Chain>, GlobalVars>,
MergeLayoutDefaults<Chain>
>,
PageVars

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed with failing regressions: PageForLayout exposed vars.dataDeps, and LayoutProvidedVars retained layout subscription metadata. Fixed in 359a74e by stripping dataDeps distributively before merging layout defaults and page vars, and removing it from the renderer-requirement baseline. This preserves union-specific fields and applies consistently to the provided/required/chain/page helpers as well as the generated-page helpers. Renderer alternatives that require vars.dataDeps are rejected because runtime cannot supply it; global vars containing dataDeps are also rejected, matching runtime. Raw exports and ValidatePageVars results retain subscription metadata for its actual purpose.

Added registry-subscriptions.test.ts covering page and async ancestor metadata, union preservation, required/optional metadata declarations, global metadata rejection, and unchanged page data isolation. Packed TS and JSDoc consumers now exercise metadata-bearing layouts and reject vars.dataDeps access. All source, lint, packed, basic-example, and serial Node checks passed. Leaving this thread unresolved for your review.

@bcomnes
bcomnes force-pushed the issue-313-layout-registry branch from 359a74e to e528fb0 Compare September 14, 2026 22:30
@bcomnes

bcomnes commented Sep 14, 2026

Copy link
Copy Markdown
Owner Author

Rebased onto master at 6cf1c4c (including #323) and pushed with force-with-lease. Commit e528fb0 adds PageOutputsForRenderer, compile-time and packed-consumer coverage, and a working assets.json hook in the basic example. The PR description now includes before/after hook types and the compatibility details. Lint, TypeScript, packed consumers, basic example checks/build, JSON/link assertions, and the full sequential Node suite passed. Generated declarations were cleaned. Existing review threads remain unresolved.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Explore a type-only layout registry for inferred layout-chain contracts

3 participants