/** * cli:scaffold-doc — generate.ts * * Deterministic file ops + wiring plan, BIMODAL on the frontend deployment * mode (lib/detector `detectFrontendMode`): * * SOURCE (SmartStack.app monorepo) — i18n locale JSON + docs-manifest.json * upsert, and a wiring plan of in-place TSX edits (i18n config.ts, * DocRoutes.tsx, DocPanelContext.tsx, UserIndexPage.tsx). * * CLIENT (generated project on the @atlashub/smartstack package) — those * files DO NOT EXIST client-side (routing is DB/PageRegistry-driven, i18n * goes through moduleResources). Instead: i18n locale JSON under the KEBAB * namespace, a `src/extensions/Registry.ts` registering the page as * `docs..` (the package's buildExtensionDocRoutes maps it to * /docs/business// inside DocsLayout), docs-manifest.json * created-if-missing + upserted, and an instruction to re-run * aggregate-component-registry. * * A missing wiring target is a HARD ERROR (res.errors) in both modes — the * historical `file: null` silent degradation shipped orphan doc pages on * client projects (written to disk, never routed, never translated). */ import path from 'node:path' import { findSmartStackStructure, detectFrontendMode, SMARTSTACK_WEB_PACKAGE, type FrontendMode, } from '../../../lib/detector.js' import { findFiles, readJson, writeJson, writeText, fileExists, directoryExists } from '../../../lib/fs.js' import { toKebabCase } from '../../../lib/string-utils.js' import { LANGS, type ScaffoldDocInput, type WiringInstruction } from './types.js' export interface GenerateResult { filesCreated: string[] filesModified: string[] warnings: string[] /** Hard failures — non-empty means the run must be reported as FAILED (exit 1). */ errors: string[] wiring: WiringInstruction[] nextSteps: string[] /** Resolved frontend mode this run generated for. */ mode: FrontendMode /** @atlashub/smartstack dependency version (client mode), else null. */ packageVersion: string | null /** * The namespace the page must pass to useTranslation(): the camelCase spec * namespace in source mode, its KEBAB form in client mode (file name = * namespace is the moduleResources channel contract). */ effectiveNamespace: string } /** * First route segments owned by the platform package under /docs/business. * A client `docs.*` key landing on a STATIC_DOC_PATHS entry is skipped by * buildExtensionDocRoutes (the static core route stays authoritative), and the * package's DocPanel docMapping hardcodes these prefixes — so a client doc * under one of them would be unreachable or shadowed. Mirror of the package's * DocRoutes.tsx STATIC_DOC_PATHS first segments (+ 'support', hardcoded in the * DocPanel docMapping). */ const RESERVED_CLIENT_DOC_ROOTS = new Set(['administration', 'myspace', 'hr', 'api', 'support', 'platform']) export async function generate(spec: ScaffoldDocInput): Promise { const res: GenerateResult = { filesCreated: [], filesModified: [], warnings: [], errors: [], wiring: [], nextSteps: [], mode: 'unknown', packageVersion: null, effectiveNamespace: spec.namespace, } const structure = await findSmartStackStructure(spec.projectPath) const web = structure.web if (!web) { res.errors.push('Web project not found under projectPath — cannot write i18n / manifest or wire the doc page.') return res } const modeInfo = await detectFrontendMode(web) const mode: FrontendMode = spec.mode === 'auto' ? modeInfo.mode : spec.mode res.mode = mode res.packageVersion = modeInfo.packageVersion if (spec.mode !== 'auto' && modeInfo.mode !== 'unknown' && spec.mode !== modeInfo.mode) { res.warnings.push( `spec.mode='${spec.mode}' overrides the detected mode '${modeInfo.mode}' (${modeInfo.evidence.join('; ')}).`, ) } if (mode === 'unknown') { res.errors.push( `Cannot determine the frontend deployment mode (source monorepo vs client package): ` + `${modeInfo.evidence.join('; ')}. Pass "mode":"source"|"client" in the spec.`, ) return res } return mode === 'client' ? generateClient(spec, web, res) : generateSource(spec, web, res) } // ───────────────────────────────────────────────────────────────────────── // SOURCE mode — the SmartStack.app monorepo (docs subsystem files in-tree) // ───────────────────────────────────────────────────────────────────────── async function generateSource(spec: ScaffoldDocInput, web: string, res: GenerateResult): Promise { const kebab = toKebabCase(spec.namespace) // docsAdministrationUsers → docs-administration-users const routeFull = `/docs/business/${spec.routePath}` // ── 1. i18n locale files (canonical central location) ──────────────────── await writeI18nFiles(spec, web, kebab, res) // ── 2. docs-manifest.json upsert (idempotent by id) ────────────────────── const manifestAbs = path.join(web, 'src', 'pages', 'docs', 'docs-manifest.json') if (await fileExists(manifestAbs)) { await upsertManifest(spec, manifestAbs, routeFull, spec.namespace) res.filesModified.push(relTo(spec.projectPath, manifestAbs)) } else { res.errors.push( `docs-manifest.json not found at ${relTo(spec.projectPath, manifestAbs)} — expected in the SmartStack.app ` + `source monorepo. If this is a generated client project, re-run with "mode":"client".`, ) } // ── 3. Wiring plan (TSX in-place edits Claude applies with Edit) ───────── res.wiring = await computeSourceWiring(spec, web, kebab, routeFull, res) res.nextSteps = [ spec.i18n ? 'i18n files written. Author the doc page body per templates.md (faithful Mock UI for user type).' : 'Author the doc page body + the 4 i18n locale JSON files (FLAT) per templates.md.', `Apply the ${res.wiring.length} wiring instruction(s) in report.data.wiring with Edit — each has an idempotencyMarker; skip if already present in the file.`, 'Run /ui-components (ui-polish CLI) on the generated page to guarantee zero hardcoded colors.', ] return res } async function computeSourceWiring( spec: ScaffoldDocInput, web: string, kebab: string, routeFull: string, res: GenerateResult, ): Promise { const wiring: WiringInstruction[] = [] const missingTarget = (glob: string): void => { res.errors.push( `Wiring target not found under the web root: ${glob} — expected in the SmartStack.app source monorepo. ` + `If this is a generated client project, re-run with "mode":"client".`, ) } // (a) i18n config.ts — register the namespace (4 insertion points). const configAbs = (await findFirst(web, '**/i18n/config.ts')) ?? (await findFirst(web, '**/i18n/config.tsx')) if (configAbs) { wiring.push({ kind: 'i18n-config', severity: 'required', file: relTo(spec.projectPath, configAbs), action: `Register i18n namespace "${spec.namespace}" — FOUR insertion points in config.ts.`, anchor: `ns: [`, insert: `1) destructured var: add \`${spec.namespace}\` to the \`const [ … ] = await Promise.all([\` list.\n` + `2) import() call (same order): \`import(\`./locales/\${lang}/${kebab}.json\`).catch(() => ({ default: {} })),\`\n` + `3) return object: \`${spec.namespace}: ${spec.namespace}.default || {},\`\n` + `4) ns array: add the string \`'${spec.namespace}'\` inside \`ns: [ … ]\`.`, idempotencyMarker: `'${spec.namespace}'`, }) } else { missingTarget('**/i18n/config.ts') } // (b) DocRoutes.tsx — lazy import + child route under /docs/business. const routerAbs = (await findFirst(web, '**/DocRoutes.tsx')) ?? (await findFirst(web, '**/DynamicRouter.tsx')) if (routerAbs) { wiring.push({ kind: 'doc-routes', severity: 'required', file: relTo(spec.projectPath, routerAbs), action: 'Add the lazy import + child route for the doc page (route group "/docs/business").', anchor: `path="/docs/business"`, insert: `// near the other lazy doc imports:\n` + `const ${spec.componentName} = lazy(() => import('${spec.pageImportPath}'));\n` + `// inside the }> group, as a child:\n` + `}><${spec.componentName} />} />`, idempotencyMarker: spec.componentName, }) } else { missingTarget('**/DocRoutes.tsx') } // (c) DocPanelContext.tsx — docMapping (+ optional appDocMapping default). const panelAbs = await findFirst(web, '**/DocPanelContext.tsx') const keys = spec.docMappings.length > 0 ? spec.docMappings : [spec.routePath] if (panelAbs) { const mappingLines = keys.map((k) => ` '${k}': '${routeFull}',`).join('\n') wiring.push({ kind: 'doc-panel-mapping', severity: 'required', file: relTo(spec.projectPath, panelAbs), action: 'Add the contextual-panel route mappings (and the app default if requested).', anchor: `const docMapping`, insert: `// inside docMapping:\n${mappingLines}` + (spec.appDocDefault && spec.application ? `\n// inside appDocMapping:\n '${spec.application}': '${routeFull}',` : ''), idempotencyMarker: `'${keys[0]}':`, }) } else { missingTarget('**/DocPanelContext.tsx') } // (d) UserIndexPage.tsx — module entry (user type only). if (spec.type === 'user') { const indexAbs = await findFirst(web, '**/UserIndexPage.tsx') if (indexAbs) { const camel = spec.target.replace(/-([a-z0-9])/g, (_, c: string) => c.toUpperCase()) wiring.push({ kind: 'user-index', severity: 'required', file: relTo(spec.projectPath, indexAbs), action: `Add the module entry under the "${spec.application ?? '?'}" application's modules[].`, anchor: `modules: [`, insert: `{ name: t('user.modules.${camel}.name'), icon: FileText, href: '${routeFull}', description: t('user.modules.${camel}.description') },\n` + `// also add user.modules.${camel}.name/.description to the docs i18n (UserIndexPage namespace), and pick a real lucide icon instead of FileText.`, idempotencyMarker: `'${routeFull}'`, }) } else { missingTarget('**/UserIndexPage.tsx') } } return wiring } // ───────────────────────────────────────────────────────────────────────── // CLIENT mode — generated project consuming @atlashub/smartstack // ───────────────────────────────────────────────────────────────────────── async function generateClient(spec: ScaffoldDocInput, web: string, res: GenerateResult): Promise { const kebab = toKebabCase(spec.namespace) res.effectiveNamespace = kebab const routeSegments = spec.routePath.split('/').filter(Boolean) const routePath = routeSegments.join('/') const routeFull = `/docs/business/${routePath}` const docsKey = `docs.${routeSegments.join('.')}` const expectedKebab = `docs-${routeSegments.join('-')}` const expectedImport = `@/pages/docs/business/${routePath}` const extensionsDir = path.join(web, 'src', 'extensions') // ── Blocking guards (collect ALL, write NOTHING when any fails) ────────── if (spec.type !== 'user') { res.errors.push( `Client mode supports ONLY user-type docs: DocRenderer/DocsSearch are not exported by ` + `${SMARTSTACK_WEB_PACKAGE}. Author '${spec.type}' docs in the SmartStack.app source repo.`, ) } if (kebab !== expectedKebab) { res.errors.push( `namespace/routePath invariant violated: toKebabCase(namespace) is '${kebab}' but routePath ` + `'${routePath}' requires '${expectedKebab}'. The kebab namespace, the i18n file name, the registry ` + `module name and the docs.* key path MUST all agree (moduleResources + docKeyToPath contract). ` + `Fix the namespace or the routePath.`, ) } if (routeSegments.length > 0 && RESERVED_CLIENT_DOC_ROOTS.has(routeSegments[0])) { res.errors.push( `routePath '${routePath}' starts with '${routeSegments[0]}' — a platform-owned /docs/business root ` + `(STATIC_DOC_PATHS / DocPanel docMapping in ${SMARTSTACK_WEB_PACKAGE}). The docs.* key would be ` + `skipped or shadowed by the package's static routes. Use the client application's own code as the ` + `first segment.`, ) } if (spec.pageImportPath !== expectedImport) { res.errors.push( `pageImportPath must be '${expectedImport}' (client routes have NO platform|personal segment — the ` + `page folder mirrors the route) — got '${spec.pageImportPath}'.`, ) } if (!(await directoryExists(extensionsDir))) { res.errors.push( `src/extensions/ not found under the web root (${relTo(spec.projectPath, extensionsDir)}) — not a ` + `generated client project? The doc registry and the aggregator both live there.`, ) } const pageBase = path.join(web, 'src', 'pages', 'docs', 'business', ...routeSegments) const pageExists = (await fileExists(`${pageBase}.tsx`)) || (await fileExists(path.join(pageBase, 'index.tsx'))) if (!pageExists && !spec.dryRun) { res.errors.push( `Doc page not found at ${relTo(spec.projectPath, path.join(pageBase, 'index.tsx'))} — author the page ` + `BEFORE running scaffold-doc (the registry would lazy-import a phantom module and ` + `aggregate-component-registry hard-errors on unresolved imports).`, ) } if (res.errors.length > 0) return res // ── 1. i18n locale files — FLAT, kebab file name = the effective namespace ─ await writeI18nFiles(spec, web, kebab, res) // ── 2. Doc-page registry — the package's docs.* PageRegistry seam ──────── const registryAbs = path.join(extensionsDir, `${kebab}Registry.ts`) const registryExisted = await fileExists(registryAbs) if (!spec.dryRun) await writeText(registryAbs, buildClientRegistry(kebab, docsKey, spec, routeFull)) ;(registryExisted ? res.filesModified : res.filesCreated).push(relTo(spec.projectPath, registryAbs)) // ── 3. docs-manifest.json — create if missing, then upsert ─────────────── const manifestAbs = path.join(web, 'src', 'pages', 'docs', 'docs-manifest.json') const manifestExisted = await fileExists(manifestAbs) if (!manifestExisted && !spec.dryRun) { await writeJson(manifestAbs, { modules: [], lastUpdated: new Date().toISOString() }) } // dryRun on a still-missing manifest: nothing to read/upsert — just report it. if (manifestExisted || !spec.dryRun) { await upsertManifest(spec, manifestAbs, routeFull, kebab) } ;(manifestExisted ? res.filesModified : res.filesCreated).push(relTo(spec.projectPath, manifestAbs)) // ── 4. Wiring — run the aggregator + the package-owned panel limitation ── const webForward = web.replace(/\\/g, '/') res.wiring.push({ kind: 'aggregate', severity: 'required', file: relTo(spec.projectPath, path.join(extensionsDir, 'componentRegistry.generated.ts')), action: 'Re-run the component-registry aggregator so the doc registry is imported (routing) and its kebab i18n namespace registered (moduleResources). Run WITHOUT --modules.', anchor: '', insert: `npx --prefer-offline tsx skills/development/frontend/routes/cli/aggregate-component-registry/index.ts --spec '{"projectPath":"${webForward}"}'`, idempotencyMarker: `./${kebab}Registry`, }) res.wiring.push({ kind: 'panel-limitation', severity: 'info', file: null, action: 'Doc-panel contextual mapping is package-owned.', anchor: '', insert: `The doc edge button resolves its target inside ${SMARTSTACK_WEB_PACKAGE} (DocPanelContext docMapping). ` + `Until the installed package derives client mappings from the docs.* PageRegistry keys, the button falls ` + `back to the user-docs root; the page stays reachable at ${routeFull} (direct URL, and "open in new tab" ` + `from the panel). No client-side file can fix this — do NOT edit files under node_modules.`, idempotencyMarker: '', }) res.nextSteps = [ `CLIENT mode: the page MUST call useTranslation('${kebab}') — the KEBAB namespace (file name = namespace ` + `in the moduleResources channel), NOT the camelCase form.`, spec.i18n ? `i18n + ${kebab}Registry.ts written. Execute the 'aggregate' wiring instruction (skip if ` + `componentRegistry.generated.ts already imports './${kebab}Registry' AND the locale bundles predate it).` : `Author the 4 i18n locale JSON files (FLAT) at src/i18n/locales/{lang}/${kebab}.json, then execute the ` + `'aggregate' wiring instruction.`, 'Run /ui-components (ui-polish CLI) on the generated page to guarantee zero hardcoded colors.', ] return res } /** The generated per-doc registry file (client mode). Overwritten on re-run. */ function buildClientRegistry(kebab: string, docsKey: string, spec: ScaffoldDocInput, routeFull: string): string { return `// ============================================================================ // ${kebab}Registry.ts — Doc-page registration (auto-generated by scaffold-doc) // ============================================================================ // // Generated by skills/documentation/cli/scaffold-doc. Do NOT edit by hand — // re-run /documentation to regenerate. // // Registered under the \`docs.\` PageRegistry prefix: the ${SMARTSTACK_WEB_PACKAGE} // package's DocRoutes (buildExtensionDocRoutes) maps '${docsKey}' to the route // ${routeFull} inside DocsLayout. The i18n namespace is the KEBAB module name // ('${kebab}') — registered by moduleResources.generated.ts from // src/i18n/locales//${kebab}.json (re-run aggregate-component-registry). import { PageRegistry, lazyWithRetry } from '${SMARTSTACK_WEB_PACKAGE}'; const ${spec.componentName} = lazyWithRetry(() => import('${spec.pageImportPath}')); PageRegistry.register('${docsKey}', ${spec.componentName}); ` } // ───────────────────────────────────────────────────────────────────────── // Shared helpers // ───────────────────────────────────────────────────────────────────────── /** Write the 4 locale files. FLAT for user; nested under the namespace for DocRenderer types. */ async function writeI18nFiles(spec: ScaffoldDocInput, web: string, kebab: string, res: GenerateResult): Promise { if (!spec.i18n) return for (const lang of LANGS) { const content = spec.i18n[lang] if (!content) continue // user-type docs are FLAT (resolved via t('title')); DocRenderer types // (developer/database/testing, resolved via t('.path')) nest the // content under the namespace key. Author flat content — the CLI wraps it. const payload = spec.type === 'user' ? content : { [spec.namespace]: content } const abs = path.join(web, 'src', 'i18n', 'locales', lang, `${kebab}.json`) const existed = await fileExists(abs) if (!spec.dryRun) await writeJson(abs, payload) ;(existed ? res.filesModified : res.filesCreated).push(relTo(spec.projectPath, abs)) } } interface ManifestEntry { id: string type: string application: string module: string name: string description: string path: string dataFile: string i18nNamespace: string version: string createdAt?: string updatedAt?: string languages: string[] deferredLanguages: string[] } /** Idempotent upsert (by id) of the docs-manifest entry. The manifest must exist (callers report created/modified). */ async function upsertManifest( spec: ScaffoldDocInput, manifestAbs: string, routeFull: string, i18nNamespace: string, ): Promise { const manifest = await readJson<{ modules?: ManifestEntry[]; lastUpdated?: string; [k: string]: unknown }>( manifestAbs, ) if (!Array.isArray(manifest.modules)) manifest.modules = [] const now = new Date().toISOString() const languages = spec.i18n ? Object.keys(spec.i18n) : [...LANGS] const dataFile = `${spec.pageImportPath.replace(/^@\/pages\/docs\//, '')}/index.tsx` const existingIdx = manifest.modules.findIndex((m) => m.id === spec.manifest.id) const entry: ManifestEntry = { id: spec.manifest.id, type: spec.type, application: spec.application ?? '', module: spec.target, name: spec.manifest.name, description: spec.manifest.description, path: routeFull, dataFile, i18nNamespace, version: spec.manifest.version, createdAt: existingIdx >= 0 ? (manifest.modules[existingIdx].createdAt ?? now) : now, updatedAt: now, languages, deferredLanguages: spec.manifest.deferredLanguages, } if (existingIdx >= 0) manifest.modules[existingIdx] = entry else manifest.modules.push(entry) manifest.lastUpdated = now if (!spec.dryRun) await writeJson(manifestAbs, manifest) } async function findFirst(cwd: string, pattern: string): Promise { const files = await findFiles(pattern, { cwd }) return files.length > 0 ? files[0] : null } function relTo(root: string, abs: string): string { return path.relative(root, abs).replace(/\\/g, '/') }