/** * lib/pwa-meta.ts — Canonical Zod schema for the per-page PWA / offline * declaration. * * SINGLE SOURCE OF TRUTH for the contract `screen.md` (Mobile bullet) → * `pagespec.pwa` → scaffold-routes / scaffold-component / scaffold-api-client, * and for the audits that verify it (DEV-PWA-004..010). * * The socle (`@atlashub/smartstack`) resolves pages for its mobile shell via * `PageMobileMeta { support, mobileComponent?, offlineCapable? }` — a page with * NO metadata is implicitly `desktop-only`. This module is the CLI-side * counterpart: it types what BA/PRD authors declare, and the scaffolders map it * onto the socle contract: * * pwa.support → PageMobileMeta.support (verbatim) * normalizeOffline(offline) → PageMobileMeta.offlineCapable (!== 'none') * * Offline levels ('write' ⊇ 'read'): * - 'none' → online-only page. Nothing is emitted (desktop-only semantics). * - 'read' → reads come from the service-worker GET cache when offline; * mutation controls are DISABLED offline (scaffold-component). * - 'write' → mutations are captured by the app-level outbox (idempotent * replay, optimistic 202) — controls stay ENABLED offline and the * page shows the outbox queue state instead. Requires the backend * entity to be versioned (IVersionedEntity rowversion → real 409s), * which scaffold-api-client enforces via its `versioned` gate. * * `PageMobileMeta.mobileComponent` (support 'full' + a dedicated .mobile.tsx * variant) is deliberately NOT generatable in v1 — validatePwaMetaV1 rejects * 'full' so nobody ships a support level the generators cannot honour. * * ── SCOPE: what this schema does NOT carry, and why ──────────────────────── * The mobile shell's navigation ("descente par paliers": Applications → * Modules → Sections, plus the transverse bottom bar) is derived ENTIRELY from * the DB navigation tables the socle already serves — no per-page metadata * feeds it. So the contract stays `{ support, offlineCapable }`: nothing here * describes bottom-nav rank, mobile icons, ordering, or grouping. * * If a future shell feature DOES need per-page metadata (a bottom-nav rank, a * mobile icon, a palier label…), this file is the SSOT it starts from, and the * change cascades — in this order — to: * 1. development/frontend/routes/cli/scaffold-routes/types.ts (spec field) * 2. development/frontend/routes/cli/scaffold-routes/generate.ts * (emission into the PageRegistry.register third argument) * 3. business-analyse/create-screen/SKILL.md — the `- **Mobile**` bullet * (how a BA author declares it) * 4. business-analyse/create-prd/SKILL.md — the bullet → `pagespec.pwa` mapping * 5. pwa/cli/derive-pwa-spec — the rollout derivation reading the pagespecs * 6. development/audit-dev-pwa/SKILL.md — rule DEV-PWA-004 (metadata coherence) * Adding a field to only some of those six is how the contract silently forks. * * @see business-analyse/create-screen/SKILL.md (authors the Mobile bullet) * @see business-analyse/create-prd/SKILL.md (maps the bullet → pagespec.pwa) * @see development/frontend/routes (emits PageRegistry mobile metadata) * @see development/frontend/component (offline read/write page behaviour) * @see development/frontend/api-client (outbox spec emission for 'write') * @see development/audit-dev-pwa/SKILL.md (DEV-PWA-001..010) */ import fs from 'node:fs' import path from 'node:path' import { z } from 'zod' /** The socle's `MobileSupport` union, verbatim. */ export const PWA_SUPPORT_LEVELS = ['full', 'adapted', 'desktop-only'] as const export type PwaSupport = (typeof PWA_SUPPORT_LEVELS)[number] export const PwaSupportSchema = z.enum(PWA_SUPPORT_LEVELS) /** Normalised offline level. 'write' implies 'read' (the optimistic overlay * folds over SW-cached reads — write-without-read is incoherent). */ export type OfflineLevel = 'none' | 'read' | 'write' /** * Authoring-side input: booleans are accepted for ergonomy/back-compat * (`true` was the read-only shape of early drafts) and normalised. */ export const OfflineInputSchema = z.union([z.boolean(), z.enum(['read', 'write'])]) export type OfflineInput = z.infer export function normalizeOffline(value: OfflineInput | undefined): OfflineLevel { if (value === undefined || value === false) return 'none' if (value === true) return 'read' return value } /** * The per-page `pwa` declaration as it appears in a pagespec machine block and * in the scaffolder specs. `.passthrough()` so a future field (e.g. v2 * `mobileComponent`) survives the pipeline before being formalised here. */ export const PwaMetaSchema = z .object({ support: PwaSupportSchema, offline: OfflineInputSchema.optional(), }) .passthrough() export type PwaMeta = z.infer /** * v1 coherence gate, shared by every consumer's validate.ts. Returns null when * the meta is generatable, or the blocking explanation string. */ export function validatePwaMetaV1(meta: PwaMeta): string | null { const level = normalizeOffline(meta.offline as OfflineInput | undefined) if (meta.support === 'full') { return ( "pwa.support 'full' requires a dedicated .mobile.tsx variant (PageMobileMeta.mobileComponent), " + "which the generators do not emit in v1 — use 'adapted' (responsive desktop page reused on mobile)." ) } if (meta.support === 'desktop-only' && level !== 'none') { return ( "pwa.offline is meaningless on a 'desktop-only' page — the mobile shell never resolves it. " + "Drop the offline flag or raise support to 'adapted'." ) } return null } /** * Map a (validated) PwaMeta onto the socle's PageMobileMeta literal emitted in * a `PageRegistry.register(key, Comp, { mobile: … })` third argument. * Write-ness is a data-layer fact (outbox specs), NOT registry metadata — * `PageMobileMeta` has no write concept and must not be extended by us. */ export function toPageMobileMetaLiteral(meta: PwaMeta): string { const offlineCapable = normalizeOffline(meta.offline as OfflineInput | undefined) !== 'none' return offlineCapable ? `{ support: '${meta.support}', offlineCapable: true }` : `{ support: '${meta.support}' }` } /** * The fenced ```json machine-block regex shared with derive-pwa-spec and * ba-develop's compute-page-diff (historically "kept in sync by convention" — * hoisted here so at least the pwa readers share one definition). */ export const FENCED_JSON_RE = /```json\s*\r?\n([\s\S]*?)\r?\n```/ /** * Count the pagespecs of the BA tree (`//// * pagespecs/*.md`) whose machine block declares a `pwa` field. Feeds * audit-dev-pwa's DEV-PWA-011 zero-scan leg: pagespecs declare PWA intent but * ZERO registry registrations were scanned → the registry layout is not * recognized, and a green verdict computed over nothing is forbidden. * Unreadable/invalid files are simply not counted (this is a signal counter, * not a validator — derive-pwa-spec owns the warnings). */ export function scanPagespecPwaDeclarations(projectPath: string, baRoot = '.smartstack/ba'): number { const root = path.join(projectPath, baRoot) let count = 0 let apps: fs.Dirent[] try { apps = fs.readdirSync(root, { withFileTypes: true }) } catch { return 0 } for (const app of apps) { if (!app.isDirectory() || app.name.startsWith('_') || app.name.startsWith('.')) continue let mods: fs.Dirent[] try { mods = fs.readdirSync(path.join(root, app.name), { withFileTypes: true }) } catch { continue } for (const mod of mods) { if (!mod.isDirectory() || mod.name.startsWith('_') || mod.name.startsWith('.')) continue const pagespecsDir = path.join(root, app.name, mod.name, 'pagespecs') let entries: string[] try { entries = fs.readdirSync(pagespecsDir) } catch { continue } for (const entry of entries) { if (!entry.endsWith('.md')) continue try { const fenced = fs.readFileSync(path.join(pagespecsDir, entry), 'utf8').match(FENCED_JSON_RE) if (!fenced) continue const json = JSON.parse(fenced[1]!) as Record if (json.pwa !== undefined && json.pwa !== null) count++ } catch { /* invalid block — not counted */ } } } } return count }