/** * cli:scaffold-routes — generate.ts * * Emits PageRegistry registrations matching SmartStack's dynamic routing * contract. The `componentKey` MUST be the dotted form derived from the route * by SmartStack (`/{app}/{module}/{section}` → `{app}.{module}.{section}`) * with optional sub-view suffix (`.create`, `.detail`, `.edit`) for non-list * views. Anything else (kebab keys, missing app prefix, missing section * segment) silently fails — DynamicRouter never resolves the page and the * user sees a perpetual spinner with no error in the console. * * Generated code imports from `@atlashub/smartstack` (npm package), not from * the SmartStack.app source tree. The page paths use the `@/pages/...` alias * configured in the client's `vite.config.ts` / `tsconfig.json`. * * See `GET /api/navigation/menu` for the authoritative key list. */ import type { ScaffoldRoutesInput, GeneratedFile } from './types.js' import { validateComponentKey } from './types.js' import { extensionsModuleId } from '../../../../../lib/app-classification.js' import { pluralize } from '../../../../../lib/string-utils.js' import { toPageMobileMetaLiteral } from '../../../../../lib/pwa-meta.js' import { resolveEditSurface } from '../../../../../lib/edit-surface.js' function kebabToCamel(s: string): string { return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase()) } /** kebab → PascalCase (`module-home` → `ModuleHome`). Used to render the hub * view names as valid TypeScript identifiers in the lazyWithRetry consts — * the legacy `view.charAt(0).toUpperCase() + view.slice(1)` produced * `Module-homePage` which is a parse error. */ function kebabToPascal(s: string): string { const camel = kebabToCamel(s) return camel.charAt(0).toUpperCase() + camel.slice(1) } /** Expand the pagespec `form` alias into the two routes-native views it * means (create + edit), deduplicated — so callers can pass pagespec views * VERBATIM and the `edit()` helper can no longer be silently dropped. */ function normalizeViews(views: readonly string[]): string[] { const out: string[] = [] for (const v of views) { // Legacy 'kanban' token: the board is a viewMode of the LIST page // (?view=kanban), not a navigable view — no componentKey, no route // helper, no page import. The token stays accepted (batch callers pass // historical view sets) but emits NOTHING; validate.ts reports it. if (v === 'kanban') continue for (const expanded of v === 'form' ? ['create', 'edit'] : [v]) { if (!out.includes(expanded)) out.push(expanded) } } return out } export function generate(spec: ScaffoldRoutesInput): GeneratedFile[] { const files: GeneratedFile[] = [] const registrations: string[] = [] const lazyBindings: string[] = [] // Dedup tracker (Bug E, 2026-05-27): both `create` and `edit` views map to // the same `${Entity}FormPage` const, so we must emit the lazyWithRetry // const exactly once per page name — even if both views appear in // `entity.views`. Registrations against different keys still happen for // each view, but they all reference the single shared const. const emittedConsts = new Set() const appLower = spec.appCode.toLowerCase() // App-scoped basename for the two emitted files. Two apps may share a module // code (e.g. rh/configuration + clients/configuration); keying the filenames // by module alone made the second app overwrite the first (BUG A). See // lib/app-classification.extensionsModuleId — scaffold-component consumes the // SAME helper for its `@/extensions/Routes` import so they never drift. const extId = extensionsModuleId(spec.appCode, spec.module) const invalidKeys: string[] = [] for (const entity of spec.entities) { const entityViews = normalizeViews(entity.views) // Sub-resource nesting (Bug D, 2026-05-27): when `parentSection` is set, // every key/path/URL gains an extra segment between the module and the // section. This mirrors how the SmartStack navigation DB stores 4-segment // keys for nested resources (`gaf.referentiels.types-affaire.types-audit`) // — without the parent segment, DynamicRouter couldn't resolve the page. const sectionKey = entity.parentSection ? `${appLower}.${spec.module}.${entity.parentSection}.${entity.section}` : `${appLower}.${spec.module}.${entity.section}` const basePath = entity.parentSection ? `@/pages/${appLower}/${spec.module}/${entity.parentSection}/${entity.section}` : `@/pages/${appLower}/${spec.module}/${entity.section}` // List pages use the plural entity name (BudgetsListPage.tsx) to match // scaffold-component; detail/form stay singular. When the BA passes an // explicit `pluralName` we use it verbatim (covers plurals the shared // helper cannot infer, like `Rues`); otherwise the shared `pluralize()` // fallback applies — the SAME fallback as scaffold-component and // scaffold-api-client, so imports and file names never drift. const pluralName = entity.pluralName ?? pluralize(entity.name) /** * Convert a project-relative file path (e.g. `src/pages/budgeting/budgets/ * BudgetsListPage.tsx`) to the import path expected by Vite's `@` alias * (`@/pages/budgeting/budgets/BudgetsListPage`). Strips the leading `src/` * + the `.tsx` suffix. */ const toImportPath = (relPath: string): string => { const normalised = relPath.replace(/\\/g, '/').replace(/\.tsx$/i, '') return normalised.startsWith('src/') ? `@/${normalised.slice(4)}` : `@/${normalised.replace(/^\.?\//, '')}` } // Unified fiche (lib/edit-surface — same SSOT as scaffold-component): an // entity with BOTH detail and form views (and no `directEdit` opt-out) // edits IN PLACE on its DetailPage, so the `.edit` route mounts the // DetailPage (which opens every section on a `/edit` pathname); the // FormPage keeps serving `.create` only. const editSurface = resolveEditSurface( entityViews.includes('edit') ? [...entityViews, 'form'] : entityViews, entity.directEdit === true ? 'direct' : 'read-first', ) for (const view of entityViews) { // The enum is exhaustive at the schema level; the final fallback covers // future view kinds and uses kebabToPascal so hyphenated hub views // (`module-home` → `ModuleHome`) emit valid TypeScript identifiers. const viewStr = view as string const unifiedEdit = view === 'edit' && editSurface === 'unified' const pageName = view === 'list' ? `${pluralName}ListPage` : view === 'detail' || unifiedEdit ? `${entity.name}DetailPage` : view === 'create' || view === 'edit' ? `${entity.name}FormPage` : `${entity.name}${kebabToPascal(viewStr)}Page` // pageFilePaths override (from pageSpec.filePath) wins over the derived // basePath. This lets the registry import from the project's actual // page location (e.g. `pages/budgeting/...`) without forcing a folder // rename when scaffold-component's default convention (`pages/{module}/...`) // doesn't match the legacy layout. const overridePath = entity.pageFilePaths?.[unifiedEdit ? 'detail' : view] const importPath = overridePath ? toImportPath(overridePath) : `${basePath}/${pageName}` // Update pageName when the override file name differs from the default // (e.g. legacy `BudgetsHomePage.tsx` instead of `BudgetsListPage.tsx`) // so the registry export resolution still finds a named export. const overrideBaseName = overridePath ? overridePath.replace(/\\/g, '/').replace(/^.*\//, '').replace(/\.tsx$/i, '') : pageName const effectiveName = overridePath ? overrideBaseName : pageName // Hub views resolve to special-shaped keys (Bug F, 2026-05-27): // `module-home` → `{app}.{module}` (the module root) // `section-home` → `{app}.{module}.{section}` (the section root — same as `list`) // Without these special cases, `module-home` produced the redundant // 4-segment `{app}.{module}.module-home.module-home` (when the entity // section was also called `module-home`) which DynamicRouter never matches. // `app-home` is left at its default for now — surface a follow-up bug // if it bites. const key = view === 'module-home' ? `${appLower}.${spec.module}` : view === 'list' || view === 'section-home' ? sectionKey : `${sectionKey}.${view}` // Fail fast: invalid componentKey is unrecoverable at runtime (silent // spinner) so we refuse to emit the registry. const validationError = validateComponentKey(key) if (validationError) { invalidKeys.push(validationError) continue } // Wrap each lazyWithRetry() in `.then((m) => ({ default: m.X ?? m.default }))` // so the import works whether the page is exported as `default` or as // a named export matching `pageName`. Generated SmartStack pages use // named exports; bare `lazyWithRetry(() => import(...))` resolves to // `undefined` for those and mounts an empty Suspense fallback — // invisible spinner forever. We also throw at resolution time if // neither export exists, so the failure surfaces as a developer-readable // error instead of a silent spinner. // // `lazyWithRetry` (vs bare `React.lazy`) wraps the dynamic import in an // exponential-backoff retry. Transient `Failed to fetch dynamically // imported module` errors (HMR rebundle window, CDN flap, stale chunk // after deploy) resolve on retry instead of surfacing as a RouteErrorBoundary. // // Bug E (2026-05-27): we emit the const at most once per page name — // both `create` and `edit` views share `${Entity}FormPage`, and TS // refuses to compile a module with duplicate `const` declarations. if (!emittedConsts.has(effectiveName)) { emittedConsts.add(effectiveName) // Explicit `` matches the generic constraint of // `lazyWithRetry>` in // @atlashub/smartstack. The bare `React.ComponentType` defaults to // `ComponentType<{}>`, which fails the constraint and Vite/TS // refuses the assignment (`Argument of type '() => Promise<{ default // … }>' is not assignable to parameter of type … ComponentType`). lazyBindings.push( `const ${effectiveName} = lazyWithRetry(() => import('${importPath}').then((m) => { const mod = m as { default?: React.ComponentType; ${effectiveName}?: React.ComponentType }; const resolved = mod.${effectiveName} ?? mod.default; if (!resolved) { throw new Error("Page '${effectiveName}' at ${importPath} has no default or named export matching its file name."); } return { default: resolved }; }) );` ) } // Mobile/offline metadata (PageMobileMeta) — per-view override wins over // the entity-level meta, which wins over the spec-level fleet default. // No meta anywhere ⇒ 2-arg emission, byte-identical to the legacy output // (the page stays implicitly desktop-only in the socle's mobile shell). // An explicit `desktop-only` IS emitted so the audit can tell // "considered and refused" from "never considered" (DEV-PWA-004). const pwaMeta = entity.pwaByView?.[view] ?? entity.pwa ?? spec.defaultPwa registrations.push( pwaMeta ? `PageRegistry.register('${key}', ${effectiveName}, {\n mobile: ${toPageMobileMetaLiteral(pwaMeta)},\n});` : `PageRegistry.register('${key}', ${effectiveName});`, ) } } if (invalidKeys.length > 0) { throw new Error( `scaffold-routes: refusing to emit ${invalidKeys.length} invalid componentKey(s) — fix the spec first:\n - ${invalidKeys.join('\n - ')}` ) } files.push({ path: `src/extensions/${extId}Registry.ts`, content: `import React from 'react'; import { PageRegistry, lazyWithRetry } from '@atlashub/smartstack'; // ============================================================================ // ${spec.appCode.toUpperCase()}/${spec.module.toUpperCase()} MODULE — Auto-generated route registrations // ============================================================================ // // Generated by skills/development/frontend/routes/cli/scaffold-routes. // Do NOT edit by hand — re-run the CLI to regenerate. // // componentKey contract: dotted form ({app}.{module}.{section}[.{view}]), // must equal the value returned by GET /api/navigation/menu for the matching // route. Mismatch = silent spinner (DynamicRouter cannot resolve the page). // // This file MUST be imported from src/main.tsx BEFORE renders so the // registrations run before the first route resolution. Import example: // import './extensions/${extId}Registry'; ${lazyBindings.join('\n\n')} ${registrations.join('\n')} `, }) // Emit a per-module routes helper alongside the registry. Generated pages // import from this file rather than hardcoding `navigate('/foo/bar/new')`, // so URL paths and registry componentKeys stay in lockstep. Audit // DEV-UI-013 catches any page that bypasses it (hardcoded absolute URL in // navigate()). // // URL convention — must match the Core Navigation Seed which registers // routes as /{app}/{module}/{section} (3-seg). Without the app prefix // the URL helpers don't resolve to the menu entries seeded in DB and // DynamicRouter falls back to misinterpreting (e.g. /budgets/budgets/budgets // gets routed to /budgets/budgets/:id with id="budgets" → "introuvable"): // list → /{app}/{module}/{section} (componentKey = section root) // detail → /{app}/{module}/{section}/${id} (.detail suffix) // edit → /{app}/{module}/{section}/${id}/edit (.edit suffix) // create → /{app}/{module}/{section}/create (.create suffix — NOT /new) // Pages MUST navigate via these helpers — hardcoding `/new` or `./new/edit` // produces a route DynamicRouter cannot resolve (silent spinner). Audit // DEV-UI-013 catches the drift. const sectionEntries = spec.entities .map((entity) => { const views = new Set(normalizeViews(entity.views)) const lines: string[] = [] // Sub-resource URLs mirror the componentKey nesting: a sub-resource // under `types-affaire` reads `/gaf/referentiels/types-affaire/types-audit`, // not the flat `/gaf/referentiels/types-audit`. Without the parent // segment, the Core Navigation Seed has no matching menu entry and the // detail/edit pages 404. const urlBase = entity.parentSection ? `/${appLower}/${spec.module}/${entity.parentSection}/${entity.section}` : `/${appLower}/${spec.module}/${entity.section}` lines.push(` ${kebabToCamel(entity.section)}: {`) lines.push(` list: () => '${urlBase}',`) if (views.has('detail')) lines.push(` detail: (id: string) => \`${urlBase}/\${id}\`,`) if (views.has('edit')) lines.push(` edit: (id: string) => \`${urlBase}/\${id}/edit\`,`) if (views.has('create')) lines.push(` create: () => '${urlBase}/create',`) if (views.has('dashboard')) lines.push(` dashboard: () => '${urlBase}/dashboard',`) // No `kanban` helper: the board rides the LIST route (?view=kanban) — // normalizeViews drops the legacy token before it reaches this set. if (views.has('reconduction')) lines.push(` reconduction: () => '${urlBase}/reconduction',`) lines.push(` },`) return lines.join('\n') }) .join('\n') files.push({ path: `src/extensions/${extId}Routes.ts`, content: `// ============================================================================ // ${spec.appCode.toUpperCase()}/${spec.module.toUpperCase()} MODULE — Auto-generated URL helpers // ============================================================================ // // Single source of truth for URL paths in the ${spec.module} module. Generated // alongside ${extId}Registry.ts by skills/development/frontend/routes/cli/scaffold-routes. // Do NOT edit by hand — re-run the CLI to regenerate. // // Generated pages MUST consume these helpers instead of hardcoding URLs in // navigate() calls. Audit DEV-UI-013 rejects any *Page.tsx that uses an // absolute string literal in navigate(...). // // Usage example: // import { routes } from '@/extensions/${extId}Routes'; // navigate(routes.budgets.detail(item.id)); // navigate(routes.budgets.create()); export const routes = { ${sectionEntries} } as const; `, }) return files }