/** * cli:derive-pwa-spec — execute.ts * * Scan `////pagespecs/*.md`, parse each * fenced ```json machine block, and derive the PWA rollout per module. In * `write` mode, apply the requested `pwa` edits to the machine blocks first * (the BA files are the SSOT — scaffolders and future ba-develop passes read * the same field; the changed block content re-triggers compute-page-diff, * which hashes the raw JSON, so the touched pages regenerate naturally). */ import fs from 'node:fs' import path from 'node:path' import { normalizeOffline, type OfflineLevel, type PwaMeta } from '../../../lib/pwa-meta.js' import type { DerivePwaSpecInput, DerivePwaSpecReport, DerivedEntity, DerivedModule, } from './types.js' /** Extract the FIRST fenced ```json block — same regex as ba-develop's * compute-page-diff/scan-pagespecs.ts (kept in sync by convention). */ const FENCED_JSON_RE = /```json\s*\r?\n([\s\S]*?)\r?\n```/ /** Pagespec `view` → scaffold-routes view names (`form` covers create+edit). */ function routesViewsOf(view: string): string[] { if (view === 'form') return ['create', 'edit'] return [view] } interface ScannedSpec { app: string module: string pagespec: string file: string json: Record } function scanBaTree(root: string, warnings: string[]): ScannedSpec[] { const out: ScannedSpec[] = [] if (!fs.existsSync(root)) return out for (const app of fs.readdirSync(root, { withFileTypes: true })) { if (!app.isDirectory() || app.name.startsWith('_') || app.name.startsWith('.')) continue const appDir = path.join(root, app.name) for (const mod of fs.readdirSync(appDir, { withFileTypes: true })) { if (!mod.isDirectory() || mod.name.startsWith('_') || mod.name.startsWith('.')) continue const pagespecsDir = path.join(appDir, mod.name, 'pagespecs') if (!fs.existsSync(pagespecsDir)) continue for (const entry of fs.readdirSync(pagespecsDir)) { if (!entry.endsWith('.md')) continue const file = path.join(pagespecsDir, entry) const md = fs.readFileSync(file, 'utf8') const fenced = md.match(FENCED_JSON_RE) if (!fenced) { warnings.push(`${app.name}/${mod.name}/pagespecs/${entry}: no fenced json block — skipped`) continue } try { const json = JSON.parse(fenced[1]) as Record out.push({ app: app.name, module: mod.name, pagespec: entry.slice(0, -3), file, json }) } catch (err) { warnings.push(`${app.name}/${mod.name}/pagespecs/${entry}: invalid JSON (${(err as Error).message}) — skipped`) } } } } return out } /** Replace the fenced json block of a pagespec .md with a re-serialised spec. */ function rewriteMachineBlock(md: string, json: Record): string { const serialised = JSON.stringify(json, null, 2) return md.replace(FENCED_JSON_RE, '```json\n' + serialised + '\n```') } const OFFLINE_RANK: Record = { none: 0, read: 1, write: 2 } export function execute(spec: DerivePwaSpecInput): { report: DerivePwaSpecReport; warnings: string[] } { const warnings: string[] = [] const baRootAbs = path.join(spec.projectPath, spec.baRoot) if (!fs.existsSync(baRootAbs)) { warnings.push(`BA root not found: ${baRootAbs} — nothing to derive`) } // ── write mode: apply updates BEFORE scanning so the report reflects them ── const writeResults: DerivePwaSpecReport['writeResults'] = [] if (spec.write) { for (const update of spec.updates) { const file = path.join(baRootAbs, update.app, update.module, 'pagespecs', `${update.pagespec}.md`) if (!fs.existsSync(file)) { warnings.push(`update target not found: ${update.app}/${update.module}/pagespecs/${update.pagespec}.md — skipped`) continue } const md = fs.readFileSync(file, 'utf8') const fenced = md.match(FENCED_JSON_RE) if (!fenced) { warnings.push(`update target has no fenced json block: ${update.pagespec} — skipped`) continue } const json = JSON.parse(fenced[1]) as Record if (update.pwa === null) delete json.pwa else json.pwa = update.pwa fs.writeFileSync(file, rewriteMachineBlock(md, json), 'utf8') writeResults.push({ app: update.app, module: update.module, pagespec: update.pagespec, action: update.pwa === null ? 'removed' : 'set', }) } if (writeResults.length > 0) { warnings.push( 'machine blocks changed — ba-develop\'s compute-page-diff hashes the raw JSON, so the touched pages ' + 'regenerate on the next pass (or run the scaffolders now via /pwa).', ) } } // ── scan + derive ────────────────────────────────────────────────────────── const scanned = scanBaTree(baRootAbs, warnings) const moduleMap = new Map>() const uncovered: DerivePwaSpecReport['uncovered'] = [] for (const s of scanned) { const entity = String(s.json.entity ?? '') const view = String(s.json.view ?? '') if (!entity || !view) continue const meta = s.json.pwa as PwaMeta | undefined if (!meta) uncovered.push({ app: s.app, module: s.module, pagespec: s.pagespec }) const moduleKey = `${s.app}/${s.module}` const entities = moduleMap.get(moduleKey) ?? new Map() const entries = entities.get(entity) ?? [] entries.push({ spec: s, views: routesViewsOf(view), meta }) entities.set(entity, entries) moduleMap.set(moduleKey, entities) } const modules: DerivedModule[] = [] const requiresVersioned: DerivePwaSpecReport['requiresVersioned'] = [] for (const [moduleKey, entities] of Array.from(moduleMap.entries()).sort(([a], [b]) => a.localeCompare(b))) { const [app, module] = moduleKey.split('/') const derived: DerivedEntity[] = [] for (const [entity, entries] of Array.from(entities.entries()).sort(([a], [b]) => a.localeCompare(b))) { const first = entries[0].spec.json const section = String(first.section ?? '') const views = entries.flatMap((e) => e.views) const declaredMetas = entries.filter((e) => e.meta !== undefined) // Highest offline level across views decides the entity's data-layer needs. const offline = declaredMetas.reduce((acc, e) => { const level = normalizeOffline(e.meta!.offline) return OFFLINE_RANK[level] > OFFLINE_RANK[acc] ? level : acc }, 'none') const entityOut: DerivedEntity = { entity, section, views, offline } const pluralName = first.pluralName ?? first.entityPlural if (typeof pluralName === 'string' && pluralName.length > 0) entityOut.pluralName = pluralName if (declaredMetas.length > 0) { const metaJsons = new Set(declaredMetas.map((e) => JSON.stringify(e.meta))) if (metaJsons.size === 1 && declaredMetas.length === entries.length) { // Every declared view shares the same meta → entity-level pwa. entityOut.pwa = declaredMetas[0].meta as Record } else { const byView: Record> = {} for (const e of declaredMetas) { for (const v of e.views) byView[v] = e.meta as Record } entityOut.pwaByView = byView } } if (offline === 'write') requiresVersioned.push({ app, module, entity }) derived.push(entityOut) } modules.push({ app, module, entities: derived }) } const report: DerivePwaSpecReport = { modules, requiresVersioned, uncovered } if (spec.write) report.writeResults = writeResults return { report, warnings } }