/** * lib/page-spec-sections.ts — Canonical Zod schema + resolver for the * first-order `sections[]` of a form/detail pagespec. * * SINGLE SOURCE OF TRUTH for the contract `screen.md` (Section « X » bullets) → * `pagespec.sections[]` → scaffold-component (form cards + sectioned detail), * and for the audits that verify it (SCR-018, PRD-111/112, DEV-UI-040). * * A section is a CATEGORY of fields rendered together as one titled card * (« Identité », « Contrat », « Temps de travail »). Before this module the * grouping only existed as a free string repeated on each field * (`field.section`) — no order, no metadata. `sections[]` promotes it to a * first-order object while `field.section` REMAINS the resolved internal pivot * every renderer groups by. * * Membership precedence (strongest wins), applied by `resolveSections`: * 1. `uiDesign.fields..section` (explicit /ui-design judgment — applied * LATER by scaffold-component's applyUiDesignOverlay, not here) * 2. `field.section` explicit in the scaffold spec * 3. `pageSpec.sections[].fields` membership ← this module seeds * 4. `pageSpec.tabs[]` seed (legacy BA Onglet groups) ← this module seeds * * Card order: `uiDesign.sections` > `pageSpec.sections[]` array order > * first-appearance of a field (legacy behaviour, preserved). * * PROVENANCE — the page-level `sectionsOrigin` key (sibling of `sections[]`, * NOT per-section): `'authored'` = the grouping was mined 1:1 from the BA's * `**Section « X »**` bullets (an already-paid human judgment — the /ui-design * precedence discipline derives it, never reinvents); `'derived'` = a machine * promoted it (tabs[] seed, derive-form-sections backfill) — the judgment * layer may regroup freely. ABSENT = presumed authored (fail-safe: a legacy * pagespec keeps its protection). Read it through `sectionsOriginOf` only. * * @see business-analyse/create-screen/levels/form-screens.md (authors the Section bullets) * @see business-analyse/create-prd/SKILL.md (propagates screen.md → pagespec.sections) * @see ui-design/cli/apply-form-directives (writes uiDesign.sections metadata) * @see development/frontend/component/cli/scaffold-component (renders the cards) * * Deliberately DEFERRED from v1 (do not add without the rendering surface): * - `kind: 'collection'` — an inline-editable child collection (the HR mockup's * assignment rows). The `kind` discriminant is reserved so it lands without a * breaking change; v1 approximates it with a related tab on the detail page. * - Section-in-tab nesting — a screen groups with sections OR tabs, not both. */ import { z } from 'zod' import type { UiDesignOverlay, UiDesignSectionMeta } from './ui-design-overlay.js' export const PAGE_SECTION_KINDS = ['fields'] as const /** Who grouped the fiche — see the PROVENANCE note in the header. */ export const SECTIONS_ORIGINS = ['authored', 'derived'] as const export type SectionsOrigin = (typeof SECTIONS_ORIGINS)[number] /** * Tolerant accessor for the page-level `sectionsOrigin` key. Unknown or absent * → `undefined` (callers treat it as authored — fail-safe). This is the ONLY * sanctioned read path; never test the raw key inline. */ export function sectionsOriginOf(block: unknown): SectionsOrigin | undefined { if (block === null || typeof block !== 'object' || Array.isArray(block)) return undefined const raw = (block as Record).sectionsOrigin return raw === 'authored' || raw === 'derived' ? raw : undefined } export const PageSectionSchema = z.object({ /** * Stable section key in lower-camel/kebab (e.g. `contract`, `temps-travail`). * It is the grouping value written onto `field.section` and drives the i18n * key `form.section.` — shared by the form AND the detail render. */ key: z .string() .min(1) .regex(/^[a-z][a-zA-Z0-9-]*$/, 'key must be lower-camel or kebab-case'), /** Explicit i18n key. Defaults to `form.section.` downstream. */ labelKey: z.string().min(1).optional(), /** Authored fallback label (becomes the t() defaultValue). */ label: z.string().optional(), /** * The pagespec field keys belonging to this section (camelCase). Seeds * `field.section` for fields that don't carry an explicit one — an explicit * `field.section` or a uiDesign directive always wins. */ fields: z.array(z.string().min(1)).optional(), /** READ-grid column count (1..3, default 3). Edit grid follows `formLayout`. */ columns: z.number().int().min(1).max(3).optional(), /** One-line description rendered under the section title. */ description: z.string().optional(), /** Reserved discriminant — v1 only knows `fields` (see the DEFERRED note). */ kind: z.enum(PAGE_SECTION_KINDS).optional(), }).passthrough() export type PageSection = z.infer /** Resolved, ordered metadata of ONE rendered section card. */ export interface ResolvedSection { /** Grouping key exactly as carried by `field.section` (verbatim, may be kebab). */ key: string labelKey?: string label?: string columns?: number description?: string } /** * Parse a pagespec's raw `sections` array. Invalid entries are returned in * `rejected` with their Zod issues (audits turn each into a finding; the * generator warns and skips — it must NOT silently drop a section's fields, * which simply stay un-seeded and fall into the default card). */ export function parsePageSections(raw: unknown): { sections: PageSection[] rejected: Array<{ index: number; issues: string[] }> } { const sections: PageSection[] = [] const rejected: Array<{ index: number; issues: string[] }> = [] if (!Array.isArray(raw)) return { sections, rejected } raw.forEach((entry, index) => { const result = PageSectionSchema.safeParse(entry) if (result.success) { sections.push(result.data) } else { rejected.push({ index, issues: result.error.issues.map(i => `${i.path.join('.')}: ${i.message}`), }) } }) return { sections, rejected } } function toCamelFirst(name: string): string { if (name.length === 0) return name return name.charAt(0).toLowerCase() + name.slice(1) } /** * Seed `field.section` from the pagespec's first-order `sections[]` (membership * lists) then from its form `tabs[]` (legacy BA Onglet groups), and return the * ordered section metadata. Pure, total, deterministic: * - a field with an explicit `section` is never touched (precedence rule 2); * - `sections[].fields` wins over a `tabs[]` seed for the same field; * - metadata/order: `uiDesign.sections` wins per key AND for the array order, * else the pagespec `sections[]` order. `order` may list keys that end up * with zero fields (final membership is only known after the caller applies * the uiDesign per-field overrides) — renderers simply skip unmatched keys; * - the uiDesign membership override itself is applied LATER by the caller * (scaffold-component's applyUiDesignOverlay) — this function only needs the * overlay for metadata precedence. * * The caller renders groups it finds on the FINAL fields; keys absent from * `order` keep their legacy first-appearance position after the ordered ones. */ export function resolveSections( fields: F[], pageSpec: { sections?: unknown; tabs?: unknown } | undefined, overlay: UiDesignOverlay | undefined, ): { fields: F[]; order: ResolvedSection[]; rejected: Array<{ index: number; issues: string[] }> } { const { sections, rejected } = parsePageSections(pageSpec?.sections) // Membership seeds — sections[] first (stronger), tabs[] second. const sectionByField = new Map() for (const s of sections) { for (const name of s.fields ?? []) { const k = toCamelFirst(name) if (!sectionByField.has(k)) sectionByField.set(k, s.key) } } const rawTabs = pageSpec?.tabs if (Array.isArray(rawTabs)) { for (const tab of rawTabs) { if (tab === null || typeof tab !== 'object') continue const t = tab as { key?: unknown; fields?: unknown } if (typeof t.key !== 'string' || t.key === '' || !Array.isArray(t.fields)) continue for (const name of t.fields) { if (typeof name !== 'string') continue const k = toCamelFirst(name) if (!sectionByField.has(k)) sectionByField.set(k, t.key) } } } const seeded = fields.map(f => { if ((f.section ?? '').trim() !== '') return f const s = sectionByField.get(toCamelFirst(f.name)) return s ? { ...f, section: s } : f }) // Metadata order — uiDesign.sections wins (per key and for array order). // First occurrence of a duplicated key wins (Map#set would keep the last). const specMetaByKey = new Map() for (const s of sections) { if (!specMetaByKey.has(s.key)) specMetaByKey.set(s.key, s) } const overlayMeta: UiDesignSectionMeta[] = Array.isArray(overlay?.sections) ? overlay.sections : [] const orderedKeys: string[] = overlayMeta.length > 0 ? overlayMeta.map(m => m.key) : sections.map(s => s.key) const overlayByKey = new Map(overlayMeta.map(m => [m.key, m])) const order: ResolvedSection[] = [] const seen = new Set() for (const key of orderedKeys) { if (seen.has(key)) continue seen.add(key) const spec = specMetaByKey.get(key) const over = overlayByKey.get(key) order.push({ key, labelKey: over?.labelKey ?? spec?.labelKey, label: over?.label ?? spec?.label, columns: over?.columns ?? spec?.columns, description: over?.description ?? spec?.description, }) } // Pagespec sections not mentioned by the overlay keep their declared order // AFTER the overlay-ordered ones (the overlay is the explicit judgment). if (overlayMeta.length > 0) { for (const s of sections) { if (seen.has(s.key)) continue seen.add(s.key) order.push({ key: s.key, labelKey: s.labelKey, label: s.label, columns: s.columns, description: s.description }) } } return { fields: seeded, order, rejected } }