UNPKG

everything-dev

Version:

A consolidated product package for building Module Federation apps with oRPC APIs.

1 lines • 11.7 kB
{"version":3,"file":"migrate-i18n-seam.cjs","names":["Effect","fileExists","APP_PATH","parseAppLocale","readText","CATALOGS_PATH","RUNTIME_PATH","writeText","ROOT_PATH","RUNTIME_IMPORT","checkI18nSeam","GateBlocked"],"sources":["../../../../../src/services/scaffold/upgrade-audit/steps/migrate-i18n-seam.ts"],"sourcesContent":["import { Effect } from \"effect\";\nimport {\n APP_PATH,\n CATALOGS_PATH,\n checkI18nSeam,\n parseAppLocale,\n ROOT_PATH,\n RUNTIME_IMPORT,\n RUNTIME_PATH,\n} from \"../checks/i18n-seam\";\nimport { GateBlocked } from \"../domain\";\nimport { fileExists, readText, writeText } from \"../fs\";\nimport type { StepResult } from \"./author-config\";\n\n/**\n * Wires the shared i18n runtime into a v2 child app's core UI.\n *\n * The host's auth plugin consumes shared `@lingui/*` with `import: false` —\n * only the app's own UI (the core-ui provider in the child's federation\n * graph) can provide them. A core UI that never imports\n * `everything-dev/ui/i18n` at runtime ships no `@lingui/*` share-scope\n * entries, and boot fails at SSR composition with RUNTIME-012 (\"The getter\n * for the shared module is not a function … shareKey @lingui/core\").\n *\n * The seam is the upstream canonical one: app-owned `ui/src/i18n/catalogs.ts`\n * (locales + message catalogs) and `ui/src/i18n/runtime.tsx`\n * (`createLocaleRuntime` → `AppI18nProvider`), mounted in the root route.\n * Locale config derives from the app's `appLocale` bootstrap export (the\n * generated stubs read it) so the two stay consistent.\n *\n * Maintenance step for post-upgrade trees:\n * `pnpm i18n-seam [--dry-run]`\n *\n * Enforcing: a run that leaves a composition-fatal seam in place (unwired\n * runtime, unmounted provider) fails with `GateBlocked` — the missing\n * share-scope only surfaces later as RUNTIME-012 at SSR composition, which is\n * past every check that could have named the cause. Dry-run previews the\n * blockers as notes instead of failing.\n */\n\nconst DEFAULT_LOCALE_CONFIG = {\n locales: [\"en\"],\n defaultLocale: \"en\",\n cookieName: \"app_locale\",\n};\n\nconst renderCatalogs = (config: {\n locales: readonly string[];\n defaultLocale: string;\n cookieName: string;\n}): string =>\n [\n 'import type { Messages } from \"@lingui/core\";',\n \"\",\n \"// No copy in this app uses <Trans> message ids yet. The catalog exists so the\",\n \"// shared i18n runtime ships in this bundle's Module Federation share scope —\",\n \"// the host's auth plugin resolves @lingui/* through it (RUNTIME-012 at SSR\",\n \"// composition otherwise). Add message ids here as surfaces adopt <Trans>.\",\n `export const APP_LOCALES = [${config.locales.map((l) => `\"${l}\"`).join(\", \")}] as const;`,\n \"export type AppLocale = (typeof APP_LOCALES)[number];\",\n `export const DEFAULT_APP_LOCALE: AppLocale = \"${config.defaultLocale}\";`,\n `export const APP_LOCALE_COOKIE = \"${config.cookieName}\";`,\n \"\",\n \"const englishMessages = {} satisfies Record<string, string>;\",\n \"\",\n \"const translatedMessages: Record<AppLocale, Partial<Record<string, string>>> = {\",\n \" en: {},\",\n \"};\",\n \"\",\n \"export function getAppMessages(locale: AppLocale): Messages {\",\n \" return { ...englishMessages, ...translatedMessages[locale] };\",\n \"}\",\n \"\",\n ].join(\"\\n\");\n\nconst renderRuntime = (): string =>\n [\n 'import { createLocaleRuntime } from \"everything-dev/ui/i18n\";',\n 'import type { ReactNode } from \"react\";',\n 'import { APP_LOCALES, APP_LOCALE_COOKIE, DEFAULT_APP_LOCALE, getAppMessages } from \"./catalogs\";',\n \"\",\n \"const appRuntime = createLocaleRuntime({\",\n \" locales: APP_LOCALES,\",\n \" defaultLocale: DEFAULT_APP_LOCALE,\",\n \" cookieName: APP_LOCALE_COOKIE,\",\n \"});\",\n \"\",\n \"export function AppI18nProvider({\",\n \" children,\",\n \" preferredLocale,\",\n \"}: {\",\n \" children: ReactNode;\",\n \" preferredLocale?: string | null;\",\n \"}) {\",\n \" return (\",\n \" <appRuntime.LocaleProvider messages={getAppMessages} preferredLocale={preferredLocale}>\",\n \" {children}\",\n \" </appRuntime.LocaleProvider>\",\n \" );\",\n \"}\",\n \"\",\n \"export const useAppLocale = appRuntime.useLocale;\",\n \"\",\n ].join(\"\\n\");\n\n/**\n * Wraps the single child of the root's `<div id=\"root\">` shell with the\n * provider. Child roots carry app-owned chrome, so anything but the known\n * one-child-shell shape is left for manual wiring rather than guessed at.\n */\nconst mountProvider = (source: string): { source: string; mounted: boolean } => {\n const lines = source.split(\"\\n\");\n const openIdx = lines.findIndex((l) => l.trim() === '<div id=\"root\">');\n if (openIdx === -1) return { source, mounted: false };\n\n const openIndent = lines[openIdx].search(/\\S/);\n let closeIdx = -1;\n for (let i = openIdx + 1; i < lines.length; i++) {\n if (lines[i].trim() === \"</div>\") {\n closeIdx = i;\n break;\n }\n }\n if (closeIdx === -1 || closeIdx === openIdx + 1) return { source, mounted: false };\n\n const childLines = lines.slice(openIdx + 1, closeIdx);\n const childIndent = childLines[0]?.search(/\\S/) ?? openIndent + 2;\n if (childIndent <= openIndent) return { source, mounted: false };\n\n const indented = childLines.map((l) => (l.trim() ? ` ${l}` : l));\n const providerOpen = `${\" \".repeat(childIndent)}<AppI18nProvider>`;\n const providerClose = `${\" \".repeat(childIndent)}</AppI18nProvider>`;\n\n const ordered = [\n ...lines.slice(0, openIdx + 1),\n providerOpen,\n ...indented,\n providerClose,\n ...lines.slice(closeIdx),\n ];\n return { source: ordered.join(\"\\n\"), mounted: true };\n};\n\nexport const migrateI18nSeam = (\n projectDir: string,\n dryRun: boolean,\n): Effect.Effect<StepResult, Error> =>\n Effect.gen(function* () {\n const writes: string[] = [];\n const notes: string[] = [];\n\n const appExists = yield* fileExists(projectDir, APP_PATH);\n const config = appExists\n ? (parseAppLocale(yield* readText(projectDir, APP_PATH)) ?? DEFAULT_LOCALE_CONFIG)\n : DEFAULT_LOCALE_CONFIG;\n if (!appExists) {\n notes.push(\n `ui/src/app.ts absent — defaulting the i18n config to ${config.defaultLocale}/${config.cookieName}`,\n );\n }\n\n const runtimeExists = yield* fileExists(projectDir, RUNTIME_PATH);\n if (!runtimeExists) {\n for (const [path, render] of [\n [CATALOGS_PATH, (): string => renderCatalogs(config)],\n [RUNTIME_PATH, renderRuntime],\n ] as const) {\n const exists = yield* fileExists(projectDir, path);\n if (exists) {\n notes.push(`${path} exists — left untouched`);\n continue;\n }\n if (!dryRun) yield* writeText(projectDir, path, render());\n writes.push(path);\n }\n } else {\n notes.push(\"ui/src/i18n/runtime.tsx exists — left untouched\");\n }\n\n const rootExists = yield* fileExists(projectDir, ROOT_PATH);\n if (!rootExists) {\n notes.push(\"ui/src/routes/__root.tsx absent — provider not mounted, inspect manually\");\n } else {\n const rootSource = yield* readText(projectDir, ROOT_PATH);\n if (!rootSource.includes(RUNTIME_IMPORT)) {\n const { source: mounted, mounted: ok } = mountProvider(rootSource);\n if (ok) {\n const withImport = `${`import { AppI18nProvider } from \"${RUNTIME_IMPORT}\";\\n`}${mounted}`;\n if (!dryRun) yield* writeText(projectDir, ROOT_PATH, withImport);\n writes.push(ROOT_PATH);\n notes.push(\n \"AppI18nProvider mounted in the root route — the UI bundle now provides @lingui/* through the share scope\",\n \"optional follow-up: thread the session user's locale as preferredLocale (the canonical upstream root does)\",\n );\n } else {\n notes.push(\n 'root route shell shape not recognized (expected a single child under <div id=\"root\">) — mount AppI18nProvider manually',\n );\n }\n } else {\n notes.push(\"root route already mounts AppI18nProvider\");\n }\n }\n\n // Enforcement tail: a step that leaves a composition-fatal seam in place\n // fails the upgrade instead of returning notes — the share-scope hole only\n // surfaces later as RUNTIME-012 at SSR composition. Dry-run previews the\n // blockers as notes instead of failing.\n const result: StepResult = { writes, deletes: [], notes };\n const findings = yield* checkI18nSeam(projectDir);\n const blockers = findings.filter((f) => f.severity === \"blocker\");\n if (blockers.length > 0) {\n if (dryRun) {\n notes.push(...blockers.map((f) => `[blocker] ${f.message}`));\n return result;\n }\n return yield* new GateBlocked({ findings: blockers });\n }\n return result;\n });\n"],"mappings":"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"}