/* Zod schema for block-declarative page content entries (DNA-24, RFC-0026). Every page .md file under apps//src/content/pages/ must conform to PageEntrySchema. Exports BlockEntrySchema and PageEntrySchema — pure declarative schemas, no I/O. Do not implement the buildPage pipeline here — that lives in @warpgogol/werkstatt-shared/page. Do not parse YAML here; callers provide parsed objects to schema.parse(). Do not validate cross-system.yaml references — that is page.block.validate's job. 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. */ import { z } from "zod"; import { MoonCatalog, PlanetCatalog, StarCatalog } from "../cosmic/index.ts"; const PRINT_ORIENTATIONS = ["portrait", "landscape", "auto"] as const; const PRINT_PAGE_SIZES = ["a4", "letter", "legal"] as const; const PRINT_MARGINS = ["normal", "narrow", "none"] as const; const PRINT_BACKGROUND_MODES = ["preserve", "flatten"] as const; const PRINT_REGIONS = [ "navigation", "breadcrumbs", "cta", "footer-links", "header-logo", "site-background", "hero-animation", ] as const; // --------------------------------------------------------------------------- // VisibilityExprSchema (re-declared here to avoid cross-package dep on @warpgogol/werkstatt-shared/page) // page.block.validate uses the canonical one from @warpgogol/werkstatt-shared/page; // the schema here is for Astro content-collection integration. // --------------------------------------------------------------------------- const visibilityExprSchema: z.ZodType = z.lazy(() => z.union([ z.object({ feature: z.string().min(1) }), z.object({ locale: z.union([z.string().min(1), z.array(z.string().min(1))]) }), z.object({ segment: z.union([z.string().min(1), z.array(z.string().min(1))]) }), z.object({ flag: z.string().min(1) }), z.object({ all: z.array(visibilityExprSchema).min(1) }), z.object({ any: z.array(visibilityExprSchema).min(1) }), z.object({ not: visibilityExprSchema }), ]), ); // --------------------------------------------------------------------------- // BlockEntrySchema // --------------------------------------------------------------------------- /** * Schema for a single block inside a page content entry. * * `use` is validated against PlanetCatalog — only known planet names are allowed. * `props` is loose here (Record); strict per-planet validation * against propsSchema runs at build time in page.block.validate. */ export const BlockEntrySchema = z .object({ /** * Mandatory stable block identifier for analytics, anchor links, and * block-level attribution. Must be kebab-case. RFC-0914 makes this required. * page.block.validate enforces uniqueness within a page (B-05). * page.blocks.extract.validate enforces presence and format (BLOCK-ID-MISSING, BLOCK-ID-INVALID). */ id: z.string().regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, "Block id must be kebab-case"), /** * Cosmic name identifying the section/component for this block. * Normally a `PlanetCatalog` value (section archetype). The five * **PASSPORT-RESERVED moons** (`Methone`, `Despina`, `Klarissa`, `Bianca`, * `Adrastea`) are also accepted because the cosmic-passport routes invoke * passport components as page-blocks (see RFC-0028). Other Moon names * remain rejected. * Must be pinned in system.yaml pages[route].planets[]. */ type: z.string().min(1).optional(), use: z .enum([ ...PlanetCatalog, ...MoonCatalog.filter((m) => ["Methone", "Despina", "Klarissa", "Bianca", "Adrastea"].includes(m), ), ] as [string, ...string[]]) .optional(), /** * Props passed to the section component. Validated against the pinned * manifest's propsSchema (strict mode, no extra keys) by page.block.validate. * Loose here to allow Astro content-collections to parse the entry. */ props: z.record(z.string(), z.unknown()).default({}), /** * Explicit shared-context inheritance edge (RFC-1185). * A pageId string subscribes this block to the same id+type block's * authored props on the named page (shallow top-level merge, authored * keys win). `false` opts the block out of a same-id collision group. * Absent means no inheritance — undeclared collisions are flagged by * shared.context.validate. */ inherit: z.union([z.string().min(1), z.literal(false)]).optional(), /** * Optional visibility expression. When absent the block is unconditionally visible. * Evaluated at build time by evalVisibility against the RuntimeContext. */ visibility: visibilityExprSchema.optional(), }) .superRefine((data, ctx) => { if (!data.type && !data.use) { ctx.addIssue({ code: "custom", path: ["type"], message: "Block selector is required; use author-facing `type` in page content", }); } }); export type BlockEntry = z.infer; // --------------------------------------------------------------------------- // PageEntrySchema // --------------------------------------------------------------------------- /** * Schema for a block-declarative page content entry (DNA-24, RFC-0026). * * Stored at: apps//src/content/pages//..md * Format: YAML frontmatter only — no markdown body allowed (enforced by mirror.quintet.validate). * * Example: * ```yaml * kind: page * cosmicStar: Vega * title: "Startseite" * description: "Beispielorganisation — Bildung, Gesundheit, Solidarität." * lang: de * blocks: * - id: hero * use: Europa * props: * headline: "Bildung für Kinder in León" * subline: "Seit 2008 — jeden Tag." * ``` */ export const PageEntrySchema = z .object({ /** * Discriminator identifying the entry kind. * - `page`: standard block-declarative page (must have ≥1 block). * - `redirect`: language-root redirect helper (no blocks required) — used by * files like `src/content/pages/root-redirect.md` that resolve to a * client-side redirect script rather than a rendered section sequence. */ kind: z.enum(["page", "redirect"]).default("page"), /** * IAU star name for this page — must match a StarCatalog entry and * the cosmicName of the page's manifest.yaml. * Cross-reference against system.yaml is validated by page.block.validate. */ cosmicStar: z.enum(StarCatalog), /** Page and social/SEO title. */ title: z.string().min(1), /** Page meta description for SEO and social cards. */ description: z.string().min(1), /** * BCP-47 language code matching the file's language suffix (e.g. "de", "en"). * The pipeline uses this as the locale for RuntimeContext. */ lang: z.string().min(1), /** * Ordered list of blocks composing this page. * For `kind: page`, must have at least one block (enforced by superRefine). * For `kind: redirect`, may be empty. * Every `use` value must be pinned in system.yaml pages[route].planets[]. * Props are validated against the pinned manifest's propsSchema by page.block.validate. */ blocks: z.array(BlockEntrySchema).default([]), /** * RFC-0257: per-page print configuration. Optional — when absent, defaults * are applied by the print validators and PDF generator. */ print: z .object({ enabled: z.boolean().optional(), orientation: z.enum(PRINT_ORIENTATIONS).optional(), pageSize: z.enum(PRINT_PAGE_SIZES).optional(), margins: z.enum(PRINT_MARGINS).optional(), expandDetails: z.boolean().optional(), hide: z.array(z.enum(PRINT_REGIONS)).optional(), background: z.enum(PRINT_BACKGROUND_MODES).optional(), }) .optional(), }) .superRefine((data, ctx) => { if (data.kind === "page" && data.blocks.length === 0) { ctx.addIssue({ code: "custom", path: ["blocks"], message: "A page must have at least one block", }); } }); export type PageEntry = z.infer<typeof PageEntrySchema>;