/*
Build resolved page models from entry data, executing the block composition
pipeline for each block defined in a page's content.
- Do not own content loading or route file dispatch.
- Do not handle growth, passport, or other cross-cutting concerns
(those are wired in the route or layout).
- RFC-0262: add the optional BuildPageOptions.validateProps dev-time fail-fast hook, called once per resolved shell and content block.
- RFC-0263: hideSectionNumber injection now keyed off registry-derived roleByCosmicName[planetName] === "hero" instead of the hardcoded UNNUMBERED_HERO_PLANETS literal set (deleted).
- RFC-0264: relocated SectionProps/SectionPageOverride here from the @warpgogol/werkstatt-shared root barrel (its natural domain owner); consumers now import from @warpgogol/werkstatt-shared/page.
- RFC-1097: step 6 — compass.migrate codemod run
Mechanical v1 to v2 header migration across the workspace: 942 files rewritten — CHANGE_SUMMARY windows collapsed into history, forbidden v1 blocks stripped, KEY_DECISIONS seeded from @ai-invariant comments (5 files) or TODO placeholders (103 files), blocks reordered to canonical order.
- RFC-1097: sweep — werkstatt-engine clean
Sweep batch 4: 73 Compass headers on headerless engine files (certification, component-runtime, isolation, evolution, testing), real KEY_DECISIONS on 75 files (kernel, cache, dht, swim, gitmesh, runtime), ~80 purpose expansions (CONTRACT-02/PURPOSE-02), non-goals on 13 CONTRACT-03 files, CS-07 history literal fix repo-wide (253 files). Policy: .template.ts/.template.astro excludedPaths. werkstatt-engine now 0 diagnostics.
RFC-0091
*/
import type { RuntimeContext } from "../runtime/runtime-context.ts";
import {
evalVisibility,
type ResolvedFeatureGraph,
EMPTY_FEATURE_GRAPH,
} from "../policy/visibility.ts";
import {
planetImportPaths as registryPlanetImportPaths,
blockTypeToCosmicName as registryBlockTypeToCosmicName,
moonImportPaths as registryMoonImportPaths,
roleByCosmicName as registryRoleByCosmicName,
} from "@warpgogol/werkstatt-shared/ontology/archetypes";
// @ai-invariant: PageEntry and BlockEntry shapes MUST stay structurally
// compatible with @warpgogol/werkstatt-shared/ontology/schemas PageEntrySchema / BlockEntrySchema.
// Any field change here requires a matching change there and in every
// system.md pages[] block that references the changed field.
export interface PageEntry {
kind: "page";
cosmicStar: string;
title: string;
description: string;
lang: string;
blocks: BlockEntry[];
}
export interface BlockEntry {
id: string;
type?: string; // CMS-facing archetype slug — validated by page.block.validate
use?: string; // PlanetName or MoonName — normalized from type for internal resolution
props: Record;
/** Explicit shared-context inheritance edge — pageId or opt-out `false` (RFC-1185). */
inherit?: string | false;
visibility?: import("../policy/visibility.ts").VisibilityExpr;
/** Block layer — shell blocks render before content blocks (RFC-0036). */
layer?: "shell" | "section";
}
// ---------------------------------------------------------------------------
// Shell block types (RFC-0036)
// ---------------------------------------------------------------------------
export interface ShellBlockConfig {
enabled?: boolean;
cosmicMoon: string; // MoonName from MoonCatalog
pin: string;
props?: Record;
}
export interface ShellConfig {
background?: ShellBlockConfig;
header?: ShellBlockConfig;
footer?: ShellBlockConfig;
}
/**
* A single block after visibility evaluation and prop validation.
* All blocks in ResolvedPage.blocks passed visibility — invisible blocks are dropped.
*/
export interface ResolvedBlock {
/** Stable kebab-case block id from the content entry. Mandatory for content blocks (RFC-0914); null only for shell blocks injected by the pipeline. */
readonly id: string | null;
/** The PlanetName or MoonName identifying this block's archetype. */
readonly planetName: string;
/**
* Stable import path into packages/ui for the component.
* Used by the page route to dynamically load the component:
* const sections = import.meta.glob("@warpgogol/werkstatt-site/ui/sections/*")
* const Component = sections[block.componentImportPath]
*/
readonly componentImportPath: string;
/** Props already evaluated and ready to spread into the component. */
readonly props: Record;
/** The raw visibility expression (retained for attribution / debugging). null if absent. */
readonly visibility: import("../policy/visibility.ts").VisibilityExpr | null;
/** Block layer — shell blocks are prepended and render before content blocks (RFC-0036). */
readonly layer?: "shell" | "section";
}
/**
* A fully resolved page: title/description from the entry, filtered block list,
* and the RuntimeContext used during resolution.
*/
export interface ResolvedPage {
/** The IAU star name identifying this page in the cosmic overlay. */
readonly star: string;
readonly title: string;
readonly description: string;
readonly lang: string;
/** Blocks that passed visibility evaluation, in document order. */
readonly blocks: readonly ResolvedBlock[];
/** The RuntimeContext used during resolution — retained for attribution. */
readonly ctx: RuntimeContext;
}
// RFC-0035: Universal Section Props Contract — every resolved content block's
// props extend this shape. RFC-0264: relocated from the @warpgogol/werkstatt-shared root
// barrel to page.ts (its natural domain owner); re-exported from the `./page`
// subpath.
export interface SectionProps {
/** Active locale for i18n rendering */
lang: string;
/** App default locale for RFC-0008 site/content fallback */
defaultLanguageCode: string;
/** Zero-padded section index (01, 02, ...) for anchors and styling */
sectionNumber: string;
/** RFC-0914: Stable kebab-case block id from content entry, used as HTML id */
blockId: string;
/** Optional link registry for CTA/link resolution */
linkRegistry?: Record;
/** Complete block.props as declared in page frontmatter */
pageOverride: Record;
/** RFC-0048: Stable pageId for localized anchor resolution */
pageId?: string;
}
/** Type helper for sections to declare their specific pageOverride shape */
export type SectionPageOverride> = SectionProps & {
pageOverride: T;
};
// @ai-invariant: PLANET_IMPORT_PATHS and MOON_IMPORT_PATHS are the single
// source of truth for cosmicName → import path resolution. Every name added
// to a manifest MUST also appear in one of these maps (via the archetype
// registry), and vice versa. Silent mismatches cause
// "[buildPage] No component import path registered for ..." at runtime.
// Three-way alignment: manifest.yaml cosmicName ↔ import-paths map ↔ system.md pin.
// Default PlanetName → import path table
// Derived from the archetype registry via @warpgogol/werkstatt-shared/ontology/archetypes
// (RFC-0091). The archetype.registry.build command regenerates
// packages/werkstatt-site/src/domain/ontology/archetypes/index.yaml from manifests.
// ---------------------------------------------------------------------------
/**
* RFC-0091 fallback: entries that cannot be derived from manifests but must
* remain registered. Empty since RFC-0175 — Amalthea (formerly a reserved
* placeholder mapped to hero) is now claimed by the chat-widget section, so the
* registry-derived mapping is authoritative. The registry is spread LAST so a
* real manifest mapping always wins over any future placeholder fallback.
*/
const PLANET_IMPORT_PATHS_FALLBACK: Record = {};
/** @internal */
export const PLANET_IMPORT_PATHS: Record = {
...PLANET_IMPORT_PATHS_FALLBACK,
...registryPlanetImportPaths,
};
// ---------------------------------------------------------------------------
// MoonCatalog → import path table (RFC-0036)
// Components that can be used as shell-level blocks.
// RFC-0097: derived from the archetype registry just like PLANET_IMPORT_PATHS.
// `packages/werkstatt-site/src/domain/ontology/archetypes/index.yaml` is regenerated by
// `archetype.registry.build` from every shell.* manifest under
// `packages/werkstatt-site/src/domain/ui/components/shell/`. The previous
// MOON_IMPORT_PATHS_FALLBACK (Hermippe → site-background) was retired by
// RFC-0108 Proposal D once the registry began discovering the shell archetype
// directly; the registry is now the single source of truth.
// ---------------------------------------------------------------------------
/** @internal */
export const MOON_IMPORT_PATHS: Record = {
...registryMoonImportPaths,
};
/** @internal */
export const BLOCK_TYPE_TO_COSMIC_NAME: Record = registryBlockTypeToCosmicName;
export function normalizeBlockType(block: BlockEntry): string {
const selector = block.type ?? block.use;
if (!selector) {
throw new Error("[buildPage] Block is missing required author-facing `type`.");
}
return BLOCK_TYPE_TO_COSMIC_NAME[selector] ?? selector;
}
/**
* Resolves a PlanetName to the stable import path of its section component.
* Returns null if no mapping is registered.
*/
export function resolveComponentPath(planetName: string): string | null {
return PLANET_IMPORT_PATHS[planetName] ?? null;
}
// ---------------------------------------------------------------------------
// BuildPageOptions
// ---------------------------------------------------------------------------
export interface BuildPageOptions {
/**
* Override the default PlanetName → import path resolver.
* Useful when the caller can resolve paths from the live uni.registry.yaml.
*/
resolveImportPath?: (planetName: string) => string | null;
/**
* Provide a resolved feature graph for visibility evaluation.
* Defaults to EMPTY_FEATURE_GRAPH (all features enabled) when omitted.
*/
featureGraph?: ResolvedFeatureGraph;
/**
* Shell-level blocks from system.yaml prepended to content blocks (RFC-0036).
* These bypass visibility evaluation and are always included if enabled.
*/
shellBlocks?: ShellBlockConfig[];
/**
* RFC-0262: dev-only fail-fast prop validation. When supplied, buildPage
* calls this once per resolved block (shell and content) with the block's
* PlanetName/MoonName, its evaluated props, and its stable block id (null
* for shell blocks). A violation should throw — buildPage does not catch
* it, matching its existing "never silently drop a block" contract.
* page.block.validate (build.check) remains the deploy-time gate; this
* hook exists purely so an agent iterating in `astro dev` sees the same
* violation immediately instead of only at the next build:check run.
*/
validateProps?: (
planetName: string,
props: Record,
blockId: string | null,
) => void | Promise;
}
// @ai-invariant: buildPage is the SINGLE page pipeline (DNA-25). Every page
// route MUST call it once per locale; no route may hand-assemble block
// composition. Routes that bypass buildPage fail page.pipeline.contract.
// buildPage throws on missing import paths — it never silently drops blocks.
/**
* The single build-time page pipeline (DNA-25, RFC-0026).
*
* Call this once per page route, once per locale:
* ```ts
* const ctx = EMPTY_RUNTIME_CONTEXT(lang);
* const page = await buildPage(entry.data, ctx);
* ```
*
* buildPage guarantees:
* - All blocks with `visibility` evaluating false are dropped from ResolvedPage.blocks.
* - All remaining blocks have a non-null componentImportPath.
* - No block is skipped silently due to an error — errors throw.
*
* Cross-reference checks (star/route coherence, system.yaml pin list,
* propsSchema strict validation) run in page.block.validate (build.check),
* not inside buildPage. buildPage is intentionally lenient at render time
* so that validators provide precise error messages.
*
* @throws {Error} if a block's `use` has no registered component import path.
*/
/**
* Combined resolver that checks both PlanetCatalog and MoonCatalog (RFC-0036).
* MoonCatalog is checked first for shell-level components.
*/
export function resolveComponentPathUnified(name: string): string | null {
return MOON_IMPORT_PATHS[name] ?? PLANET_IMPORT_PATHS[name] ?? null;
}
export async function buildPage(
entry: PageEntry,
ctx: RuntimeContext,
options?: BuildPageOptions,
): Promise {
const resolver = options?.resolveImportPath ?? resolveComponentPathUnified;
const featureGraph: ResolvedFeatureGraph = options?.featureGraph ?? EMPTY_FEATURE_GRAPH;
const shellBlocks = options?.shellBlocks ?? [];
const resolvedBlocks: ResolvedBlock[] = [];
// Process shell blocks first (RFC-0036) — these bypass visibility
for (const shellBlock of shellBlocks) {
if (shellBlock.enabled === false) continue;
const componentImportPath = resolver(shellBlock.cosmicMoon);
if (componentImportPath === null) {
throw new Error(
`[buildPage] No component import path registered for moon "${shellBlock.cosmicMoon}". ` +
`Register it in MOON_IMPORT_PATHS or supply a custom resolveImportPath option.`,
);
}
const shellProps = shellBlock.props ?? {};
if (options?.validateProps)
await options.validateProps(shellBlock.cosmicMoon, shellProps, null);
resolvedBlocks.push({
id: null,
planetName: shellBlock.cosmicMoon,
componentImportPath,
props: shellProps,
visibility: null,
layer: "shell",
});
}
// Process content blocks with visibility evaluation
for (const block of entry.blocks) {
// Evaluate visibility — drop blocks that evaluate false
const visible = evalVisibility(block.visibility ?? null, ctx, featureGraph);
if (!visible) continue;
// Resolve the component import path
const planetName = normalizeBlockType(block);
const componentImportPath = resolver(planetName);
if (componentImportPath === null) {
throw new Error(
`[buildPage] No component import path registered for block "${planetName}". ` +
`Register it in PLANET_IMPORT_PATHS or supply a custom resolveImportPath option.`,
);
}
const contentProps =
registryRoleByCosmicName[planetName] === "hero"
? { ...block.props, hideSectionNumber: true }
: block.props;
if (options?.validateProps) {
await options.validateProps(planetName, contentProps, block.id ?? null);
}
resolvedBlocks.push({
id: block.id ?? null,
planetName,
componentImportPath,
props: contentProps,
visibility: block.visibility ?? null,
layer: block.layer ?? "section",
});
}
return {
star: entry.cosmicStar,
title: entry.title,
description: entry.description,
lang: entry.lang,
blocks: resolvedBlocks,
ctx,
};
}