/**
* 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;
}