/** * lib/frontend-fixers.ts — Shared mechanical rewrite helpers for *.tsx pages. * * Used by both `ui-polish/apply.ts` (CSS / token rewrites) and * `audit-dev-frontend/apply.ts` (PRD-vs-code rewrites). Keeping the shared * fixers here avoids divergence between the two skills' implementations. * * Each fixer is a pure function: `(source, optsByRule) → string | null`. * Returning null = "couldn't apply safely; mark as skipped". */ // ─── Helpers ────────────────────────────────────────────────────────────── /** * Walk forward from after the opening JSX tag and find the matching `);` of * the surrounding return(...). Returns the absolute offset of the `;` (or * the closing `)` if no `;`). Returns -1 if no balanced close found. */ export function findMatchingReturnClose(text: string): number { let depth = 1; // we already consumed the opening tag let i = 0; while (i < text.length) { const c = text[i]; if (c === '<') { if (text[i + 1] === '/') depth--; else if (text[i + 1] !== '!') depth++; } if (depth === 0) { const close = text.slice(i).match(/>\s*\)\s*;?/); if (!close) return -1; return i + close.index! + close[0].length; } i++; } return -1; } /** Inject an import line after the last existing top-level import. */ export function injectImport(source: string, importLine: string): string { if (source.includes(importLine)) return source; const lastImport = source.match(/^import[\s\S]*?from\s+['"][^'"]+['"];?\s*$/gm); if (lastImport && lastImport.length > 0) { const last = lastImport[lastImport.length - 1]; const insertAt = source.indexOf(last) + last.length; return source.slice(0, insertAt) + `\n${importLine}` + source.slice(insertAt); } return importLine + '\n' + source; } // ─── Fixers ─────────────────────────────────────────────────────────────── /** * Wrap the page body in .... * * - Skips if PageTemplate is already imported and used. * - Inserts the import after the last existing import. * - Heuristic JSX wrap: replace `return ( ` with * `return ( ` and rebalance close. * - Returns null if no JSX return found (page shape too exotic to patch). * * Equivalent to ui-polish's fixR1; extracted so audit-dev-frontend reuses it * for DEV-UI-005. */ export function fixPageTemplateWrap(source: string): string | null { if (!/from\s+['"]@\/components\/ui\/PageTemplate['"]/.test(source)) { source = injectImport(source, `import { PageTemplate } from '@/components/ui/PageTemplate';`); } if (/]/.test(source)) { // Already wraps, just return source as-is (import was inserted if missing). return source; } const returnMatch = source.match(/return\s*\(\s*\n(\s*)(<(?!PageTemplate\b|>|\/)[^>]+>)/); if (!returnMatch) return null; const indent = returnMatch[1]; const bodyOpen = returnMatch[2]; const beforeReturn = source.slice(0, returnMatch.index! + returnMatch[0].length - bodyOpen.length); const afterBodyOpen = source.slice(returnMatch.index! + returnMatch[0].length); const closingParenIdx = findMatchingReturnClose(afterBodyOpen); if (closingParenIdx === -1) return null; const bodyInner = afterBodyOpen.slice(0, closingParenIdx); const rest = afterBodyOpen.slice(closingParenIdx); return ( beforeReturn + `\n${indent} ${bodyOpen}` + bodyInner.replace(/\n/g, '\n ') + `\n${indent}` + rest ); } /** * Wrap the page body in .... * * Same shape as fixPageTemplateWrap but for PermissionGuard. If the page * already wraps in , the guard goes outside (Permission first, * then PageTemplate). Used for DEV-UI-011. */ export function fixPermissionGuardWrap(source: string, permission: string): string | null { if (new RegExp(`PermissionGuard[^>]*permission=\\{?["'\`]${permission.replace(/\./g, '\\.')}["'\`]`).test(source)) { return source; // already wrapped with this exact permission } if (!/from\s+['"]@\/components\/PermissionGuard['"]/.test(source) && !/PermissionGuard.*from\s+['"]@atlashub\/smartstack['"]/.test(source)) { source = injectImport(source, `import { PermissionGuard } from '@/components/PermissionGuard';`); } const returnMatch = source.match(/return\s*\(\s*\n(\s*)(<(?!PermissionGuard\b|>|\/)[^>]+>)/); if (!returnMatch) return null; const indent = returnMatch[1]; const bodyOpen = returnMatch[2]; const beforeReturn = source.slice(0, returnMatch.index! + returnMatch[0].length - bodyOpen.length); const afterBodyOpen = source.slice(returnMatch.index! + returnMatch[0].length); const closingParenIdx = findMatchingReturnClose(afterBodyOpen); if (closingParenIdx === -1) return null; const bodyInner = afterBodyOpen.slice(0, closingParenIdx); const rest = afterBodyOpen.slice(closingParenIdx); return ( beforeReturn + `\n${indent} ${bodyOpen}` + bodyInner.replace(/\n/g, '\n ') + `\n${indent}` + rest ); } /** * Append a `PageRegistry.register(key, lazyWithRetry(() => import(importPath)))` * line to the registry file source, AFTER the last existing register call (or * at the end of the file if none exist). * * Used for DEV-UI-002 in-place fix when an orphan page exists and the * appropriate module registry is identified. Uses `lazyWithRetry` (not bare * `React.lazy`) for parity with scaffold-routes output — survives transient * HMR/CDN chunk-load failures. */ export function appendRegistryEntry(source: string, key: string, importPath: string): string { const registerLine = `PageRegistry.register('${key}', lazyWithRetry(() => import('${importPath}')));`; if (source.includes(registerLine)) return source; const lastRegister = [...source.matchAll(/(?:Page|Component)Registry\.register\s*\([^;]+;\s*$/gm)]; if (lastRegister.length > 0) { const last = lastRegister[lastRegister.length - 1]; const insertAt = last.index! + last[0].length; return source.slice(0, insertAt) + `\n${registerLine}` + source.slice(insertAt); } // No existing register call — append before the last closing brace if any. if (!/import\s*\{[^}]*\b(?:lazy|lazyWithRetry)\b[^}]*\}\s*from/.test(source)) { source = injectImport(source, `import { lazyWithRetry } from '@atlashub/smartstack';`); } if (!/import\s*\{[^}]*\bPageRegistry\b[^}]*\}\s*from/.test(source)) { source = injectImport(source, `import { PageRegistry } from '@atlashub/smartstack';`); } return source.trimEnd() + '\n\n' + registerLine + '\n'; } /** * The canonical no-op `src/i18n/index.ts` of a generated app. * * DUPLICATED from src/lib/i18n-noop-template.ts (the CLI's init/upgrade copy) — * templates/skills cannot import from the CLI's src/. Keep the two byte-for-byte * identical: `ss init --here` hashes this exact content, so a file fixed here * reads as unchanged instead of "modified by user". */ export const I18N_NOOP_TEMPLATE = `/** * Extends the SmartStack i18n instance with this app's namespaces. * * Do NOT create a new i18next instance — the package already provides one * (\`@atlashub/smartstack\` → i18n) wired into react-i18next. A parallel instance * is silently clobbered when the SDK boots its own (its init() replaces the * resource store), so its translations never reach the rendering instance. * * Business-module namespaces (taches, employes, …) are registered automatically * by \`src/extensions/moduleResources.generated.ts\` — emitted by * aggregate-component-registry and imported AFTER the SDK init via * componentRegistry.generated.ts. Do NOT register modules here too. * * Use this file only for app-wide, non-module client resources you add by hand: * * import { addClientResources } from '@atlashub/smartstack'; * import enCommon from './locales/en/common.json'; * import frCommon from './locales/fr/common.json'; * addClientResources('en', { common: enCommon }); * addClientResources('fr', { common: frCommon }); */ export {}; `; /** * Replace a LEGACY parallel-i18next-init file with the no-op seam. Used for * DEV-UI-035: the pre-5.6 scaffold shipped a `src/i18n/index.ts` that calls * `.init()` on the shared i18next singleton, replacing the SDK's resource * store (business pages then render raw i18n keys). * * Only the recognisable legacy shape is rewritten — imports the `i18next` * singleton, wires `initReactI18next`, calls `.init(`, and does NOT already * use the `addClientResources` seam. Anything else returns null (custom logic * needs a manual port to addClientResources). */ export function neutralizeParallelI18nInit(source: string): string | null { const isLegacy = !source.includes('addClientResources') && /from\s+['"]i18next['"]/.test(source) && source.includes('initReactI18next') && source.includes('.init('); return isLegacy ? I18N_NOOP_TEMPLATE : null; }