Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
6eb4972
feat(core): add localization registry and formatting
cursoragent Oct 26, 2026
7b25082
feat(editor): localize viewer and build panel surfaces
cursoragent Oct 26, 2026
abc7dec
chore(i18n): format localization sources
cursoragent Oct 26, 2026
304eb1a
feat(i18n): add catalog maintenance tooling
cursoragent Oct 26, 2026
4de32bd
fix(i18n): format generated catalogs
cursoragent Oct 26, 2026
11e5d61
test(i18n): preserve ungrouped length formatting
cursoragent Oct 26, 2026
18554d8
fix(i18n): preserve formatting and runtime updates
cursoragent Oct 26, 2026
d931282
fix(i18n): preserve locale and numeric fallbacks
cursoragent Oct 26, 2026
c144128
fix(i18n): use source plural rules for fallbacks
cursoragent Oct 26, 2026
2fc1547
test(i18n): isolate English host override
cursoragent Oct 26, 2026
abfdd4a
fix(i18n): keep canonical English in code
cursoragent Oct 26, 2026
9356b30
ci: rerun flaky quality job
cursoragent Oct 26, 2026
2d381ce
fix(i18n): address locale review findings
cursoragent Oct 26, 2026
f91254c
refactor(i18n): adopt Lingui runtime and PO catalogs
cursoragent Oct 26, 2026
74bbe0b
fix(i18n): stabilize catalog validation
cursoragent Oct 26, 2026
c93ff63
test(i18n): assert localized server output
cursoragent Oct 26, 2026
f91ad4c
fix(i18n): honor scoped locales in editor controls
cursoragent Oct 26, 2026
1041515
fix(i18n): make catalog checks deterministic
cursoragent Oct 26, 2026
13e9095
fix(i18n): isolate locale scopes and localized inputs
cursoragent Oct 26, 2026
05bfde2
test(i18n): cover deterministic catalog tooling
cursoragent Oct 26, 2026
17f3547
test(i18n): cover ambiguous localized decimals
cursoragent Oct 26, 2026
0293d90
test(i18n): allow catalog check process to finish
cursoragent Oct 26, 2026
3cad13e
fix(i18n): detect source catalog drift
cursoragent Oct 27, 2026
1dbbd10
Merge main and preserve proxy locale forwarding
cursoragent Oct 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ jobs:
- name: Lint & format check
run: bun run check

- name: Check localization catalogs
run: bun run i18n:check

