/** * cli:derive-form-sections — execute.ts * * Pure core (`promotePagespecSections`) + a thin filesystem wrapper. The core * takes one pagespec markdown source and returns the rewritten markdown + the * outcome — no I/O, unit-testable, IDEMPOTENT (an existing `sections[]` is * never touched; re-running yields `already`). */ import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' import { isAbsolute, join } from 'node:path' import { pickUiDesignOverlay } from '../../../../lib/ui-design-overlay.js' import type { DeriveFormSectionsReport, DeriveFormSectionsSpec, PagespecOutcome, PromotedSection } from './types.js' /** Matches the FIRST fenced ```json … ``` block (the pagespec machine block). */ const JSON_BLOCK_RE = /```json\s*\r?\n([\s\S]*?)\r?\n```/ const FORM_FAMILY_VIEWS = new Set(['form', 'detail', 'create', 'edit']) function toCamel(code: string): string { const parts = code.split('-') return parts[0]! + parts.slice(1).map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join('') } function humanize(name: string): string { return name .replace(/([A-Z])/g, ' $1') .replace(/[_-]+/g, ' ') .trim() .replace(/^./, (c) => c.toUpperCase()) } /** * Promote the pagespec's uiDesign per-field sections into a first-order * `sections[]`. Section ORDER: `uiDesign.sections` metadata first (its array * order), then first appearance following the field sequence (`uiDesign.order` * when present, else the pagespec `fields[]` order, else the overlay's own * field-entry order). `form.section.` is seeded into `i18nKeys.fr` * when absent (label > humanised key) — en/it/de stay for /ba-translate-prd. */ export function promotePagespecSections( md: string, path: string, ): { md: string; outcome: PagespecOutcome } { const m = JSON_BLOCK_RE.exec(md) if (!m) return { md, outcome: { path, view: null, status: 'skipped', reason: 'no ```json machine block' } } let block: Record try { block = JSON.parse(m[1]!) as Record } catch (e) { return { md, outcome: { path, view: null, status: 'skipped', reason: `machine block is not valid JSON — ${e instanceof Error ? e.message : String(e)}` } } } const view = typeof block.view === 'string' ? block.view : null if (view === null || !FORM_FAMILY_VIEWS.has(view)) { return { md, outcome: { path, view, status: 'skipped', reason: 'not a form/detail-family view' } } } if (Array.isArray(block.sections) && block.sections.length > 0) { return { md, outcome: { path, view, status: 'already' } } } const overlay = pickUiDesignOverlay(block) const overlayFields = overlay?.fields ?? {} const memberOf = new Map() for (const [fieldKey, directive] of Object.entries(overlayFields)) { const section = (directive?.section ?? '').trim() if (section !== '') memberOf.set(fieldKey, section) } const specFieldKeys = (Array.isArray(block.fields) ? block.fields : []) .map((f) => (f !== null && typeof f === 'object' ? (f as { key?: unknown }).key : undefined)) .filter((k): k is string => typeof k === 'string') if (memberOf.size === 0) { if (view === 'form' && Array.isArray(block.tabs) && block.tabs.length > 0) { return { md, outcome: { path, view, status: 'tabs', reason: 'the form groups through tabs[] — tabs stay tabs' } } } if (specFieldKeys.length >= 8) { return { md, outcome: { path, view, status: 'needs-judgment', reason: `${specFieldKeys.length} fields with no grouping — run /ui-design (no mechanical invention)` } } } return { md, outcome: { path, view, status: 'flat' } } } // Section order — metadata first, then first appearance along the field sequence. const metaEntries = Array.isArray(overlay?.sections) ? overlay.sections : [] const metaByKey = new Map(metaEntries.map((s) => [s.key, s] as const)) const fieldSequence = (overlay?.order && overlay.order.length > 0 ? overlay.order : (specFieldKeys.length > 0 ? specFieldKeys : Object.keys(overlayFields))) const orderedKeys: string[] = [] const seen = new Set() for (const meta of metaEntries) { if (!seen.has(meta.key)) { orderedKeys.push(meta.key); seen.add(meta.key) } } for (const fieldKey of fieldSequence) { const s = memberOf.get(fieldKey) if (s !== undefined && !seen.has(s)) { orderedKeys.push(s); seen.add(s) } } for (const s of memberOf.values()) { if (!seen.has(s)) { orderedKeys.push(s); seen.add(s) } } const sections: PromotedSection[] = orderedKeys .map((key) => { const meta = metaByKey.get(key) const members = fieldSequence.filter((f) => memberOf.get(f) === key) for (const f of memberOf.keys()) { if (memberOf.get(f) === key && !members.includes(f)) members.push(f) } return { key, labelKey: meta?.labelKey ?? `form.section.${toCamel(key)}`, ...(meta?.label !== undefined ? { label: meta.label } : {}), ...(meta?.description !== undefined ? { description: meta.description } : {}), ...(meta?.columns !== undefined ? { columns: meta.columns } : {}), fields: members, } }) .filter((s) => s.fields.length > 0) if (sections.length === 0) { return { md, outcome: { path, view, status: 'flat', reason: 'overlay sections reference no known field' } } } block.sections = sections // Provenance: this grouping is MACHINE-promoted — the /ui-design precedence // discipline hands it back to the judgment layer (an authored grouping // stays protected; SSOT lib/page-spec-sections.sectionsOriginOf). block.sectionsOrigin = 'derived' // Seed the FR label floor (PRD-111 leg b). The other locales are the // /ba-translate-prd backfill's job — never machine-translated here. const i18nKeys = block.i18nKeys as Record> | undefined if (i18nKeys && i18nKeys.fr && typeof i18nKeys.fr === 'object') { for (const s of sections) { if (i18nKeys.fr[s.labelKey] === undefined) { i18nKeys.fr[s.labelKey] = s.label ?? humanize(s.key) } } } const newJson = JSON.stringify(block, null, 2) const md2 = md.slice(0, m.index) + '```json\n' + newJson + '\n```' + md.slice(m.index + m[0].length) return { md: md2, outcome: { path, view, status: 'promoted', sections } } } /** Resolve the pagespec file list from the spec (explicit paths win). */ export function resolvePagespecPaths(spec: DeriveFormSectionsSpec, workdir: string): string[] { const abs = (p: string) => (isAbsolute(p) ? p : join(workdir, p)) if (spec.pagespecs !== undefined) return spec.pagespecs.map(abs) const dir = abs(spec.pagespecDir!) return readdirSync(dir) .filter((f) => f.endsWith('.md')) .map((f) => join(dir, f)) .filter((p) => statSync(p).isFile()) } /** Filesystem wrapper: read each pagespec, promote, write back (derive mode). */ export function execute(spec: DeriveFormSectionsSpec, workdir: string): DeriveFormSectionsReport { const outcomes: PagespecOutcome[] = [] let written = 0 for (const path of resolvePagespecPaths(spec, workdir)) { const md = readFileSync(path, 'utf-8') const { md: md2, outcome } = promotePagespecSections(md, path) outcomes.push(outcome) if (spec.mode === 'derive' && outcome.status === 'promoted' && md2 !== md) { writeFileSync(path, md2, 'utf-8') written++ } } return { outcomes, written } }