/** * scaffold-screen-controller — parse-pagespec.ts * * Reads pagespecs/*.md files in a module folder, extracts the fenced ```json * machine block, validates against PageSpecSchema, and returns the parsed * pagespecs filtered to a given section. * * Pure — no Zod throws bubble up; malformed files produce a warning in the * second tuple element and are skipped (so a single bad pagespec doesn't * abort the whole section generation). */ import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs' import { join } from 'node:path' import { FENCED_JSON_RE } from '../../../../../lib/pagespec-blocks.js' import { PageSpecSchema } from './types.js' import type { PageSpec } from './types.js' export interface ParseWarning { file: string reason: string } export interface ParseResult { pagespecs: PageSpec[] warnings: ParseWarning[] } /** Extract the FIRST fenced ```json block from a markdown string. Returns null * if no block found. * * Lot 14 (2026-09-24): the grammar is `lib/pagespec-blocks`' `FENCED_JSON_RE` * — ONE spelling for every reader, this file used to carry its own. Column-0 * fences on a line of their own (trailing blanks tolerated), CRLF accepted * and the capture never keeps the CR before the closing fence. An indented * or inline ```json mention in the human body is prose, not a block — it no * longer opens a phantom that swallows the real block after it. */ export function extractFencedJson(md: string): string | null { const m = FENCED_JSON_RE.exec(md) return m ? m[1] : null } /** Parse a single pagespec.md file. Returns the PageSpec or a warning. */ export function parsePagespecFile(filePath: string, content: string): { ok: PageSpec | null; warning: ParseWarning | null } { const json = extractFencedJson(content) if (json === null) { return { ok: null, warning: { file: filePath, reason: 'no fenced ```json block found' } } } let raw: unknown try { raw = JSON.parse(json) } catch (err) { return { ok: null, warning: { file: filePath, reason: `invalid JSON: ${(err as Error).message}` } } } const parsed = PageSpecSchema.safeParse(raw) if (!parsed.success) { const first = parsed.error.issues[0] return { ok: null, warning: { file: filePath, reason: `schema violation at ${first.path.join('.')}: ${first.message}` } } } return { ok: parsed.data, warning: null } } /** Read every .md under /pagespecs/ (one level), parse each, and * return only the pagespecs whose section AND entity match the filters. * When `entity` is omitted, only the section filter is applied (legacy mode). */ export function parsePagespecsForSection(moduleDir: string, section: string, entity?: string): ParseResult { const dir = join(moduleDir, 'pagespecs') const out: ParseResult = { pagespecs: [], warnings: [] } if (!existsSync(dir)) return out let entries: string[] try { entries = readdirSync(dir) } catch (err) { out.warnings.push({ file: dir, reason: `cannot read pagespecs/: ${(err as Error).message}` }) return out } for (const name of entries) { if (!name.endsWith('.md')) continue const full = join(dir, name) if (!statSync(full).isFile()) continue let content: string try { content = readFileSync(full, 'utf8') } catch (err) { out.warnings.push({ file: full, reason: `cannot read file: ${(err as Error).message}` }) continue } const { ok, warning } = parsePagespecFile(full, content) if (warning) out.warnings.push(warning) if (!ok) continue if (ok.section !== section) continue if (entity !== undefined && ok.entity !== entity) continue out.pagespecs.push(ok) } // Stable ordering: list → detail → form → dashboard → others (alpha), // so the generated file is deterministic regardless of OS readdir order. const viewOrder = ['list', 'detail', 'form', 'dashboard', 'kanban', 'card', 'app-home', 'module-home', 'section-home'] out.pagespecs.sort((a, b) => { const ai = viewOrder.indexOf(a.view) const bi = viewOrder.indexOf(b.view) if (ai !== bi) return ai - bi return a.screenCode.localeCompare(b.screenCode) }) return out }