- name: Validate agent skills and plugin packages
if: needs.changes.outputs.skills == 'true' || github.event_name == 'push'
run: bun run skills:validate
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Public, open-source home of `@pascal-app/{core,viewer,editor,nodes,mcp,cli,ifc-c

## Commands

`bun run ci` is exactly what CI runs (check, skills:validate, check-types, test, build). The git hooks (installed by `bun install`) are fast feedback: Biome on staged files, typecheck of the packages you changed.
`bun run ci` is exactly what CI runs (check, i18n:check, skills:validate, check-types, test, build). Localization catalog and language-pack guidance lives in `i18n/README.md`. The git hooks (installed by `bun install`) are fast feedback: Biome on staged files, typecheck of the packages you changed.

## Working agreements

Expand Down
25 changes: 23 additions & 2 deletions apps/editor/app/client-bootstrap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,39 @@
// `loaded` guard inside `../lib/bootstrap` keeps the side effect
// idempotent under HMR.
import '../lib/bootstrap'
import { type ReactNode, useEffect } from 'react'
import { createI18n, defaultI18n, PascalI18nProvider } from '@pascal-app/core'
import { registerBundledLocales } from '@pascal-app/editor/locales'
import { type ReactNode, useEffect, useMemo } from 'react'

registerBundledLocales()

export function ClientBootstrap({
children,
enableDevDiagnostics,
locale,
}: {
children: ReactNode
enableDevDiagnostics: boolean
locale: string
}) {
const i18n = useMemo(() => createI18n({ locale }), [locale])

useEffect(() => {
if (!enableDevDiagnostics) return
import('react-scan').then(({ scan }) => scan({ enabled: true }))
}, [enableDevDiagnostics])
return children

useEffect(() => {
const syncDocumentLocale = () => {
document.documentElement.lang = i18n.getLocale()
}
syncDocumentLocale()
return i18n.subscribe(syncDocumentLocale)
}, [i18n])

useEffect(() => {
return defaultI18n.subscribeLocale((nextLocale) => i18n.setLocale(nextLocale))
}, [i18n])

return <PascalI18nProvider i18n={i18n}>{children}</PascalI18nProvider>
}
14 changes: 11 additions & 3 deletions apps/editor/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { registerBundledLocales } from '@pascal-app/editor/locales'
import { Agentation } from 'agentation'
import { GeistPixelSquare } from 'geist/font/pixel'
import { Barlow } from 'next/font/google'
import localFont from 'next/font/local'
import { headers } from 'next/headers'
import { PASCAL_LOCALE_HEADER, resolveUiLocale } from '@/lib/ui-locale'
import { ClientBootstrap } from './client-bootstrap'
import './globals.css'

registerBundledLocales()

const geistSans = localFont({
src: './fonts/GeistVF.woff',
variable: '--font-geist-sans',
Expand All @@ -21,21 +26,24 @@ const barlow = Barlow({
display: 'swap',
})

export default function RootLayout({
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
const enableDevDiagnostics =
process.env.NODE_ENV === 'development' && process.env.PASCAL_DEV_DIAGNOSTICS === '1'
const locale = resolveUiLocale((await headers()).get(PASCAL_LOCALE_HEADER))

return (
<html
className={`${geistSans.variable} ${geistMono.variable} ${GeistPixelSquare.variable} ${barlow.variable}`}
lang="en"
lang={locale}
>
<body className="font-sans">
<ClientBootstrap enableDevDiagnostics={enableDevDiagnostics}>{children}</ClientBootstrap>
<ClientBootstrap enableDevDiagnostics={enableDevDiagnostics} locale={locale}>
{children}
</ClientBootstrap>
{enableDevDiagnostics && <Agentation />}
</body>
</html>
Expand Down
11 changes: 11 additions & 0 deletions apps/editor/lib/ui-locale.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export const PASCAL_LOCALE_HEADER = 'x-pascal-locale'

export function resolveUiLocale(value: string | null | undefined): string {
const requested = value?.trim()
if (!requested) return 'en'
try {
return Intl.getCanonicalLocales(requested)[0] ?? 'en'
} catch {
return 'en'
}
}
32 changes: 30 additions & 2 deletions apps/editor/proxy.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { afterEach, expect, test } from 'bun:test'
import { readdirSync } from 'node:fs'
import { unstable_doesMiddlewareMatch } from 'next/experimental/testing/server'
import { issueSession, SESSION_COOKIE } from './lib/self-host-auth'
import { resolveUiLocale } from './lib/ui-locale'
import { config, gateRequest } from './proxy'

const OLD_ENV = { ...process.env }
Expand Down Expand Up @@ -31,10 +33,10 @@ test('an unconfigured network host sends pages to setup and APIs to 503', async
test('a password gates pages behind login and keeps the requested path', () => {
process.env.PASCAL_ADMIN_PASSWORD = 'secret'

const response = gateRequest(request('/project/abc?tab=build'))
const response = gateRequest(request('/project/abc?tab=build&lang=fr'))
expect(response?.status).toBe(307)
expect(response?.headers.get('location')).toBe(
'https://pascal.example/login?next=%2Fproject%2Fabc%3Ftab%3Dbuild',
'https://pascal.example/login?next=%2Fproject%2Fabc%3Ftab%3Dbuild%26lang%3Dfr',
)
expect(gateRequest(request('/api/plugins/mint/session'))?.status).toBe(401)
})
Expand Down Expand Up @@ -107,6 +109,25 @@ test('Next and public/ static assets skip the proxy', () => {
}
})

test('the proxy exclusion list stays aligned with public/ and app/ folders', () => {
const topLevelFolders = (relativePath: string) =>
readdirSync(new URL(relativePath, import.meta.url), { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort()
const publicFolders = topLevelFolders('./public/')
const appFolders = topLevelFolders('./app/')

for (const folder of publicFolders) {
const path = `/${folder}/__proxy-drift-check__`
expect([folder, unstable_doesMiddlewareMatch({ config, url: path })]).toEqual([folder, false])
}
for (const folder of appFolders.filter((folder) => !publicFolders.includes(folder))) {
const path = `/${folder}/__proxy-drift-check__`
expect([folder, unstable_doesMiddlewareMatch({ config, url: path })]).toEqual([folder, true])
}
})

test('loopback and an upstream proxy are open', () => {
delete process.env.PASCAL_ADMIN_PASSWORD
const loopback = new Request('http://localhost:3002/', {
Expand All @@ -116,3 +137,10 @@ test('loopback and an upstream proxy are open', () => {
process.env.PASCAL_AUTH = 'proxy'
expect(gateRequest(request('/'))).toBeNull()
})

test('UI locale canonicalization is deterministic for server rendering', () => {
expect(resolveUiLocale('fr')).toBe('fr')
expect(resolveUiLocale('zh-hans-cn')).toBe('zh-Hans-CN')
expect(resolveUiLocale('')).toBe('en')
expect(resolveUiLocale('not_a_locale')).toBe('en')
})
8 changes: 7 additions & 1 deletion apps/editor/proxy.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { type NextRequest, NextResponse } from 'next/server'
import { resolveAuthMode, sessionPrincipal } from '@/lib/self-host-auth'
import { PASCAL_LOCALE_HEADER, resolveUiLocale } from '@/lib/ui-locale'

/**
* Self-host gate. Pages redirect to `/login` (or `/setup` when nothing is
Expand All @@ -17,7 +18,12 @@ const OPEN_PREFIXES = ['/embed/']
const SELF_GUARDED_API_PREFIXES = ['/api/scenes', '/api/artifacts']

export function proxy(request: NextRequest) {
return gateRequest(request) ?? NextResponse.next()
const gated = gateRequest(request)
if (gated) return gated

const headers = new Headers(request.headers)
headers.set(PASCAL_LOCALE_HEADER, resolveUiLocale(request.nextUrl.searchParams.get('lang')))
return NextResponse.next({ request: { headers } })
}

export function gateRequest(request: Request): NextResponse | null {
Expand Down
2 changes: 2 additions & 0 deletions biome.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@
"!**/build",
"!**/public",
"!**/components/ui",
// Generated by `lingui compile`; source of truth is the adjacent PO file.
"!packages/editor/src/i18n/locales/**/messages.ts",
// Recorded API responses; kept byte-exact.
"!**/__fixtures__",
"!**/next-env.d.ts"
Expand Down
Loading
Loading