merge: todo/mvp0-i18n-baseline into dev

This commit is contained in:
2026-02-10 21:21:28 +01:00
25 changed files with 414 additions and 85 deletions

View File

@@ -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 domain model finalized (roles, permissions, resource scopes)
- [x] [P1] RBAC enforcement at route and action level in admin - [x] [P1] RBAC enforcement at route and action level in admin
- [x] [P1] Permission matrix documented and tested - [x] [P1] Permission matrix documented and tested
- [ ] [P1] i18n baseline architecture (default locale, supported locales, routing strategy) - [~] [P1] i18n baseline architecture (default locale, supported locales, routing strategy)
- [ ] [P1] i18n runtime integration baseline for both apps (locale provider + message loading) - [~] [P1] i18n runtime integration baseline for both apps (locale provider + message loading)
- [ ] [P1] Locale persistence and switcher base component (cookie/header + UI) - [~] [P1] Locale persistence and switcher base component (cookie/header + UI)
- [x] [P1] Integrate Better Auth core configuration and session wiring - [x] [P1] Integrate Better Auth core configuration and session wiring
- [x] [P1] Bootstrap first-run owner account creation via initial registration flow - [x] [P1] Bootstrap first-run owner account creation via initial registration flow
- [x] [P1] Enforce invariant: exactly one owner user must always exist - [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] 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] `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] 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 ## How We Use This File

View File

@@ -1,7 +1,10 @@
import type { NextConfig } from "next" import type { NextConfig } from "next"
import createNextIntlPlugin from "next-intl/plugin"
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts")
const nextConfig: NextConfig = { 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)

View File

@@ -13,10 +13,12 @@
"dependencies": { "dependencies": {
"@cms/content": "workspace:*", "@cms/content": "workspace:*",
"@cms/db": "workspace:*", "@cms/db": "workspace:*",
"@cms/i18n": "workspace:*",
"@cms/ui": "workspace:*", "@cms/ui": "workspace:*",
"@tanstack/react-query": "5.90.20", "@tanstack/react-query": "5.90.20",
"@tanstack/react-query-devtools": "5.91.3", "@tanstack/react-query-devtools": "5.91.3",
"next": "16.1.6", "next": "16.1.6",
"next-intl": "4.4.0",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"zustand": "5.0.11" "zustand": "5.0.11"

View File

@@ -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 (
<NextIntlClientProvider locale={locale}>
<Providers>{children}</Providers>
</NextIntlClientProvider>
)
}

View File

@@ -1,25 +1,29 @@
import { listPosts } from "@cms/db" import { listPosts } from "@cms/db"
import { Button } from "@cms/ui/button" import { Button } from "@cms/ui/button"
import { getTranslations } from "next-intl/server"
import { LanguageSwitcher } from "@/components/language-switcher"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
export default async function HomePage() { export default async function HomePage() {
const posts = await listPosts() const [posts, t] = await Promise.all([listPosts(), getTranslations("Home")])
return ( return (
<main className="mx-auto flex min-h-screen w-full max-w-3xl flex-col gap-6 px-6 py-16"> <main className="mx-auto flex min-h-screen w-full max-w-3xl flex-col gap-6 px-6 py-16">
<header className="space-y-3"> <header className="space-y-3">
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">Web App</p> <div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-4xl font-semibold tracking-tight">Your Next.js CMS Frontend</h1> <p className="text-sm uppercase tracking-[0.2em] text-neutral-500">{t("badge")}</p>
<p className="text-neutral-600"> <LanguageSwitcher />
This page reads posts through the shared database package. </div>
</p> <h1 className="text-4xl font-semibold tracking-tight">{t("title")}</h1>
<p className="text-neutral-600">{t("description")}</p>
</header> </header>
<section className="space-y-4 rounded-xl border border-neutral-200 p-6"> <section className="space-y-4 rounded-xl border border-neutral-200 p-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-xl font-medium">Latest posts</h2> <h2 className="text-xl font-medium">{t("latestPosts")}</h2>
<Button variant="secondary">Explore</Button> <Button variant="secondary">{t("explore")}</Button>
</div> </div>
<ul className="space-y-3"> <ul className="space-y-3">
@@ -27,7 +31,7 @@ export default async function HomePage() {
<li key={post.id} className="rounded-lg border border-neutral-200 p-4"> <li key={post.id} className="rounded-lg border border-neutral-200 p-4">
<p className="text-xs uppercase tracking-wide text-neutral-500">{post.status}</p> <p className="text-xs uppercase tracking-wide text-neutral-500">{post.status}</p>
<h3 className="mt-1 text-lg font-medium">{post.title}</h3> <h3 className="mt-1 text-lg font-medium">{post.title}</h3>
<p className="mt-2 text-sm text-neutral-600">{post.excerpt ?? "No excerpt"}</p> <p className="mt-2 text-sm text-neutral-600">{post.excerpt ?? t("noExcerpt")}</p>
</li> </li>
))} ))}
</ul> </ul>

View File

@@ -2,7 +2,6 @@ import type { Metadata } from "next"
import type { ReactNode } from "react" import type { ReactNode } from "react"
import "./globals.css" import "./globals.css"
import { Providers } from "./providers"
export const metadata: Metadata = { export const metadata: Metadata = {
title: "CMS Web", title: "CMS Web",
@@ -12,9 +11,7 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: ReactNode }) { export default function RootLayout({ children }: { children: ReactNode }) {
return ( return (
<html lang="en"> <html lang="en">
<body> <body>{children}</body>
<Providers>{children}</Providers>
</body>
</html> </html>
) )
} }

View File

@@ -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 (
<label className="inline-flex items-center gap-2 text-sm text-neutral-700">
<span>{t("label")}</span>
<select
className="rounded-md border border-neutral-300 bg-white px-2 py-1 text-sm"
value={locale}
disabled={isPending}
onChange={(event) => {
const nextLocale = event.target.value as AppLocale
setLocale(nextLocale)
startTransition(() => {
router.replace(pathname, { locale: nextLocale })
})
}}
>
{locales.map((value) => (
<option key={value} value={value}>
{t(`localeNames.${value}`)} ({localeLabels[value]})
</option>
))}
</select>
</label>
)
}

View File

@@ -0,0 +1,5 @@
import { createNavigation } from "next-intl/navigation"
import { routing } from "./routing"
export const { Link, redirect, usePathname, useRouter, getPathname } = createNavigation(routing)

View File

@@ -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,
}
})

View File

@@ -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",
})

View File

@@ -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"
}
}
}

View File

@@ -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"
}
}
}

View File

@@ -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"
}
}
}

View File

@@ -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"
}
}
}

14
apps/web/src/proxy.ts Normal file
View File

@@ -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|.*\\..*).*)"],
}

View File

@@ -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")
})
})

View File

@@ -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<LocaleStore>((set) => ({
locale: defaultLocale,
setLocale: (value) => set({ locale: value }),
}))

170
bun.lock
View File

@@ -5,23 +5,23 @@
"": { "": {
"name": "cms-monorepo", "name": "cms-monorepo",
"devDependencies": { "devDependencies": {
"@biomejs/biome": "latest", "@biomejs/biome": "2.3.14",
"@commitlint/cli": "latest", "@commitlint/cli": "20.4.1",
"@commitlint/config-conventional": "latest", "@commitlint/config-conventional": "20.4.1",
"@playwright/test": "latest", "@playwright/test": "1.58.2",
"@testing-library/jest-dom": "latest", "@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "latest", "@testing-library/react": "16.3.2",
"@testing-library/user-event": "latest", "@testing-library/user-event": "14.6.1",
"@vitejs/plugin-react": "latest", "@vitejs/plugin-react": "5.1.3",
"@vitest/coverage-istanbul": "latest", "@vitest/coverage-istanbul": "4.0.18",
"conventional-changelog-cli": "latest", "conventional-changelog-cli": "5.0.0",
"jsdom": "latest", "jsdom": "28.0.0",
"msw": "latest", "msw": "2.12.9",
"turbo": "latest", "turbo": "2.8.3",
"typescript": "latest", "typescript": "5.9.3",
"vite-tsconfig-paths": "latest", "vite-tsconfig-paths": "6.1.0",
"vitepress": "latest", "vitepress": "1.6.4",
"vitest": "latest", "vitest": "4.0.18",
}, },
}, },
"apps/admin": { "apps/admin": {
@@ -31,25 +31,25 @@
"@cms/content": "workspace:*", "@cms/content": "workspace:*",
"@cms/db": "workspace:*", "@cms/db": "workspace:*",
"@cms/ui": "workspace:*", "@cms/ui": "workspace:*",
"@tanstack/react-form": "latest", "@tanstack/react-form": "1.28.0",
"@tanstack/react-query": "latest", "@tanstack/react-query": "5.90.20",
"@tanstack/react-query-devtools": "latest", "@tanstack/react-query-devtools": "5.91.3",
"@tanstack/react-table": "latest", "@tanstack/react-table": "8.21.3",
"better-auth": "1.4.18", "better-auth": "1.4.18",
"next": "latest", "next": "16.1.6",
"react": "latest", "react": "19.2.4",
"react-dom": "latest", "react-dom": "19.2.4",
"zustand": "latest", "zustand": "5.0.11",
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "latest", "@biomejs/biome": "2.3.14",
"@cms/config": "workspace:*", "@cms/config": "workspace:*",
"@tailwindcss/postcss": "latest", "@tailwindcss/postcss": "4.1.18",
"@types/node": "latest", "@types/node": "25.2.2",
"@types/react": "latest", "@types/react": "19.2.13",
"@types/react-dom": "latest", "@types/react-dom": "19.2.3",
"tailwindcss": "latest", "tailwindcss": "4.1.18",
"typescript": "latest", "typescript": "5.9.3",
}, },
}, },
"apps/web": { "apps/web": {
@@ -58,23 +58,25 @@
"dependencies": { "dependencies": {
"@cms/content": "workspace:*", "@cms/content": "workspace:*",
"@cms/db": "workspace:*", "@cms/db": "workspace:*",
"@cms/i18n": "workspace:*",
"@cms/ui": "workspace:*", "@cms/ui": "workspace:*",
"@tanstack/react-query": "latest", "@tanstack/react-query": "5.90.20",
"@tanstack/react-query-devtools": "latest", "@tanstack/react-query-devtools": "5.91.3",
"next": "latest", "next": "16.1.6",
"react": "latest", "next-intl": "4.4.0",
"react-dom": "latest", "react": "19.2.4",
"zustand": "latest", "react-dom": "19.2.4",
"zustand": "5.0.11",
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "latest", "@biomejs/biome": "2.3.14",
"@cms/config": "workspace:*", "@cms/config": "workspace:*",
"@tailwindcss/postcss": "latest", "@tailwindcss/postcss": "4.1.18",
"@types/node": "latest", "@types/node": "25.2.2",
"@types/react": "latest", "@types/react": "19.2.13",
"@types/react-dom": "latest", "@types/react-dom": "19.2.3",
"tailwindcss": "latest", "tailwindcss": "4.1.18",
"typescript": "latest", "typescript": "5.9.3",
}, },
}, },
"packages/config": { "packages/config": {
@@ -85,12 +87,12 @@
"name": "@cms/content", "name": "@cms/content",
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"zod": "latest", "zod": "4.3.6",
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "latest", "@biomejs/biome": "2.3.14",
"@cms/config": "workspace:*", "@cms/config": "workspace:*",
"typescript": "latest", "typescript": "5.9.3",
}, },
}, },
"packages/db": { "packages/db": {
@@ -98,39 +100,47 @@
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"@cms/content": "workspace:*", "@cms/content": "workspace:*",
"@prisma/adapter-pg": "latest", "@prisma/adapter-pg": "7.3.0",
"@prisma/client": "latest", "@prisma/client": "7.3.0",
"pg": "latest", "pg": "8.18.0",
"zod": "latest", "zod": "4.3.6",
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "latest", "@biomejs/biome": "2.3.14",
"@cms/config": "workspace:*", "@cms/config": "workspace:*",
"@types/node": "latest", "@types/node": "25.2.2",
"@types/pg": "latest", "@types/pg": "8.16.0",
"better-auth": "1.4.18", "prisma": "7.3.0",
"prisma": "latest", "typescript": "5.9.3",
"typescript": "latest", },
},
"packages/i18n": {
"name": "@cms/i18n",
"version": "0.0.1",
"devDependencies": {
"@biomejs/biome": "2.3.14",
"@cms/config": "workspace:*",
"typescript": "5.9.3",
}, },
}, },
"packages/ui": { "packages/ui": {
"name": "@cms/ui", "name": "@cms/ui",
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"class-variance-authority": "latest", "class-variance-authority": "0.7.1",
"clsx": "latest", "clsx": "2.1.1",
"tailwind-merge": "latest", "tailwind-merge": "3.4.0",
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "latest", "@biomejs/biome": "2.3.14",
"@cms/config": "workspace:*", "@cms/config": "workspace:*",
"@types/react": "latest", "@types/react": "19.2.13",
"@types/react-dom": "latest", "@types/react-dom": "19.2.3",
"typescript": "latest", "typescript": "5.9.3",
}, },
"peerDependencies": { "peerDependencies": {
"react": "latest", "react": "19.2.4",
"react-dom": "latest", "react-dom": "19.2.4",
}, },
}, },
}, },
@@ -265,6 +275,8 @@
"@cms/db": ["@cms/db@workspace:packages/db"], "@cms/db": ["@cms/db@workspace:packages/db"],
"@cms/i18n": ["@cms/i18n@workspace:packages/i18n"],
"@cms/ui": ["@cms/ui@workspace:packages/ui"], "@cms/ui": ["@cms/ui@workspace:packages/ui"],
"@cms/web": ["@cms/web@workspace:apps/web"], "@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=="], "@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=="], "@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=="], "@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=="], "@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/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=="], "@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=="], "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-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=="], "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=="], "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-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=="], "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=="], "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=="], "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": ["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-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], "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=="], "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=="], "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=="], "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=="], "@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=="], "@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=="], "@prisma/engines/@prisma/get-platform": ["@prisma/get-platform@7.3.0", "", { "dependencies": { "@prisma/debug": "7.3.0" } }, "sha512-N7c6m4/I0Q6JYmWKP2RCD/sM9eWiyCPY98g5c0uEktObNSZnugW2U/PO+pwL0UaqzxqTXt7gTsYsb0FnMnJNbg=="],

View File

@@ -20,6 +20,7 @@ export default defineConfig({
{ text: "Getting Started", link: "/getting-started" }, { text: "Getting Started", link: "/getting-started" },
{ text: "Architecture", link: "/architecture" }, { text: "Architecture", link: "/architecture" },
{ text: "Better Auth Baseline", link: "/product-engineering/auth-baseline" }, { 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: "RBAC And Permissions", link: "/product-engineering/rbac-permission-model" },
{ text: "Workflow", link: "/workflow" }, { text: "Workflow", link: "/workflow" },
], ],

View File

@@ -7,6 +7,7 @@
- `packages/db`: prisma + data access - `packages/db`: prisma + data access
- `packages/content`: shared schemas and domain contracts - `packages/content`: shared schemas and domain contracts
- `packages/ui`: shared UI layer - `packages/ui`: shared UI layer
- `packages/i18n`: shared locale definitions and i18n helpers
- `packages/config`: shared TS config - `packages/config`: shared TS config
## Design Principles ## Design Principles
@@ -14,6 +15,7 @@
- Shared contracts before feature implementation - Shared contracts before feature implementation
- RBAC and CRUD base as prerequisites for MVP1 feature work - RBAC and CRUD base as prerequisites for MVP1 feature work
- Keep admin and public responsibilities clearly separated - Keep admin and public responsibilities clearly separated
- Public routing is path-stable; locale is resolved via `next-intl` middleware + cookie
## Pending Documentation ## Pending Documentation

View File

@@ -39,6 +39,7 @@ bun run dev
``` ```
- Web: `http://localhost:3000` - Web: `http://localhost:3000`
- Web locale switching: use the language switcher in the page header
- Admin: `http://localhost:3001` - Admin: `http://localhost:3001`
- Admin welcome (first start): `http://localhost:3001/welcome` - Admin welcome (first start): `http://localhost:3001/welcome`
- Admin login: `http://localhost:3001/login` - Admin login: `http://localhost:3001/login`

View File

@@ -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`.

View File

@@ -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"
}
}

View File

@@ -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<AppLocale, string> = {
de: "Deutsch",
en: "English",
es: "Español",
fr: "Français",
}
export function isAppLocale(value: string): value is AppLocale {
return locales.includes(value as AppLocale)
}

View File

@@ -0,0 +1,8 @@
{
"extends": "@cms/config/tsconfig/base",
"compilerOptions": {
"noEmit": false,
"outDir": "dist"
},
"include": ["src/**/*.ts"]
}