diff --git a/TODO.md b/TODO.md index e56a3e9..22f3cf6 100644 --- a/TODO.md +++ b/TODO.md @@ -21,9 +21,9 @@ This file is the single source of truth for roadmap and delivery progress. - [x] [P1] RBAC domain model finalized (roles, permissions, resource scopes) - [x] [P1] RBAC enforcement at route and action level in admin - [x] [P1] Permission matrix documented and tested -- [ ] [P1] i18n baseline architecture (default locale, supported locales, routing strategy) -- [ ] [P1] i18n runtime integration baseline for both apps (locale provider + message loading) -- [ ] [P1] Locale persistence and switcher base component (cookie/header + UI) +- [~] [P1] i18n baseline architecture (default locale, supported locales, routing strategy) +- [~] [P1] i18n runtime integration baseline for both apps (locale provider + message loading) +- [~] [P1] Locale persistence and switcher base component (cookie/header + UI) - [x] [P1] Integrate Better Auth core configuration and session wiring - [x] [P1] Bootstrap first-run owner account creation via initial registration flow - [x] [P1] Enforce invariant: exactly one owner user must always exist @@ -192,6 +192,8 @@ This file is the single source of truth for roadmap and delivery progress. - [2026-02-10] Next.js 16 deprecates `middleware.ts` convention in favor of `proxy.ts`; admin route guard now lives at `apps/admin/src/proxy.ts`. - [2026-02-10] `server-only` imports break Bun CLI scripts; shared auth bootstrap code used by scripts must avoid Next-only runtime markers. - [2026-02-10] Auth delete-account endpoints now block protected users (support + canonical owner); admin user-management delete/demote guards remain to be implemented. +- [2026-02-10] Public app i18n baseline now uses `next-intl` with a Zustand-backed language switcher and path-stable routes; admin i18n runtime is still pending. +- [2026-02-10] Public baseline locales are now `de`, `en`, `es`, `fr`; locale enable/disable policy will move to admin settings later. ## How We Use This File diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 2843708..d0b6311 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -1,7 +1,10 @@ import type { NextConfig } from "next" +import createNextIntlPlugin from "next-intl/plugin" + +const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts") const nextConfig: NextConfig = { - transpilePackages: ["@cms/ui", "@cms/content", "@cms/db"], + transpilePackages: ["@cms/ui", "@cms/content", "@cms/db", "@cms/i18n"], } -export default nextConfig +export default withNextIntl(nextConfig) diff --git a/apps/web/package.json b/apps/web/package.json index e6c1d9a..6eec5e0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -13,10 +13,12 @@ "dependencies": { "@cms/content": "workspace:*", "@cms/db": "workspace:*", + "@cms/i18n": "workspace:*", "@cms/ui": "workspace:*", "@tanstack/react-query": "5.90.20", "@tanstack/react-query-devtools": "5.91.3", "next": "16.1.6", + "next-intl": "4.4.0", "react": "19.2.4", "react-dom": "19.2.4", "zustand": "5.0.11" diff --git a/apps/web/src/app/[locale]/layout.tsx b/apps/web/src/app/[locale]/layout.tsx new file mode 100644 index 0000000..d7bf5c4 --- /dev/null +++ b/apps/web/src/app/[locale]/layout.tsx @@ -0,0 +1,27 @@ +import { notFound } from "next/navigation" +import { hasLocale, NextIntlClientProvider } from "next-intl" +import type { ReactNode } from "react" + +import { routing } from "@/i18n/routing" +import { Providers } from "../providers" + +type LocaleLayoutProps = { + children: ReactNode + params: Promise<{ + locale: string + }> +} + +export default async function LocaleLayout({ children, params }: LocaleLayoutProps) { + const { locale } = await params + + if (!hasLocale(routing.locales, locale)) { + notFound() + } + + return ( + + {children} + + ) +} diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/[locale]/page.tsx similarity index 56% rename from apps/web/src/app/page.tsx rename to apps/web/src/app/[locale]/page.tsx index 9ddd230..ccc84c3 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/[locale]/page.tsx @@ -1,25 +1,29 @@ import { listPosts } from "@cms/db" import { Button } from "@cms/ui/button" +import { getTranslations } from "next-intl/server" + +import { LanguageSwitcher } from "@/components/language-switcher" export const dynamic = "force-dynamic" export default async function HomePage() { - const posts = await listPosts() + const [posts, t] = await Promise.all([listPosts(), getTranslations("Home")]) return (
-

