From 0597812893cdde283befee467de761fc998e1d8e Mon Sep 17 00:00:00 2001 From: Marve10s Date: Fri, 10 Apr 2026 23:08:27 +0300 Subject: [PATCH] feat: add i18n category (i18next, next-intl) Add internationalization as a new TypeScript ecosystem category with two options: i18next (works with all frontends) and next-intl (Next.js only). Schema/types: I18nSchema, I18n type, option-metadata, compatibility rules CLI: prompt, index.ts wiring (4 spots), mcp.ts (6 spots), bts-config, command-handlers, generate-reproducible-command, config-processing Web: constant.ts, preview-config, stack-defaults, stack-url-keys, stack-url-state (3 spots), stack-utils, tech-icons, tech-resource-links Generator: i18n-deps processor, i18n template handler, templates for both i18next (config + locales + React provider) and next-intl (request config + messages) Tests: test-utils defaults, generate-reproducible-command.test.ts, smoke test options.ts and presets.ts Compatibility: next-intl disabled when frontend is not Next.js --- apps/cli/src/constants.ts | 1 + apps/cli/src/helpers/core/command-handlers.ts | 1 + apps/cli/src/index.ts | 4 ++ apps/cli/src/mcp.ts | 7 +- apps/cli/src/prompts/config-prompts.ts | 8 +++ apps/cli/src/prompts/i18n.ts | 42 ++++++++++++ apps/cli/src/utils/bts-config.ts | 2 + apps/cli/src/utils/config-processing.ts | 5 ++ .../utils/generate-reproducible-command.ts | 1 + .../generate-reproducible-command.test.ts | 3 + apps/cli/test/test-utils.ts | 3 + apps/web/public/icon/next-intl.svg | 4 ++ apps/web/src/lib/constant.ts | 27 ++++++++ apps/web/src/lib/preview-config.ts | 1 + apps/web/src/lib/stack-defaults.ts | 2 + apps/web/src/lib/stack-url-keys.ts | 1 + apps/web/src/lib/stack-url-state.ts | 3 + apps/web/src/lib/stack-utils.ts | 2 + apps/web/src/lib/tech-icons.ts | 4 ++ apps/web/src/lib/tech-resource-links.ts | 8 +++ packages/template-generator/src/generator.ts | 2 + .../src/processors/i18n-deps.ts | 64 +++++++++++++++++++ .../src/processors/index.ts | 3 + .../src/template-handlers/i18n.ts | 52 +++++++++++++++ .../src/template-handlers/index.ts | 1 + .../template-generator/src/utils/add-deps.ts | 9 +++ .../web/base/public/locales/en/common.json | 5 ++ .../web/base/public/locales/fr/common.json | 5 ++ .../i18next/web/base/src/i18n/config.ts.hbs | 25 ++++++++ .../web/react/src/i18n/provider.tsx.hbs | 7 ++ .../i18n/next-intl/web/base/messages/en.json | 7 ++ .../i18n/next-intl/web/base/messages/fr.json | 7 ++ .../next-intl/web/base/src/i18n/config.ts.hbs | 4 ++ .../web/base/src/i18n/request.ts.hbs | 10 +++ packages/types/src/compatibility.ts | 23 +++++++ packages/types/src/option-metadata.ts | 8 +++ packages/types/src/schemas.ts | 8 +++ packages/types/src/types.ts | 2 + testing/lib/generate-combos/options.ts | 3 + testing/lib/presets.ts | 1 + 40 files changed, 374 insertions(+), 1 deletion(-) create mode 100644 apps/cli/src/prompts/i18n.ts create mode 100644 apps/web/public/icon/next-intl.svg create mode 100644 packages/template-generator/src/processors/i18n-deps.ts create mode 100644 packages/template-generator/src/template-handlers/i18n.ts create mode 100644 packages/template-generator/templates/i18n/i18next/web/base/public/locales/en/common.json create mode 100644 packages/template-generator/templates/i18n/i18next/web/base/public/locales/fr/common.json create mode 100644 packages/template-generator/templates/i18n/i18next/web/base/src/i18n/config.ts.hbs create mode 100644 packages/template-generator/templates/i18n/i18next/web/react/src/i18n/provider.tsx.hbs create mode 100644 packages/template-generator/templates/i18n/next-intl/web/base/messages/en.json create mode 100644 packages/template-generator/templates/i18n/next-intl/web/base/messages/fr.json create mode 100644 packages/template-generator/templates/i18n/next-intl/web/base/src/i18n/config.ts.hbs create mode 100644 packages/template-generator/templates/i18n/next-intl/web/base/src/i18n/request.ts.hbs diff --git a/apps/cli/src/constants.ts b/apps/cli/src/constants.ts index f4def88b7..2a3322f56 100644 --- a/apps/cli/src/constants.ts +++ b/apps/cli/src/constants.ts @@ -35,6 +35,7 @@ export const DEFAULT_CONFIG_BASE = { realtime: "none", jobQueue: "none", caching: "none", + i18n: "none", search: "none", fileStorage: "none", animation: "none", diff --git a/apps/cli/src/helpers/core/command-handlers.ts b/apps/cli/src/helpers/core/command-handlers.ts index 86fd819ae..bbd2e1d95 100644 --- a/apps/cli/src/helpers/core/command-handlers.ts +++ b/apps/cli/src/helpers/core/command-handlers.ts @@ -163,6 +163,7 @@ export async function createProjectHandler( rustCaching: "none", cms: "none", caching: "none", + i18n: "none", search: "none", featureFlags: "none", analytics: "none", diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 4297362ed..d35753b93 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -79,6 +79,8 @@ import { type CMS, CachingSchema, type Caching, + I18nSchema, + type I18n, SearchSchema, FileStorageSchema, RustWebFrameworkSchema, @@ -191,6 +193,7 @@ export const router = os.router({ analytics: AnalyticsSchema.optional().describe("Privacy-focused analytics"), cms: CMSSchema.optional().describe("Headless CMS solution"), caching: CachingSchema.optional().describe("Caching solution"), + i18n: I18nSchema.optional().describe("Internationalization (i18n) library"), search: SearchSchema.optional().describe("Search engine solution"), fileStorage: FileStorageSchema.optional().describe("File storage solution (S3, R2)"), frontend: z.array(FrontendSchema).optional(), @@ -574,6 +577,7 @@ export async function createVirtual( analytics: options.analytics || "none", cms: options.cms || "none", caching: options.caching || "none", + i18n: options.i18n || "none", search: options.search || "none", fileStorage: options.fileStorage || "none", // Rust ecosystem options diff --git a/apps/cli/src/mcp.ts b/apps/cli/src/mcp.ts index e314a8126..76abaf3b2 100644 --- a/apps/cli/src/mcp.ts +++ b/apps/cli/src/mcp.ts @@ -30,6 +30,7 @@ import { GoLoggingSchema, GoOrmSchema, GoWebFrameworkSchema, + I18nSchema, JobQueueSchema, LoggingSchema, ObservabilitySchema, @@ -172,6 +173,7 @@ const SCHEMA_MAP: Record = { analytics: AnalyticsSchema, cms: CMSSchema, caching: CachingSchema, + i18n: I18nSchema, search: SearchSchema, fileStorage: FileStorageSchema, addons: AddonsSchema, @@ -210,7 +212,7 @@ const ECOSYSTEM_CATEGORIES: Record = { "email", "fileUpload", "effect", "ai", "stateManagement", "forms", "validation", "testing", "cssFramework", "uiLibrary", "realtime", "jobQueue", "animation", "logging", "observability", "featureFlags", "analytics", "cms", "caching", - "search", "fileStorage", "astroIntegration", + "i18n", "search", "fileStorage", "astroIntegration", ], rust: ["rustWebFramework", "rustFrontend", "rustOrm", "rustApi", "rustCli", "rustLibraries", "rustLogging", "rustErrorHandling", "rustCaching"], python: ["pythonWebFramework", "pythonOrm", "pythonValidation", "pythonAi", "pythonAuth", "pythonTaskQueue", "pythonQuality"], @@ -311,6 +313,7 @@ function buildProjectConfig( analytics: "none", cms: (input.cms as ProjectConfig["cms"]) ?? "none", caching: (input.caching as ProjectConfig["caching"]) ?? "none", + i18n: (input.i18n as ProjectConfig["i18n"]) ?? "none", search: (input.search as ProjectConfig["search"]) ?? "none", fileStorage: (input.fileStorage as ProjectConfig["fileStorage"]) ?? "none", addons: (input.addons as ProjectConfig["addons"]) ?? [], @@ -400,6 +403,7 @@ function buildCompatibilityInput(input: Record): CompatibilityI realtime: (input.realtime as string) ?? "none", jobQueue: (input.jobQueue as string) ?? "none", caching: (input.caching as string) ?? "none", + i18n: (input.i18n as string) ?? "none", animation: (input.animation as string) ?? "none", cssFramework: (input.cssFramework as string) ?? "tailwind", uiLibrary: (input.uiLibrary as string) ?? "none", @@ -635,6 +639,7 @@ export async function startMcpServer() { observability: ObservabilitySchema.optional().describe("Observability"), search: SearchSchema.optional().describe("Search engine"), caching: CachingSchema.optional().describe("Caching solution"), + i18n: I18nSchema.optional().describe("Internationalization (i18n) library"), cms: CMSSchema.optional().describe("CMS"), fileStorage: FileStorageSchema.optional().describe("File storage"), fileUpload: FileUploadSchema.optional().describe("File upload"), diff --git a/apps/cli/src/prompts/config-prompts.ts b/apps/cli/src/prompts/config-prompts.ts index 5f25a1412..76599d12b 100644 --- a/apps/cli/src/prompts/config-prompts.ts +++ b/apps/cli/src/prompts/config-prompts.ts @@ -11,6 +11,7 @@ import type { Caching, CMS, CSSFramework, + I18n, Database, DatabaseSetup, Ecosystem, @@ -93,6 +94,7 @@ import { getGoOrmChoice, getGoWebFrameworkChoice, } from "./go-ecosystem"; +import { getI18nChoice } from "./i18n"; import { getinstallChoice } from "./install"; import { getJobQueueChoice } from "./job-queue"; import { getLoggingChoice } from "./logging"; @@ -170,6 +172,7 @@ type PromptGroupResults = { analytics: Analytics; cms: CMS; caching: Caching; + i18n: I18n; search: Search; fileStorage: FileStorage; // Rust ecosystem @@ -412,6 +415,10 @@ export async function gatherConfig( if (results.ecosystem !== "typescript") return Promise.resolve("none" as Caching); return getCachingChoice(flags.caching, results.backend); }, + i18n: ({ results }) => { + if (results.ecosystem !== "typescript") return Promise.resolve("none" as I18n); + return getI18nChoice(flags.i18n, results.frontend); + }, search: ({ results }) => { if (results.ecosystem !== "typescript") return Promise.resolve("none" as Search); return getSearchChoice(flags.search, results.backend); @@ -569,6 +576,7 @@ export async function gatherConfig( analytics: result.analytics, cms: result.cms, caching: result.caching, + i18n: result.i18n, search: result.search, fileStorage: result.fileStorage, // Ecosystem diff --git a/apps/cli/src/prompts/i18n.ts b/apps/cli/src/prompts/i18n.ts new file mode 100644 index 000000000..cf5b10996 --- /dev/null +++ b/apps/cli/src/prompts/i18n.ts @@ -0,0 +1,42 @@ +import type { Frontend, I18n } from "../types"; + +import { exitCancelled } from "../utils/errors"; +import { isCancel, navigableSelect } from "./navigable"; + +export async function getI18nChoice(i18n?: I18n, frontend?: Frontend[]) { + if (i18n !== undefined) return i18n; + + const hasNext = frontend?.includes("next") ?? false; + + const options = [ + { + value: "i18next" as const, + label: "i18next", + hint: "Full-featured i18n framework, works with all frontends", + }, + ...(hasNext + ? [ + { + value: "next-intl" as const, + label: "next-intl", + hint: "Lightweight i18n for Next.js with App Router support", + }, + ] + : []), + { + value: "none" as const, + label: "None", + hint: "No internationalization setup", + }, + ]; + + const response = await navigableSelect({ + message: "Select internationalization (i18n) library", + options, + initialValue: "none", + }); + + if (isCancel(response)) return exitCancelled("Operation cancelled"); + + return response; +} diff --git a/apps/cli/src/utils/bts-config.ts b/apps/cli/src/utils/bts-config.ts index c663643c3..c557ebc44 100644 --- a/apps/cli/src/utils/bts-config.ts +++ b/apps/cli/src/utils/bts-config.ts @@ -47,6 +47,7 @@ export async function writeBtsConfig(projectConfig: ProjectConfig) { analytics: projectConfig.analytics, cms: projectConfig.cms, caching: projectConfig.caching, + i18n: projectConfig.i18n, search: projectConfig.search, fileStorage: projectConfig.fileStorage, rustWebFramework: projectConfig.rustWebFramework, @@ -112,6 +113,7 @@ export async function writeBtsConfig(projectConfig: ProjectConfig) { analytics: btsConfig.analytics, cms: btsConfig.cms, caching: btsConfig.caching, + i18n: btsConfig.i18n, search: btsConfig.search, fileStorage: btsConfig.fileStorage, rustWebFramework: btsConfig.rustWebFramework, diff --git a/apps/cli/src/utils/config-processing.ts b/apps/cli/src/utils/config-processing.ts index dd7a2f5d0..3f0743674 100644 --- a/apps/cli/src/utils/config-processing.ts +++ b/apps/cli/src/utils/config-processing.ts @@ -27,6 +27,7 @@ import type { GoOrm, GoApi, GoWebFramework, + I18n, JobQueue, Logging, Observability, @@ -172,6 +173,10 @@ export function processFlags(options: CLIInput, projectName?: string) { config.caching = options.caching as Caching; } + if (options.i18n !== undefined) { + config.i18n = options.i18n as I18n; + } + if (options.search !== undefined) { config.search = options.search as Search; } diff --git a/apps/cli/src/utils/generate-reproducible-command.ts b/apps/cli/src/utils/generate-reproducible-command.ts index 8334eafcf..eb6520787 100644 --- a/apps/cli/src/utils/generate-reproducible-command.ts +++ b/apps/cli/src/utils/generate-reproducible-command.ts @@ -88,6 +88,7 @@ function getTypeScriptFlags(config: ProjectConfig) { flags.push(`--logging ${config.logging}`); flags.push(`--observability ${config.observability}`); flags.push(`--caching ${config.caching}`); + flags.push(`--i18n ${config.i18n}`); flags.push(`--cms ${config.cms}`); flags.push(`--search ${config.search}`); flags.push(`--file-storage ${config.fileStorage}`); diff --git a/apps/cli/test/generate-reproducible-command.test.ts b/apps/cli/test/generate-reproducible-command.test.ts index 92bde28b3..c38c7bef9 100644 --- a/apps/cli/test/generate-reproducible-command.test.ts +++ b/apps/cli/test/generate-reproducible-command.test.ts @@ -53,6 +53,7 @@ function makeConfig(overrides: Partial = {}): ProjectConfig { analytics: "none", cms: "none", caching: "none", + i18n: "none", search: "none", fileStorage: "none", rustWebFramework: "none", @@ -113,6 +114,7 @@ describe("generateReproducibleCommand", () => { observability: "none", cms: "none", caching: "none", + i18n: "none", search: "none", fileStorage: "none", pythonWebFramework: "django", @@ -178,6 +180,7 @@ describe("generateReproducibleCommand", () => { observability: "none", cms: "none", caching: "none", + i18n: "none", search: "none", fileStorage: "none", pythonWebFramework: "fastapi", diff --git a/apps/cli/test/test-utils.ts b/apps/cli/test/test-utils.ts index 15e5a6255..b4d7e17cd 100644 --- a/apps/cli/test/test-utils.ts +++ b/apps/cli/test/test-utils.ts @@ -35,6 +35,7 @@ import type { Observability, CMS, Caching, + I18n, Search, Ecosystem, AI, @@ -156,6 +157,7 @@ export async function runTRPCTest(config: TestConfig): Promise { "logging", "observability", "caching", + "i18n", "search", "fileStorage", "cms", @@ -205,6 +207,7 @@ export async function runTRPCTest(config: TestConfig): Promise { logging: "none" as Logging, observability: "none" as Observability, caching: "none" as Caching, + i18n: "none" as I18n, search: "none" as Search, fileStorage: "none" as FileStorage, cms: "none" as CMS, diff --git a/apps/web/public/icon/next-intl.svg b/apps/web/public/icon/next-intl.svg new file mode 100644 index 000000000..1d5406e07 --- /dev/null +++ b/apps/web/public/icon/next-intl.svg @@ -0,0 +1,4 @@ + + + intl + diff --git a/apps/web/src/lib/constant.ts b/apps/web/src/lib/constant.ts index aacc43d6e..85e518dc2 100644 --- a/apps/web/src/lib/constant.ts +++ b/apps/web/src/lib/constant.ts @@ -2253,6 +2253,32 @@ export const TECH_OPTIONS: Record< default: true, }, ], + i18n: [ + { + id: "i18next", + name: "i18next", + description: "Full-featured i18n framework with plugins for all major frontends", + icon: "https://cdn.simpleicons.org/i18next/26A69A", + color: "from-teal-500 to-green-600", + default: false, + }, + { + id: "next-intl", + name: "next-intl", + description: "Lightweight internationalization for Next.js with App Router support", + icon: "/icon/next-intl.svg", + color: "from-blue-500 to-indigo-600", + default: false, + }, + { + id: "none", + name: "No i18n", + description: "Skip internationalization setup", + icon: "", + color: "from-gray-400 to-gray-600", + default: true, + }, + ], search: [ { id: "meilisearch", @@ -3241,6 +3267,7 @@ export const ECOSYSTEM_CATEGORIES: Record = { "observability", "featureFlags", "caching", + "i18n", "ai", "cms", "codeQuality", diff --git a/apps/web/src/lib/preview-config.ts b/apps/web/src/lib/preview-config.ts index 8f3194a1f..9f0359dc7 100644 --- a/apps/web/src/lib/preview-config.ts +++ b/apps/web/src/lib/preview-config.ts @@ -95,6 +95,7 @@ export function stackStateToProjectConfig(input: Partial): ProjectCo analytics: stack.analytics as ProjectConfig["analytics"], cms: stack.cms as ProjectConfig["cms"], caching: stack.caching as ProjectConfig["caching"], + i18n: stack.i18n as ProjectConfig["i18n"], search: stack.search as ProjectConfig["search"], fileStorage: stack.fileStorage as ProjectConfig["fileStorage"], rustWebFramework: stack.rustWebFramework as ProjectConfig["rustWebFramework"], diff --git a/apps/web/src/lib/stack-defaults.ts b/apps/web/src/lib/stack-defaults.ts index c835a0821..263c9d05d 100644 --- a/apps/web/src/lib/stack-defaults.ts +++ b/apps/web/src/lib/stack-defaults.ts @@ -27,6 +27,7 @@ export type StackState = { realtime: string; jobQueue: string; caching: string; + i18n: string; animation: string; cssFramework: string; uiLibrary: string; @@ -104,6 +105,7 @@ export const DEFAULT_STACK: StackState = { realtime: "none", jobQueue: "none", caching: "none", + i18n: "none", animation: "none", cssFramework: "tailwind", uiLibrary: "shadcn-ui", diff --git a/apps/web/src/lib/stack-url-keys.ts b/apps/web/src/lib/stack-url-keys.ts index 276ff3f17..6f52c2b10 100644 --- a/apps/web/src/lib/stack-url-keys.ts +++ b/apps/web/src/lib/stack-url-keys.ts @@ -38,6 +38,7 @@ export const stackUrlKeys = { realtime: "rt2", jobQueue: "jq", caching: "cache", + i18n: "i18n", animation: "anim", cms: "cms", search: "srch", diff --git a/apps/web/src/lib/stack-url-state.ts b/apps/web/src/lib/stack-url-state.ts index 01d62bba8..6b882490c 100644 --- a/apps/web/src/lib/stack-url-state.ts +++ b/apps/web/src/lib/stack-url-state.ts @@ -74,6 +74,7 @@ export function loadStackParams( realtime: getString("realtime", DEFAULT_STACK.realtime), jobQueue: getString("jobQueue", DEFAULT_STACK.jobQueue), caching: getString("caching", DEFAULT_STACK.caching), + i18n: getString("i18n", DEFAULT_STACK.i18n), animation: getString("animation", DEFAULT_STACK.animation), cms: getString("cms", DEFAULT_STACK.cms), search: getString("search", DEFAULT_STACK.search), @@ -175,6 +176,7 @@ export function serializeStackParams(basePath: string, stack: StackState): strin addParam("realtime", stack.realtime); addParam("jobQueue", stack.jobQueue); addParam("caching", stack.caching); + addParam("i18n", stack.i18n); addParam("animation", stack.animation); addParam("cms", stack.cms); addParam("search", stack.search); @@ -260,6 +262,7 @@ function searchToStack(search: StackSearchParams | undefined): StackState { realtime: search.rt2 ?? DEFAULT_STACK.realtime, jobQueue: search.jq ?? DEFAULT_STACK.jobQueue, caching: search.cache ?? DEFAULT_STACK.caching, + i18n: search.i18n ?? DEFAULT_STACK.i18n, animation: search.anim ?? DEFAULT_STACK.animation, cms: search.cms ?? DEFAULT_STACK.cms, search: search.srch ?? DEFAULT_STACK.search, diff --git a/apps/web/src/lib/stack-utils.ts b/apps/web/src/lib/stack-utils.ts index cae093b67..3f07cc644 100644 --- a/apps/web/src/lib/stack-utils.ts +++ b/apps/web/src/lib/stack-utils.ts @@ -48,6 +48,7 @@ const TYPESCRIPT_CATEGORY_ORDER: Array = [ "realtime", "jobQueue", "caching", + "i18n", "search", "fileStorage", "animation", @@ -228,6 +229,7 @@ export function generateStackCommand(stack: StackState) { `--realtime ${stack.realtime}`, `--job-queue ${stack.jobQueue}`, `--caching ${stack.caching}`, + `--i18n ${stack.i18n}`, `--search ${stack.search}`, `--file-storage ${stack.fileStorage}`, `--cms ${stack.cms}`, diff --git a/apps/web/src/lib/tech-icons.ts b/apps/web/src/lib/tech-icons.ts index c3d8aaa95..0f37165a4 100644 --- a/apps/web/src/lib/tech-icons.ts +++ b/apps/web/src/lib/tech-icons.ts @@ -280,6 +280,10 @@ export const ICON_REGISTRY: Record = { // ─── Caching ─────────────────────────────────────────────────────────────── "upstash-redis": { type: "si", slug: "upstash", hex: "00E9A3" }, + // ─── i18n ───────────────────────────────────────────────────────────────── + i18next: { type: "si", slug: "i18next", hex: "26A69A" }, + "next-intl": { type: "local", src: "/icon/next-intl.svg" }, + // ─── Search ──────────────────────────────────────────────────────────────── meilisearch: { type: "si", slug: "meilisearch", hex: "FF5CAA" }, typesense: { type: "local", src: "/icon/typesense.png" }, diff --git a/apps/web/src/lib/tech-resource-links.ts b/apps/web/src/lib/tech-resource-links.ts index f562efcc0..8d698fd7a 100644 --- a/apps/web/src/lib/tech-resource-links.ts +++ b/apps/web/src/lib/tech-resource-links.ts @@ -561,6 +561,14 @@ const BASE_LINKS: LinkMap = { docsUrl: "https://upstash.com/docs/redis", githubUrl: "https://github.com/upstash/redis-js", }, + i18next: { + docsUrl: "https://www.i18next.com/", + githubUrl: "https://github.com/i18next/i18next", + }, + "next-intl": { + docsUrl: "https://next-intl.dev/", + githubUrl: "https://github.com/amannn/next-intl", + }, meilisearch: { docsUrl: "https://www.meilisearch.com/docs", githubUrl: "https://github.com/meilisearch/meilisearch", diff --git a/packages/template-generator/src/generator.ts b/packages/template-generator/src/generator.ts index 46ec132da..a2a101d8d 100644 --- a/packages/template-generator/src/generator.ts +++ b/packages/template-generator/src/generator.ts @@ -36,6 +36,7 @@ import { processAnalyticsTemplates, processJobQueueTemplates, processCMSTemplates, + processI18nTemplates, processSearchTemplates, processFileStorageTemplates, processTestingTemplates, @@ -88,6 +89,7 @@ export async function generateVirtualProject(options: GeneratorOptions): Promise await processAnalyticsTemplates(vfs, templates, config); await processJobQueueTemplates(vfs, templates, config); await processCMSTemplates(vfs, templates, config); + await processI18nTemplates(vfs, templates, config); await processSearchTemplates(vfs, templates, config); await processFileStorageTemplates(vfs, templates, config); await processTestingTemplates(vfs, templates, config); diff --git a/packages/template-generator/src/processors/i18n-deps.ts b/packages/template-generator/src/processors/i18n-deps.ts new file mode 100644 index 000000000..1d9ec72f6 --- /dev/null +++ b/packages/template-generator/src/processors/i18n-deps.ts @@ -0,0 +1,64 @@ +import type { ProjectConfig } from "@better-fullstack/types"; + +import type { VirtualFileSystem } from "../core/virtual-fs"; + +import { addPackageDependency, type AvailableDependencies } from "../utils/add-deps"; + +export function processI18nDeps(vfs: VirtualFileSystem, config: ProjectConfig): void { + const { i18n, frontend, backend } = config; + + // Skip if not selected or set to "none" + if (!i18n || i18n === "none") return; + + if (i18n === "next-intl") { + // next-intl goes into the web package only (Next.js frontend) + const webPath = "apps/web/package.json"; + if (vfs.exists(webPath)) { + addPackageDependency({ + vfs, + packagePath: webPath, + dependencies: ["next-intl"], + }); + } + return; + } + + if (i18n === "i18next") { + // i18next goes into the web package for all frontends + const webPath = "apps/web/package.json"; + if (vfs.exists(webPath)) { + const deps: AvailableDependencies[] = [ + "i18next", + "i18next-browser-languagedetector", + "i18next-http-backend", + ]; + + // Add React bindings for React-based frontends + const hasReactWeb = frontend.some((f) => + ["next", "tanstack-router", "react-router", "tanstack-start", "react-vite"].includes(f), + ); + + if (hasReactWeb) { + deps.push("react-i18next"); + } + + addPackageDependency({ + vfs, + packagePath: webPath, + dependencies: deps, + }); + } + + // Also add to server if there is a standalone backend + if (backend !== "none" && backend !== "convex" && backend !== "self") { + const serverPath = "apps/server/package.json"; + if (vfs.exists(serverPath)) { + addPackageDependency({ + vfs, + packagePath: serverPath, + dependencies: ["i18next"], + }); + } + } + } +} diff --git a/packages/template-generator/src/processors/index.ts b/packages/template-generator/src/processors/index.ts index 8d491f098..05fcc89df 100644 --- a/packages/template-generator/src/processors/index.ts +++ b/packages/template-generator/src/processors/index.ts @@ -12,6 +12,7 @@ import { processAuthDeps } from "./auth-deps"; import { processAuthPlugins } from "./auth-plugins"; import { processBackendDeps } from "./backend-deps"; import { processCachingDeps } from "./caching-deps"; +import { processI18nDeps } from "./i18n-deps"; import { processCMSDeps } from "./cms-deps"; import { processCSSAndUILibraryDeps } from "./css-ui-deps"; import { processDatabaseDeps } from "./db-deps"; @@ -72,6 +73,7 @@ export function processDependencies(vfs: VirtualFileSystem, config: ProjectConfi processCSSAndUILibraryDeps(vfs, config); processCMSDeps(vfs, config); processCachingDeps(vfs, config); + processI18nDeps(vfs, config); processSearchDeps(vfs, config); processFileStorageDeps(vfs, config); processTurboConfig(vfs, config); @@ -86,6 +88,7 @@ export { processAuthDeps, processBackendDeps, processCachingDeps, + processI18nDeps, processSearchDeps, processFileStorageDeps, processCMSDeps, diff --git a/packages/template-generator/src/template-handlers/i18n.ts b/packages/template-generator/src/template-handlers/i18n.ts new file mode 100644 index 000000000..ba44aa29f --- /dev/null +++ b/packages/template-generator/src/template-handlers/i18n.ts @@ -0,0 +1,52 @@ +import type { ProjectConfig } from "@better-fullstack/types"; + +import type { VirtualFileSystem } from "../core/virtual-fs"; + +import { type TemplateData, processTemplatesFromPrefix } from "./utils"; + +export async function processI18nTemplates( + vfs: VirtualFileSystem, + templates: TemplateData, + config: ProjectConfig, +): Promise { + if (!config.i18n || config.i18n === "none") return; + + if (config.i18n === "next-intl") { + // next-intl templates go into apps/web (Next.js only) + processTemplatesFromPrefix( + vfs, + templates, + "i18n/next-intl/web/base", + "apps/web", + config, + ); + return; + } + + if (config.i18n === "i18next") { + // i18next: check which frontend families are present + const hasReactWeb = config.frontend.some((f) => + ["next", "tanstack-router", "react-router", "tanstack-start", "react-vite"].includes(f), + ); + + // Shared i18next config + processTemplatesFromPrefix( + vfs, + templates, + "i18n/i18next/web/base", + "apps/web", + config, + ); + + // React-specific bindings + if (hasReactWeb) { + processTemplatesFromPrefix( + vfs, + templates, + "i18n/i18next/web/react", + "apps/web", + config, + ); + } + } +} diff --git a/packages/template-generator/src/template-handlers/index.ts b/packages/template-generator/src/template-handlers/index.ts index 66f3fcab7..fb1930d30 100644 --- a/packages/template-generator/src/template-handlers/index.ts +++ b/packages/template-generator/src/template-handlers/index.ts @@ -21,6 +21,7 @@ export { processFeatureFlagsTemplates } from "./feature-flags"; export { processAnalyticsTemplates } from "./analytics"; export { processJobQueueTemplates } from "./job-queue"; export { processCMSTemplates } from "./cms"; +export { processI18nTemplates } from "./i18n"; export { processSearchTemplates } from "./search"; export { processFileStorageTemplates } from "./file-storage"; export { processTestingTemplates } from "./testing"; diff --git a/packages/template-generator/src/utils/add-deps.ts b/packages/template-generator/src/utils/add-deps.ts index 5c7e4530a..00a003ed3 100644 --- a/packages/template-generator/src/utils/add-deps.ts +++ b/packages/template-generator/src/utils/add-deps.ts @@ -674,6 +674,15 @@ export const dependencyVersionMap = { // Caching - Upstash Redis "@upstash/redis": "^1.37.0", + // i18n - i18next + i18next: "^25.0.1", + "react-i18next": "^15.5.1", + "i18next-browser-languagedetector": "^8.0.4", + "i18next-http-backend": "^3.0.2", + + // i18n - next-intl + "next-intl": "^4.1.0", + // Search - Meilisearch meilisearch: "^0.57.0", diff --git a/packages/template-generator/templates/i18n/i18next/web/base/public/locales/en/common.json b/packages/template-generator/templates/i18n/i18next/web/base/public/locales/en/common.json new file mode 100644 index 000000000..0bdf97e37 --- /dev/null +++ b/packages/template-generator/templates/i18n/i18next/web/base/public/locales/en/common.json @@ -0,0 +1,5 @@ +{ + "welcome": "Welcome to {{projectName}}", + "description": "Get started by editing this page", + "language": "Language" +} diff --git a/packages/template-generator/templates/i18n/i18next/web/base/public/locales/fr/common.json b/packages/template-generator/templates/i18n/i18next/web/base/public/locales/fr/common.json new file mode 100644 index 000000000..7e9e07639 --- /dev/null +++ b/packages/template-generator/templates/i18n/i18next/web/base/public/locales/fr/common.json @@ -0,0 +1,5 @@ +{ + "welcome": "Bienvenue sur {{projectName}}", + "description": "Commencez par modifier cette page", + "language": "Langue" +} diff --git a/packages/template-generator/templates/i18n/i18next/web/base/src/i18n/config.ts.hbs b/packages/template-generator/templates/i18n/i18next/web/base/src/i18n/config.ts.hbs new file mode 100644 index 000000000..598c4f82b --- /dev/null +++ b/packages/template-generator/templates/i18n/i18next/web/base/src/i18n/config.ts.hbs @@ -0,0 +1,25 @@ +import i18n from "i18next"; +import LanguageDetector from "i18next-browser-languagedetector"; +import HttpBackend from "i18next-http-backend"; + +i18n + .use(HttpBackend) + .use(LanguageDetector) + .init({ + fallbackLng: "en", + supportedLngs: ["en", "fr"], + defaultNS: "common", + ns: ["common"], + interpolation: { + escapeValue: false, + }, + backend: { + loadPath: "/locales/{{lng}}/{{ns}}.json", + }, + detection: { + order: ["navigator", "htmlTag"], + caches: ["localStorage"], + }, + }); + +export default i18n; diff --git a/packages/template-generator/templates/i18n/i18next/web/react/src/i18n/provider.tsx.hbs b/packages/template-generator/templates/i18n/i18next/web/react/src/i18n/provider.tsx.hbs new file mode 100644 index 000000000..8709a962b --- /dev/null +++ b/packages/template-generator/templates/i18n/i18next/web/react/src/i18n/provider.tsx.hbs @@ -0,0 +1,7 @@ +import { I18nextProvider } from "react-i18next"; + +import i18n from "./config"; + +export function I18nProvider({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/packages/template-generator/templates/i18n/next-intl/web/base/messages/en.json b/packages/template-generator/templates/i18n/next-intl/web/base/messages/en.json new file mode 100644 index 000000000..4953c9272 --- /dev/null +++ b/packages/template-generator/templates/i18n/next-intl/web/base/messages/en.json @@ -0,0 +1,7 @@ +{ + "common": { + "welcome": "Welcome to {projectName}", + "description": "Get started by editing this page", + "language": "Language" + } +} diff --git a/packages/template-generator/templates/i18n/next-intl/web/base/messages/fr.json b/packages/template-generator/templates/i18n/next-intl/web/base/messages/fr.json new file mode 100644 index 000000000..8f09b8374 --- /dev/null +++ b/packages/template-generator/templates/i18n/next-intl/web/base/messages/fr.json @@ -0,0 +1,7 @@ +{ + "common": { + "welcome": "Bienvenue sur {projectName}", + "description": "Commencez par modifier cette page", + "language": "Langue" + } +} diff --git a/packages/template-generator/templates/i18n/next-intl/web/base/src/i18n/config.ts.hbs b/packages/template-generator/templates/i18n/next-intl/web/base/src/i18n/config.ts.hbs new file mode 100644 index 000000000..4d11a4bd8 --- /dev/null +++ b/packages/template-generator/templates/i18n/next-intl/web/base/src/i18n/config.ts.hbs @@ -0,0 +1,4 @@ +export const locales = ["en", "fr"] as const; +export const defaultLocale = "en" as const; + +export type Locale = (typeof locales)[number]; diff --git a/packages/template-generator/templates/i18n/next-intl/web/base/src/i18n/request.ts.hbs b/packages/template-generator/templates/i18n/next-intl/web/base/src/i18n/request.ts.hbs new file mode 100644 index 000000000..bd5106da9 --- /dev/null +++ b/packages/template-generator/templates/i18n/next-intl/web/base/src/i18n/request.ts.hbs @@ -0,0 +1,10 @@ +import { getRequestConfig } from "next-intl/server"; + +export default getRequestConfig(async () => { + const locale = "en"; + + return { + locale, + messages: (await import(`../../messages/${locale}.json`)).default, + }; +}); diff --git a/packages/types/src/compatibility.ts b/packages/types/src/compatibility.ts index 082b077bd..145f459d7 100644 --- a/packages/types/src/compatibility.ts +++ b/packages/types/src/compatibility.ts @@ -41,6 +41,7 @@ export type CompatibilityCategory = | "realtime" | "jobQueue" | "caching" + | "i18n" | "search" | "fileStorage" | "animation" @@ -126,6 +127,7 @@ export type CompatibilityInput = { cssFramework: string; uiLibrary: string; cms: string; + i18n: string; search: string; fileStorage: string; codeQuality: string[]; @@ -196,6 +198,7 @@ const TYPESCRIPT_CATEGORY_ORDER: CompatibilityCategory[] = [ "realtime", "jobQueue", "caching", + "i18n", "search", "fileStorage", "animation", @@ -338,6 +341,15 @@ export const getCategoryDisplayName = (categoryKey: string): string => { return goCategoryNames[categoryKey]; } + // Custom display names for TypeScript categories + const tsCategoryNames: Record = { + i18n: "Internationalization (i18n)", + }; + + if (tsCategoryNames[categoryKey]) { + return tsCategoryNames[categoryKey]; + } + const result = categoryKey.replace(/([A-Z])/g, " $1"); return result.charAt(0).toUpperCase() + result.slice(1); }; @@ -1938,6 +1950,17 @@ export const getDisabledReason = ( } } + // ============================================ + // I18N RULES + // ============================================ + if (category === "i18n") { + if (optionId === "next-intl") { + if (!currentStack.webFrontend.includes("next")) { + return "next-intl requires Next.js"; + } + } + } + return null; }; diff --git a/packages/types/src/option-metadata.ts b/packages/types/src/option-metadata.ts index 715e577cd..9aef622e1 100644 --- a/packages/types/src/option-metadata.ts +++ b/packages/types/src/option-metadata.ts @@ -8,6 +8,7 @@ import { ASTRO_INTEGRATION_VALUES, AUTH_VALUES, CACHING_VALUES, + I18N_VALUES, CMS_VALUES, CSS_FRAMEWORK_VALUES, DATABASE_SETUP_VALUES, @@ -90,6 +91,7 @@ export type OptionCategory = | "realtime" | "jobQueue" | "caching" + | "i18n" | "search" | "fileStorage" | "animation" @@ -264,6 +266,7 @@ const CATEGORY_VALUE_IDS: Record = { realtime: REALTIME_VALUES, jobQueue: JOB_QUEUE_VALUES, caching: CACHING_VALUES, + i18n: I18N_VALUES, search: SEARCH_VALUES, fileStorage: FILE_STORAGE_VALUES, animation: ANIMATION_VALUES, @@ -402,6 +405,10 @@ const EXACT_LABEL_OVERRIDES: Partial; export type Analytics = z.infer; export type CMS = z.infer; export type Caching = z.infer; +export type I18n = z.infer; export type Search = z.infer; export type FileStorage = z.infer; export type Ecosystem = z.infer; diff --git a/testing/lib/generate-combos/options.ts b/testing/lib/generate-combos/options.ts index 46c7ae116..1d47d78a9 100644 --- a/testing/lib/generate-combos/options.ts +++ b/testing/lib/generate-combos/options.ts @@ -14,6 +14,7 @@ import { AUTH_VALUES, BACKEND_VALUES, CACHING_VALUES, + I18N_VALUES, CMS_VALUES, CSS_FRAMEWORK_VALUES, DATABASE_SETUP_VALUES, @@ -265,6 +266,7 @@ function makeTypeScriptDraft(args: GeneratorArgs): CandidateDraft { uiLibrary, cms: sampleScalar(CMS_VALUES, 0.88), caching: sampleScalar(CACHING_VALUES, 0.88), + i18n: sampleScalar(I18N_VALUES, 0.88), search: sampleScalar(SEARCH_VALUES, 0.9), fileStorage: sampleScalar(FILE_STORAGE_VALUES, 0.84), webDeploy: sampleScalar(WEB_DEPLOY_VALUES, 0.92), @@ -381,6 +383,7 @@ function createValidationBase(projectName: string, draft: CandidateDraft): Proje realtime: "none", jobQueue: "none", caching: "none", + i18n: "none", search: "none", fileStorage: "none", animation: "none", diff --git a/testing/lib/presets.ts b/testing/lib/presets.ts index 07097c7d8..136f3ff22 100644 --- a/testing/lib/presets.ts +++ b/testing/lib/presets.ts @@ -39,6 +39,7 @@ export function makeBaseConfig(name: string, ecosystem: Ecosystem): ProjectConfi uiLibrary: "none", cms: "none", caching: "none", + i18n: "none", search: "none", fileStorage: "none", webDeploy: "none",