/** * 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 { 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. */ export function extractFencedJson(md: string): string | null { // Matches ```json\n ... \n``` (lazy, multiline). The closing fence may have // trailing whitespace but no other content on its line. const re = /```json\s*\r?\n([\s\S]*?)\r?\n```/ const m = md.match(re) 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 }