Web App

-

Your Next.js CMS Frontend

-

- This page reads posts through the shared database package. -

+
+

{t("badge")}

+ +
+

{t("title")}

+

{t("description")}

-

Latest posts

- +

{t("latestPosts")}

+
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 46d33cd..b07fc12 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -2,7 +2,6 @@ import type { Metadata } from "next" import type { ReactNode } from "react" import "./globals.css" -import { Providers } from "./providers" export const metadata: Metadata = { title: "CMS Web", @@ -12,9 +11,7 @@ export const metadata: Metadata = { export default function RootLayout({ children }: { children: ReactNode }) { return ( - - {children} - + {children} ) } diff --git a/apps/web/src/components/language-switcher.tsx b/apps/web/src/components/language-switcher.tsx new file mode 100644 index 0000000..555e263 --- /dev/null +++ b/apps/web/src/components/language-switcher.tsx @@ -0,0 +1,50 @@ +"use client" + +import { type AppLocale, localeLabels, locales } from "@cms/i18n" +import { useLocale, useTranslations } from "next-intl" +import { useEffect, useTransition } from "react" + +import { usePathname, useRouter } from "@/i18n/navigation" +import { useLocaleStore } from "@/store/locale" + +export function LanguageSwitcher() { + const t = useTranslations("LanguageSwitcher") + const currentLocale = useLocale() as AppLocale + const pathname = usePathname() + const router = useRouter() + const [isPending, startTransition] = useTransition() + + const locale = useLocaleStore((state) => state.locale) + const setLocale = useLocaleStore((state) => state.setLocale) + + useEffect(() => { + if (locale !== currentLocale) { + setLocale(currentLocale) + } + }, [currentLocale, locale, setLocale]) + + return ( + + ) +} diff --git a/apps/web/src/i18n/navigation.ts b/apps/web/src/i18n/navigation.ts new file mode 100644 index 0000000..fd57f90 --- /dev/null +++ b/apps/web/src/i18n/navigation.ts @@ -0,0 +1,5 @@ +import { createNavigation } from "next-intl/navigation" + +import { routing } from "./routing" + +export const { Link, redirect, usePathname, useRouter, getPathname } = createNavigation(routing) diff --git a/apps/web/src/i18n/request.ts b/apps/web/src/i18n/request.ts new file mode 100644 index 0000000..ff37c18 --- /dev/null +++ b/apps/web/src/i18n/request.ts @@ -0,0 +1,14 @@ +import { hasLocale } from "next-intl" +import { getRequestConfig } from "next-intl/server" + +import { routing } from "./routing" + +export default getRequestConfig(async ({ requestLocale }) => { + const requested = await requestLocale + const locale = hasLocale(routing.locales, requested) ? requested : routing.defaultLocale + + return { + locale, + messages: (await import(`../messages/${locale}.json`)).default, + } +}) diff --git a/apps/web/src/i18n/routing.ts b/apps/web/src/i18n/routing.ts new file mode 100644 index 0000000..86b9234 --- /dev/null +++ b/apps/web/src/i18n/routing.ts @@ -0,0 +1,8 @@ +import { defaultLocale, locales } from "@cms/i18n" +import { defineRouting } from "next-intl/routing" + +export const routing = defineRouting({ + locales: [...locales], + defaultLocale, + localePrefix: "never", +}) diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json new file mode 100644 index 0000000..f7d4473 --- /dev/null +++ b/apps/web/src/messages/de.json @@ -0,0 +1,19 @@ +{ + "Home": { + "badge": "Web-App", + "title": "Dein Next.js CMS Frontend", + "description": "Diese Seite liest Beiträge über das gemeinsame Datenbank-Paket.", + "latestPosts": "Neueste Beiträge", + "explore": "Entdecken", + "noExcerpt": "Kein Auszug" + }, + "LanguageSwitcher": { + "label": "Sprache", + "localeNames": { + "de": "Deutsch", + "en": "Englisch", + "es": "Spanisch", + "fr": "Französisch" + } + } +} diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json new file mode 100644 index 0000000..22b2dfc --- /dev/null +++ b/apps/web/src/messages/en.json @@ -0,0 +1,19 @@ +{ + "Home": { + "badge": "Web App", + "title": "Your Next.js CMS Frontend", + "description": "This page reads posts through the shared database package.", + "latestPosts": "Latest posts", + "explore": "Explore", + "noExcerpt": "No excerpt" + }, + "LanguageSwitcher": { + "label": "Language", + "localeNames": { + "de": "German", + "en": "English", + "es": "Spanish", + "fr": "French" + } + } +} diff --git a/apps/web/src/messages/es.json b/apps/web/src/messages/es.json new file mode 100644 index 0000000..a157de6 --- /dev/null +++ b/apps/web/src/messages/es.json @@ -0,0 +1,19 @@ +{ + "Home": { + "badge": "Aplicación Web", + "title": "Tu Frontend CMS con Next.js", + "description": "Esta página lee publicaciones a través del paquete compartido de base de datos.", + "latestPosts": "Últimas publicaciones", + "explore": "Explorar", + "noExcerpt": "Sin extracto" + }, + "LanguageSwitcher": { + "label": "Idioma", + "localeNames": { + "de": "Alemán", + "en": "Inglés", + "es": "Español", + "fr": "Francés" + } + } +} diff --git a/apps/web/src/messages/fr.json b/apps/web/src/messages/fr.json new file mode 100644 index 0000000..9817dee --- /dev/null +++ b/apps/web/src/messages/fr.json @@ -0,0 +1,19 @@ +{ + "Home": { + "badge": "Application Web", + "title": "Votre Frontend CMS Next.js", + "description": "Cette page lit les publications via le package base de données partagé.", + "latestPosts": "Dernières publications", + "explore": "Explorer", + "noExcerpt": "Aucun extrait" + }, + "LanguageSwitcher": { + "label": "Langue", + "localeNames": { + "de": "Allemand", + "en": "Anglais", + "es": "Espagnol", + "fr": "Français" + } + } +} diff --git a/apps/web/src/proxy.ts b/apps/web/src/proxy.ts new file mode 100644 index 0000000..abc49c7 --- /dev/null +++ b/apps/web/src/proxy.ts @@ -0,0 +1,14 @@ +import type { NextRequest } from "next/server" +import createMiddleware from "next-intl/middleware" + +import { routing } from "@/i18n/routing" + +const handleI18nRouting = createMiddleware(routing) + +export function proxy(request: NextRequest) { + return handleI18nRouting(request) +} + +export const config = { + matcher: ["/((?!api|trpc|_next|_vercel|.*\\..*).*)"], +} diff --git a/apps/web/src/store/locale.test.ts b/apps/web/src/store/locale.test.ts new file mode 100644 index 0000000..70baebb --- /dev/null +++ b/apps/web/src/store/locale.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest" + +import { useLocaleStore } from "./locale" + +describe("web locale store", () => { + it("sets locale", () => { + useLocaleStore.setState({ locale: "en" }) + useLocaleStore.getState().setLocale("de") + + expect(useLocaleStore.getState().locale).toBe("de") + }) +}) diff --git a/apps/web/src/store/locale.ts b/apps/web/src/store/locale.ts new file mode 100644 index 0000000..43a3946 --- /dev/null +++ b/apps/web/src/store/locale.ts @@ -0,0 +1,12 @@ +import { type AppLocale, defaultLocale } from "@cms/i18n" +import { create } from "zustand" + +type LocaleStore = { + locale: AppLocale + setLocale: (value: AppLocale) => void +} + +export const useLocaleStore = create((set) => ({ + locale: defaultLocale, + setLocale: (value) => set({ locale: value }), +})) diff --git a/bun.lock b/bun.lock index ce4df4c..3dfdeef 100644 --- a/bun.lock +++ b/bun.lock @@ -5,23 +5,23 @@ "": { "name": "cms-monorepo", "devDependencies": { - "@biomejs/biome": "latest", - "@commitlint/cli": "latest", - "@commitlint/config-conventional": "latest", - "@playwright/test": "latest", - "@testing-library/jest-dom": "latest", - "@testing-library/react": "latest", - "@testing-library/user-event": "latest", - "@vitejs/plugin-react": "latest", - "@vitest/coverage-istanbul": "latest", - "conventional-changelog-cli": "latest", - "jsdom": "latest", - "msw": "latest", - "turbo": "latest", - "typescript": "latest", - "vite-tsconfig-paths": "latest", - "vitepress": "latest", - "vitest": "latest", + "@biomejs/biome": "2.3.14", + "@commitlint/cli": "20.4.1", + "@commitlint/config-conventional": "20.4.1", + "@playwright/test": "1.58.2", + "@testing-library/jest-dom": "6.9.1", + "@testing-library/react": "16.3.2", + "@testing-library/user-event": "14.6.1", + "@vitejs/plugin-react": "5.1.3", + "@vitest/coverage-istanbul": "4.0.18", + "conventional-changelog-cli": "5.0.0", + "jsdom": "28.0.0", + "msw": "2.12.9", + "turbo": "2.8.3", + "typescript": "5.9.3", + "vite-tsconfig-paths": "6.1.0", + "vitepress": "1.6.4", + "vitest": "4.0.18", }, }, "apps/admin": { @@ -31,25 +31,25 @@ "@cms/content": "workspace:*", "@cms/db": "workspace:*", "@cms/ui": "workspace:*", - "@tanstack/react-form": "latest", - "@tanstack/react-query": "latest", - "@tanstack/react-query-devtools": "latest", - "@tanstack/react-table": "latest", + "@tanstack/react-form": "1.28.0", + "@tanstack/react-query": "5.90.20", + "@tanstack/react-query-devtools": "5.91.3", + "@tanstack/react-table": "8.21.3", "better-auth": "1.4.18", - "next": "latest", - "react": "latest", - "react-dom": "latest", - "zustand": "latest", + "next": "16.1.6", + "react": "19.2.4", + "react-dom": "19.2.4", + "zustand": "5.0.11", }, "devDependencies": { - "@biomejs/biome": "latest", + "@biomejs/biome": "2.3.14", "@cms/config": "workspace:*", - "@tailwindcss/postcss": "latest", - "@types/node": "latest", - "@types/react": "latest", - "@types/react-dom": "latest", - "tailwindcss": "latest", - "typescript": "latest", + "@tailwindcss/postcss": "4.1.18", + "@types/node": "25.2.2", + "@types/react": "19.2.13", + "@types/react-dom": "19.2.3", + "tailwindcss": "4.1.18", + "typescript": "5.9.3", }, }, "apps/web": { @@ -58,23 +58,25 @@ "dependencies": { "@cms/content": "workspace:*", "@cms/db": "workspace:*", + "@cms/i18n": "workspace:*", "@cms/ui": "workspace:*", - "@tanstack/react-query": "latest", - "@tanstack/react-query-devtools": "latest", - "next": "latest", - "react": "latest", - "react-dom": "latest", - "zustand": "latest", + "@tanstack/react-query": "5.90.20", + "@tanstack/react-query-devtools": "5.91.3", + "next": "16.1.6", + "next-intl": "4.4.0", + "react": "19.2.4", + "react-dom": "19.2.4", + "zustand": "5.0.11", }, "devDependencies": { - "@biomejs/biome": "latest", + "@biomejs/biome": "2.3.14", "@cms/config": "workspace:*", - "@tailwindcss/postcss": "latest", - "@types/node": "latest", - "@types/react": "latest", - "@types/react-dom": "latest", - "tailwindcss": "latest", - "typescript": "latest", + "@tailwindcss/postcss": "4.1.18", + "@types/node": "25.2.2", + "@types/react": "19.2.13", + "@types/react-dom": "19.2.3", + "tailwindcss": "4.1.18", + "typescript": "5.9.3", }, }, "packages/config": { @@ -85,12 +87,12 @@ "name": "@cms/content", "version": "0.0.1", "dependencies": { - "zod": "latest", + "zod": "4.3.6", }, "devDependencies": { - "@biomejs/biome": "latest", + "@biomejs/biome": "2.3.14", "@cms/config": "workspace:*", - "typescript": "latest", + "typescript": "5.9.3", }, }, "packages/db": { @@ -98,39 +100,47 @@ "version": "0.0.1", "dependencies": { "@cms/content": "workspace:*", - "@prisma/adapter-pg": "latest", - "@prisma/client": "latest", - "pg": "latest", - "zod": "latest", + "@prisma/adapter-pg": "7.3.0", + "@prisma/client": "7.3.0", + "pg": "8.18.0", + "zod": "4.3.6", }, "devDependencies": { - "@biomejs/biome": "latest", + "@biomejs/biome": "2.3.14", "@cms/config": "workspace:*", - "@types/node": "latest", - "@types/pg": "latest", - "better-auth": "1.4.18", - "prisma": "latest", - "typescript": "latest", + "@types/node": "25.2.2", + "@types/pg": "8.16.0", + "prisma": "7.3.0", + "typescript": "5.9.3", + }, + }, + "packages/i18n": { + "name": "@cms/i18n", + "version": "0.0.1", + "devDependencies": { + "@biomejs/biome": "2.3.14", + "@cms/config": "workspace:*", + "typescript": "5.9.3", }, }, "packages/ui": { "name": "@cms/ui", "version": "0.0.1", "dependencies": { - "class-variance-authority": "latest", - "clsx": "latest", - "tailwind-merge": "latest", + "class-variance-authority": "0.7.1", + "clsx": "2.1.1", + "tailwind-merge": "3.4.0", }, "devDependencies": { - "@biomejs/biome": "latest", + "@biomejs/biome": "2.3.14", "@cms/config": "workspace:*", - "@types/react": "latest", - "@types/react-dom": "latest", - "typescript": "latest", + "@types/react": "19.2.13", + "@types/react-dom": "19.2.3", + "typescript": "5.9.3", }, "peerDependencies": { - "react": "latest", - "react-dom": "latest", + "react": "19.2.4", + "react-dom": "19.2.4", }, }, }, @@ -265,6 +275,8 @@ "@cms/db": ["@cms/db@workspace:packages/db"], + "@cms/i18n": ["@cms/i18n@workspace:packages/i18n"], + "@cms/ui": ["@cms/ui@workspace:packages/ui"], "@cms/web": ["@cms/web@workspace:apps/web"], @@ -385,6 +397,16 @@ "@exodus/bytes": ["@exodus/bytes@1.12.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-BuCOHA/EJdPN0qQ5MdgAiJSt9fYDHbghlgrj33gRdy/Yp1/FMCDhU6vJfcKrLC0TPWGSrfH3vYXBQWmFHxlddw=="], + "@formatjs/ecma402-abstract": ["@formatjs/ecma402-abstract@3.1.1", "", { "dependencies": { "@formatjs/fast-memoize": "3.1.0", "@formatjs/intl-localematcher": "0.8.1", "decimal.js": "^10.6.0", "tslib": "^2.8.1" } }, "sha512-jhZbTwda+2tcNrs4kKvxrPLPjx8QsBCLCUgrrJ/S+G9YrGHWLhAyFMMBHJBnBoOwuLHd7L14FgYudviKaxkO2Q=="], + + "@formatjs/fast-memoize": ["@formatjs/fast-memoize@3.1.0", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-b5mvSWCI+XVKiz5WhnBCY3RJ4ZwfjAidU0yVlKa3d3MSgKmH1hC3tBGEAtYyN5mqL7N0G5x0BOUYyO8CEupWgg=="], + + "@formatjs/icu-messageformat-parser": ["@formatjs/icu-messageformat-parser@3.5.1", "", { "dependencies": { "@formatjs/ecma402-abstract": "3.1.1", "@formatjs/icu-skeleton-parser": "2.1.1", "tslib": "^2.8.1" } }, "sha512-sSDmSvmmoVQ92XqWb499KrIhv/vLisJU8ITFrx7T7NZHUmMY7EL9xgRowAosaljhqnj/5iufG24QrdzB6X3ItA=="], + + "@formatjs/icu-skeleton-parser": ["@formatjs/icu-skeleton-parser@2.1.1", "", { "dependencies": { "@formatjs/ecma402-abstract": "3.1.1", "tslib": "^2.8.1" } }, "sha512-PSFABlcNefjI6yyk8f7nyX1DC7NHmq6WaCHZLySEXBrXuLOB2f935YsnzuPjlz+ibhb9yWTdPeVX1OVcj24w2Q=="], + + "@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.5.10", "", { "dependencies": { "tslib": "2" } }, "sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q=="], + "@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="], "@hutson/parse-repository-url": ["@hutson/parse-repository-url@5.0.0", "", {}, "sha512-e5+YUKENATs1JgYHMzTr2MW/NDcXGfYFAuOQU8gJgF/kEh4EqKgfGrfLI67bMD4tbhZVlkigz/9YYwWcbOFthg=="], @@ -577,6 +599,8 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA=="], + "@schummar/icu-type-parser": ["@schummar/icu-type-parser@1.21.5", "", {}, "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw=="], + "@shikijs/core": ["@shikijs/core@2.5.0", "", { "dependencies": { "@shikijs/engine-javascript": "2.5.0", "@shikijs/engine-oniguruma": "2.5.0", "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.4" } }, "sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg=="], "@shikijs/engine-javascript": ["@shikijs/engine-javascript@2.5.0", "", { "dependencies": { "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^3.1.0" } }, "sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w=="], @@ -1015,6 +1039,8 @@ "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "icu-minify": ["icu-minify@4.8.2", "", { "dependencies": { "@formatjs/icu-messageformat-parser": "^3.4.0" } }, "sha512-LHBQV+skKkjZSPd590pZ7ZAHftUgda3eFjeuNwA8/15L8T8loCNBktKQyTlkodAU86KovFXeg/9WntlAo5wA5A=="], + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], @@ -1025,6 +1051,8 @@ "ini": ["ini@4.1.1", "", {}, "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g=="], + "intl-messageformat": ["intl-messageformat@11.1.2", "", { "dependencies": { "@formatjs/ecma402-abstract": "3.1.1", "@formatjs/fast-memoize": "3.1.0", "@formatjs/icu-messageformat-parser": "3.5.1", "tslib": "^2.8.1" } }, "sha512-ucSrQmZGAxfiBHfBRXW/k7UC8MaGFlEj4Ry1tKiDcmgwQm1y3EDl40u+4VNHYomxJQMJi9NEI3riDRlth96jKg=="], + "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], @@ -1167,10 +1195,14 @@ "nanostores": ["nanostores@1.1.0", "", {}, "sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA=="], + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], "next": ["next@16.1.6", "", { "dependencies": { "@next/env": "16.1.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.6", "@next/swc-darwin-x64": "16.1.6", "@next/swc-linux-arm64-gnu": "16.1.6", "@next/swc-linux-arm64-musl": "16.1.6", "@next/swc-linux-x64-gnu": "16.1.6", "@next/swc-linux-x64-musl": "16.1.6", "@next/swc-win32-arm64-msvc": "16.1.6", "@next/swc-win32-x64-msvc": "16.1.6", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw=="], + "next-intl": ["next-intl@4.4.0", "", { "dependencies": { "@formatjs/intl-localematcher": "^0.5.4", "negotiator": "^1.0.0", "use-intl": "^4.4.0" }, "peerDependencies": { "next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0", "typescript": "^5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-QHqnP9V9Pe7Tn0PdVQ7u1Z8k9yCkW5SJKeRy2g5gxzhSt/C01y3B9qNxuj3Fsmup/yreIHe6osxU6sFa+9WIkQ=="], + "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], @@ -1443,6 +1475,8 @@ "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + "use-intl": ["use-intl@4.8.2", "", { "dependencies": { "@formatjs/fast-memoize": "^3.1.0", "@schummar/icu-type-parser": "1.21.5", "icu-minify": "^4.8.2", "intl-messageformat": "^11.1.0" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" } }, "sha512-3VNXZgDnPFqhIYosQ9W1Hc6K5q+ZelMfawNbexdwL/dY7BTHbceLUBX5Eeex9lgogxTp0pf1SjHuhYNAjr9H3g=="], + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], "valibot": ["valibot@1.2.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg=="], @@ -1509,6 +1543,8 @@ "@conventional-changelog/git-client/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@formatjs/ecma402-abstract/@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.8.1", "", { "dependencies": { "@formatjs/fast-memoize": "3.1.0", "tslib": "^2.8.1" } }, "sha512-xwEuwQFdtSq1UKtQnyTZWC+eHdv7Uygoa+H2k/9uzBVQjDyp9r20LNDNKedWXll7FssT3GRHvqsdJGYSUWqYFA=="], + "@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], "@prisma/engines/@prisma/get-platform": ["@prisma/get-platform@7.3.0", "", { "dependencies": { "@prisma/debug": "7.3.0" } }, "sha512-N7c6m4/I0Q6JYmWKP2RCD/sM9eWiyCPY98g5c0uEktObNSZnugW2U/PO+pwL0UaqzxqTXt7gTsYsb0FnMnJNbg=="], diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index c2115b5..821079a 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -20,6 +20,7 @@ export default defineConfig({ { text: "Getting Started", link: "/getting-started" }, { text: "Architecture", link: "/architecture" }, { text: "Better Auth Baseline", link: "/product-engineering/auth-baseline" }, + { text: "i18n Baseline", link: "/product-engineering/i18n-baseline" }, { text: "RBAC And Permissions", link: "/product-engineering/rbac-permission-model" }, { text: "Workflow", link: "/workflow" }, ], diff --git a/docs/architecture.md b/docs/architecture.md index de61661..2f3e00c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -7,6 +7,7 @@ - `packages/db`: prisma + data access - `packages/content`: shared schemas and domain contracts - `packages/ui`: shared UI layer +- `packages/i18n`: shared locale definitions and i18n helpers - `packages/config`: shared TS config ## Design Principles @@ -14,6 +15,7 @@ - Shared contracts before feature implementation - RBAC and CRUD base as prerequisites for MVP1 feature work - Keep admin and public responsibilities clearly separated +- Public routing is path-stable; locale is resolved via `next-intl` middleware + cookie ## Pending Documentation diff --git a/docs/getting-started.md b/docs/getting-started.md index 866ca3f..477616d 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -39,6 +39,7 @@ bun run dev ``` - Web: `http://localhost:3000` +- Web locale switching: use the language switcher in the page header - Admin: `http://localhost:3001` - Admin welcome (first start): `http://localhost:3001/welcome` - Admin login: `http://localhost:3001/login` diff --git a/docs/product-engineering/i18n-baseline.md b/docs/product-engineering/i18n-baseline.md new file mode 100644 index 0000000..5a12acd --- /dev/null +++ b/docs/product-engineering/i18n-baseline.md @@ -0,0 +1,20 @@ +# i18n Baseline + +## Scope + +MVP0 introduces i18n runtime only for the public app (`@cms/web`) using `next-intl`. + +Current baseline: + +- Shared locale contract in `@cms/i18n` (`de`, `en`, `es`, `fr`; default `en`) +- Path-stable routing (no locale in URL) via `apps/web/src/proxy.ts` +- Message loading through `apps/web/src/i18n/request.ts` +- Locale-aware navigation helpers in `apps/web/src/i18n/navigation.ts` +- Public language switcher component backed by Zustand store + +## Notes + +- Public app locale is resolved through `next-intl` middleware + cookie. +- Enabled locales are currently static in code and will later be managed from admin settings. +- Admin app i18n provider/message loading is still pending. +- Translation key conventions and workflow docs are tracked in `TODO.md`. diff --git a/packages/i18n/package.json b/packages/i18n/package.json new file mode 100644 index 0000000..754a783 --- /dev/null +++ b/packages/i18n/package.json @@ -0,0 +1,19 @@ +{ + "name": "@cms/i18n", + "version": "0.0.1", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "lint": "biome check src", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "devDependencies": { + "@cms/config": "workspace:*", + "@biomejs/biome": "2.3.14", + "typescript": "5.9.3" + } +} diff --git a/packages/i18n/src/index.ts b/packages/i18n/src/index.ts new file mode 100644 index 0000000..cf945fd --- /dev/null +++ b/packages/i18n/src/index.ts @@ -0,0 +1,16 @@ +export const locales = ["de", "en", "es", "fr"] as const + +export type AppLocale = (typeof locales)[number] + +export const defaultLocale: AppLocale = "en" + +export const localeLabels: Record = { + de: "Deutsch", + en: "English", + es: "Español", + fr: "Français", +} + +export function isAppLocale(value: string): value is AppLocale { + return locales.includes(value as AppLocale) +} diff --git a/packages/i18n/tsconfig.json b/packages/i18n/tsconfig.json new file mode 100644 index 0000000..a094fe2 --- /dev/null +++ b/packages/i18n/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@cms/config/tsconfig/base", + "compilerOptions": { + "noEmit": false, + "outDir": "dist" + }, + "include": ["src/**/*.ts"] +}