diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb1f1d782e..dd88e5e69d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 7fbd0af1bb..88686365b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/apps/editor/app/client-bootstrap.tsx b/apps/editor/app/client-bootstrap.tsx index 821544fec6..0c307cd874 100644 --- a/apps/editor/app/client-bootstrap.tsx +++ b/apps/editor/app/client-bootstrap.tsx @@ -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 {children} } diff --git a/apps/editor/app/layout.tsx b/apps/editor/app/layout.tsx index 01965f4ba3..1107368272 100644 --- a/apps/editor/app/layout.tsx +++ b/apps/editor/app/layout.tsx @@ -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', @@ -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 ( - {children} + + {children} + {enableDevDiagnostics && } diff --git a/apps/editor/lib/ui-locale.ts b/apps/editor/lib/ui-locale.ts new file mode 100644 index 0000000000..135f1d1cf9 --- /dev/null +++ b/apps/editor/lib/ui-locale.ts @@ -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' + } +} diff --git a/apps/editor/proxy.test.ts b/apps/editor/proxy.test.ts index 5009e809b0..8dc03b0c5c 100644 --- a/apps/editor/proxy.test.ts +++ b/apps/editor/proxy.test.ts @@ -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 } @@ -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) }) @@ -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/', { @@ -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') +}) diff --git a/apps/editor/proxy.ts b/apps/editor/proxy.ts index 15676a8ecc..798fe6c19a 100644 --- a/apps/editor/proxy.ts +++ b/apps/editor/proxy.ts @@ -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 @@ -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 { diff --git a/biome.jsonc b/biome.jsonc index 72ec2714aa..c54a71bea4 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -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" diff --git a/bun.lock b/bun.lock index 39548ae0ec..9afeaaf62a 100644 --- a/bun.lock +++ b/bun.lock @@ -5,14 +5,23 @@ "": { "name": "editor", "devDependencies": { + "@ai-sdk/anthropic": "^4.0.89", + "@ai-sdk/openai": "^4.0.103", "@biomejs/biome": "^2.6.3", + "@lingui/cli": "6.10.1", + "@lingui/conf": "6.10.1", + "@lingui/format-po": "6.10.1", + "@lingui/message-utils": "6.10.1", "@typescript/native-preview": "7.0.0-dev.20260707.2", "@typescript/typescript6": "6.0.2", + "ai": "^7.0.154", "dotenv-cli": "^11.0.0", "fast-xml-parser": "^5.11.2", + "pofile-ts": "^4.0.3", "turbo": "^2.11.10", "typescript": "npm:@typescript/typescript6@6.0.2", "ultracite": "^8.1.0", + "zod": "^4.6.7", }, "optionalDependencies": { "@tailwindcss/oxide-darwin-arm64": "4.3.3", @@ -127,6 +136,8 @@ "name": "@pascal-app/core", "version": "1.0.6", "dependencies": { + "@lingui/core": "6.10.1", + "@lingui/message-utils": "6.10.1", "@pascal-app/lingo": "^0.5.0", "dedent": "^1.7.2", "idb-keyval": "^6.3.0", @@ -161,6 +172,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@iconify/react": "^6.0.3", + "@lingui/core": "6.10.1", "@number-flow/react": "^0.6.2", "@pascal-app/geometry-script": "^1.0.6", "@pascal-app/ifc-converter": "^1.0.6", @@ -398,6 +410,16 @@ "three": "0.186.1", }, "packages": { + "@ai-sdk/anthropic": ["@ai-sdk/anthropic@4.0.89", "", { "dependencies": { "@ai-sdk/provider": "4.0.34", "@ai-sdk/provider-utils": "5.0.69" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-PCXExlOB4P51rzJ9hGIcZ+6DfHdLpcbs0IzgbtBEubITa6f90+7K/sD38rXfdDzYdVtjFitdtSl3cr4KAY2wQw=="], + + "@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.127", "", { "dependencies": { "@ai-sdk/provider": "4.0.34", "@ai-sdk/provider-utils": "5.0.69", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ENcIQNrZxgFnNSx29X2o/mLKZRzMN91c53Qlp/UrF4RQGVSfaROjFQAq4eeJliRPWwjHvEhJYG42IrOwtWUGOQ=="], + + "@ai-sdk/openai": ["@ai-sdk/openai@4.0.103", "", { "dependencies": { "@ai-sdk/provider": "4.0.34", "@ai-sdk/provider-utils": "5.0.69" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZaMITn2RBzILNzrpXxJDMPRpNHGwZdq77GWqd9UBuz5VAA8EOu9JPy8hTk5OVhPMzDZbNgpb/D/1G+NbeVP22w=="], + + "@ai-sdk/provider": ["@ai-sdk/provider@4.0.34", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Ip7ZQBq/FiGpM1mmOVseTtX9PBjnzo0bLC41WTgBSBBRUBz6LhNFGbYaNUzI6hPm6ll1oNSPOAZc/q/z+3QZEQ=="], + + "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.69", "", { "dependencies": { "@ai-sdk/provider": "4.0.34", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", "undici": "^7.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-oGCXIoBKLpNrrAzJvltFHdBl20FJMyT+7edD9D4nwxoI8Scbj3y1EgKzjoF2XFhNYkMTcv1oTFDgUzLxdjqSKg=="], + "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], "@astrojs/compiler": ["@astrojs/compiler@4.0.0", "", {}, "sha512-eouss7G8ygdZqHuke033VMcVw5HTZUu+PXd/h06DGDUg/jt5btPYPqh66ENWw/mU78rBrf/oeC4oqoBwMtDMNA=="], @@ -468,6 +490,8 @@ "@clack/prompts": ["@clack/prompts@1.8.1", "", { "dependencies": { "@clack/core": "1.5.1", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ=="], + "@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="], + "@dgreenheck/ez-tree": ["@dgreenheck/ez-tree@1.1.0", "", { "peerDependencies": { "three": ">=0.167" } }, "sha512-6pvS6hD6B6h00dm0SnkgYeT4ABU5Y1Z9M44p1tXiV5C0eKrQy2sKECXshoaUv0qAOqYVL68w/PwadUxDFDiHUg=="], "@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="], @@ -606,6 +630,10 @@ "@iwer/sem": ["@iwer/sem@0.2.5", "", { "dependencies": { "three": "^0.165.0", "ts-proto": "^2.6.0" }, "peerDependencies": { "iwer": "^2.0.0" } }, "sha512-vMCfpu/7Qqc+hkBiGD9pxjeObgrhXOrL0KX94CA3yzJaU0dq0y49HXZT6fC+6X/jOmjaM3hjyE1m2h7ZmLzzyA=="], + "@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], + + "@jest/types": ["@jest/types@29.6.3", "", { "dependencies": { "@jest/schemas": "^29.6.3", "@types/istanbul-lib-coverage": "^2.0.0", "@types/istanbul-reports": "^3.0.0", "@types/node": "*", "@types/yargs": "^17.0.8", "chalk": "^4.0.0" } }, "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -622,6 +650,40 @@ "@keyv/serialize": ["@keyv/serialize@1.1.1", "", {}, "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA=="], + "@lingui/babel-plugin-extract-messages": ["@lingui/babel-plugin-extract-messages@6.10.1", "", { "dependencies": { "@lingui/conf": "6.10.1" } }, "sha512-geg2chO+ahysUT7RdPdOMHNVb2hpM7jDrQpro+IyZKG6NwZ6BlsroxXpJKC27k/Jw1g7uvMBrkw7zOPeGCfi5A=="], + + "@lingui/babel-plugin-lingui-macro": ["@lingui/babel-plugin-lingui-macro@6.10.1", "", { "dependencies": { "@lingui/conf": "6.10.1", "@lingui/message-utils": "6.10.1" }, "peerDependencies": { "@babel/core": "^7.20.12 || ^8.0.0" }, "optionalPeers": ["@babel/core"] }, "sha512-AQs7UsweJS+JMb1ySzETHFktdgwmdQaJM7B9WWVvuhJv0BtiI/S/XJcl2Nyh93mvWD3HArIFRWECKnZ0y5lIeQ=="], + + "@lingui/cli": ["@lingui/cli@6.10.1", "", { "dependencies": { "@babel/core": "^7.21.0", "@babel/generator": "^7.28.5", "@babel/parser": "^7.22.0", "@babel/types": "^7.21.2", "@lingui/babel-plugin-extract-messages": "6.10.1", "@lingui/babel-plugin-lingui-macro": "6.10.1", "@lingui/conf": "6.10.1", "@lingui/core": "6.10.1", "@lingui/format-po": "6.10.1", "@lingui/message-utils": "6.10.1", "@lingui/native-tools": "^0.1.6", "chokidar": "5.0.0", "cli-table3": "^0.6.5", "commander": "^14.0.2", "jiti": "^2.6.1", "micromatch": "^4.0.7", "ms": "^2.1.3", "normalize-path": "^3.0.0", "ora": "^9.1.0", "pseudolocale": "^3.1.0", "source-map": "^0.7.6", "tinypool": "^2.1.0" }, "peerDependencies": { "esbuild": "^0.28.1", "rolldown": "^1.0.0" }, "optionalPeers": ["esbuild", "rolldown"], "bin": { "lingui": "dist/lingui.js" } }, "sha512-C80aWjnGutoPFkw7F995yt3APK32+7X5d36DdyL5MKSrAHf8irBOG2wjy2nhm6gjDWOS0+s6zCTh8HX2HDC+CQ=="], + + "@lingui/conf": ["@lingui/conf@6.10.1", "", { "dependencies": { "jest-validate": "^29.4.3", "jiti": "^2.5.1", "lilconfig": "^3.1.3", "normalize-path": "^3.0.0" } }, "sha512-utgrZY0AUj08dLi/9jSrR0chwBlvPKjGGDCPHR1UHCVZb/EIxskbITiCVITUfVuxp5+FMSPMm0V1lzF1Q86qlg=="], + + "@lingui/core": ["@lingui/core@6.10.1", "", { "dependencies": { "@lingui/babel-plugin-lingui-macro": "6.10.1", "@lingui/message-utils": "6.10.1" }, "peerDependencies": { "babel-plugin-macros": "2 || 3" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-s2b2yyXBwNFdKz5r3S9zaGLlJpSTH3+k+q0RGQr/TbjoTDsuTCMbHHT6stTWj377/4p/E3UTOrIy7r7J5jTGUA=="], + + "@lingui/format-po": ["@lingui/format-po@6.10.1", "", { "dependencies": { "@lingui/conf": "6.10.1", "@lingui/message-utils": "6.10.1", "pofile-ts": "^4.0.3" } }, "sha512-C+PjhCV4ERBCTFFymcIpBNfZaQfMcwh97+YENvmqgaUNQ95WcMVV9dk+qV+V+QG4g82a6ZCNbce1zSAxoy7rdw=="], + + "@lingui/message-utils": ["@lingui/message-utils@6.10.1", "", { "dependencies": { "@messageformat/date-skeleton": "^1.1.0", "@messageformat/parser": "^5.0.0", "js-sha256": "^0.10.1" } }, "sha512-IzCtKG+wtC0uMFs7pONVAFscHzQN0FjW6RG7DULVJcHaLmH/hOGc7QpGX7Jzm7d8QguDfUM0QmSwFwLkYDmNvQ=="], + + "@lingui/native-tools": ["@lingui/native-tools@0.1.6", "", { "dependencies": { "@swc/types": "^0.1.25" }, "optionalDependencies": { "@lingui/native-tools-android-arm64": "0.1.6", "@lingui/native-tools-darwin-arm64": "0.1.6", "@lingui/native-tools-darwin-x64": "0.1.6", "@lingui/native-tools-linux-arm64-gnu": "0.1.6", "@lingui/native-tools-linux-arm64-musl": "0.1.6", "@lingui/native-tools-linux-x64-gnu": "0.1.6", "@lingui/native-tools-linux-x64-musl": "0.1.6", "@lingui/native-tools-win32-arm64-msvc": "0.1.6", "@lingui/native-tools-win32-x64-msvc": "0.1.6" }, "peerDependencies": { "@lingui/conf": "^6.0.1" } }, "sha512-xsn2R9YvWL9WuemM0ivSlXy14ORlfvPYLxFP3kbcFU3Pg5T9aRnmYOTfwszwGnjt4QsORO1mHHZT6k/Wr7V9pA=="], + + "@lingui/native-tools-android-arm64": ["@lingui/native-tools-android-arm64@0.1.6", "", { "os": "android", "cpu": "arm64" }, "sha512-kAwVqJlKv868dGJOhqXXqHwhI3bOlvdHSZz8rozlEEidvBuw6Srr0Es5hwisLlwybzcTYstjOeVGxmvu3QAV4g=="], + + "@lingui/native-tools-darwin-arm64": ["@lingui/native-tools-darwin-arm64@0.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0KEoSob+pYS1jyRB+2bJp78L2L2i/VODFIk5Gs8LmKV58xZ+uN8MOhf1dYBBsi6+0tiqPartSCk3WA4i+lKPUQ=="], + + "@lingui/native-tools-darwin-x64": ["@lingui/native-tools-darwin-x64@0.1.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-e7liIjkzbJ8w3F9PHzZPmpYpeiv7XO4Np9J1tEpUamKYEdYBNt4AKr/UmWAQ7AjxbZSV9N8lj1ulUl3pdFUuaQ=="], + + "@lingui/native-tools-linux-arm64-gnu": ["@lingui/native-tools-linux-arm64-gnu@0.1.6", "", { "os": "linux", "cpu": "arm64", "libc": "glibc" }, "sha512-JKu/8CvLsYUiRbRSnuzmrQk24tfuizgnDYlMxE5/awUUrRE+GlpaMP76PhJpkl+tV0v7t6zVe3+I5lbq7LaqVw=="], + + "@lingui/native-tools-linux-arm64-musl": ["@lingui/native-tools-linux-arm64-musl@0.1.6", "", { "os": "linux", "cpu": "arm64", "libc": "musl" }, "sha512-Aeexf/Ta0nZDZz3cDMAZ95GmFZ2lOxMwf1FK/ShK+U4WcNVSn3aI2KeuOeaLUfot9ZT8AiIIELPV4gO6b/GJgg=="], + + "@lingui/native-tools-linux-x64-gnu": ["@lingui/native-tools-linux-x64-gnu@0.1.6", "", { "os": "linux", "cpu": "x64", "libc": "glibc" }, "sha512-SwX1dLcQE9pVcXwIxSGTE2+zBmaO6NbjD9XPqCiyoO8RFTkUTLjdnRDGHxFlFpIGMlVArQz5mMQ6coyUFl+AiA=="], + + "@lingui/native-tools-linux-x64-musl": ["@lingui/native-tools-linux-x64-musl@0.1.6", "", { "os": "linux", "cpu": "x64", "libc": "musl" }, "sha512-aWI8e10I6i7K9BG77LJDHREozx/VT8j8onf0wxd43ACor2S1VwS0ZNlIfpNWQTWovmU/CUliGO/VWQq2jGa+Sg=="], + + "@lingui/native-tools-win32-arm64-msvc": ["@lingui/native-tools-win32-arm64-msvc@0.1.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-M2TZP4gKgLaLbiboXIrY5+8GsaQLHvZ8E6cUFgAuSaBa8AMa73+pZO1eay/St9t8yDh6EL6hOlqN0HedUHMwyQ=="], + + "@lingui/native-tools-win32-x64-msvc": ["@lingui/native-tools-win32-x64-msvc@0.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-5KvuSTFtDB8UGHS3Rxd+juYZgc51isQSx0hPukDObre6UxZGuUVCBmJ9YwoGVzXedViiNGmsQvrnCTBbVqI75g=="], + "@mapbox/jsonlint-lines-primitives": ["@mapbox/jsonlint-lines-primitives@2.0.3", "", {}, "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg=="], "@mapbox/point-geometry": ["@mapbox/point-geometry@1.1.0", "", {}, "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ=="], @@ -642,6 +704,10 @@ "@mediapipe/tasks-vision": ["@mediapipe/tasks-vision@0.10.17", "", {}, "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg=="], + "@messageformat/date-skeleton": ["@messageformat/date-skeleton@1.1.0", "", {}, "sha512-rmGAfB1tIPER+gh3p/RgA+PVeRE/gxuQ2w4snFWPF5xtb5mbWR7Cbw7wCOftcUypbD6HVoxrVdyyghPm3WzP5A=="], + + "@messageformat/parser": ["@messageformat/parser@5.2.0", "", { "dependencies": { "moo": "^0.5.1" } }, "sha512-sc3hNZ0JtC32GhL5nUgPbh2ilcwji4G+Vmj/vhMd8hZaH//NDOvkfwKK1mFnRWG5h2DpksN8xr/HB5/+vMg3iw=="], + "@mint/pascal-plugin": ["@mint/pascal-plugin@github:mintdotgg/mint-pascal-plugin#902c546", { "peerDependencies": { "@pascal-app/core": ">=0.9.2 <1.0.0 || >=1.0.0-beta.1 <1.0.0", "@pascal-app/editor": ">=0.9.2 <1.0.0 || >=1.0.0-beta.1 <1.0.0", "@pascal-app/viewer": ">=0.9.2 <1.0.0 || >=1.0.0-beta.1 <1.0.0", "react": "^18 || ^19", "three": "^0.185" } }, "mintdotgg-mint-pascal-plugin-902c546", "sha512-/itUH9r9OIP8ZPrklW8iWe6B2SDOtlL1m8r9hlVM4Rw5josYtDdkDKQ37FbanvxxuUKcQnukHbtxWe3svRaCrQ=="], "@modelcontextprotocol/client": ["@modelcontextprotocol/client@2.4.0", "", { "dependencies": { "@modelcontextprotocol/core": "2.4.0", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.8", "jose": "^6.1.3", "pkce-challenge": "^5.0.0", "zod": "^4.2.0" } }, "sha512-LgrD2OphHuG12vfKFKz6SOnq7bKHQmhGJIAYdD+MZBn2npHEyry6uD8zvktodtl5CQM85MO0anUyD9JwmUmxww=="], @@ -974,10 +1040,18 @@ "@shaderfrog/glsl-parser": ["@shaderfrog/glsl-parser@7.0.1", "", {}, "sha512-8mpfsoPeRhesY3pOrzNZBL8uG6N5GVX1EHLBYbd4gzKs+c7vaEIqpTNK5VrffU33qQN4cwpP2v3u4aPPBU32sw=="], + "@sinclair/typebox": ["@sinclair/typebox@0.27.12", "", {}, "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g=="], + "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="], + "@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="], + "@swc/types": ["@swc/types@0.1.28", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw=="], + "@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="], @@ -1044,6 +1118,12 @@ "@types/howler": ["@types/howler@2.2.13", "", {}, "sha512-40+EBjqIHHrC4VShlz/7i0lBUsE3QkgzZinQQji74Hd8sBkJZUBaT7LWFLK6rcabsDOOQpoMbEJvtaFQwxOu/g=="], + "@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="], + + "@types/istanbul-lib-report": ["@types/istanbul-lib-report@3.0.3", "", { "dependencies": { "@types/istanbul-lib-coverage": "*" } }, "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA=="], + + "@types/istanbul-reports": ["@types/istanbul-reports@3.0.4", "", { "dependencies": { "@types/istanbul-lib-report": "*" } }, "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], "@types/ndarray": ["@types/ndarray@1.0.14", "", {}, "sha512-oANmFZMnFQvb219SSBIhI1Ih/r4CvHDOzkWyJS/XRqkMrGH5/kaPSA1hQhdIBzouaE+5KpE/f5ylI9cujmckQg=="], @@ -1066,6 +1146,10 @@ "@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="], + "@types/yargs": ["@types/yargs@17.0.35", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg=="], + + "@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.72.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.72.0", "@typescript-eslint/type-utils": "8.72.0", "@typescript-eslint/utils": "8.72.0", "@typescript-eslint/visitor-keys": "8.72.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.72.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-aysKgysPX9XOv/dk95UkvUDe++Q3n3dJPG9CEWT3RUccxfkPJ+Qn6nhKPDhZXU6Ofo9kgwGWwKyQCnZIXTgngg=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.72.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.72.0", "@typescript-eslint/types": "8.72.0", "@typescript-eslint/typescript-estree": "8.72.0", "@typescript-eslint/visitor-keys": "8.72.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-0O+x3ynciD6zuXb1CKSu165haQV6ey53DKhHHUnujP7U+XlltJG+/keT0Xhd1HXCze+IHf7Tvt39ovyepX/LNw=="], @@ -1150,12 +1234,16 @@ "@use-gesture/react": ["@use-gesture/react@10.3.1", "", { "dependencies": { "@use-gesture/core": "10.3.1" }, "peerDependencies": { "react": ">= 16.8.0" } }, "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g=="], + "@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="], + "@visual-json/core": ["@visual-json/core@0.4.0", "", {}, "sha512-T/HvRIp7r6SehxVnO+akV72NLv2OMBEoQdYTFbpwMi6uU2TaXQOpIOHBG4r9XBz7OqC2e8aedOwal/NtqtzyCg=="], "@visual-json/react": ["@visual-json/react@0.4.0", "", { "dependencies": { "@visual-json/core": "0.4.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-qiDrXI7wu9VoTE0TaLoJp7kwRj8SQTpkS/wkts5sBPA8078zdrIKc+0u0Y8wAXGYd3udw9dNfD4kmpO48OEwXQ=="], "@webxr/plugin": ["@webxr/plugin@github:sudhir9297/webxr-pascal-plugin#9cd9045", { "dependencies": { "@iwer/devui": "2.3.0", "iwer": "2.3.0", "lucide-react": "^1.7.0" }, "peerDependencies": { "@pascal-app/core": "^1.0.0-beta.5", "@pascal-app/editor": "^1.0.0-beta.5", "@pascal-app/viewer": "^1.0.0-beta.5", "@react-three/drei": "^10", "@react-three/fiber": "^9", "@react-three/xr": "^6", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.185 || ^0.186", "zustand": "^5" } }, "sudhir9297-webxr-pascal-plugin-9cd9045", "sha512-oICXf9DgdzLR+dVdAzp8z7knF+fZUUX2MJKZ/G2ljT5dXg6gmjPH16HWRy/Reb/hKILvcDGh5lw8RYQj5OBAuA=="], + "@workflow/serde": ["@workflow/serde@4.1.0", "", {}, "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ=="], + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "acorn-import-attributes": ["acorn-import-attributes@1.9.5", "", { "peerDependencies": { "acorn": "^8" } }, "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ=="], @@ -1166,6 +1254,8 @@ "agentation": ["agentation@3.1.2", "", { "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-ZZr6PRgX3ussGbvC9NVh5vFfKKFkQyHL6KuxOfBAAM/mJS3bM2XyhZ7wLPakDwU3pqkzq7u6vEfLikFsCOu5+Q=="], + "ai": ["ai@7.0.154", "", { "dependencies": { "@ai-sdk/gateway": "4.0.127", "@ai-sdk/provider": "4.0.34", "@ai-sdk/provider-utils": "5.0.69" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-vpvOZoV2Xcu6vWYM/X2XAFJG93T/ELF+qbslXxRnpqqhKZ6dtxfZf1KHFqYBz784uV/XVjXkNjgs9Zc239NY3Q=="], + "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], @@ -1174,7 +1264,7 @@ "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "anynum": ["anynum@1.0.1", "", {}, "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A=="], @@ -1238,6 +1328,8 @@ "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + "camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="], + "camelize": ["camelize@1.0.1", "", {}, "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ=="], "camera-controls": ["camera-controls@3.1.2", "", { "peerDependencies": { "three": ">=0.126.1" } }, "sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA=="], @@ -1246,7 +1338,9 @@ "case-anything": ["case-anything@2.1.13", "", {}, "sha512-zlOQ80VrQ2Ue+ymH5OuM/DlDq64mEm+B9UTdHULv5osUMD6HalNTblf2b1u/m6QecjsnOkBpqVZ+XPwIVsy7Ng=="], - "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], "citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="], @@ -1258,6 +1352,8 @@ "cli-spinners": ["cli-spinners@3.4.0", "", {}, "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw=="], + "cli-table3": ["cli-table3@0.6.5", "", { "dependencies": { "string-width": "^4.2.0" }, "optionalDependencies": { "@colors/colors": "1.5.0" } }, "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ=="], + "cli-truncate": ["cli-truncate@6.1.1", "", { "dependencies": { "slice-ansi": "^9.0.0", "string-width": "^8.2.0" } }, "sha512-06p9vyLahLa4zkGcgsGxU6iEkSOiuI4fhCH6Emhe2lPAcoUv73n72DnODsnHA+5wwXGnV0n9M9/qOQJSjYhFhw=="], "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], @@ -1274,7 +1370,7 @@ "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - "commander": ["commander@15.0.0", "", {}, "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg=="], + "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], @@ -1346,6 +1442,8 @@ "electron-to-chromium": ["electron-to-chromium@1.5.464", "", {}, "sha512-nRa65aFge40viPAKxMKULIY8tcXKwKboJWNn4RoKwyWXJ6SP7BnrDGKUTzc64818jIciH0PK7LSF8QzZes19GA=="], + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "empathic": ["empathic@2.1.0", "", {}, "sha512-AnfC1ATldl49/cvZdLPDjBfrRNwbDO05aibiOtzQu3qtlbJtomNLhF30HEtn/7iBz50dlMECqATo3fG0LrdEgw=="], "enhanced-resolve": ["enhanced-resolve@5.26.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw=="], @@ -1570,7 +1668,7 @@ "is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="], - "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], "is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="], @@ -1624,10 +1722,16 @@ "iwer": ["iwer@2.3.0", "", { "dependencies": { "gl-matrix": "^3.4.4", "webxr-layers-polyfill": "^1.1.0" } }, "sha512-+aY/BXVIjztCtS4F1hAO2rQy0P1/0JbJ6Jq5QHVscUBBlv8xJlTkkyeg+uSiD9RQ7i5B6k1Blpcx84FQaI7Myw=="], + "jest-get-type": ["jest-get-type@29.6.3", "", {}, "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw=="], + + "jest-validate": ["jest-validate@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "camelcase": "^6.2.0", "chalk": "^4.0.0", "jest-get-type": "^29.6.3", "leven": "^3.1.0", "pretty-format": "^29.7.0" } }, "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw=="], + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], "jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], + "js-sha256": ["js-sha256@0.10.1", "", {}, "sha512-5obBtsz9301ULlsgggLg542s/jqtddfOpV5KJc4hajc9JV8GeY2gZHSVpYBn4nWqAUTJ9v+xwtbJ1mIBgIH5Vw=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.3.2", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA=="], @@ -1636,6 +1740,8 @@ "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], @@ -1658,6 +1764,8 @@ "ktx-parse": ["ktx-parse@1.1.0", "", {}, "sha512-mKp3y+FaYgR7mXWAbyyzpa/r1zDWeaunH+INJO4fou3hb45XuNSwar+7llrRyvpMWafxSIi99RNFJ05MHedaJQ=="], + "leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="], + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], "lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="], @@ -1686,6 +1794,8 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + "lilconfig": ["lilconfig@3.1.4", "", {}, "sha512-zJZyBmQhArDRefuWTypZxwDpFDIYmrj8ypSBvwGD5P13Y95s6DjzKgu4DVdGbeQa5PDEsAOCAPNk4X+QOR006w=="], + "linebreak": ["linebreak@1.1.0", "", { "dependencies": { "base64-js": "0.0.8", "unicode-trie": "^2.0.0" } }, "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ=="], "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], @@ -1734,6 +1844,8 @@ "module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="], + "moo": ["moo@0.5.3", "", {}, "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA=="], + "motion": ["motion@14.6.0", "", { "dependencies": { "framer-motion": "14.6.0", "tslib": "^2.4.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-qqaZbyubgk7x3zxjPgP1l3Br/KQXol/IIV1uqb358SoN4ApcqNuV7+wyDPg/XLErmuyQL5lL8JVI/fOmDsmBKQ=="], "motion-dom": ["motion-dom@14.6.0", "", { "dependencies": { "motion-utils": "14.6.0" } }, "sha512-lIojTl9aYSPIkeOOSzBliy1nNvpdO3JtVzW2WgTXQlOP4ZMuJhtWO3fP7OD4TVFwlRucdvxlDSMkL+fEQnQ52A=="], @@ -1762,6 +1874,8 @@ "node-releases": ["node-releases@2.0.60", "", {}, "sha512-OmO8+GM1JUZqXET720VrJCwkjUCXmCMQm0Z3UDDRbfNLHS4NEAvY60j5jiFv0tkKw2PNNlqOLhahVmwJIOLDIQ=="], + "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + "npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="], "number-flow": ["number-flow@0.6.2", "", { "dependencies": { "esm-env": "^1.1.4" } }, "sha512-MCnImG4Q5vPwhSXnov56nOuyyKn6LC+Qd7II1UiKc+ACRtug5iAtn0+CwXNxM38AC5lSowEY+oYEtZX2qMnUyw=="], @@ -1826,7 +1940,7 @@ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + "picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], @@ -1836,6 +1950,8 @@ "png-js": ["png-js@2.0.0", "", { "dependencies": { "fflate": "^0.8.2" } }, "sha512-GdzJuUMc6ZSpxFJWVxtOH1bzYHym+TOnveqUjb+VJIbZWbZzyiRGFiKhbiielfpYbgMlhHVhsJ0FTazfuRFkMA=="], + "pofile-ts": ["pofile-ts@4.0.3", "", {}, "sha512-sz1pnjgEfPyZ+QvaeX3NtCmbYnEvG01LZRLoN/uXoLtPZtxCIH5IctL7yXXc0fFyk/fqV6K8g3hlNfr6IJwupA=="], + "polygon-clipping": ["polygon-clipping@0.15.7", "", { "dependencies": { "robust-predicates": "^3.0.2", "splaytree": "^3.1.0" } }, "sha512-nhfdr83ECBg6xtqOAJab1tbksbBAOMUltN60bU+llHVOL0e5Onm1WpAXXWXVB39L8AJFssoIhEVuy/S90MmotA=="], "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], @@ -1850,6 +1966,8 @@ "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + "pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], + "pretty-ms": ["pretty-ms@9.3.1", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA=="], "promise-worker-transferable": ["promise-worker-transferable@1.0.4", "", { "dependencies": { "is-promise": "^2.1.0", "lie": "^3.0.2" } }, "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw=="], @@ -1862,6 +1980,8 @@ "protocol-buffers-schema": ["protocol-buffers-schema@3.6.1", "", {}, "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ=="], + "pseudolocale": ["pseudolocale@3.1.0", "", { "dependencies": { "commander": "^13.1.0" }, "bin": { "pseudolocale": "dist/cli.mjs" } }, "sha512-jBidehQu5WO8zbFUod0ETJYoVPxZ0ixsdI9B8ilkE0eA+YTydxHA7ujNdU4YhTexD5yH5Griv9r5CuXx+nwFUA=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "qified": ["qified@0.10.1", "", { "dependencies": { "hookified": "^2.1.1" } }, "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA=="], @@ -1892,6 +2012,8 @@ "react-use-measure": ["react-use-measure@2.1.7", "", { "peerDependencies": { "react": ">=16.13", "react-dom": ">=16.13" }, "optionalPeers": ["react-dom"] }, "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg=="], + "readdirp": ["readdirp@5.1.1", "", {}, "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA=="], + "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], @@ -1954,6 +2076,8 @@ "slice-ansi": ["slice-ansi@9.0.1", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-aBY19bn/XA+hKOKX0qL7Z0UfdBsbQvc9hn993U8ALUjzxCvDcuZqLoRXjGJrUARWAlwMnsRVD9sw2AFNOYvalA=="], + "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], "splaytree": ["splaytree@3.2.3", "", {}, "sha512-7OXrNWzy6CK+r7Ch9OLPBDTKfB6XlWHjX4P0RU5B3IgFuWPeYN0XtRtlexGRjgbQxpfaUve6jTAwBGWuGntz/w=="], @@ -2024,6 +2148,8 @@ "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "tinypool": ["tinypool@2.2.0", "", {}, "sha512-jBrmx4lYmaC9k/mgPbylxs7kBUxHtD8256up+HjLaDFfXScKJQyil+SWXSvhAtT7XHo+yTVlpB81PGmHP8oLSQ=="], + "tinyqueue": ["tinyqueue@3.0.0", "", {}, "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], @@ -2074,6 +2200,8 @@ "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], + "undici": ["undici@7.30.1", "", {}, "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg=="], + "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], "unicode-properties": ["unicode-properties@1.4.1", "", { "dependencies": { "base64-js": "^1.3.0", "unicode-trie": "^2.0.0" } }, "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg=="], @@ -2160,6 +2288,8 @@ "@iwer/devui/@pmndrs/pointer-events": ["@pmndrs/pointer-events@6.6.30", "", {}, "sha512-YD2jWdgEqqAWJNOOZ1WZMunUby8jwuQyOMk8zbeqC39R4nkZnGvu1Pa5EMGbV1zd2vZTxXDxYcAVxtQuhVwf3g=="], + "@jest/types/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "@next/eslint-plugin-next/fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="], "@oxc-resolver/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="], @@ -2188,8 +2318,6 @@ "@pmndrs/xr/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], - "@react-grab/cli/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], - "@react-grab/cli/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "@react-grab/cli/tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], @@ -2202,6 +2330,8 @@ "@repo/ui/typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], + "@rollup/pluginutils/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], @@ -2216,15 +2346,13 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], - "agent-install/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], - "agent-install/yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "browserslist/caniuse-lite": ["caniuse-lite@1.0.30001819", "", {}, "sha512-LFUqIIAwpzsIuevnKnsR9qnStBcgzPShTX61lBwN9uFkkf8o8zLiebEjbW8AYtZiF+1YKDWfdt/pWDtMuG8Xwg=="], - "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "cli-table3/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "cmdk/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA=="], @@ -2252,10 +2380,14 @@ "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "fdir/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + "fontkit/@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], "ifc-converter-app/typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], + "jest-validate/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "linebreak/base64-js": ["base64-js@0.0.8", "", {}, "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="], "log-symbols/yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], @@ -2266,16 +2398,12 @@ "maplibre-gl/potpack": ["potpack@2.1.0", "", {}, "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ=="], - "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], - "next/postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="], "node-exports-info/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], - "ora/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], - "ora/string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="], "oxlint-plugin-react-doctor/lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], @@ -2284,6 +2412,12 @@ "postcss/nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="], + "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + + "pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + + "pseudolocale/commander": ["commander@13.1.0", "", {}, "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw=="], + "qified/hookified": ["hookified@2.2.0", "", {}, "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA=="], "react-doctor/agent-install": ["agent-install@0.0.5", "", { "dependencies": { "@iarna/toml": "^2.2.5", "commander": "^14.0.0", "jsonc-parser": "^3.3.1", "picocolors": "^1.1.1", "prompts": "^2.4.2", "yaml": "^2.8.3" }, "bin": { "agent-install": "bin/agent-install.mjs" } }, "sha512-nHlms9BkP8ZiY79HrwCGiA2DcNaXrAaJrCM/BEqQ7MEsSKyCk+2A76xPGylIfASZSZE0SaU3T0bNSg4rBPIJAQ=="], @@ -2292,12 +2426,22 @@ "react-scan/bippy": ["bippy@0.5.41", "", { "peerDependencies": { "react": ">=17.0.1" } }, "sha512-jCP2pXXLhXqPrAN+iSEFZmLI4uUM4fjSqajh0K+TmM062VehfDT3ZJNkrTGyN701Z5XMejs9qAudSqkMGhSMKg=="], - "react-scan/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], "three-stdlib/fflate": ["fflate@0.6.11", "", {}, "sha512-3JyEFWGjFn7zHmoa9+zG1BmW7X2okcmAB+0Cnu9UFbVs/jCBnl2A8o065ZlXiw145K3eBM3uLuzrYXC0RK7eDg=="], + "tinyglobby/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + "tunnel-rat/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], + "ultracite/commander": ["commander@15.0.0", "", {}, "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg=="], + + "unplugin/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + + "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "@eslint/eslintrc/espree/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], "@eslint/eslintrc/minimatch/brace-expansion": ["brace-expansion@1.1.21", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw=="], @@ -2310,6 +2454,8 @@ "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "cli-table3/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "cmdk/@radix-ui/react-id/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA=="], "cmdk/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-rCMO3QsIVKv5JTY5CVbo2MvO77SpEqqYc8AvRE7OWqRDOIqAKjsp+DrmnY9uc8NPdxB5E2z47HTYGeE2+NTptg=="], @@ -2326,6 +2472,8 @@ "eslint-plugin-react/eslint/@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], + "eslint-plugin-react/eslint/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "eslint-plugin-react/eslint/eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], "eslint-plugin-react/eslint/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], @@ -2360,12 +2508,12 @@ "oxlint-plugin-react-doctor/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], - "react-doctor/agent-install/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], - "react-doctor/agent-install/yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], "@eslint/eslintrc/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "cli-table3/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "eslint-plugin-react/eslint/@eslint/config-array/@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], "eslint-plugin-react/eslint/file-entry-cache/flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], diff --git a/i18n/README.md b/i18n/README.md new file mode 100644 index 0000000000..0d317721fc --- /dev/null +++ b/i18n/README.md @@ -0,0 +1,122 @@ +# Localization + +> The localization API is experimental and unreleased. Its signatures may +> change before the first i18n-capable package release. + +Pascal uses Lingui 6 for ICU MessageFormat, React updates, extraction, and +catalog compilation. A thin Pascal registry keeps the ownership contract that +an embeddable editor needs: namespaced host and plugin packs, ordered +overrides, locale fallback, isolated request/editor scopes, and locale-aware +number formatting. + +Published packages use Lingui’s plain runtime call form, not macros: + +```tsx +const i18n = usePascalI18n() + +i18n._({ + id: 'viewer.camera.topView', + message: 'Top view', +}) +``` + +Hosts therefore need no Lingui compiler plugin or extra transform. English UI +copy stays beside the component that renders it and is also compiled into the +bundled English catalog. Scene data, exports, catalog content, MCP/agent text, +and developer errors are not translated. + +## Catalogs + +The source and translated catalogs are PO files under +`packages/editor/src/i18n/locales//messages.po`. Compiled TypeScript +catalogs next to them are what the browser loads; PO files and the ICU compiler +are not part of the normal rendering path. + +After adding or changing a runtime message: + +1. Run `bun run i18n:extract`. Lingui updates the PO catalogs and compiled + modules. +2. Translate every new entry in each bundled locale. +3. Keep the `Source-Hash` comment in sync and mark a reviewed entry + `Review: human` without the `fuzzy` flag. +4. Review the copy in context, then run `bun run i18n:check`. + +`i18n:check` needs no API key. It fails for extraction/compiled-catalog drift, +missing or stale translations, unreviewed entries, malformed ICU, changed +placeholders or tags, invalid target-locale plural branches, and glossary +violations. It is read-only: use `i18n:extract` or `i18n:compile` when catalogs +need to be written. It only warns when a new literal bypasses Lingui on the +converted viewer/build-panel surfaces. + +`bun run i18n:compile` runs Lingui’s strict compiler directly when only the +generated runtime catalogs need refreshing. + +To bundle another language, add its locale to `lingui.config.ts` and +`packages/editor/src/i18n/locales.ts`, extract, translate, compile, and add it +to the locale-pack test. + +## Hosts, plugins, and overrides + +Keep namespaces with their owner: `core.*`, `viewer.*`, `editor.*`, +`node..*`, or `plugin..*`. Raw community and host packs use ICU +strings and are compiled atomically through the lazy authoring path: + +```ts +import { registerMessages, setLocale } from '@pascal-app/core/i18n' +import { registerBundledLocales } from '@pascal-app/editor/locales' + +registerBundledLocales() + +await registerMessages('fr', 'viewer', { + 'camera.topView': 'Vue en plan', +}) +await registerMessages('en', 'viewer', { + 'camera.topView': 'Plan view', +}) + +setLocale('fr') +``` + +Later registrations win, including English overrides. Regional fallback is +automatic (`pt-BR` → `pt` → the English literal), while Simplified Chinese +script tags can use the `zh-CN` pack without crossing into Traditional Chinese. + +Pack authors that already compile with Lingui may call the synchronous +`registerCompiledMessages(locale, namespace, messages)` instead. Raw +`registerMessages` is intentionally asynchronous because it loads the ICU +compiler only for that authoring/host path. + +For server rendering, create one isolated scope per request and render the same +scope on the client: + +```tsx +const i18n = createI18n({ locale: requestedLocale }) + +return {children} +``` + +The standalone app resolves `?lang=` in its request proxy, so localized text +and numbers are present in the server response and match hydration. +`` creates the same kind of isolated scope for embeds. +Unscoped editors follow the default `setLocale`; scoped hosts switch at runtime +with `i18n.setLocale(...)`. + +## AI-assisted fill + +`bun run i18n:translate -- fr` sends only missing or source-stale PO entries +through the Vercel AI SDK. Configure one provider and model: + +- `I18N_PROVIDER=openai`, `OPENAI_API_KEY`, and `I18N_MODEL`; or +- `I18N_PROVIDER=anthropic`, `ANTHROPIC_API_KEY`, and `I18N_MODEL`. + +`OPENAI_BASE_URL` and `ANTHROPIC_BASE_URL` are optional. The structured-output +request includes the English ICU source, source locations, and +`i18n/glossary.json`. Every returned ID, ICU message, placeholder, tag, plural +branch, and applicable glossary term is validated before any PO file is +written. + +AI output is always marked `fuzzy`, `Review: required`, and records provider, +model, and review notes. It never becomes a shipping translation until a human +reviews it, removes `fuzzy`, and changes the review marker to `human`. +Translation never runs in the app or ordinary CI, and API keys must not be +committed. diff --git a/i18n/glossary.json b/i18n/glossary.json new file mode 100644 index 0000000000..8a7bd9ec58 --- /dev/null +++ b/i18n/glossary.json @@ -0,0 +1,52 @@ +{ + "productContext": "Pascal is an architecture and building-design editor. Use concise professional UI language familiar to architects, CAD users, builders, and interior designers.", + "terms": { + "ceiling": { + "zh-CN": "天花板", + "fr": "plafond", + "es": "techo", + "ja": "天井", + "note": "A generic overhead room surface, not necessarily a suspended ceiling." + }, + "floor plan": { + "zh-CN": "平面图", + "fr": "plan d’étage", + "es": "plano de planta", + "ja": "平面図", + "note": "A 2D architectural drawing, not a project plan." + }, + "level": { + "zh-CN": "楼层", + "fr": "niveau", + "es": "planta", + "ja": "階層", + "note": "A building storey." + }, + "room": { + "zh-CN": "房间", + "fr": "pièce", + "es": "estancia", + "ja": "部屋" + }, + "scan": { + "zh-CN": "扫描模型", + "fr": "relevé 3D", + "es": "escaneo 3D", + "ja": "3Dスキャン", + "note": "A captured 3D reference, not the verb scan." + }, + "wall": { + "zh-CN": "墙体", + "fr": "mur", + "es": "muro", + "ja": "壁" + }, + "walkthrough": { + "zh-CN": "漫游", + "fr": "visite", + "es": "recorrido", + "ja": "ウォークスルー", + "note": "First-person navigation through the building." + } + } +} diff --git a/lingui.config.ts b/lingui.config.ts new file mode 100644 index 0000000000..8be0f48ace --- /dev/null +++ b/lingui.config.ts @@ -0,0 +1,30 @@ +import type { LinguiConfig } from '@lingui/conf' +import { formatter } from '@lingui/format-po' + +const config: LinguiConfig = { + catalogs: [ + { + path: 'packages/editor/src/i18n/locales/{locale}/messages', + include: [ + 'packages/core/src', + 'packages/viewer/src', + 'packages/editor/src', + 'packages/nodes/src', + 'apps/editor', + ], + exclude: ['**/*.test.{ts,tsx}', '**/__fixtures__/**'], + }, + ], + compileNamespace: 'es', + fallbackLocales: false, + format: formatter({ + explicitIdAsDefault: true, + lineNumbers: false, + origins: true, + }), + locales: ['en', 'zh-CN', 'fr', 'es', 'ja'], + orderBy: 'messageId', + sourceLocale: 'en', +} + +export default config diff --git a/package.json b/package.json index 9853ad0dbd..c28b819148 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,12 @@ "checks": "bun run check && bun run check-types", "check:fix": "biome check --write", "check-types": "turbo run check-types", - "test": "turbo run test", - "ci": "bun run check && bun run skills:validate && bun run check-types && bun run test && bun run build", + "i18n:check": "bun scripts/i18n.ts check", + "i18n:compile": "bun scripts/i18n.ts compile", + "i18n:extract": "bun scripts/i18n.ts extract", + "i18n:translate": "bun scripts/i18n.ts translate", + "test": "turbo run test && bun test scripts/i18n.test.ts apps/editor/proxy.test.ts", + "ci": "bun run check && bun run i18n:check && bun run skills:validate && bun run check-types && bun run test && bun run build", "postinstall": "([ -n \"$CI\" ] || [ ! -e .git ] || git config core.hooksPath .githooks)", "skills:validate": "bun scripts/validate-skills.ts && bun scripts/sync-agent-versions.ts --check && bun test scripts/sync-agent-versions.test.ts scripts/cursor-plugin-policy.test.ts scripts/clawhub-ignore-policy.test.ts scripts/claude-mcp-config-policy.test.ts scripts/openai-tool-annotation-policy.test.ts scripts/path-containment.test.ts scripts/public-skill-discovery-policy.test.ts", "kill": "lsof -ti:3002 | xargs kill -9 2>/dev/null || echo 'No processes found on port 3002'", @@ -26,14 +30,23 @@ "release:major": "gh workflow run release.yml -f bump=major" }, "devDependencies": { + "@ai-sdk/anthropic": "^4.0.89", + "@ai-sdk/openai": "^4.0.103", "@biomejs/biome": "^2.6.3", + "@lingui/cli": "6.10.1", + "@lingui/conf": "6.10.1", + "@lingui/format-po": "6.10.1", + "@lingui/message-utils": "6.10.1", "@typescript/native-preview": "7.0.0-dev.20260707.2", "@typescript/typescript6": "6.0.2", + "ai": "^7.0.154", "dotenv-cli": "^11.0.0", "fast-xml-parser": "^5.11.2", + "pofile-ts": "^4.0.3", "turbo": "^2.11.10", "typescript": "npm:@typescript/typescript6@6.0.2", - "ultracite": "^8.1.0" + "ultracite": "^8.1.0", + "zod": "^4.6.7" }, "engines": { "node": "^22.23.4 || ^24.22.1 || >=26.12.1" diff --git a/packages/core/package.json b/packages/core/package.json index c4b9c92cee..9b19655432 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -11,6 +11,11 @@ "import": "./dist/index.js", "default": "./dist/index.js" }, + "./i18n": { + "types": "./dist/i18n/index.d.ts", + "import": "./dist/i18n/index.js", + "default": "./dist/i18n/index.js" + }, "./clone-scene-graph": { "types": "./dist/utils/clone-scene-graph.d.ts", "import": "./dist/utils/clone-scene-graph.js", @@ -153,6 +158,8 @@ "three": "^0.186" }, "dependencies": { + "@lingui/core": "6.10.1", + "@lingui/message-utils": "6.10.1", "@pascal-app/lingo": "^0.5.0", "dedent": "^1.7.2", "idb-keyval": "^6.3.0", diff --git a/packages/core/src/i18n/format.ts b/packages/core/src/i18n/format.ts new file mode 100644 index 0000000000..85c974d391 --- /dev/null +++ b/packages/core/src/i18n/format.ts @@ -0,0 +1,123 @@ +import { getLocale } from './registry' + +export interface NumberFormatOptions { + /** Minimum fraction digits. */ + min?: number + /** Maximum fraction digits. */ + max?: number + useGrouping?: boolean + /** + * Locale used for formatting. React renderers must pass useLocale() so the + * server and hydration snapshots use the same locale. + */ + locale?: string +} + +export type LengthUnit = 'm' | 'cm' | 'mm' | 'ft' | 'in' + +const UNIT_SCALE: Record = { + m: 1, + cm: 100, + mm: 1000, + ft: 1 / 0.3048, + in: 1 / 0.0254, +} + +const DEFAULT_UNIT_DIGITS: Record = { + m: 2, + cm: 1, + mm: 0, + ft: 2, + in: 1, +} + +function fractionDigits(value: number | undefined, fallback: number): number { + if (!(typeof value === 'number' && Number.isFinite(value))) return fallback + return Math.max(0, Math.min(20, Math.round(value))) +} + +function decimalSeparator(locale: string): string { + try { + return ( + new Intl.NumberFormat([locale, 'en']) + .formatToParts(1.1) + .find((part) => part.type === 'decimal')?.value ?? '.' + ) + } catch { + return '.' + } +} + +/** + * The persisted level is zero-based at grade. Japanese and Simplified Chinese + * label that same storey as floor 1, so floors above it display one higher. + */ +export function levelDisplayNumber(level: number, locale = getLocale()): number { + if (level <= 0) return level + try { + const parsed = new Intl.Locale(locale) + if (parsed.language === 'ja') return level + 1 + if ( + parsed.language === 'zh' && + (parsed.script === 'Hans' || (!parsed.script && parsed.region === 'CN')) + ) { + return level + 1 + } + } catch { + // Invalid locales use the stored English numbering. + } + return level +} + +export function formatNumber(value: number, options: NumberFormatOptions = {}): string { + if (!Number.isFinite(value)) return '--' + const minimumFractionDigits = fractionDigits(options.min, 0) + const maximumFractionDigits = Math.max( + minimumFractionDigits, + fractionDigits(options.max, Math.max(3, minimumFractionDigits)), + ) + + try { + return new Intl.NumberFormat([options.locale ?? 'en', 'en'], { + minimumFractionDigits, + maximumFractionDigits, + useGrouping: options.useGrouping ?? false, + }).format(value) + } catch { + return new Intl.NumberFormat('en', { + minimumFractionDigits, + maximumFractionDigits, + useGrouping: options.useGrouping ?? false, + }).format(value) + } +} + +export function formatLength( + meters: number, + unit: LengthUnit, + options: NumberFormatOptions = {}, +): string { + if (!Number.isFinite(meters)) return '--' + return `${formatNumber(meters * UNIT_SCALE[unit], { + ...options, + max: options.max ?? DEFAULT_UNIT_DIGITS[unit], + })}\u00a0${unit}` +} + +/** + * Normalize a decimal for parsers that expect `.`. Localized inputs accept + * their locale separator and `.`; grouping is intentionally not accepted in + * numeric editor fields because it is ambiguous with decimal commas. + */ +export function normalizeDecimalInput(input: string, locale = getLocale()): string { + const separator = decimalSeparator(locale) + if (separator === '.' || input.includes('.')) return input + return input.replaceAll(separator, '.') +} + +export function parseLocalizedNumber(input: string, locale = getLocale()): number | null { + const normalized = normalizeDecimalInput(input.trim(), locale).replace('−', '-') + if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(normalized)) return null + const value = Number(normalized) + return Number.isFinite(value) ? value : null +} diff --git a/packages/core/src/i18n/i18n.test.tsx b/packages/core/src/i18n/i18n.test.tsx new file mode 100644 index 0000000000..0a57faf24e --- /dev/null +++ b/packages/core/src/i18n/i18n.test.tsx @@ -0,0 +1,305 @@ +import { afterEach, describe, expect, spyOn, test } from 'bun:test' +import { renderToString } from 'react-dom/server' +import { + BuildingNode, + createI18n, + defaultI18n, + formatLength, + formatNumber, + LevelNode, + levelDisplayNumber, + PascalI18nProvider, + parseLocalizedNumber, + registerCompiledMessages, + registerMessages, + SiteNode, + setLocale, + subscribe, + translate, + useLocale, + usePascalI18n, +} from '../index' + +afterEach(() => { + setLocale('en') +}) + +describe('message registry', () => { + test('merges host overrides and falls back from a regional locale to its language', async () => { + await registerMessages('pt', 'viewer', { + 'controls.open': 'Abrir', + 'controls.close': 'Fechar', + }) + await registerMessages('pt-BR', 'viewer', { 'controls.close': 'Encerrar' }) + await registerMessages('pt-BR', 'viewer', { 'controls.close': 'Fechar agora' }) + + setLocale('pt-BR') + + expect(translate('viewer.controls.open', 'Open')).toBe('Abrir') + expect(translate('viewer.controls.close', 'Close')).toBe('Fechar agora') + expect(translate('viewer.controls.unknown', 'Unknown')).toBe('Unknown') + }) + + test('falls back through script before the base language', async () => { + await registerMessages('zh', 'viewer', { greeting: '你好' }) + await registerMessages('zh-Hant', 'viewer', { greeting: '您好' }) + setLocale('zh-Hant-HK') + + expect(translate('viewer.greeting', 'Hello')).toBe('您好') + }) + + test('maps explicit Simplified Chinese scripts to zh-CN without crossing into Traditional', async () => { + await registerMessages('zh-CN', 'viewer', { 'test.simplified-alias': '简体中文' }) + + expect(translate('viewer.test.simplified-alias', 'English', undefined, 'zh-Hans')).toBe( + '简体中文', + ) + expect(translate('viewer.test.simplified-alias', 'English', undefined, 'zh-Hans-CN')).toBe( + '简体中文', + ) + expect(translate('viewer.test.simplified-alias', 'English', undefined, 'zh-Hant')).toBe( + 'English', + ) + expect(translate('viewer.test.simplified-alias', 'English', undefined, 'zh-TW')).toBe('English') + }) + + test('allows later English registrations to override the in-code default', async () => { + await registerMessages('en', 'viewer', { hostGreeting: 'Welcome' }) + + expect(translate('viewer.hostGreeting', 'Hello')).toBe('Welcome') + expect(translate('viewer.unregistered', 'In-code fallback')).toBe('In-code fallback') + }) + + test('rejects prototype-mutating catalog keys', async () => { + const rawMessages = Object.create(null) as Record + rawMessages.__proto__ = 'polluted' + await expect(registerMessages('en', '', rawMessages)).rejects.toThrow('__proto__') + + const compiledMessages = Object.create(null) + compiledMessages.__proto__ = ['polluted'] + expect(() => registerCompiledMessages('en', '', compiledMessages)).toThrow('__proto__') + expect(Object.getPrototypeOf({})).toBe(Object.prototype) + expect(({} as { polluted?: string }).polluted).toBeUndefined() + }) + + test('interpolates values and selects ICU plurals', async () => { + await registerMessages('fr', 'editor', { + selection: '{count, plural, one {# élément sélectionné} other {# éléments sélectionnés}}', + welcome: 'Bonjour {name}', + }) + setLocale('fr') + + expect( + translate('editor.selection', '{count, plural, one {# item} other {# items}}', { count: 1 }), + ).toBe('1 élément sélectionné') + expect( + translate('editor.selection', '{count, plural, one {# item} other {# items}}', { count: 3 }), + ).toBe('3 éléments sélectionnés') + expect(translate('editor.welcome', 'Hello {name}', { name: 'Ada' })).toBe('Bonjour Ada') + }) + + test('uses English plural rules when a translation is missing', async () => { + await registerMessages('en', 'viewer', { + 'test.english-plural-fallback': + '{count, plural, one {# fallback item} other {# fallback items}}', + }) + setLocale('ru') + + expect( + translate( + 'viewer.test.english-plural-fallback', + '{count, plural, one {# item} other {# items}}', + { count: 21 }, + ), + ).toBe('21 fallback items') + }) + + test('notifies subscribers for locale and active-catalog changes', async () => { + let changes = 0 + let localeChanges = 0 + const unsubscribe = subscribe(() => { + changes += 1 + }) + const unsubscribeLocale = defaultI18n.subscribeLocale(() => { + localeChanges += 1 + }) + + setLocale('ja') + await registerMessages('ja', 'viewer', { test: 'テスト' }) + unsubscribe() + unsubscribeLocale() + setLocale('es') + + expect(changes).toBe(2) + expect(localeChanges).toBe(1) + }) + + test('keeps a scoped locale across catalog refreshes and accepts repeated global locale intent', async () => { + const scope = createI18n({ locale: 'fr' }) + const release = scope.retain() + const unsubscribe = defaultI18n.subscribeLocale((locale) => scope.setLocale(locale)) + + await registerMessages('fr', 'viewer', { 'test.catalog-refresh': 'Actualisé' }) + expect(scope.getLocale()).toBe('fr') + expect(scope._({ id: 'viewer.test.catalog-refresh', message: 'Refreshed' })).toBe('Actualisé') + + setLocale('en') + expect(scope.getLocale()).toBe('en') + + unsubscribe() + release() + scope.destroy() + }) + + test('keeps request-local scopes isolated', async () => { + await registerMessages('fr', 'viewer', { isolated: 'Français' }) + await registerMessages('ja', 'viewer', { isolated: '日本語' }) + const french = createI18n({ locale: 'fr' }) + const japanese = createI18n({ locale: 'ja' }) + + expect(french._({ id: 'viewer.isolated', message: 'English' })).toBe('Français') + expect(japanese._({ id: 'viewer.isolated', message: 'English' })).toBe('日本語') + french.setLocale('en') + expect(japanese.getLocale()).toBe('ja') + + french.destroy() + japanese.destroy() + }) + + test('does not retain server scopes and can reattach after Strict Mode effect replay', async () => { + const serverScope = createI18n({ locale: 'fr' }) + let serverRefreshes = 0 + serverScope.subscribe(() => { + serverRefreshes += 1 + }) + renderToString( + + server + , + ) + + await registerMessages('fr', 'viewer', { 'test.after-server-render': 'Après' }) + expect(serverRefreshes).toBe(0) + expect( + serverScope._({ id: 'viewer.test.after-server-render', message: 'After server render' }), + ).toBe('After server render') + + const clientScope = createI18n({ locale: 'fr' }) + let clientRefreshes = 0 + clientScope.subscribe(() => { + clientRefreshes += 1 + }) + const firstCleanup = clientScope.retain() + firstCleanup() + firstCleanup() + const secondCleanup = clientScope.retain() + + await registerMessages('fr', 'viewer', { 'test.after-effect-replay': 'Rattaché' }) + expect(clientRefreshes).toBe(1) + expect(clientScope._({ id: 'viewer.test.after-effect-replay', message: 'Reattached' })).toBe( + 'Rattaché', + ) + + secondCleanup() + serverScope.destroy() + clientScope.destroy() + }) + + test('warns once in development and never exposes the missing key', () => { + const previous = process.env.NODE_ENV + process.env.NODE_ENV = 'development' + const warn = spyOn(console, 'warn').mockImplementation(() => {}) + setLocale('ja') + + expect(translate('viewer.test.missing-warning', 'Visible fallback')).toBe('Visible fallback') + expect(translate('viewer.test.missing-warning', 'Visible fallback')).toBe('Visible fallback') + expect(warn).toHaveBeenCalledTimes(1) + + warn.mockRestore() + if (previous === undefined) delete process.env.NODE_ENV + else process.env.NODE_ENV = previous + }) +}) + +describe('locale formatting', () => { + test('formats numbers and lengths without coupling locale to the unit', () => { + expect(formatNumber(2.75, { locale: 'fr', max: 2 })).toBe('2,75') + expect(formatLength(2.75, 'm', { locale: 'fr' })).toBe('2,75\u00a0m') + expect(formatLength(2.75, 'mm', { locale: 'fr' })).toBe('2750\u00a0mm') + expect(formatLength(2.75, 'm', { locale: 'en' })).toBe('2.75\u00a0m') + }) + + test('falls back to English formatting for an unsupported locale', () => { + expect(formatNumber(2.75, { locale: 'zz', max: 2 })).toBe('2.75') + expect(formatLength(2.75, 'm', { locale: 'zz' })).toBe('2.75\u00a0m') + }) + + test('accepts the locale decimal separator as well as a dot', () => { + expect(parseLocalizedNumber('2,75', 'fr')).toBe(2.75) + expect(parseLocalizedNumber('2.75', 'fr')).toBe(2.75) + expect(parseLocalizedNumber('1,25e2', 'fr')).toBe(125) + expect(parseLocalizedNumber('2,75', 'en')).toBeNull() + }) + + test('uses local floor numbering without changing persisted level indices', () => { + expect(levelDisplayNumber(1, 'en')).toBe(1) + expect(levelDisplayNumber(1, 'ja')).toBe(2) + expect(levelDisplayNumber(1, 'zh-CN')).toBe(2) + expect(levelDisplayNumber(1, 'zh-Hans')).toBe(2) + expect(levelDisplayNumber(1, 'zh-Hant')).toBe(1) + expect(levelDisplayNumber(1, 'zh-TW')).toBe(1) + expect(levelDisplayNumber(0, 'ja')).toBe(0) + }) +}) + +test('server rendering uses the selected locale for text and numbers', async () => { + await registerMessages('fr', 'viewer', { greeting: 'Bonjour' }) + const scope = createI18n({ locale: 'fr' }) + + function Greeting() { + const i18n = usePascalI18n() + const locale = useLocale() + return ( + + {i18n._({ id: 'viewer.greeting', message: 'Hello' })}: + {formatNumber(2.75, { locale, max: 2 })} + + ) + } + + const serverHtml = renderToString( + + + , + ) + + expect(serverHtml).toContain('Bonjour') + expect(serverHtml).toContain('2,75') + expect(serverHtml).not.toContain('Hello') + scope.destroy() +}) + +test('building and serializing a scene is locale-neutral', () => { + function serializeFixture() { + const level = LevelNode.parse({ id: 'level_i18n', parentId: 'building_i18n', children: [] }) + const building = BuildingNode.parse({ + id: 'building_i18n', + parentId: 'site_i18n', + children: [level.id], + }) + const site = SiteNode.parse({ id: 'site_i18n', children: [building.id] }) + return JSON.stringify({ + nodes: { + [site.id]: site, + [building.id]: building, + [level.id]: level, + }, + rootNodeIds: [site.id], + }) + } + + setLocale('en') + const english = serializeFixture() + setLocale('ja') + expect(serializeFixture()).toBe(english) +}) diff --git a/packages/core/src/i18n/index.ts b/packages/core/src/i18n/index.ts new file mode 100644 index 0000000000..82f51409a7 --- /dev/null +++ b/packages/core/src/i18n/index.ts @@ -0,0 +1,25 @@ +export { + formatLength, + formatNumber, + type LengthUnit, + levelDisplayNumber, + type NumberFormatOptions, + normalizeDecimalInput, + parseLocalizedNumber, +} from './format' +export { + type CompiledTranslationMessages, + createI18n, + defaultI18n, + getLocale, + type PascalI18n, + registerCompiledMessages, + registerMessages, + setLocale, + subscribe, + type TranslationMessage, + type TranslationMessages, + type TranslationParams, + translate, +} from './registry' +export { PascalI18nProvider, useLocale, usePascalI18n } from './use-translate' diff --git a/packages/core/src/i18n/registry.ts b/packages/core/src/i18n/registry.ts new file mode 100644 index 0000000000..e476e6226c --- /dev/null +++ b/packages/core/src/i18n/registry.ts @@ -0,0 +1,293 @@ +import { + type I18n as LinguiI18n, + type MessageDescriptor, + type MessageOptions, + type Messages, + setupI18n, + type Values, +} from '@lingui/core' + +/** @experimental Unreleased API; signatures may change before the first i18n release. */ +export type TranslationParams = Record +/** @experimental Unreleased API; signatures may change before the first i18n release. */ +export type TranslationMessage = string +/** @experimental Unreleased API; signatures may change before the first i18n release. */ +export type TranslationMessages = Record +/** @experimental Unreleased API; signatures may change before the first i18n release. */ +export type CompiledTranslationMessages = Messages + +const catalogs = new Map() +const scopes = new Set() +const warnedMissingKeys = new Set() + +function emptyMessages(): Messages { + return Object.create(null) as Messages +} + +function assertSafeMessageKey(key: string): void { + if (key.split('.').includes('__proto__')) { + throw new TypeError('Translation message keys cannot contain "__proto__"') + } +} + +function canonicalLocale(value: string): string { + const trimmed = value.trim() + if (!trimmed) return 'en' + try { + return Intl.getCanonicalLocales(trimmed)[0] ?? 'en' + } catch { + return 'en' + } +} + +function localeFallbacks(value: string): string[] { + const exact = canonicalLocale(value) + try { + const parsed = new Intl.Locale(exact) + const candidates = [exact] + if (parsed.baseName !== exact) candidates.push(parsed.baseName) + if (parsed.script) candidates.push(`${parsed.language}-${parsed.script}`) + if (parsed.language === 'zh' && parsed.script === 'Hans') candidates.push('zh-CN') + candidates.push(parsed.language) + return [...new Set(candidates)] + } catch { + return [exact] + } +} + +function isDevelopment(): boolean { + try { + const meta = import.meta as { env?: { DEV?: boolean } } + if (typeof meta.env?.DEV === 'boolean') return meta.env.DEV + } catch { + // Fall through for runtimes without import.meta.env. + } + return typeof process !== 'undefined' && process.env?.NODE_ENV === 'development' +} + +function namespacedMessages(namespace: string, messages: Messages): Messages { + const normalizedNamespace = namespace.replace(/^\.+|\.+$/g, '') + const namespaced = emptyMessages() + for (const [key, message] of Object.entries(messages)) { + const normalizedKey = key.replace(/^\.+/g, '') + assertSafeMessageKey(normalizedKey) + const fullKey = normalizedNamespace ? `${normalizedNamespace}.${normalizedKey}` : normalizedKey + assertSafeMessageKey(fullKey) + namespaced[fullKey] = message + } + return namespaced +} + +function mergedMessages(requestedLocale: string): Messages { + const messages = emptyMessages() + for (const candidate of localeFallbacks(requestedLocale).reverse()) { + Object.assign(messages, catalogs.get(candidate)) + } + return messages +} + +function warnMissing(key: string, requestedLocale: string): void { + if (!isDevelopment() || requestedLocale === 'en') return + const warningId = `${requestedLocale}:${key}` + if (warnedMissingKeys.has(warningId)) return + warnedMissingKeys.add(warningId) + console.warn(`[i18n] Missing "${key}" for locale "${requestedLocale}"; using English.`) +} + +/** + * A request- or editor-local locale selection over Pascal's shared message + * registry. Message parsing, interpolation, and ICU selection are delegated to + * private Lingui instances. + * + * @experimental Unreleased API; signatures may change before the first i18n release. + */ +export class PascalI18n { + readonly i18n: LinguiI18n + private readonly englishI18n: LinguiI18n + private readonly listeners = new Set<() => void>() + private readonly localeListeners = new Set<(locale: string) => void>() + private retentionCount = 0 + private locale: string + private version = 0 + + constructor(initialLocale = 'en') { + this.locale = canonicalLocale(initialLocale) + this.i18n = setupI18n() + this.englishI18n = setupI18n() + this.refresh(false) + } + + getLocale(): string { + return this.locale + } + + getVersion(): number { + return this.version + } + + setLocale(nextLocale: string): void { + const normalized = canonicalLocale(nextLocale) + if (normalized !== this.locale) { + this.locale = normalized + this.refresh() + } + for (const listener of [...this.localeListeners]) listener(normalized) + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener) + return () => { + this.listeners.delete(listener) + } + } + + /** Subscribe only to locale selection, excluding catalog refreshes. */ + subscribeLocale(listener: (locale: string) => void): () => void { + this.localeListeners.add(listener) + return () => { + this.localeListeners.delete(listener) + } + } + + /** + * Retain this scope for shared catalog updates while a client provider is + * mounted. Server rendering never calls effects, so request scopes are not + * held by the module registry. + */ + retain(): () => void { + this.retentionCount += 1 + if (this.retentionCount === 1) scopes.add(this) + let retained = true + return () => { + if (!retained) return + retained = false + if (this.retentionCount === 0) return + this.retentionCount -= 1 + if (this.retentionCount === 0) scopes.delete(this) + } + } + + destroy(): void { + this.retentionCount = 0 + scopes.delete(this) + this.listeners.clear() + this.localeListeners.clear() + } + + _(descriptor: MessageDescriptor): string + _(id: string, values?: Values, options?: MessageOptions): string + _(idOrDescriptor: string | MessageDescriptor, values?: Values, options?: MessageOptions): string { + const id = typeof idOrDescriptor === 'string' ? idOrDescriptor : idOrDescriptor.id + const translated = + this.locale === 'en' || Object.prototype.hasOwnProperty.call(this.i18n.messages, id) + const runtime = translated ? this.i18n : this.englishI18n + if (!translated) warnMissing(id, this.locale) + return typeof idOrDescriptor === 'string' + ? runtime._(idOrDescriptor, values, options) + : runtime._(idOrDescriptor) + } + + refresh(notify = true): void { + this.i18n.loadAndActivate({ + locale: this.locale, + messages: mergedMessages(this.locale), + }) + this.englishI18n.loadAndActivate({ + locale: 'en', + messages: mergedMessages('en'), + }) + if (!notify) return + this.version += 1 + for (const listener of [...this.listeners]) listener() + } +} + +/** + * Create an isolated locale scope backed by the shared Pascal message packs. + * + * @experimental Unreleased API; signatures may change before the first i18n release. + */ +export function createI18n(options: { locale?: string } = {}): PascalI18n { + return new PascalI18n(options.locale) +} + +/** @experimental Unreleased API; signatures may change before the first i18n release. */ +export const defaultI18n = createI18n() +defaultI18n.retain() + +/** + * Register a raw ICU MessageFormat pack. The Lingui compiler is loaded only + * for this authoring/host path; bundled packs use registerCompiledMessages. + * The whole pack is validated before it becomes visible. + * + * @experimental Unreleased API; signatures may change before the first i18n release. + */ +export async function registerMessages( + messageLocale: string, + namespace: string, + messages: TranslationMessages, +): Promise { + const { compileMessageOrThrow } = await import('@lingui/message-utils/compileMessage') + const compiled = emptyMessages() + for (const [key, message] of Object.entries(messages)) { + assertSafeMessageKey(key) + compiled[key] = compileMessageOrThrow(message) + } + registerCompiledMessages(messageLocale, namespace, compiled) +} + +/** + * Register a catalog produced by `lingui compile`. Later registrations replace + * earlier messages with the same fully namespaced ID. + * + * @experimental Unreleased API; signatures may change before the first i18n release. + */ +export function registerCompiledMessages( + messageLocale: string, + namespace: string, + messages: CompiledTranslationMessages, +): void { + const normalizedLocale = canonicalLocale(messageLocale) + const catalog = catalogs.get(normalizedLocale) ?? emptyMessages() + Object.assign(catalog, namespacedMessages(namespace, messages)) + catalogs.set(normalizedLocale, catalog) + for (const scope of [...scopes]) scope.refresh() +} + +/** @experimental Unreleased API; signatures may change before the first i18n release. */ +export function setLocale(nextLocale: string): void { + defaultI18n.setLocale(nextLocale) +} + +/** @experimental Unreleased API; signatures may change before the first i18n release. */ +export function getLocale(): string { + return defaultI18n.getLocale() +} + +/** @experimental Unreleased API; signatures may change before the first i18n release. */ +export function subscribe(listener: () => void): () => void { + return defaultI18n.subscribe(listener) +} + +/** @experimental Unreleased API; signatures may change before the first i18n release. */ +export function getI18nVersion(): number { + return defaultI18n.getVersion() +} + +/** @experimental Unreleased API; signatures may change before the first i18n release. */ +export function translate( + key: string, + englishDefault: TranslationMessage, + params?: TranslationParams, + requestedLocale = getLocale(), +): string { + const scope = + canonicalLocale(requestedLocale) === defaultI18n.getLocale() + ? defaultI18n + : createI18n({ locale: requestedLocale }) + try { + return scope._({ id: key, message: englishDefault, values: params }) + } finally { + if (scope !== defaultI18n) scope.destroy() + } +} diff --git a/packages/core/src/i18n/use-translate.ts b/packages/core/src/i18n/use-translate.ts new file mode 100644 index 0000000000..214db0d3d9 --- /dev/null +++ b/packages/core/src/i18n/use-translate.ts @@ -0,0 +1,39 @@ +'use client' + +import { + createContext, + createElement, + type ReactNode, + useContext, + useEffect, + useSyncExternalStore, +} from 'react' +import { defaultI18n, type PascalI18n } from './registry' + +const PascalI18nContext = createContext(defaultI18n) + +/** + * Bind a request- or editor-local Pascal scope to React and Lingui. + * + * @experimental Unreleased API; signatures may change before the first i18n release. + */ +export function PascalI18nProvider({ children, i18n }: { children: ReactNode; i18n: PascalI18n }) { + useEffect(() => i18n.retain(), [i18n]) + return createElement(PascalI18nContext.Provider, { value: i18n }, children) +} + +/** @experimental Unreleased API; signatures may change before the first i18n release. */ +export function usePascalI18n(): PascalI18n { + const i18n = useContext(PascalI18nContext) + useSyncExternalStore( + i18n.subscribe.bind(i18n), + i18n.getVersion.bind(i18n), + i18n.getVersion.bind(i18n), + ) + return i18n +} + +/** @experimental Unreleased API; signatures may change before the first i18n release. */ +export function useLocale(): string { + return usePascalI18n().getLocale() +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ced875d413..8b1903787a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -113,6 +113,7 @@ export { type WallConstructionResolution, } from './hooks/spatial-grid/support-host-patch' export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query' +export * from './i18n' export { createAngleAccumulator } from './lib/angle-accumulator' export { ARTIFACT_URL_PREFIX, diff --git a/packages/editor/package.json b/packages/editor/package.json index 4e909a1078..97bf5e8909 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -7,6 +7,7 @@ "exports": { ".": "./src/index.tsx", "./catalog": "./src/components/ui/item-catalog/catalog-items.tsx", + "./locales": "./src/i18n/locales.ts", "./procedural-items": "./src/components/procedural-items/inspector.tsx", "./distribution": "./src/components/tools/distribution/distribution-public.ts" }, @@ -30,6 +31,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@iconify/react": "^6.0.3", + "@lingui/core": "6.10.1", "@number-flow/react": "^0.6.2", "@pascal-app/geometry-script": "^1.0.6", "@pascal-app/ifc-converter": "^1.0.6", diff --git a/packages/editor/src/components/editor-2d/floorplan-measurement-tool-layer.tsx b/packages/editor/src/components/editor-2d/floorplan-measurement-tool-layer.tsx index 70840020e3..e24fc9fca4 100644 --- a/packages/editor/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-measurement-tool-layer.tsx @@ -16,6 +16,7 @@ import { measurementPerimeter, measurementPrismVolume, nodeRegistry, + useLocale, useScene, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' @@ -27,9 +28,9 @@ import { formatLinearMeasurement, formatVolumeLabel, getLinearUnitLabel, - linearUnitToMeters, MEASUREMENT_ACTIVE_COLOR, metersToLinearUnit, + parseLinearMeasurementInput, } from '../../lib/measurements' import { clearSurfacePlanSnapFeedback, @@ -636,6 +637,7 @@ function FloorplanExtrusionControl({ unitsPerPixel: number }) { const unit = useViewer((state) => state.unit) + const locale = useLocale() const extrusionHeight = useMeasurementDraft((state) => state.extrusionHeight) const points = useMeasurementDraft((state) => state.points) const baseNormal = useMeasurementDraft((state) => state.baseNormal) @@ -654,12 +656,12 @@ function FloorplanExtrusionControl({ const y = center[2] - height / 2 const commit = () => { - const numericValue = Number.parseFloat(value) - if (!Number.isFinite(numericValue)) { + const meters = parseLinearMeasurementInput(value, unit, locale) + if (meters === null) { useMeasurementDraft.getState().setExtrusionHeight('2d', 0) return } - useMeasurementDraft.getState().setExtrusionHeight('2d', linearUnitToMeters(numericValue, unit)) + useMeasurementDraft.getState().setExtrusionHeight('2d', meters) const draft = useMeasurementDraft.getState() if (draft.finishExtrusion('2d')) commitMeasurementDraft('2d') } @@ -710,11 +712,9 @@ function FloorplanExtrusionControl({ onChange={(event) => { const next = event.target.value setValue(next) - const numericValue = Number.parseFloat(next) - if (Number.isFinite(numericValue)) { - useMeasurementDraft - .getState() - .setExtrusionHeight('2d', linearUnitToMeters(numericValue, unit)) + const meters = parseLinearMeasurementInput(next, unit, locale) + if (meters !== null) { + useMeasurementDraft.getState().setExtrusionHeight('2d', meters) } else { useMeasurementDraft.getState().setExtrusionHeight('2d', 0) } @@ -728,7 +728,7 @@ function FloorplanExtrusionControl({ }} placeholder="0" step="0.1" - type="number" + type="text" value={value} /> diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx index a28e419eba..640164afe1 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx @@ -1,7 +1,7 @@ 'use client' import type { AnyNode, AnyNodeId } from '@pascal-app/core' -import { useScene } from '@pascal-app/core' +import { useLocale, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' @@ -78,6 +78,7 @@ export function FloorplanDimensionEditOverlay(): React.ReactElement | null { const [error, setError] = useState(null) const inputRef = useRef(null) const unit = useViewer((state) => state.unit) + const locale = useLocale() useEffect(() => { let down: { x: number; y: number } | null = null @@ -154,7 +155,7 @@ export function FloorplanDimensionEditOverlay(): React.ReactElement | null { const commit = useCallback(() => { if (!target) return - const nextLength = parseDimensionInput(draft, unit) + const nextLength = parseDimensionInput(draft, unit, locale) if (nextLength === null) { setError(`Cannot read "${draft.trim()}"`) return @@ -166,7 +167,7 @@ export function FloorplanDimensionEditOverlay(): React.ReactElement | null { } setTarget(null) setError(null) - }, [draft, target, unit]) + }, [draft, locale, target, unit]) if (!target) return null if (typeof document === 'undefined') return null diff --git a/packages/editor/src/components/editor/floorplan-panel.test.ts b/packages/editor/src/components/editor/floorplan-panel.test.ts new file mode 100644 index 0000000000..4b24209cc1 --- /dev/null +++ b/packages/editor/src/components/editor/floorplan-panel.test.ts @@ -0,0 +1,7 @@ +import { expect, test } from 'bun:test' +import { parseReferenceScaleLength } from './floorplan-panel' + +test('floor-plan reference scales parse with the scoped locale', () => { + expect(parseReferenceScaleLength('1,000', 'meters', 'fr')).toBe(1) + expect(parseReferenceScaleLength('1,000', 'meters', 'en')).toBe(1000) +}) diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index d6fa6c9074..b257b63f69 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -50,6 +50,7 @@ import { useInteractive, useLiveNodeOverrides, useLiveTransforms, + useLocale, useScene, type WallNode, WallNode as WallNodeSchema, @@ -526,7 +527,7 @@ type GuideTransformDraft = { rotation: number } -type ReferenceScaleUnit = 'meters' | 'centimeters' | 'feet' | 'inches' +export type ReferenceScaleUnit = 'meters' | 'centimeters' | 'feet' | 'inches' // The in-flight reference-scale measurement. Only the per-CLICK fields live // here (guide + start anchor); the rubber-band's moving END is the shared @@ -2465,22 +2466,35 @@ const REFERENCE_SCALE_LINGO_UNIT: Record(null) const svgRef = useRef(null) const floorplanBackgroundRef = useRef(null) @@ -7161,7 +7176,7 @@ export function FloorplanPanel({ return } - const displayLength = parseReferenceScaleLength(referenceScaleValue, referenceScaleUnit) + const displayLength = parseReferenceScaleLength(referenceScaleValue, referenceScaleUnit, locale) if (!(displayLength && displayLength > 0)) { return } @@ -7215,6 +7230,7 @@ export function FloorplanPanel({ setPendingReferenceScale(null) }, [ guideById, + locale, pendingReferenceScale, referenceScaleUnit, referenceScaleValue, @@ -11205,7 +11221,7 @@ export function FloorplanPanel({ isPanning || isRotatingFloorplan ? 'grabbing' : isSpacePanPressed ? 'grab' : null const isFloorplanNavigationOverlayVisible = isSpacePanPressed || isPanning || isRotatingFloorplan const pendingReferenceDisplayLength = - parseReferenceScaleLength(referenceScaleValue, referenceScaleUnit) ?? Number.NaN + parseReferenceScaleLength(referenceScaleValue, referenceScaleUnit, locale) ?? Number.NaN const pendingReferenceRealLengthMeters = pendingReferenceScale && pendingReferenceDisplayLength > 0 ? convertReferenceLengthToMeters(pendingReferenceDisplayLength, referenceScaleUnit) @@ -11228,7 +11244,7 @@ export function FloorplanPanel({ : 'Length must be greater than 0.' const referenceScaleHint = referenceScaleInputError ? null - : referenceScaleLengthHint(referenceScaleValue, referenceScaleUnit) + : referenceScaleLengthHint(referenceScaleValue, referenceScaleUnit, locale) return (
void { } } export interface EditorProps { + /** UI locale. Hosts may also call `setLocale` before mounting. */ + locale?: string // Layout version — 'v1' (default) or 'v2' (navbar + two-column) layoutVersion?: 'v1' | 'v2' // Viewport edge the tool menu docks to (desktop only; mobile stays at the bottom) @@ -1960,10 +1965,28 @@ function EditorContent({ ) } -export default function Editor(props: EditorProps) { +function EditorFrame(props: EditorProps) { return ( ) } + +function LocalizedEditor(props: EditorProps & { locale: string }) { + const i18n = useMemo(() => createI18n({ locale: props.locale }), [props.locale]) + + return ( + + + + ) +} + +export default function Editor(props: EditorProps) { + return props.locale ? ( + + ) : ( + + ) +} diff --git a/packages/editor/src/components/editor/room-labels.tsx b/packages/editor/src/components/editor/room-labels.tsx index 73e1052d48..489a4eb6c1 100644 --- a/packages/editor/src/components/editor/room-labels.tsx +++ b/packages/editor/src/components/editor/room-labels.tsx @@ -7,10 +7,12 @@ import { containsPoint, DEFAULT_LEVEL_HEIGHT, emitter, + formatNumber, type Polygon, polygonInteriorPoint, type Ring, sceneRegistry, + useLocale, useScene, type ZoneNode, } from '@pascal-app/core' @@ -92,8 +94,12 @@ export function roomLabelsVisible(state: RoomLabelVisibilityState): boolean { ) } -export function formatRoomArea(squareMeters: number, unit: LinearUnit): string { - return `${squareMetersToAreaUnit(squareMeters, unit).toFixed(1)} ${getAreaUnitLabel(unit)}` +export function formatRoomArea(squareMeters: number, unit: LinearUnit, locale?: string): string { + return `${formatNumber(squareMetersToAreaUnit(squareMeters, unit), { + min: 1, + max: 1, + locale, + })} ${getAreaUnitLabel(unit)}` } function useRoomLabelsVisible() { @@ -1169,11 +1175,12 @@ export function RoomPillChip({ onLeave: () => void }) { const unit = useViewer((s) => s.unit) - const areaLabel = formatRoomArea(areaM2, unit) + const locale = useLocale() + const areaLabel = formatRoomArea(areaM2, unit, locale) // biome-ignore lint/correctness/useExhaustiveDependencies: A dot's border box does not resize when its hidden text changes. useLayoutEffect(() => { if (elements instanceof PillElements) elements.measure(zoneId) - }, [areaM2, elements, name, unit, zoneId]) + }, [areaM2, elements, locale, name, unit, zoneId]) const register = useCallback( (element: HTMLElement | null) => { if (element) elements.set(zoneId, element) @@ -1227,6 +1234,7 @@ export function RoomPillDetail({ areaM2: number }) { const unit = useViewer((s) => s.unit) + const locale = useLocale() return (
- +
) } diff --git a/packages/editor/src/components/ui/build-panel/build-panel.tsx b/packages/editor/src/components/ui/build-panel/build-panel.tsx index beba274971..4a6c3f77ed 100644 --- a/packages/editor/src/components/ui/build-panel/build-panel.tsx +++ b/packages/editor/src/components/ui/build-panel/build-panel.tsx @@ -1,10 +1,10 @@ 'use client' +import { type PascalI18n, usePascalI18n } from '@pascal-app/core' import { ChevronRight } from 'lucide-react' import { type ComponentPropsWithoutRef, type ReactNode, useEffect, useState } from 'react' import { cn } from '../../../lib/utils' import { - WALL_DRAW_IDLE_CAPTION, WALL_DRAW_VARIANTS, type WallDrawVariant, } from '../../../lib/wall-draw-variant' @@ -70,6 +70,7 @@ export function BuildPanelAdvancedSection({ description?: string hint?: string }) { + const i18n = usePascalI18n() const [open, setOpen] = useState(false) useEffect(() => setOpen(readAdvancedOpen()), []) useEffect(() => { @@ -94,7 +95,9 @@ export function BuildPanelAdvancedSection({ open && 'rotate-90', )} /> -

Advanced

+

+ {i18n._({ id: 'editor.buildPanel.advanced', message: 'Advanced' })} +

{hint ? ( {hint} ) : null} @@ -188,12 +191,23 @@ export function BuildToolTile({ ) } +const CAPTION_LEAD_MARKER = '__PASCAL_CAPTION_LEAD__' + /** The line under the Rooms tiles: what the armed shape does, lead in bold. */ -export function BuildPanelCaption({ lead, rest }: { lead?: string; rest: string }) { +export function BuildPanelCaption({ caption, lead }: { caption: string; lead?: string }) { + const markerIndex = caption.indexOf(CAPTION_LEAD_MARKER) + const hasMarkedLead = lead && markerIndex >= 0 return (

- {lead ? {lead} : null} - {rest} + {hasMarkedLead ? ( + <> + {caption.slice(0, markerIndex)} + {lead} + {caption.slice(markerIndex + CAPTION_LEAD_MARKER.length)} + + ) : ( + caption + )}

) } @@ -214,29 +228,103 @@ export function BuildPanelRoomsSection({ /** Extra attributes per tile (e.g. a host's guide-target hook). */ tileProps?: (variant: WallDrawVariant) => Record }) { + const i18n = usePascalI18n() const active = WALL_DRAW_VARIANTS.find((variant) => variant.id === activeVariant) + const activeMessages = active ? roomVariantMessages(active.id, i18n) : null return ( - + - {WALL_DRAW_VARIANTS.map((variant) => ( - onSelect(variant.id)} - onMouseEnter={() => onHover?.(variant.id)} - size="lg" - title={variant.title} - {...tileProps?.(variant.id)} - /> - ))} + {WALL_DRAW_VARIANTS.map((variant) => { + const messages = roomVariantMessages(variant.id, i18n) + return ( + onSelect(variant.id)} + onMouseEnter={() => onHover?.(variant.id)} + size="lg" + title={messages.title} + {...tileProps?.(variant.id)} + /> + ) + })} ) } + +function roomVariantMessages(variant: WallDrawVariant, i18n: PascalI18n) { + if (variant === 'rectangle') { + return { + label: i18n._({ + id: 'editor.buildPanel.rooms.rectangle.label', + message: 'Rectangle', + }), + title: i18n._({ + id: 'editor.buildPanel.rooms.rectangle.title', + message: 'Rectangle room', + }), + lead: i18n._({ + id: 'editor.buildPanel.rooms.rectangle.lead', + message: 'Click two corners', + }), + caption: i18n._({ + id: 'editor.buildPanel.rooms.rectangle.caption', + message: '{lead} on the grid. Walls, floor and ceiling are created together.', + values: { lead: CAPTION_LEAD_MARKER }, + }), + } + } + if (variant === 'walls') { + return { + label: i18n._({ id: 'editor.buildPanel.rooms.walls.label', message: 'Walls' }), + title: i18n._({ id: 'editor.buildPanel.rooms.walls.title', message: 'Walls' }), + lead: i18n._({ + id: 'editor.buildPanel.rooms.walls.lead', + message: 'Draw one wall at a time.', + }), + caption: i18n._({ + id: 'editor.buildPanel.rooms.walls.caption', + message: '{lead} Close a loop and it becomes a room; leave it open for partitions.', + values: { lead: CAPTION_LEAD_MARKER }, + }), + } + } + return { + label: i18n._({ id: 'editor.buildPanel.rooms.polygon.label', message: 'Polygon' }), + title: i18n._({ + id: 'editor.buildPanel.rooms.polygon.title', + message: 'Polygon room', + }), + lead: i18n._({ + id: 'editor.buildPanel.rooms.polygon.lead', + message: 'Click each corner,', + }), + caption: i18n._({ + id: 'editor.buildPanel.rooms.polygon.caption', + message: '{lead} then the first one again to close. For L-shapes and angled rooms.', + values: { lead: CAPTION_LEAD_MARKER }, + }), + } +} diff --git a/packages/editor/src/components/ui/controls/metric-control.test.ts b/packages/editor/src/components/ui/controls/metric-control.test.ts new file mode 100644 index 0000000000..14ee72eee2 --- /dev/null +++ b/packages/editor/src/components/ui/controls/metric-control.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from 'bun:test' +import { parseMetricInput } from './metric-control' + +test('metric controls parse stair dimensions with the scoped locale', () => { + expect( + parseMetricInput('1,000', 'm', { + isImperial: false, + locale: 'fr', + parseUnit: 'm', + }), + ).toBe(1) + expect( + parseMetricInput('1,000', 'm', { + isImperial: false, + locale: 'en', + parseUnit: 'm', + }), + ).toBe(1000) +}) diff --git a/packages/editor/src/components/ui/controls/metric-control.tsx b/packages/editor/src/components/ui/controls/metric-control.tsx index 749ad84378..b810e96560 100644 --- a/packages/editor/src/components/ui/controls/metric-control.tsx +++ b/packages/editor/src/components/ui/controls/metric-control.tsx @@ -1,6 +1,6 @@ 'use client' -import { useScene } from '@pascal-app/core' +import { parseLocalizedNumber, useLocale, useScene } from '@pascal-app/core' import { useCallback, useEffect, useRef, useState } from 'react' import { lingoUnitSpec, measurementHint, parseMeasurement } from '../../../lib/measurement-parser' import { useLinearDisplay } from '../../../lib/use-linear-display' @@ -20,6 +20,21 @@ interface MetricControlProps { restoreOnCommit?: boolean } +export function parseMetricInput( + text: string, + unit: string, + options: { isImperial: boolean; locale: string; parseUnit?: string }, +): number | null { + const spec = lingoUnitSpec(unit) + return spec + ? parseMeasurement(text, spec, { + bareUnit: options.parseUnit ?? spec.unitId, + locale: options.locale, + system: options.isImperial ? 'us' : 'metric', + }) + : parseLocalizedNumber(text, options.locale) +} + export function MetricControl({ label, value, @@ -33,6 +48,7 @@ export function MetricControl({ unit = '', restoreOnCommit = true, }: MetricControlProps) { + const locale = useLocale() const { isImperial, displayUnit, @@ -230,16 +246,8 @@ export function MetricControl({ const submitValue = useCallback(() => { const spec = lingoUnitSpec(unit) - let stored = spec - ? parseMeasurement(inputValue, spec, { - bareUnit: parseUnit ?? spec.unitId, - system: isImperial ? 'us' : 'metric', - }) - : null - if (stored === null) { - const numValue = Number.parseFloat(inputValue) - stored = Number.isFinite(numValue) ? toStoredValue(numValue) : null - } + const parsed = parseMetricInput(inputValue, unit, { isImperial, locale, parseUnit }) + const stored = parsed === null ? null : spec ? parsed : toStoredValue(parsed) if (stored === null) { setInputValue(toDisplayValue(value).toFixed(precision)) } else { @@ -249,6 +257,7 @@ export function MetricControl({ }, [ inputValue, unit, + locale, isImperial, parseUnit, applyCommittedValue, @@ -265,6 +274,7 @@ export function MetricControl({ ? measurementHint(inputValue, spec, { bareUnit: parseUnit ?? spec.unitId, system: isImperial ? 'us' : 'metric', + locale, displayUnit: parseUnit ?? spec.unitId, precision, clamp, diff --git a/packages/editor/src/components/ui/controls/slider-control.test.tsx b/packages/editor/src/components/ui/controls/slider-control.test.tsx new file mode 100644 index 0000000000..bfb964d811 --- /dev/null +++ b/packages/editor/src/components/ui/controls/slider-control.test.tsx @@ -0,0 +1,21 @@ +import { expect, test } from 'bun:test' +import { createI18n, PascalI18nProvider } from '@pascal-app/core' +import { renderToString } from 'react-dom/server' +import { parseSliderBareValue, SliderControl } from './slider-control' + +test('slider numbers use the scoped locale for display and parsing', () => { + const i18n = createI18n({ locale: 'fr' }) + try { + const html = renderToString( + + {}} precision={2} value={2.75} /> + , + ) + + expect(html).toContain('2,75') + expect(parseSliderBareValue('2,75', i18n.getLocale())).toBe(2.75) + expect(parseSliderBareValue('1e2', i18n.getLocale())).toBe(100) + } finally { + i18n.destroy() + } +}) diff --git a/packages/editor/src/components/ui/controls/slider-control.tsx b/packages/editor/src/components/ui/controls/slider-control.tsx index 3bf74c6b75..995c02a494 100644 --- a/packages/editor/src/components/ui/controls/slider-control.tsx +++ b/packages/editor/src/components/ui/controls/slider-control.tsx @@ -1,6 +1,6 @@ 'use client' -import { useScene } from '@pascal-app/core' +import { formatNumber, parseLocalizedNumber, useLocale, useScene } from '@pascal-app/core' import { useCallback, useEffect, useRef, useState } from 'react' import { lingoUnitSpec, measurementHint, parseMeasurement } from '../../../lib/measurement-parser' import { useLinearDisplay } from '../../../lib/use-linear-display' @@ -23,6 +23,10 @@ interface SliderControlProps { mixed?: boolean } +export function parseSliderBareValue(text: string, locale: string): number | null { + return parseLocalizedNumber(text, locale) +} + function stepPrecision(s: number): number { if (s <= 0) return 0 return Math.max(0, Math.ceil(-Math.log10(s))) @@ -67,14 +71,24 @@ export function SliderControl({ restoreOnCommit = true, mixed = false, }: SliderControlProps) { + const locale = useLocale() // Values and bounds stay in meters; gestures and input use the displayed unit. const { isImperial, displayUnit, parseUnit, precision, step, toDisplay, toStored } = useLinearDisplay(unit, storedPrecision, storedStep) + const formatDisplay = useCallback( + (displayValue: number) => + formatNumber(displayValue, { + locale, + min: precision, + max: precision, + }), + [locale, precision], + ) const [isEditing, setIsEditing] = useState(false) const [isDragging, setIsDragging] = useState(false) const [isHovered, setIsHovered] = useState(false) - const [inputValue, setInputValue] = useState(toDisplay(value).toFixed(precision)) + const [inputValue, setInputValue] = useState(() => formatDisplay(toDisplay(value))) // Live readout while dragging. Multi-edit previews write live overrides // instead of the scene, so `value` would otherwise stay frozen even though // the meshes are moving. @@ -116,9 +130,9 @@ export function SliderControl({ useEffect(() => { if (!isEditing) { - setInputValue(toDisplay(value).toFixed(precision)) + setInputValue(formatDisplay(toDisplay(value))) } - }, [value, precision, isEditing, toDisplay]) + }, [value, isEditing, toDisplay, formatDisplay]) // Wheel support on the label useEffect(() => { @@ -258,8 +272,8 @@ export function SliderControl({ const handleValueClick = useCallback(() => { editingRef.current = true setIsEditing(true) - setInputValue(toDisplay(value).toFixed(precision)) - }, [value, precision, toDisplay]) + setInputValue(formatDisplay(toDisplay(value))) + }, [value, toDisplay, formatDisplay]) const parseInputValue = useCallback( (text: string) => { @@ -267,16 +281,17 @@ export function SliderControl({ const parsed = spec ? parseMeasurement(text, spec, { bareUnit: parseUnit ?? spec.unitId, + locale, system: isImperial ? 'us' : 'metric', }) : null - const bare = Number(text) - const stored = parsed ?? (text.trim() && Number.isFinite(bare) ? toStored(bare) : null) + const bare = parseSliderBareValue(text, locale) + const stored = parsed ?? (bare === null ? null : toStored(bare)) return stored === null ? null : clamp(toStored(Number.parseFloat(toDisplay(stored).toFixed(precision)))) }, - [unit, parseUnit, isImperial, toStored, clamp, toDisplay, precision], + [unit, parseUnit, locale, isImperial, toStored, clamp, toDisplay, precision], ) const submitValue = useCallback(() => { @@ -285,13 +300,13 @@ export function SliderControl({ const nextValue = parseInputValue(inputValue) if (nextValue === null) { onCancel?.() - setInputValue(toDisplay(value).toFixed(precision)) + setInputValue(formatDisplay(toDisplay(value))) } else { onChange(nextValue) onCommit?.(nextValue) } setIsEditing(false) - }, [inputValue, parseInputValue, onCancel, onChange, onCommit, toDisplay, value, precision]) + }, [inputValue, parseInputValue, onCancel, onChange, onCommit, toDisplay, value, formatDisplay]) const spec = lingoUnitSpec(unit) const hint = @@ -299,6 +314,7 @@ export function SliderControl({ ? measurementHint(inputValue, spec, { bareUnit: parseUnit ?? spec.unitId, system: isImperial ? 'us' : 'metric', + locale, displayUnit: parseUnit ?? spec.unitId, precision, clamp, @@ -317,10 +333,10 @@ export function SliderControl({ const adjustedStep = getAdjustedStep(step, e) const newV = applyDisplayDelta(value, direction * adjustedStep, adjustedStep) onChange(newV) - setInputValue(toDisplay(newV).toFixed(precision)) + setInputValue(formatDisplay(toDisplay(newV))) } }, - [submitValue, cancelEdit, value, precision, step, applyDisplayDelta, onChange, toDisplay], + [submitValue, cancelEdit, value, step, applyDisplayDelta, onChange, toDisplay, formatDisplay], ) const displayValue = toDisplay(shown) @@ -414,7 +430,7 @@ export function SliderControl({ onClick={handleValueClick} > - {Number(displayValue.toFixed(precision)).toFixed(precision)} + {formatDisplay(displayValue)} {displayUnit && {displayUnit}}
diff --git a/packages/editor/src/components/ui/floating-level-selector.tsx b/packages/editor/src/components/ui/floating-level-selector.tsx index 47e34bab33..40d6b176d3 100644 --- a/packages/editor/src/components/ui/floating-level-selector.tsx +++ b/packages/editor/src/components/ui/floating-level-selector.tsx @@ -55,11 +55,15 @@ import { type LevelDuplicatePreset, } from '@pascal-app/core/building' import { + formatNumber, getDefaultLevelName, getLevelDisplayName, roofLevelOrderBreach, servesAsRoofLevel, + useLocale, + usePascalI18n, } from '@pascal-app/core' +import { localizedLevelName } from '../../i18n/level-name' import { addStoreyBelow, addStoreyOnTop, setGroundFloor } from '../../lib/add-storey' import { deleteLevelWithFallbackSelection } from '../../lib/level-selection' import { unitMemberLevels, leaveUnitFocus } from '../../lib/units' @@ -173,14 +177,19 @@ function LevelRow({ const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false) const [isEditing, setIsEditing] = useState(false) const updateNode = useScene((s) => s.updateNode) + const i18n = usePascalI18n() + const locale = useLocale() const { isImperial, toDisplay, displayUnit, precision: displayPrecision } = useLinearDisplay('m', 2) const storeyHeight = getStoredLevelHeight(level) // Decimal units keep the compact readout; integer millimeters must retain trailing zeroes. - const formattedStoreyHeight = toDisplay(storeyHeight).toFixed(displayPrecision) - const storeyHeightLabel = `${ - displayPrecision > 0 ? formattedStoreyHeight.replace(/0$/, '') : formattedStoreyHeight - } ${displayUnit}` + const formattedStoreyHeight = formatNumber(toDisplay(storeyHeight), { + locale, + min: Math.max(0, displayPrecision - 1), + max: displayPrecision, + }) + const storeyHeightLabel = `${formattedStoreyHeight} ${displayUnit}` + const levelName = localizedLevelName(level, i18n, locale) // Same rule as the site panel and command palette: the ordinal-0 ground // floor is the vertical model's zero anchor and must never be deletable. const canDeleteLevel = level.level !== 0 @@ -219,7 +228,7 @@ function LevelRow({ > - Align view to north + {label} ) } diff --git a/packages/editor/src/components/viewer/floorplan-preview.tsx b/packages/editor/src/components/viewer/floorplan-preview.tsx index 36ea8198a2..d4fefda9a3 100644 --- a/packages/editor/src/components/viewer/floorplan-preview.tsx +++ b/packages/editor/src/components/viewer/floorplan-preview.tsx @@ -6,9 +6,10 @@ import { type FloorplanGeometry, type FloorplanPalette, type GeometryContext, - getLevelDisplayName, isNodeKindEnabled, nodeRegistry, + useLocale, + usePascalI18n, useScene, } from '@pascal-app/core' import { AnyNode as AnyNodeSchema } from '@pascal-app/core/schema' @@ -27,6 +28,7 @@ import { useState, } from 'react' import { createPortal } from 'react-dom' +import { localizedLevelName } from '../../i18n/level-name' import { FLOORPLAN_VIEW_ROTATION_DEG, floorplanLocalToWorldPoint, @@ -143,13 +145,6 @@ function boundsToViewBox(bounds: FloorplanBounds): FloorplanViewBox { } } -function levelLabel(level: AnyNode): string { - return getLevelDisplayName({ - name: (level as { name?: string }).name, - level: (level as { level?: number }).level ?? 0, - }) -} - function collectLevelTree(root: AnyNode, nodes: Record): AnyNode[] { const result: AnyNode[] = [root] const seen = new Set() @@ -309,6 +304,8 @@ export function FloorplanPreview({ showLevelSelector = true, synchronizeNavigation = false, }: FloorplanPreviewProps) { + const i18n = usePascalI18n() + const locale = useLocale() const storeNodes = useScene((state) => (scene ? EMPTY_PREVIEW_NODES : state.nodes)) const storeInstalledPlugins = useScene((state) => scene ? EMPTY_INSTALLED_PLUGINS : state.installedPlugins, @@ -869,7 +866,10 @@ export function FloorplanPreview({ className={className} style={{ display: 'grid', placeItems: 'center', background: '#f8fafc', color: '#64748b' }} > - No floor plans are available for this scene. + {i18n._({ + id: 'viewer.floorplan.empty', + message: 'No floor plans are available for this scene.', + })} ) } @@ -882,7 +882,15 @@ export function FloorplanPreview({ {synchronizeNavigation ? : null}
- Floor + {i18n._({ id: 'viewer.floorplan.floor', message: 'Floor' })} @@ -1013,28 +1021,28 @@ export function FloorplanPreview({ }} > @@ -228,7 +238,7 @@ export const ViewerSceneHeader = ({ key={section.kind} > - {SECTION_TITLES[section.kind]} + {sectionTitle(section.kind, i18n)} {section.rows.map((row) => { const active = @@ -257,7 +267,7 @@ export const ViewerSceneHeader = ({ /> {row.label} - {formatRoomArea(row.areaM2, unit)} + {formatRoomArea(row.areaM2, unit, locale)} ) diff --git a/packages/editor/src/components/viewer/viewer-stage-switcher.tsx b/packages/editor/src/components/viewer/viewer-stage-switcher.tsx index ea137c6556..a7c7f3de81 100644 --- a/packages/editor/src/components/viewer/viewer-stage-switcher.tsx +++ b/packages/editor/src/components/viewer/viewer-stage-switcher.tsx @@ -1,5 +1,6 @@ 'use client' +import { usePascalI18n } from '@pascal-app/core' import { Box, Columns2, Map as MapIcon } from 'lucide-react' import type { ReactNode } from 'react' import { cn } from '../../lib/utils' @@ -22,11 +23,12 @@ export function ViewerStageSwitcher({ modes, onChange, }: ViewerStageSwitcherProps) { + const i18n = usePascalI18n() const enabledModes = normalizeViewerStageModes(modes) return (
1 ? 'hidden md:flex' : undefined} icon={} - label="Split" + label={i18n._({ id: 'viewer.stage.split', message: 'Split' })} onClick={() => onChange('split')} /> ) : null} diff --git a/packages/editor/src/i18n/level-name.ts b/packages/editor/src/i18n/level-name.ts new file mode 100644 index 0000000000..7f38af079f --- /dev/null +++ b/packages/editor/src/i18n/level-name.ts @@ -0,0 +1,36 @@ +import { getDefaultLevelName, levelDisplayNumber, type PascalI18n } from '@pascal-app/core' + +type DisplayLevel = { + level?: number + name?: string +} + +export function localizedLevelName(level: DisplayLevel, i18n: PascalI18n, locale: string): string { + const levelNumber = level.level ?? 0 + const authoredName = level.name?.trim() + if ( + authoredName && + authoredName !== `Level ${levelNumber}` && + authoredName !== getDefaultLevelName(levelNumber) + ) { + return authoredName + } + if (levelNumber === 0) { + return i18n._({ id: 'core.level.groundFloor', message: 'Ground floor' }) + } + if (levelNumber === -1) { + return i18n._({ id: 'core.level.basement', message: 'Basement' }) + } + if (levelNumber < -1) { + return i18n._({ + id: 'core.level.basementNumber', + message: 'Basement {number}', + values: { number: -levelNumber }, + }) + } + return i18n._({ + id: 'core.level.floorNumber', + message: 'Floor {number}', + values: { number: levelDisplayNumber(levelNumber, locale) }, + }) +} diff --git a/packages/editor/src/i18n/locales.test.ts b/packages/editor/src/i18n/locales.test.ts new file mode 100644 index 0000000000..b191a31b81 --- /dev/null +++ b/packages/editor/src/i18n/locales.test.ts @@ -0,0 +1,56 @@ +import { afterEach, expect, test } from 'bun:test' +import { createI18n, setLocale, translate } from '@pascal-app/core' +import { localizedLevelName } from './level-name' +import { registerBundledLocales } from './locales' + +afterEach(() => setLocale('en')) + +test('bundled locale packs cover the converted viewer and editor surfaces', () => { + registerBundledLocales() + + const expectations = { + 'zh-CN': ['显示设置', '房间'], + fr: ['Paramètres d’affichage', 'Pièces'], + es: ['Ajustes de visualización', 'Estancias'], + ja: ['表示設定', '部屋'], + } as const + + for (const [locale, [display, rooms]] of Object.entries(expectations)) { + setLocale(locale) + expect(translate('viewer.display.title', 'Display settings')).toBe(display) + expect(translate('editor.buildPanel.rooms.title', 'Rooms')).toBe(rooms) + } +}) + +test('Simplified Chinese aliases use zh-CN without affecting Traditional Chinese', () => { + registerBundledLocales() + + setLocale('zh-Hans') + expect(translate('viewer.display.title', 'Display settings')).toBe('显示设置') + + setLocale('zh-Hans-CN') + expect(translate('viewer.display.title', 'Display settings')).toBe('显示设置') + + setLocale('zh-Hant') + expect(translate('viewer.display.title', 'Display settings')).toBe('Display settings') + + setLocale('zh-TW') + expect(translate('viewer.display.title', 'Display settings')).toBe('Display settings') +}) + +test('generated stored level names localize while authored names remain unchanged', () => { + registerBundledLocales() + const french = createI18n({ locale: 'fr' }) + const japanese = createI18n({ locale: 'ja' }) + try { + expect(localizedLevelName({ level: 0, name: 'Ground floor' }, french, 'fr')).toBe( + 'Rez-de-chaussée', + ) + expect(localizedLevelName({ level: 1, name: 'Floor 1' }, french, 'fr')).toBe('Étage 1') + expect(localizedLevelName({ level: 1, name: 'Floor 1' }, japanese, 'ja')).toBe('2階') + expect(localizedLevelName({ level: 1, name: 'Mezzanine' }, french, 'fr')).toBe('Mezzanine') + } finally { + french.destroy() + japanese.destroy() + } +}) diff --git a/packages/editor/src/i18n/locales.ts b/packages/editor/src/i18n/locales.ts new file mode 100644 index 0000000000..42445578ec --- /dev/null +++ b/packages/editor/src/i18n/locales.ts @@ -0,0 +1,32 @@ +import { type CompiledTranslationMessages, registerCompiledMessages } from '@pascal-app/core' +import { messages as en } from './locales/en/messages' +import { messages as es } from './locales/es/messages' +import { messages as fr } from './locales/fr/messages' +import { messages as ja } from './locales/ja/messages' +import { messages as zhCN } from './locales/zh-CN/messages' + +export type BundledLocale = 'en' | 'zh-CN' | 'fr' | 'es' | 'ja' + +type LocaleCatalog = { + locale: BundledLocale + messages: CompiledTranslationMessages +} + +export const bundledLocalePacks: Record = { + en: { locale: 'en', messages: en }, + 'zh-CN': { locale: 'zh-CN', messages: zhCN }, + fr: { locale: 'fr', messages: fr }, + es: { locale: 'es', messages: es }, + ja: { locale: 'ja', messages: ja }, +} + +let registered = false + +export function registerBundledLocales(): void { + if (registered) return + registered = true + + for (const catalog of Object.values(bundledLocalePacks)) { + registerCompiledMessages(catalog.locale, '', catalog.messages) + } +} diff --git a/packages/editor/src/i18n/locales/en/messages.po b/packages/editor/src/i18n/locales/en/messages.po new file mode 100644 index 0000000000..96b505b693 --- /dev/null +++ b/packages/editor/src/i18n/locales/en/messages.po @@ -0,0 +1,348 @@ +msgid "" +msgstr "" +"POT-Creation-Date: 2026-10-26 22:28+0000\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: @lingui/cli\n" +"Language: en\n" + +#: packages/editor/src/i18n/level-name.ts +msgid "core.level.basement" +msgstr "Basement" + +#: packages/editor/src/i18n/level-name.ts +msgid "core.level.basementNumber" +msgstr "Basement {number}" + +#: packages/editor/src/i18n/level-name.ts +msgid "core.level.floorNumber" +msgstr "Floor {number}" + +#: packages/editor/src/i18n/level-name.ts +msgid "core.level.groundFloor" +msgstr "Ground floor" + +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.advanced" +msgstr "Advanced" + +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.hint" +msgstr "walls · floor · ceiling" + +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.idle" +msgstr "Pick a shape to start. Every room brings its own walls, floor and ceiling." + +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.polygon.caption" +msgstr "{lead} then the first one again to close. For L-shapes and angled rooms." + +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.polygon.label" +msgstr "Polygon" + +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.polygon.lead" +msgstr "Click each corner," + +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.polygon.title" +msgstr "Polygon room" + +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.rectangle.caption" +msgstr "{lead} on the grid. Walls, floor and ceiling are created together." + +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.rectangle.label" +msgstr "Rectangle" + +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.rectangle.lead" +msgstr "Click two corners" + +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.rectangle.title" +msgstr "Rectangle room" + +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.title" +msgstr "Rooms" + +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.walls.caption" +msgstr "{lead} Close a loop and it becomes a room; leave it open for partitions." + +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.walls.label" +msgstr "Walls" + +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.walls.lead" +msgstr "Draw one wall at a time." + +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.walls.title" +msgstr "Walls" + +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.camera.orbitLeft" +msgstr "Orbit left" + +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.camera.orbitRight" +msgstr "Orbit right" + +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.camera.topView" +msgstr "Top view" + +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.common.off" +msgstr "Off" + +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.common.on" +msgstr "On" + +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.camera" +msgstr "Camera" + +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.colored" +msgstr "Colored" + +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.colors" +msgstr "Colors" + +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.edges.off" +msgstr "Off" + +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.edges.offDetail" +msgstr "No edge lines" + +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.edges.soft" +msgstr "Soft" + +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.edges.softDetail" +msgstr "Faint outline of major creases" + +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.edges.strong" +msgstr "Strong" + +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.edges.strongDetail" +msgstr "Crisp, opaque edge lines" + +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.edges.title" +msgstr "Edges" + +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.monochrome" +msgstr "Monochrome" + +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.orthographic" +msgstr "Orthographic" + +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.perspective" +msgstr "Perspective" + +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.render.rendered" +msgstr "Rendered" + +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.render.renderedDetail" +msgstr "Full ambient occlusion" + +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.render.solid" +msgstr "Solid" + +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.render.solidDetail" +msgstr "Flat and fast — no ambient occlusion" + +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.render.title" +msgstr "Render" + +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.shadows" +msgstr "Shadows" + +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.title" +msgstr "Display settings" + +#: packages/editor/src/components/viewer/floorplan-compass-button.tsx +msgid "viewer.floorplan.alignNorth" +msgstr "Align view to north" + +#: packages/editor/src/components/viewer/floorplan-preview.tsx +msgid "viewer.floorplan.empty" +msgstr "No floor plans are available for this scene." + +#: packages/editor/src/components/viewer/floorplan-preview.tsx +msgid "viewer.floorplan.fit" +msgstr "Fit floor plan" + +#: packages/editor/src/components/viewer/floorplan-preview.tsx +msgid "viewer.floorplan.floor" +msgstr "Floor" + +#: packages/editor/src/components/viewer/floorplan-preview.tsx +msgid "viewer.floorplan.floorPlan" +msgstr "Floor plan" + +#: packages/editor/src/components/viewer/floorplan-preview.tsx +msgid "viewer.floorplan.viewLabel" +msgstr "{floor} 2D view" + +#: packages/editor/src/components/viewer/floorplan-preview.tsx +msgid "viewer.floorplan.zoomIn" +msgstr "Zoom in" + +#: packages/editor/src/components/viewer/floorplan-preview.tsx +msgid "viewer.floorplan.zoomOut" +msgstr "Zoom out" + +#: packages/editor/src/components/viewer/viewer-scene-header.tsx +msgid "viewer.header.back" +msgstr "Back" + +#: packages/editor/src/components/viewer/viewer-scene-header.tsx +msgid "viewer.header.levels" +msgstr "Levels" + +#: packages/editor/src/components/viewer/viewer-scene-header.tsx +msgid "viewer.header.rooms" +msgstr "Rooms" + +#: packages/editor/src/components/viewer/viewer-scene-header.tsx +msgid "viewer.header.selection" +msgstr "Selection" + +#: packages/editor/src/components/viewer/viewer-scene-header.tsx +msgid "viewer.header.units" +msgstr "Units" + +#: packages/editor/src/components/viewer/viewer-scene-header.tsx +msgid "viewer.header.untitled" +msgstr "Untitled" + +#: packages/editor/src/components/viewer/viewer-scene-header.tsx +msgid "viewer.header.zones" +msgstr "Zones" + +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.levels.exploded" +msgstr "Exploded" + +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.levels.label" +msgstr "Levels: {mode}" + +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.levels.manual" +msgstr "Manual" + +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.levels.solo" +msgstr "Solo" + +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.levels.stacked" +msgstr "Stacked" + +#: packages/editor/src/components/viewer/viewer-stage-switcher.tsx +msgid "viewer.stage.layout" +msgstr "Viewer layout" + +#: packages/editor/src/components/viewer/viewer-stage-switcher.tsx +msgid "viewer.stage.split" +msgstr "Split" + +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.blueprint" +msgstr "Blueprint" + +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.mediterranean" +msgstr "Mediterranean" + +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.night" +msgstr "Night" + +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.overcast" +msgstr "Overcast" + +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.paper" +msgstr "Paper" + +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.studio" +msgstr "Studio" + +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.sunset" +msgstr "Sunset" + +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.title" +msgstr "Scene theme" + +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.twilight" +msgstr "Twilight" + +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.verdant" +msgstr "Verdant" + +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.visibility.guides" +msgstr "Guides" + +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.visibility.scans" +msgstr "Scans" + +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.visibility.title" +msgstr "Visibility" + +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.walkthrough.label" +msgstr "Walkthrough: {state}" + +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.walls.cutaway" +msgstr "Cutaway" + +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.walls.fullHeight" +msgstr "Full height" + +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.walls.label" +msgstr "Walls: {mode}" + +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.walls.low" +msgstr "Low" diff --git a/packages/editor/src/i18n/locales/en/messages.ts b/packages/editor/src/i18n/locales/en/messages.ts new file mode 100644 index 0000000000..0cd37f56df --- /dev/null +++ b/packages/editor/src/i18n/locales/en/messages.ts @@ -0,0 +1 @@ +/*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"core.level.basement\":[\"Basement\"],\"core.level.basementNumber\":[\"Basement \",[\"number\"]],\"core.level.floorNumber\":[\"Floor \",[\"number\"]],\"core.level.groundFloor\":[\"Ground floor\"],\"editor.buildPanel.advanced\":[\"Advanced\"],\"editor.buildPanel.rooms.hint\":[\"walls · floor · ceiling\"],\"editor.buildPanel.rooms.idle\":[\"Pick a shape to start. Every room brings its own walls, floor and ceiling.\"],\"editor.buildPanel.rooms.polygon.caption\":[[\"lead\"],\" then the first one again to close. For L-shapes and angled rooms.\"],\"editor.buildPanel.rooms.polygon.label\":[\"Polygon\"],\"editor.buildPanel.rooms.polygon.lead\":[\"Click each corner,\"],\"editor.buildPanel.rooms.polygon.title\":[\"Polygon room\"],\"editor.buildPanel.rooms.rectangle.caption\":[[\"lead\"],\" on the grid. Walls, floor and ceiling are created together.\"],\"editor.buildPanel.rooms.rectangle.label\":[\"Rectangle\"],\"editor.buildPanel.rooms.rectangle.lead\":[\"Click two corners\"],\"editor.buildPanel.rooms.rectangle.title\":[\"Rectangle room\"],\"editor.buildPanel.rooms.title\":[\"Rooms\"],\"editor.buildPanel.rooms.walls.caption\":[[\"lead\"],\" Close a loop and it becomes a room; leave it open for partitions.\"],\"editor.buildPanel.rooms.walls.label\":[\"Walls\"],\"editor.buildPanel.rooms.walls.lead\":[\"Draw one wall at a time.\"],\"editor.buildPanel.rooms.walls.title\":[\"Walls\"],\"viewer.camera.orbitLeft\":[\"Orbit left\"],\"viewer.camera.orbitRight\":[\"Orbit right\"],\"viewer.camera.topView\":[\"Top view\"],\"viewer.common.off\":[\"Off\"],\"viewer.common.on\":[\"On\"],\"viewer.display.camera\":[\"Camera\"],\"viewer.display.colored\":[\"Colored\"],\"viewer.display.colors\":[\"Colors\"],\"viewer.display.edges.off\":[\"Off\"],\"viewer.display.edges.offDetail\":[\"No edge lines\"],\"viewer.display.edges.soft\":[\"Soft\"],\"viewer.display.edges.softDetail\":[\"Faint outline of major creases\"],\"viewer.display.edges.strong\":[\"Strong\"],\"viewer.display.edges.strongDetail\":[\"Crisp, opaque edge lines\"],\"viewer.display.edges.title\":[\"Edges\"],\"viewer.display.monochrome\":[\"Monochrome\"],\"viewer.display.orthographic\":[\"Orthographic\"],\"viewer.display.perspective\":[\"Perspective\"],\"viewer.display.render.rendered\":[\"Rendered\"],\"viewer.display.render.renderedDetail\":[\"Full ambient occlusion\"],\"viewer.display.render.solid\":[\"Solid\"],\"viewer.display.render.solidDetail\":[\"Flat and fast — no ambient occlusion\"],\"viewer.display.render.title\":[\"Render\"],\"viewer.display.shadows\":[\"Shadows\"],\"viewer.display.title\":[\"Display settings\"],\"viewer.floorplan.alignNorth\":[\"Align view to north\"],\"viewer.floorplan.empty\":[\"No floor plans are available for this scene.\"],\"viewer.floorplan.fit\":[\"Fit floor plan\"],\"viewer.floorplan.floor\":[\"Floor\"],\"viewer.floorplan.floorPlan\":[\"Floor plan\"],\"viewer.floorplan.viewLabel\":[[\"floor\"],\" 2D view\"],\"viewer.floorplan.zoomIn\":[\"Zoom in\"],\"viewer.floorplan.zoomOut\":[\"Zoom out\"],\"viewer.header.back\":[\"Back\"],\"viewer.header.levels\":[\"Levels\"],\"viewer.header.rooms\":[\"Rooms\"],\"viewer.header.selection\":[\"Selection\"],\"viewer.header.units\":[\"Units\"],\"viewer.header.untitled\":[\"Untitled\"],\"viewer.header.zones\":[\"Zones\"],\"viewer.levels.exploded\":[\"Exploded\"],\"viewer.levels.label\":[\"Levels: \",[\"mode\"]],\"viewer.levels.manual\":[\"Manual\"],\"viewer.levels.solo\":[\"Solo\"],\"viewer.levels.stacked\":[\"Stacked\"],\"viewer.stage.layout\":[\"Viewer layout\"],\"viewer.stage.split\":[\"Split\"],\"viewer.theme.blueprint\":[\"Blueprint\"],\"viewer.theme.mediterranean\":[\"Mediterranean\"],\"viewer.theme.night\":[\"Night\"],\"viewer.theme.overcast\":[\"Overcast\"],\"viewer.theme.paper\":[\"Paper\"],\"viewer.theme.studio\":[\"Studio\"],\"viewer.theme.sunset\":[\"Sunset\"],\"viewer.theme.title\":[\"Scene theme\"],\"viewer.theme.twilight\":[\"Twilight\"],\"viewer.theme.verdant\":[\"Verdant\"],\"viewer.visibility.guides\":[\"Guides\"],\"viewer.visibility.scans\":[\"Scans\"],\"viewer.visibility.title\":[\"Visibility\"],\"viewer.walkthrough.label\":[\"Walkthrough: \",[\"state\"]],\"viewer.walls.cutaway\":[\"Cutaway\"],\"viewer.walls.fullHeight\":[\"Full height\"],\"viewer.walls.label\":[\"Walls: \",[\"mode\"]],\"viewer.walls.low\":[\"Low\"]}")as Messages; \ No newline at end of file diff --git a/packages/editor/src/i18n/locales/es/messages.po b/packages/editor/src/i18n/locales/es/messages.po new file mode 100644 index 0000000000..9d86e70e75 --- /dev/null +++ b/packages/editor/src/i18n/locales/es/messages.po @@ -0,0 +1,609 @@ +msgid "" +msgstr "" +"POT-Creation-Date: 2026-10-26 22:28+0000\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: @lingui/cli\n" +"Language: es\n" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: \n" +"Last-Translator: \n" +"Language-Team: \n" +"Plural-Forms: \n" + +# Source-Hash: 8caa959c0ae800c3 +# Review: human +# English: "Basement" +#: packages/editor/src/i18n/level-name.ts +msgid "core.level.basement" +msgstr "Sótano" + +# Source-Hash: 14927085ee91ba05 +# Review: human +# English: "Basement {number}" +#: packages/editor/src/i18n/level-name.ts +msgid "core.level.basementNumber" +msgstr "Sótano {number}" + +# Source-Hash: e59074a7b9b3f56e +# Review: human +# English: "Floor {number}" +#: packages/editor/src/i18n/level-name.ts +msgid "core.level.floorNumber" +msgstr "Planta {number}" + +# Source-Hash: cde3efc9b7ddfa41 +# Review: human +# English: "Ground floor" +#: packages/editor/src/i18n/level-name.ts +msgid "core.level.groundFloor" +msgstr "Planta baja" + +# Source-Hash: f571cb2d06ae01ae +# Review: human +# English: "Advanced" +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.advanced" +msgstr "Avanzado" + +# Source-Hash: fb40866c2d6db5fb +# Review: human +# English: "walls · floor · ceiling" +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.hint" +msgstr "muros · suelo · techo" + +# Source-Hash: 271039bd919dc4c1 +# Review: human +# English: "Pick a shape to start. Every room brings its own walls, floor and ceiling." +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.idle" +msgstr "Elige una forma para empezar. Cada estancia incluye sus muros, suelo y techo." + +# Source-Hash: ae2800b6c9e2b6ed +# Review: human +# English: "{lead} then the first one again to close. For L-shapes and angled rooms." +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.polygon.caption" +msgstr "{lead} y vuelve a pulsar la primera para cerrar. Para estancias en L o con ángulos." + +# Source-Hash: 4e186fc057733162 +# Review: human +# English: "Polygon" +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.polygon.label" +msgstr "Polígono" + +# Source-Hash: 345e3aca3879b5c5 +# Review: human +# English: "Click each corner," +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.polygon.lead" +msgstr "Haz clic en cada esquina" + +# Source-Hash: 8ec6152e139b0e3f +# Review: human +# English: "Polygon room" +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.polygon.title" +msgstr "Estancia poligonal" + +# Source-Hash: 9b46295eedcbedae +# Review: human +# English: "{lead} on the grid. Walls, floor and ceiling are created together." +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.rectangle.caption" +msgstr "{lead} en la cuadrícula. Los muros, el suelo y el techo se crean a la vez." + +# Source-Hash: 2c2c20272b0370cc +# Review: human +# English: "Rectangle" +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.rectangle.label" +msgstr "Rectángulo" + +# Source-Hash: 060faa48e299d7c9 +# Review: human +# English: "Click two corners" +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.rectangle.lead" +msgstr "Haz clic en dos esquinas" + +# Source-Hash: 83a45603ac7f41e8 +# Review: human +# English: "Rectangle room" +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.rectangle.title" +msgstr "Estancia rectangular" + +# Source-Hash: 3e7408ed44676176 +# Review: human +# English: "Rooms" +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.title" +msgstr "Estancias" + +# Source-Hash: 8a21a03c4acf396b +# Review: human +# English: "{lead} Close a loop and it becomes a room; leave it open for partitions." +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.walls.caption" +msgstr "{lead} Cierra el contorno para crear una estancia o déjalo abierto para tabiques." + +# Source-Hash: aac9a732d2159ccc +# Review: human +# English: "Walls" +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.walls.label" +msgstr "Muros" + +# Source-Hash: 9843edd68426fa2d +# Review: human +# English: "Draw one wall at a time." +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.walls.lead" +msgstr "Dibuja un muro cada vez." + +# Source-Hash: aac9a732d2159ccc +# Review: human +# English: "Walls" +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.walls.title" +msgstr "Muros" + +# Source-Hash: 8bf25afa3cb8baea +# Review: human +# English: "Orbit left" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.camera.orbitLeft" +msgstr "Orbitar a la izquierda" + +# Source-Hash: b9cc64c1d1bb0d46 +# Review: human +# English: "Orbit right" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.camera.orbitRight" +msgstr "Orbitar a la derecha" + +# Source-Hash: 1ab1eb3a14f4b0e0 +# Review: human +# English: "Top view" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.camera.topView" +msgstr "Vista superior" + +# Source-Hash: 50accd740fa2e736 +# Review: human +# English: "Off" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.common.off" +msgstr "Desactivado" + +# Source-Hash: 9cc01faad79c28da +# Review: human +# English: "On" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.common.on" +msgstr "Activado" + +# Source-Hash: 14622745dbf93f4c +# Review: human +# English: "Camera" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.camera" +msgstr "Cámara" + +# Source-Hash: d3d478fa6c6d7ddc +# Review: human +# English: "Colored" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.colored" +msgstr "A color" + +# Source-Hash: 82b74c4f5ef43336 +# Review: human +# English: "Colors" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.colors" +msgstr "Colores" + +# Source-Hash: 50accd740fa2e736 +# Review: human +# English: "Off" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.edges.off" +msgstr "Desactivadas" + +# Source-Hash: 4ff58913a4492199 +# Review: human +# English: "No edge lines" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.edges.offDetail" +msgstr "Sin líneas de arista" + +# Source-Hash: d266d83ec490df4e +# Review: human +# English: "Soft" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.edges.soft" +msgstr "Suaves" + +# Source-Hash: 0c583f1d4201293b +# Review: human +# English: "Faint outline of major creases" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.edges.softDetail" +msgstr "Contorno tenue de los pliegues principales" + +# Source-Hash: f82927a01d4c5e45 +# Review: human +# English: "Strong" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.edges.strong" +msgstr "Marcadas" + +# Source-Hash: 2416a48df28d6205 +# Review: human +# English: "Crisp, opaque edge lines" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.edges.strongDetail" +msgstr "Líneas de arista nítidas y opacas" + +# Source-Hash: 027bbc8b7287c06a +# Review: human +# English: "Edges" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.edges.title" +msgstr "Aristas" + +# Source-Hash: ecde2f7c26914be3 +# Review: human +# English: "Monochrome" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.monochrome" +msgstr "Monocromo" + +# Source-Hash: ed59dd8ce7797a66 +# Review: human +# English: "Orthographic" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.orthographic" +msgstr "Ortográfica" + +# Source-Hash: 316e25993ff72b88 +# Review: human +# English: "Perspective" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.perspective" +msgstr "Perspectiva" + +# Source-Hash: f363702a10aedaeb +# Review: human +# English: "Rendered" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.render.rendered" +msgstr "Realista" + +# Source-Hash: 8bb9e54fc786ca77 +# Review: human +# English: "Full ambient occlusion" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.render.renderedDetail" +msgstr "Oclusión ambiental completa" + +# Source-Hash: ef3e765e692faf4c +# Review: human +# English: "Solid" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.render.solid" +msgstr "Sólido" + +# Source-Hash: 8827df1cdeb02ed9 +# Review: human +# English: "Flat and fast — no ambient occlusion" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.render.solidDetail" +msgstr "Plano y rápido, sin oclusión ambiental" + +# Source-Hash: 5e58af99d6d4fa6a +# Review: human +# English: "Render" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.render.title" +msgstr "Renderizado" + +# Source-Hash: 4c6bcb30b40047fc +# Review: human +# English: "Shadows" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.shadows" +msgstr "Sombras" + +# Source-Hash: d0db46f206a88e2e +# Review: human +# English: "Display settings" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.title" +msgstr "Ajustes de visualización" + +# Source-Hash: 2bdf83801d4a40af +# Review: human +# English: "Align view to north" +#: packages/editor/src/components/viewer/floorplan-compass-button.tsx +msgid "viewer.floorplan.alignNorth" +msgstr "Orientar la vista al norte" + +# Source-Hash: 286a757d8b52bb02 +# Review: human +# English: "No floor plans are available for this scene." +#: packages/editor/src/components/viewer/floorplan-preview.tsx +msgid "viewer.floorplan.empty" +msgstr "No hay planos de planta disponibles para esta escena." + +# Source-Hash: bf63c679ab94dbcd +# Review: human +# English: "Fit floor plan" +#: packages/editor/src/components/viewer/floorplan-preview.tsx +msgid "viewer.floorplan.fit" +msgstr "Ajustar plano" + +# Source-Hash: d7148fa065bc709b +# Review: human +# English: "Floor" +#: packages/editor/src/components/viewer/floorplan-preview.tsx +msgid "viewer.floorplan.floor" +msgstr "Planta" + +# Source-Hash: fee6203bb9046929 +# Review: human +# English: "Floor plan" +#: packages/editor/src/components/viewer/floorplan-preview.tsx +msgid "viewer.floorplan.floorPlan" +msgstr "Plano de planta" + +# Source-Hash: 28b255b7bf5f1084 +# Review: human +# English: "{floor} 2D view" +#: packages/editor/src/components/viewer/floorplan-preview.tsx +msgid "viewer.floorplan.viewLabel" +msgstr "Vista 2D de {floor}" + +# Source-Hash: d17804412f8dd081 +# Review: human +# English: "Zoom in" +#: packages/editor/src/components/viewer/floorplan-preview.tsx +msgid "viewer.floorplan.zoomIn" +msgstr "Acercar" + +# Source-Hash: 23b012a283b2c14a +# Review: human +# English: "Zoom out" +#: packages/editor/src/components/viewer/floorplan-preview.tsx +msgid "viewer.floorplan.zoomOut" +msgstr "Alejar" + +# Source-Hash: 7ceade8f25b58fe9 +# Review: human +# English: "Back" +#: packages/editor/src/components/viewer/viewer-scene-header.tsx +msgid "viewer.header.back" +msgstr "Volver" + +# Source-Hash: cc3d601d59e2e26c +# Review: human +# English: "Levels" +#: packages/editor/src/components/viewer/viewer-scene-header.tsx +msgid "viewer.header.levels" +msgstr "Plantas" + +# Source-Hash: 3e7408ed44676176 +# Review: human +# English: "Rooms" +#: packages/editor/src/components/viewer/viewer-scene-header.tsx +msgid "viewer.header.rooms" +msgstr "Estancias" + +# Source-Hash: 170b2049ef0f9131 +# Review: human +# English: "Selection" +#: packages/editor/src/components/viewer/viewer-scene-header.tsx +msgid "viewer.header.selection" +msgstr "Selección" + +# Source-Hash: b404dded35241274 +# Review: human +# English: "Units" +#: packages/editor/src/components/viewer/viewer-scene-header.tsx +msgid "viewer.header.units" +msgstr "Unidades" + +# Source-Hash: 6547a4c0c368042a +# Review: human +# English: "Untitled" +#: packages/editor/src/components/viewer/viewer-scene-header.tsx +msgid "viewer.header.untitled" +msgstr "Sin título" + +# Source-Hash: 665db49a26f81425 +# Review: human +# English: "Zones" +#: packages/editor/src/components/viewer/viewer-scene-header.tsx +msgid "viewer.header.zones" +msgstr "Zonas" + +# Source-Hash: be12f8788f1f0a05 +# Review: human +# English: "Exploded" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.levels.exploded" +msgstr "Separadas" + +# Source-Hash: 4bf4a6861d7d633e +# Review: human +# English: "Levels: {mode}" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.levels.label" +msgstr "Plantas: {mode}" + +# Source-Hash: 387082cd5f118bed +# Review: human +# English: "Manual" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.levels.manual" +msgstr "Manual" + +# Source-Hash: e9f59b513f2f45ac +# Review: human +# English: "Solo" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.levels.solo" +msgstr "Aislada" + +# Source-Hash: c203c84eee18c0f6 +# Review: human +# English: "Stacked" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.levels.stacked" +msgstr "Apiladas" + +# Source-Hash: 9dd21ca47cffa3b8 +# Review: human +# English: "Viewer layout" +#: packages/editor/src/components/viewer/viewer-stage-switcher.tsx +msgid "viewer.stage.layout" +msgstr "Disposición de vistas" + +# Source-Hash: e4cd64b1680ac0d2 +# Review: human +# English: "Split" +#: packages/editor/src/components/viewer/viewer-stage-switcher.tsx +msgid "viewer.stage.split" +msgstr "Dividida" + +# Source-Hash: f14b89d713d2528a +# Review: human +# English: "Blueprint" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.blueprint" +msgstr "Plano técnico" + +# Source-Hash: 142bcb2841f423c5 +# Review: human +# English: "Mediterranean" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.mediterranean" +msgstr "Mediterráneo" + +# Source-Hash: b399942ec5d7e1ba +# Review: human +# English: "Night" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.night" +msgstr "Noche" + +# Source-Hash: f512d1b947b8ae5a +# Review: human +# English: "Overcast" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.overcast" +msgstr "Nublado" + +# Source-Hash: 583542b3bc837192 +# Review: human +# English: "Paper" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.paper" +msgstr "Papel" + +# Source-Hash: 7abc2dc6e39b1747 +# Review: human +# English: "Studio" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.studio" +msgstr "Estudio" + +# Source-Hash: efe94d3ec8d8c44e +# Review: human +# English: "Sunset" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.sunset" +msgstr "Atardecer" + +# Source-Hash: 82720864e5034398 +# Review: human +# English: "Scene theme" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.title" +msgstr "Ambiente" + +# Source-Hash: 4c10ea0aa0e2ea41 +# Review: human +# English: "Twilight" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.twilight" +msgstr "Crepúsculo" + +# Source-Hash: f9592fb0672740c0 +# Review: human +# English: "Verdant" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.verdant" +msgstr "Verde" + +# Source-Hash: 892d5dd34372028b +# Review: human +# English: "Guides" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.visibility.guides" +msgstr "Guías" + +# Source-Hash: 3260bfa69cca50e5 +# Review: human +# English: "Scans" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.visibility.scans" +msgstr "Escaneos 3D" + +# Source-Hash: 2cefbb554eadbb6e +# Review: human +# English: "Visibility" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.visibility.title" +msgstr "Visibilidad" + +# Source-Hash: 31afd9dee47ba943 +# Review: human +# English: "Walkthrough: {state}" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.walkthrough.label" +msgstr "Recorrido: {state}" + +# Source-Hash: 4da8b3678ed77b45 +# Review: human +# English: "Cutaway" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.walls.cutaway" +msgstr "Vista seccionada" + +# Source-Hash: 131bac2467f4fdf6 +# Review: human +# English: "Full height" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.walls.fullHeight" +msgstr "Altura completa" + +# Source-Hash: 32f3d058cf2526d2 +# Review: human +# English: "Walls: {mode}" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.walls.label" +msgstr "Muros: {mode}" + +# Source-Hash: 803532407fb5962b +# Review: human +# English: "Low" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.walls.low" +msgstr "Bajos" diff --git a/packages/editor/src/i18n/locales/es/messages.ts b/packages/editor/src/i18n/locales/es/messages.ts new file mode 100644 index 0000000000..24fa7da3af --- /dev/null +++ b/packages/editor/src/i18n/locales/es/messages.ts @@ -0,0 +1 @@ +/*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"core.level.basement\":[\"Sótano\"],\"core.level.basementNumber\":[\"Sótano \",[\"number\"]],\"core.level.floorNumber\":[\"Planta \",[\"number\"]],\"core.level.groundFloor\":[\"Planta baja\"],\"editor.buildPanel.advanced\":[\"Avanzado\"],\"editor.buildPanel.rooms.hint\":[\"muros · suelo · techo\"],\"editor.buildPanel.rooms.idle\":[\"Elige una forma para empezar. Cada estancia incluye sus muros, suelo y techo.\"],\"editor.buildPanel.rooms.polygon.caption\":[[\"lead\"],\" y vuelve a pulsar la primera para cerrar. Para estancias en L o con ángulos.\"],\"editor.buildPanel.rooms.polygon.label\":[\"Polígono\"],\"editor.buildPanel.rooms.polygon.lead\":[\"Haz clic en cada esquina\"],\"editor.buildPanel.rooms.polygon.title\":[\"Estancia poligonal\"],\"editor.buildPanel.rooms.rectangle.caption\":[[\"lead\"],\" en la cuadrícula. Los muros, el suelo y el techo se crean a la vez.\"],\"editor.buildPanel.rooms.rectangle.label\":[\"Rectángulo\"],\"editor.buildPanel.rooms.rectangle.lead\":[\"Haz clic en dos esquinas\"],\"editor.buildPanel.rooms.rectangle.title\":[\"Estancia rectangular\"],\"editor.buildPanel.rooms.title\":[\"Estancias\"],\"editor.buildPanel.rooms.walls.caption\":[[\"lead\"],\" Cierra el contorno para crear una estancia o déjalo abierto para tabiques.\"],\"editor.buildPanel.rooms.walls.label\":[\"Muros\"],\"editor.buildPanel.rooms.walls.lead\":[\"Dibuja un muro cada vez.\"],\"editor.buildPanel.rooms.walls.title\":[\"Muros\"],\"viewer.camera.orbitLeft\":[\"Orbitar a la izquierda\"],\"viewer.camera.orbitRight\":[\"Orbitar a la derecha\"],\"viewer.camera.topView\":[\"Vista superior\"],\"viewer.common.off\":[\"Desactivado\"],\"viewer.common.on\":[\"Activado\"],\"viewer.display.camera\":[\"Cámara\"],\"viewer.display.colored\":[\"A color\"],\"viewer.display.colors\":[\"Colores\"],\"viewer.display.edges.off\":[\"Desactivadas\"],\"viewer.display.edges.offDetail\":[\"Sin líneas de arista\"],\"viewer.display.edges.soft\":[\"Suaves\"],\"viewer.display.edges.softDetail\":[\"Contorno tenue de los pliegues principales\"],\"viewer.display.edges.strong\":[\"Marcadas\"],\"viewer.display.edges.strongDetail\":[\"Líneas de arista nítidas y opacas\"],\"viewer.display.edges.title\":[\"Aristas\"],\"viewer.display.monochrome\":[\"Monocromo\"],\"viewer.display.orthographic\":[\"Ortográfica\"],\"viewer.display.perspective\":[\"Perspectiva\"],\"viewer.display.render.rendered\":[\"Realista\"],\"viewer.display.render.renderedDetail\":[\"Oclusión ambiental completa\"],\"viewer.display.render.solid\":[\"Sólido\"],\"viewer.display.render.solidDetail\":[\"Plano y rápido, sin oclusión ambiental\"],\"viewer.display.render.title\":[\"Renderizado\"],\"viewer.display.shadows\":[\"Sombras\"],\"viewer.display.title\":[\"Ajustes de visualización\"],\"viewer.floorplan.alignNorth\":[\"Orientar la vista al norte\"],\"viewer.floorplan.empty\":[\"No hay planos de planta disponibles para esta escena.\"],\"viewer.floorplan.fit\":[\"Ajustar plano\"],\"viewer.floorplan.floor\":[\"Planta\"],\"viewer.floorplan.floorPlan\":[\"Plano de planta\"],\"viewer.floorplan.viewLabel\":[\"Vista 2D de \",[\"floor\"]],\"viewer.floorplan.zoomIn\":[\"Acercar\"],\"viewer.floorplan.zoomOut\":[\"Alejar\"],\"viewer.header.back\":[\"Volver\"],\"viewer.header.levels\":[\"Plantas\"],\"viewer.header.rooms\":[\"Estancias\"],\"viewer.header.selection\":[\"Selección\"],\"viewer.header.units\":[\"Unidades\"],\"viewer.header.untitled\":[\"Sin título\"],\"viewer.header.zones\":[\"Zonas\"],\"viewer.levels.exploded\":[\"Separadas\"],\"viewer.levels.label\":[\"Plantas: \",[\"mode\"]],\"viewer.levels.manual\":[\"Manual\"],\"viewer.levels.solo\":[\"Aislada\"],\"viewer.levels.stacked\":[\"Apiladas\"],\"viewer.stage.layout\":[\"Disposición de vistas\"],\"viewer.stage.split\":[\"Dividida\"],\"viewer.theme.blueprint\":[\"Plano técnico\"],\"viewer.theme.mediterranean\":[\"Mediterráneo\"],\"viewer.theme.night\":[\"Noche\"],\"viewer.theme.overcast\":[\"Nublado\"],\"viewer.theme.paper\":[\"Papel\"],\"viewer.theme.studio\":[\"Estudio\"],\"viewer.theme.sunset\":[\"Atardecer\"],\"viewer.theme.title\":[\"Ambiente\"],\"viewer.theme.twilight\":[\"Crepúsculo\"],\"viewer.theme.verdant\":[\"Verde\"],\"viewer.visibility.guides\":[\"Guías\"],\"viewer.visibility.scans\":[\"Escaneos 3D\"],\"viewer.visibility.title\":[\"Visibilidad\"],\"viewer.walkthrough.label\":[\"Recorrido: \",[\"state\"]],\"viewer.walls.cutaway\":[\"Vista seccionada\"],\"viewer.walls.fullHeight\":[\"Altura completa\"],\"viewer.walls.label\":[\"Muros: \",[\"mode\"]],\"viewer.walls.low\":[\"Bajos\"]}")as Messages; \ No newline at end of file diff --git a/packages/editor/src/i18n/locales/fr/messages.po b/packages/editor/src/i18n/locales/fr/messages.po new file mode 100644 index 0000000000..89431ce1c1 --- /dev/null +++ b/packages/editor/src/i18n/locales/fr/messages.po @@ -0,0 +1,609 @@ +msgid "" +msgstr "" +"POT-Creation-Date: 2026-10-26 22:28+0000\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: @lingui/cli\n" +"Language: fr\n" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: \n" +"Last-Translator: \n" +"Language-Team: \n" +"Plural-Forms: \n" + +# Source-Hash: 8caa959c0ae800c3 +# Review: human +# English: "Basement" +#: packages/editor/src/i18n/level-name.ts +msgid "core.level.basement" +msgstr "Sous-sol" + +# Source-Hash: 14927085ee91ba05 +# Review: human +# English: "Basement {number}" +#: packages/editor/src/i18n/level-name.ts +msgid "core.level.basementNumber" +msgstr "Sous-sol {number}" + +# Source-Hash: e59074a7b9b3f56e +# Review: human +# English: "Floor {number}" +#: packages/editor/src/i18n/level-name.ts +msgid "core.level.floorNumber" +msgstr "Étage {number}" + +# Source-Hash: cde3efc9b7ddfa41 +# Review: human +# English: "Ground floor" +#: packages/editor/src/i18n/level-name.ts +msgid "core.level.groundFloor" +msgstr "Rez-de-chaussée" + +# Source-Hash: f571cb2d06ae01ae +# Review: human +# English: "Advanced" +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.advanced" +msgstr "Avancé" + +# Source-Hash: fb40866c2d6db5fb +# Review: human +# English: "walls · floor · ceiling" +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.hint" +msgstr "murs · sol · plafond" + +# Source-Hash: 271039bd919dc4c1 +# Review: human +# English: "Pick a shape to start. Every room brings its own walls, floor and ceiling." +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.idle" +msgstr "Choisissez une forme pour commencer. Chaque pièce crée ses murs, son sol et son plafond." + +# Source-Hash: ae2800b6c9e2b6ed +# Review: human +# English: "{lead} then the first one again to close. For L-shapes and angled rooms." +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.polygon.caption" +msgstr "{lead} puis de nouveau sur le premier pour fermer. Pour les pièces en L et les angles obliques." + +# Source-Hash: 4e186fc057733162 +# Review: human +# English: "Polygon" +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.polygon.label" +msgstr "Polygone" + +# Source-Hash: 345e3aca3879b5c5 +# Review: human +# English: "Click each corner," +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.polygon.lead" +msgstr "Cliquez sur chaque angle," + +# Source-Hash: 8ec6152e139b0e3f +# Review: human +# English: "Polygon room" +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.polygon.title" +msgstr "Pièce polygonale" + +# Source-Hash: 9b46295eedcbedae +# Review: human +# English: "{lead} on the grid. Walls, floor and ceiling are created together." +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.rectangle.caption" +msgstr "{lead} sur la grille. Les murs, le sol et le plafond sont créés ensemble." + +# Source-Hash: 2c2c20272b0370cc +# Review: human +# English: "Rectangle" +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.rectangle.label" +msgstr "Rectangle" + +# Source-Hash: 060faa48e299d7c9 +# Review: human +# English: "Click two corners" +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.rectangle.lead" +msgstr "Cliquez sur deux angles" + +# Source-Hash: 83a45603ac7f41e8 +# Review: human +# English: "Rectangle room" +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.rectangle.title" +msgstr "Pièce rectangulaire" + +# Source-Hash: 3e7408ed44676176 +# Review: human +# English: "Rooms" +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.title" +msgstr "Pièces" + +# Source-Hash: 8a21a03c4acf396b +# Review: human +# English: "{lead} Close a loop and it becomes a room; leave it open for partitions." +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.walls.caption" +msgstr "{lead} Fermez la boucle pour créer une pièce, ou laissez-la ouverte pour une cloison." + +# Source-Hash: aac9a732d2159ccc +# Review: human +# English: "Walls" +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.walls.label" +msgstr "Murs" + +# Source-Hash: 9843edd68426fa2d +# Review: human +# English: "Draw one wall at a time." +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.walls.lead" +msgstr "Tracez un mur à la fois." + +# Source-Hash: aac9a732d2159ccc +# Review: human +# English: "Walls" +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.walls.title" +msgstr "Murs" + +# Source-Hash: 8bf25afa3cb8baea +# Review: human +# English: "Orbit left" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.camera.orbitLeft" +msgstr "Pivoter à gauche" + +# Source-Hash: b9cc64c1d1bb0d46 +# Review: human +# English: "Orbit right" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.camera.orbitRight" +msgstr "Pivoter à droite" + +# Source-Hash: 1ab1eb3a14f4b0e0 +# Review: human +# English: "Top view" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.camera.topView" +msgstr "Vue de dessus" + +# Source-Hash: 50accd740fa2e736 +# Review: human +# English: "Off" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.common.off" +msgstr "Désactivé" + +# Source-Hash: 9cc01faad79c28da +# Review: human +# English: "On" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.common.on" +msgstr "Activé" + +# Source-Hash: 14622745dbf93f4c +# Review: human +# English: "Camera" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.camera" +msgstr "Caméra" + +# Source-Hash: d3d478fa6c6d7ddc +# Review: human +# English: "Colored" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.colored" +msgstr "En couleur" + +# Source-Hash: 82b74c4f5ef43336 +# Review: human +# English: "Colors" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.colors" +msgstr "Couleurs" + +# Source-Hash: 50accd740fa2e736 +# Review: human +# English: "Off" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.edges.off" +msgstr "Désactivées" + +# Source-Hash: 4ff58913a4492199 +# Review: human +# English: "No edge lines" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.edges.offDetail" +msgstr "Aucune ligne d’arête" + +# Source-Hash: d266d83ec490df4e +# Review: human +# English: "Soft" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.edges.soft" +msgstr "Douces" + +# Source-Hash: 0c583f1d4201293b +# Review: human +# English: "Faint outline of major creases" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.edges.softDetail" +msgstr "Contour léger des plis principaux" + +# Source-Hash: f82927a01d4c5e45 +# Review: human +# English: "Strong" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.edges.strong" +msgstr "Marquées" + +# Source-Hash: 2416a48df28d6205 +# Review: human +# English: "Crisp, opaque edge lines" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.edges.strongDetail" +msgstr "Lignes d’arête nettes et opaques" + +# Source-Hash: 027bbc8b7287c06a +# Review: human +# English: "Edges" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.edges.title" +msgstr "Arêtes" + +# Source-Hash: ecde2f7c26914be3 +# Review: human +# English: "Monochrome" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.monochrome" +msgstr "Monochrome" + +# Source-Hash: ed59dd8ce7797a66 +# Review: human +# English: "Orthographic" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.orthographic" +msgstr "Orthographique" + +# Source-Hash: 316e25993ff72b88 +# Review: human +# English: "Perspective" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.perspective" +msgstr "Perspective" + +# Source-Hash: f363702a10aedaeb +# Review: human +# English: "Rendered" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.render.rendered" +msgstr "Réaliste" + +# Source-Hash: 8bb9e54fc786ca77 +# Review: human +# English: "Full ambient occlusion" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.render.renderedDetail" +msgstr "Occlusion ambiante complète" + +# Source-Hash: ef3e765e692faf4c +# Review: human +# English: "Solid" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.render.solid" +msgstr "Solide" + +# Source-Hash: 8827df1cdeb02ed9 +# Review: human +# English: "Flat and fast — no ambient occlusion" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.render.solidDetail" +msgstr "Rapide et uniforme, sans occlusion ambiante" + +# Source-Hash: 5e58af99d6d4fa6a +# Review: human +# English: "Render" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.render.title" +msgstr "Rendu" + +# Source-Hash: 4c6bcb30b40047fc +# Review: human +# English: "Shadows" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.shadows" +msgstr "Ombres" + +# Source-Hash: d0db46f206a88e2e +# Review: human +# English: "Display settings" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.title" +msgstr "Paramètres d’affichage" + +# Source-Hash: 2bdf83801d4a40af +# Review: human +# English: "Align view to north" +#: packages/editor/src/components/viewer/floorplan-compass-button.tsx +msgid "viewer.floorplan.alignNorth" +msgstr "Orienter la vue vers le nord" + +# Source-Hash: 286a757d8b52bb02 +# Review: human +# English: "No floor plans are available for this scene." +#: packages/editor/src/components/viewer/floorplan-preview.tsx +msgid "viewer.floorplan.empty" +msgstr "Aucun plan d’étage n’est disponible pour cette scène." + +# Source-Hash: bf63c679ab94dbcd +# Review: human +# English: "Fit floor plan" +#: packages/editor/src/components/viewer/floorplan-preview.tsx +msgid "viewer.floorplan.fit" +msgstr "Ajuster le plan" + +# Source-Hash: d7148fa065bc709b +# Review: human +# English: "Floor" +#: packages/editor/src/components/viewer/floorplan-preview.tsx +msgid "viewer.floorplan.floor" +msgstr "Niveau" + +# Source-Hash: fee6203bb9046929 +# Review: human +# English: "Floor plan" +#: packages/editor/src/components/viewer/floorplan-preview.tsx +msgid "viewer.floorplan.floorPlan" +msgstr "Plan d’étage" + +# Source-Hash: 28b255b7bf5f1084 +# Review: human +# English: "{floor} 2D view" +#: packages/editor/src/components/viewer/floorplan-preview.tsx +msgid "viewer.floorplan.viewLabel" +msgstr "Vue 2D — {floor}" + +# Source-Hash: d17804412f8dd081 +# Review: human +# English: "Zoom in" +#: packages/editor/src/components/viewer/floorplan-preview.tsx +msgid "viewer.floorplan.zoomIn" +msgstr "Zoom avant" + +# Source-Hash: 23b012a283b2c14a +# Review: human +# English: "Zoom out" +#: packages/editor/src/components/viewer/floorplan-preview.tsx +msgid "viewer.floorplan.zoomOut" +msgstr "Zoom arrière" + +# Source-Hash: 7ceade8f25b58fe9 +# Review: human +# English: "Back" +#: packages/editor/src/components/viewer/viewer-scene-header.tsx +msgid "viewer.header.back" +msgstr "Retour" + +# Source-Hash: cc3d601d59e2e26c +# Review: human +# English: "Levels" +#: packages/editor/src/components/viewer/viewer-scene-header.tsx +msgid "viewer.header.levels" +msgstr "Niveaux" + +# Source-Hash: 3e7408ed44676176 +# Review: human +# English: "Rooms" +#: packages/editor/src/components/viewer/viewer-scene-header.tsx +msgid "viewer.header.rooms" +msgstr "Pièces" + +# Source-Hash: 170b2049ef0f9131 +# Review: human +# English: "Selection" +#: packages/editor/src/components/viewer/viewer-scene-header.tsx +msgid "viewer.header.selection" +msgstr "Sélection" + +# Source-Hash: b404dded35241274 +# Review: human +# English: "Units" +#: packages/editor/src/components/viewer/viewer-scene-header.tsx +msgid "viewer.header.units" +msgstr "Unités" + +# Source-Hash: 6547a4c0c368042a +# Review: human +# English: "Untitled" +#: packages/editor/src/components/viewer/viewer-scene-header.tsx +msgid "viewer.header.untitled" +msgstr "Sans titre" + +# Source-Hash: 665db49a26f81425 +# Review: human +# English: "Zones" +#: packages/editor/src/components/viewer/viewer-scene-header.tsx +msgid "viewer.header.zones" +msgstr "Zones" + +# Source-Hash: be12f8788f1f0a05 +# Review: human +# English: "Exploded" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.levels.exploded" +msgstr "Éclatés" + +# Source-Hash: 4bf4a6861d7d633e +# Review: human +# English: "Levels: {mode}" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.levels.label" +msgstr "Niveaux : {mode}" + +# Source-Hash: 387082cd5f118bed +# Review: human +# English: "Manual" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.levels.manual" +msgstr "Manuel" + +# Source-Hash: e9f59b513f2f45ac +# Review: human +# English: "Solo" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.levels.solo" +msgstr "Isolé" + +# Source-Hash: c203c84eee18c0f6 +# Review: human +# English: "Stacked" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.levels.stacked" +msgstr "Superposés" + +# Source-Hash: 9dd21ca47cffa3b8 +# Review: human +# English: "Viewer layout" +#: packages/editor/src/components/viewer/viewer-stage-switcher.tsx +msgid "viewer.stage.layout" +msgstr "Disposition des vues" + +# Source-Hash: e4cd64b1680ac0d2 +# Review: human +# English: "Split" +#: packages/editor/src/components/viewer/viewer-stage-switcher.tsx +msgid "viewer.stage.split" +msgstr "Partagé" + +# Source-Hash: f14b89d713d2528a +# Review: human +# English: "Blueprint" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.blueprint" +msgstr "Plan" + +# Source-Hash: 142bcb2841f423c5 +# Review: human +# English: "Mediterranean" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.mediterranean" +msgstr "Méditerranéen" + +# Source-Hash: b399942ec5d7e1ba +# Review: human +# English: "Night" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.night" +msgstr "Nuit" + +# Source-Hash: f512d1b947b8ae5a +# Review: human +# English: "Overcast" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.overcast" +msgstr "Ciel couvert" + +# Source-Hash: 583542b3bc837192 +# Review: human +# English: "Paper" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.paper" +msgstr "Papier" + +# Source-Hash: 7abc2dc6e39b1747 +# Review: human +# English: "Studio" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.studio" +msgstr "Studio" + +# Source-Hash: efe94d3ec8d8c44e +# Review: human +# English: "Sunset" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.sunset" +msgstr "Coucher de soleil" + +# Source-Hash: 82720864e5034398 +# Review: human +# English: "Scene theme" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.title" +msgstr "Ambiance" + +# Source-Hash: 4c10ea0aa0e2ea41 +# Review: human +# English: "Twilight" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.twilight" +msgstr "Crépuscule" + +# Source-Hash: f9592fb0672740c0 +# Review: human +# English: "Verdant" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.verdant" +msgstr "Verdoyant" + +# Source-Hash: 892d5dd34372028b +# Review: human +# English: "Guides" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.visibility.guides" +msgstr "Repères" + +# Source-Hash: 3260bfa69cca50e5 +# Review: human +# English: "Scans" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.visibility.scans" +msgstr "Relevés 3D" + +# Source-Hash: 2cefbb554eadbb6e +# Review: human +# English: "Visibility" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.visibility.title" +msgstr "Visibilité" + +# Source-Hash: 31afd9dee47ba943 +# Review: human +# English: "Walkthrough: {state}" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.walkthrough.label" +msgstr "Visite : {state}" + +# Source-Hash: 4da8b3678ed77b45 +# Review: human +# English: "Cutaway" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.walls.cutaway" +msgstr "Vue en coupe" + +# Source-Hash: 131bac2467f4fdf6 +# Review: human +# English: "Full height" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.walls.fullHeight" +msgstr "Pleine hauteur" + +# Source-Hash: 32f3d058cf2526d2 +# Review: human +# English: "Walls: {mode}" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.walls.label" +msgstr "Murs : {mode}" + +# Source-Hash: 803532407fb5962b +# Review: human +# English: "Low" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.walls.low" +msgstr "Bas" diff --git a/packages/editor/src/i18n/locales/fr/messages.ts b/packages/editor/src/i18n/locales/fr/messages.ts new file mode 100644 index 0000000000..42848704ea --- /dev/null +++ b/packages/editor/src/i18n/locales/fr/messages.ts @@ -0,0 +1 @@ +/*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"core.level.basement\":[\"Sous-sol\"],\"core.level.basementNumber\":[\"Sous-sol \",[\"number\"]],\"core.level.floorNumber\":[\"Étage \",[\"number\"]],\"core.level.groundFloor\":[\"Rez-de-chaussée\"],\"editor.buildPanel.advanced\":[\"Avancé\"],\"editor.buildPanel.rooms.hint\":[\"murs · sol · plafond\"],\"editor.buildPanel.rooms.idle\":[\"Choisissez une forme pour commencer. Chaque pièce crée ses murs, son sol et son plafond.\"],\"editor.buildPanel.rooms.polygon.caption\":[[\"lead\"],\" puis de nouveau sur le premier pour fermer. Pour les pièces en L et les angles obliques.\"],\"editor.buildPanel.rooms.polygon.label\":[\"Polygone\"],\"editor.buildPanel.rooms.polygon.lead\":[\"Cliquez sur chaque angle,\"],\"editor.buildPanel.rooms.polygon.title\":[\"Pièce polygonale\"],\"editor.buildPanel.rooms.rectangle.caption\":[[\"lead\"],\" sur la grille. Les murs, le sol et le plafond sont créés ensemble.\"],\"editor.buildPanel.rooms.rectangle.label\":[\"Rectangle\"],\"editor.buildPanel.rooms.rectangle.lead\":[\"Cliquez sur deux angles\"],\"editor.buildPanel.rooms.rectangle.title\":[\"Pièce rectangulaire\"],\"editor.buildPanel.rooms.title\":[\"Pièces\"],\"editor.buildPanel.rooms.walls.caption\":[[\"lead\"],\" Fermez la boucle pour créer une pièce, ou laissez-la ouverte pour une cloison.\"],\"editor.buildPanel.rooms.walls.label\":[\"Murs\"],\"editor.buildPanel.rooms.walls.lead\":[\"Tracez un mur à la fois.\"],\"editor.buildPanel.rooms.walls.title\":[\"Murs\"],\"viewer.camera.orbitLeft\":[\"Pivoter à gauche\"],\"viewer.camera.orbitRight\":[\"Pivoter à droite\"],\"viewer.camera.topView\":[\"Vue de dessus\"],\"viewer.common.off\":[\"Désactivé\"],\"viewer.common.on\":[\"Activé\"],\"viewer.display.camera\":[\"Caméra\"],\"viewer.display.colored\":[\"En couleur\"],\"viewer.display.colors\":[\"Couleurs\"],\"viewer.display.edges.off\":[\"Désactivées\"],\"viewer.display.edges.offDetail\":[\"Aucune ligne d’arête\"],\"viewer.display.edges.soft\":[\"Douces\"],\"viewer.display.edges.softDetail\":[\"Contour léger des plis principaux\"],\"viewer.display.edges.strong\":[\"Marquées\"],\"viewer.display.edges.strongDetail\":[\"Lignes d’arête nettes et opaques\"],\"viewer.display.edges.title\":[\"Arêtes\"],\"viewer.display.monochrome\":[\"Monochrome\"],\"viewer.display.orthographic\":[\"Orthographique\"],\"viewer.display.perspective\":[\"Perspective\"],\"viewer.display.render.rendered\":[\"Réaliste\"],\"viewer.display.render.renderedDetail\":[\"Occlusion ambiante complète\"],\"viewer.display.render.solid\":[\"Solide\"],\"viewer.display.render.solidDetail\":[\"Rapide et uniforme, sans occlusion ambiante\"],\"viewer.display.render.title\":[\"Rendu\"],\"viewer.display.shadows\":[\"Ombres\"],\"viewer.display.title\":[\"Paramètres d’affichage\"],\"viewer.floorplan.alignNorth\":[\"Orienter la vue vers le nord\"],\"viewer.floorplan.empty\":[\"Aucun plan d’étage n’est disponible pour cette scène.\"],\"viewer.floorplan.fit\":[\"Ajuster le plan\"],\"viewer.floorplan.floor\":[\"Niveau\"],\"viewer.floorplan.floorPlan\":[\"Plan d’étage\"],\"viewer.floorplan.viewLabel\":[\"Vue 2D — \",[\"floor\"]],\"viewer.floorplan.zoomIn\":[\"Zoom avant\"],\"viewer.floorplan.zoomOut\":[\"Zoom arrière\"],\"viewer.header.back\":[\"Retour\"],\"viewer.header.levels\":[\"Niveaux\"],\"viewer.header.rooms\":[\"Pièces\"],\"viewer.header.selection\":[\"Sélection\"],\"viewer.header.units\":[\"Unités\"],\"viewer.header.untitled\":[\"Sans titre\"],\"viewer.header.zones\":[\"Zones\"],\"viewer.levels.exploded\":[\"Éclatés\"],\"viewer.levels.label\":[\"Niveaux : \",[\"mode\"]],\"viewer.levels.manual\":[\"Manuel\"],\"viewer.levels.solo\":[\"Isolé\"],\"viewer.levels.stacked\":[\"Superposés\"],\"viewer.stage.layout\":[\"Disposition des vues\"],\"viewer.stage.split\":[\"Partagé\"],\"viewer.theme.blueprint\":[\"Plan\"],\"viewer.theme.mediterranean\":[\"Méditerranéen\"],\"viewer.theme.night\":[\"Nuit\"],\"viewer.theme.overcast\":[\"Ciel couvert\"],\"viewer.theme.paper\":[\"Papier\"],\"viewer.theme.studio\":[\"Studio\"],\"viewer.theme.sunset\":[\"Coucher de soleil\"],\"viewer.theme.title\":[\"Ambiance\"],\"viewer.theme.twilight\":[\"Crépuscule\"],\"viewer.theme.verdant\":[\"Verdoyant\"],\"viewer.visibility.guides\":[\"Repères\"],\"viewer.visibility.scans\":[\"Relevés 3D\"],\"viewer.visibility.title\":[\"Visibilité\"],\"viewer.walkthrough.label\":[\"Visite : \",[\"state\"]],\"viewer.walls.cutaway\":[\"Vue en coupe\"],\"viewer.walls.fullHeight\":[\"Pleine hauteur\"],\"viewer.walls.label\":[\"Murs : \",[\"mode\"]],\"viewer.walls.low\":[\"Bas\"]}")as Messages; \ No newline at end of file diff --git a/packages/editor/src/i18n/locales/ja/messages.po b/packages/editor/src/i18n/locales/ja/messages.po new file mode 100644 index 0000000000..7fe420abfb --- /dev/null +++ b/packages/editor/src/i18n/locales/ja/messages.po @@ -0,0 +1,609 @@ +msgid "" +msgstr "" +"POT-Creation-Date: 2026-10-26 22:28+0000\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: @lingui/cli\n" +"Language: ja\n" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: \n" +"Last-Translator: \n" +"Language-Team: \n" +"Plural-Forms: \n" + +# Source-Hash: 8caa959c0ae800c3 +# Review: human +# English: "Basement" +#: packages/editor/src/i18n/level-name.ts +msgid "core.level.basement" +msgstr "地下1階" + +# Source-Hash: 14927085ee91ba05 +# Review: human +# English: "Basement {number}" +#: packages/editor/src/i18n/level-name.ts +msgid "core.level.basementNumber" +msgstr "地下{number}階" + +# Source-Hash: e59074a7b9b3f56e +# Review: human +# English: "Floor {number}" +#: packages/editor/src/i18n/level-name.ts +msgid "core.level.floorNumber" +msgstr "{number}階" + +# Source-Hash: cde3efc9b7ddfa41 +# Review: human +# English: "Ground floor" +#: packages/editor/src/i18n/level-name.ts +msgid "core.level.groundFloor" +msgstr "1階" + +# Source-Hash: f571cb2d06ae01ae +# Review: human +# English: "Advanced" +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.advanced" +msgstr "詳細" + +# Source-Hash: fb40866c2d6db5fb +# Review: human +# English: "walls · floor · ceiling" +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.hint" +msgstr "壁 · 床 · 天井" + +# Source-Hash: 271039bd919dc4c1 +# Review: human +# English: "Pick a shape to start. Every room brings its own walls, floor and ceiling." +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.idle" +msgstr "形状を選んで開始します。部屋ごとに壁・床・天井が作成されます。" + +# Source-Hash: ae2800b6c9e2b6ed +# Review: human +# English: "{lead} then the first one again to close. For L-shapes and angled rooms." +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.polygon.caption" +msgstr "{lead}最後に始点をクリックして閉じます。L字型や斜めの部屋に使用します。" + +# Source-Hash: 4e186fc057733162 +# Review: human +# English: "Polygon" +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.polygon.label" +msgstr "多角形" + +# Source-Hash: 345e3aca3879b5c5 +# Review: human +# English: "Click each corner," +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.polygon.lead" +msgstr "各頂点をクリックし、" + +# Source-Hash: 8ec6152e139b0e3f +# Review: human +# English: "Polygon room" +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.polygon.title" +msgstr "多角形の部屋" + +# Source-Hash: 9b46295eedcbedae +# Review: human +# English: "{lead} on the grid. Walls, floor and ceiling are created together." +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.rectangle.caption" +msgstr "グリッド上で{lead}します。壁・床・天井をまとめて作成します。" + +# Source-Hash: 2c2c20272b0370cc +# Review: human +# English: "Rectangle" +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.rectangle.label" +msgstr "長方形" + +# Source-Hash: 060faa48e299d7c9 +# Review: human +# English: "Click two corners" +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.rectangle.lead" +msgstr "対角の2点をクリック" + +# Source-Hash: 83a45603ac7f41e8 +# Review: human +# English: "Rectangle room" +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.rectangle.title" +msgstr "長方形の部屋" + +# Source-Hash: 3e7408ed44676176 +# Review: human +# English: "Rooms" +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.title" +msgstr "部屋" + +# Source-Hash: 8a21a03c4acf396b +# Review: human +# English: "{lead} Close a loop and it becomes a room; leave it open for partitions." +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.walls.caption" +msgstr "{lead}輪郭を閉じると部屋になり、開いたままにすると間仕切りになります。" + +# Source-Hash: aac9a732d2159ccc +# Review: human +# English: "Walls" +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.walls.label" +msgstr "壁" + +# Source-Hash: 9843edd68426fa2d +# Review: human +# English: "Draw one wall at a time." +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.walls.lead" +msgstr "壁を1面ずつ描画します。" + +# Source-Hash: aac9a732d2159ccc +# Review: human +# English: "Walls" +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.walls.title" +msgstr "壁" + +# Source-Hash: 8bf25afa3cb8baea +# Review: human +# English: "Orbit left" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.camera.orbitLeft" +msgstr "左に回転" + +# Source-Hash: b9cc64c1d1bb0d46 +# Review: human +# English: "Orbit right" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.camera.orbitRight" +msgstr "右に回転" + +# Source-Hash: 1ab1eb3a14f4b0e0 +# Review: human +# English: "Top view" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.camera.topView" +msgstr "上面図" + +# Source-Hash: 50accd740fa2e736 +# Review: human +# English: "Off" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.common.off" +msgstr "オフ" + +# Source-Hash: 9cc01faad79c28da +# Review: human +# English: "On" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.common.on" +msgstr "オン" + +# Source-Hash: 14622745dbf93f4c +# Review: human +# English: "Camera" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.camera" +msgstr "カメラ" + +# Source-Hash: d3d478fa6c6d7ddc +# Review: human +# English: "Colored" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.colored" +msgstr "カラー表示" + +# Source-Hash: 82b74c4f5ef43336 +# Review: human +# English: "Colors" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.colors" +msgstr "カラー" + +# Source-Hash: 50accd740fa2e736 +# Review: human +# English: "Off" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.edges.off" +msgstr "オフ" + +# Source-Hash: 4ff58913a4492199 +# Review: human +# English: "No edge lines" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.edges.offDetail" +msgstr "エッジ線を表示しない" + +# Source-Hash: d266d83ec490df4e +# Review: human +# English: "Soft" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.edges.soft" +msgstr "ソフト" + +# Source-Hash: 0c583f1d4201293b +# Review: human +# English: "Faint outline of major creases" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.edges.softDetail" +msgstr "主要な稜線を薄く表示" + +# Source-Hash: f82927a01d4c5e45 +# Review: human +# English: "Strong" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.edges.strong" +msgstr "強調" + +# Source-Hash: 2416a48df28d6205 +# Review: human +# English: "Crisp, opaque edge lines" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.edges.strongDetail" +msgstr "エッジ線をくっきり表示" + +# Source-Hash: 027bbc8b7287c06a +# Review: human +# English: "Edges" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.edges.title" +msgstr "エッジ" + +# Source-Hash: ecde2f7c26914be3 +# Review: human +# English: "Monochrome" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.monochrome" +msgstr "モノクロ" + +# Source-Hash: ed59dd8ce7797a66 +# Review: human +# English: "Orthographic" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.orthographic" +msgstr "平行投影" + +# Source-Hash: 316e25993ff72b88 +# Review: human +# English: "Perspective" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.perspective" +msgstr "透視投影" + +# Source-Hash: f363702a10aedaeb +# Review: human +# English: "Rendered" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.render.rendered" +msgstr "レンダリング" + +# Source-Hash: 8bb9e54fc786ca77 +# Review: human +# English: "Full ambient occlusion" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.render.renderedDetail" +msgstr "アンビエントオクルージョンを完全表示" + +# Source-Hash: ef3e765e692faf4c +# Review: human +# English: "Solid" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.render.solid" +msgstr "ソリッド" + +# Source-Hash: 8827df1cdeb02ed9 +# Review: human +# English: "Flat and fast — no ambient occlusion" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.render.solidDetail" +msgstr "軽量表示(アンビエントオクルージョンなし)" + +# Source-Hash: 5e58af99d6d4fa6a +# Review: human +# English: "Render" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.render.title" +msgstr "レンダリング" + +# Source-Hash: 4c6bcb30b40047fc +# Review: human +# English: "Shadows" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.shadows" +msgstr "影" + +# Source-Hash: d0db46f206a88e2e +# Review: human +# English: "Display settings" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.title" +msgstr "表示設定" + +# Source-Hash: 2bdf83801d4a40af +# Review: human +# English: "Align view to north" +#: packages/editor/src/components/viewer/floorplan-compass-button.tsx +msgid "viewer.floorplan.alignNorth" +msgstr "北を上に表示" + +# Source-Hash: 286a757d8b52bb02 +# Review: human +# English: "No floor plans are available for this scene." +#: packages/editor/src/components/viewer/floorplan-preview.tsx +msgid "viewer.floorplan.empty" +msgstr "このシーンには表示できる平面図がありません。" + +# Source-Hash: bf63c679ab94dbcd +# Review: human +# English: "Fit floor plan" +#: packages/editor/src/components/viewer/floorplan-preview.tsx +msgid "viewer.floorplan.fit" +msgstr "全体を表示" + +# Source-Hash: d7148fa065bc709b +# Review: human +# English: "Floor" +#: packages/editor/src/components/viewer/floorplan-preview.tsx +msgid "viewer.floorplan.floor" +msgstr "階" + +# Source-Hash: fee6203bb9046929 +# Review: human +# English: "Floor plan" +#: packages/editor/src/components/viewer/floorplan-preview.tsx +msgid "viewer.floorplan.floorPlan" +msgstr "平面図" + +# Source-Hash: 28b255b7bf5f1084 +# Review: human +# English: "{floor} 2D view" +#: packages/editor/src/components/viewer/floorplan-preview.tsx +msgid "viewer.floorplan.viewLabel" +msgstr "{floor}の2Dビュー" + +# Source-Hash: d17804412f8dd081 +# Review: human +# English: "Zoom in" +#: packages/editor/src/components/viewer/floorplan-preview.tsx +msgid "viewer.floorplan.zoomIn" +msgstr "拡大" + +# Source-Hash: 23b012a283b2c14a +# Review: human +# English: "Zoom out" +#: packages/editor/src/components/viewer/floorplan-preview.tsx +msgid "viewer.floorplan.zoomOut" +msgstr "縮小" + +# Source-Hash: 7ceade8f25b58fe9 +# Review: human +# English: "Back" +#: packages/editor/src/components/viewer/viewer-scene-header.tsx +msgid "viewer.header.back" +msgstr "戻る" + +# Source-Hash: cc3d601d59e2e26c +# Review: human +# English: "Levels" +#: packages/editor/src/components/viewer/viewer-scene-header.tsx +msgid "viewer.header.levels" +msgstr "階層" + +# Source-Hash: 3e7408ed44676176 +# Review: human +# English: "Rooms" +#: packages/editor/src/components/viewer/viewer-scene-header.tsx +msgid "viewer.header.rooms" +msgstr "部屋" + +# Source-Hash: 170b2049ef0f9131 +# Review: human +# English: "Selection" +#: packages/editor/src/components/viewer/viewer-scene-header.tsx +msgid "viewer.header.selection" +msgstr "選択" + +# Source-Hash: b404dded35241274 +# Review: human +# English: "Units" +#: packages/editor/src/components/viewer/viewer-scene-header.tsx +msgid "viewer.header.units" +msgstr "住戸" + +# Source-Hash: 6547a4c0c368042a +# Review: human +# English: "Untitled" +#: packages/editor/src/components/viewer/viewer-scene-header.tsx +msgid "viewer.header.untitled" +msgstr "名称未設定" + +# Source-Hash: 665db49a26f81425 +# Review: human +# English: "Zones" +#: packages/editor/src/components/viewer/viewer-scene-header.tsx +msgid "viewer.header.zones" +msgstr "ゾーン" + +# Source-Hash: be12f8788f1f0a05 +# Review: human +# English: "Exploded" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.levels.exploded" +msgstr "分解" + +# Source-Hash: 4bf4a6861d7d633e +# Review: human +# English: "Levels: {mode}" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.levels.label" +msgstr "階層:{mode}" + +# Source-Hash: 387082cd5f118bed +# Review: human +# English: "Manual" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.levels.manual" +msgstr "手動" + +# Source-Hash: e9f59b513f2f45ac +# Review: human +# English: "Solo" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.levels.solo" +msgstr "単独" + +# Source-Hash: c203c84eee18c0f6 +# Review: human +# English: "Stacked" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.levels.stacked" +msgstr "積層" + +# Source-Hash: 9dd21ca47cffa3b8 +# Review: human +# English: "Viewer layout" +#: packages/editor/src/components/viewer/viewer-stage-switcher.tsx +msgid "viewer.stage.layout" +msgstr "ビュー配置" + +# Source-Hash: e4cd64b1680ac0d2 +# Review: human +# English: "Split" +#: packages/editor/src/components/viewer/viewer-stage-switcher.tsx +msgid "viewer.stage.split" +msgstr "分割" + +# Source-Hash: f14b89d713d2528a +# Review: human +# English: "Blueprint" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.blueprint" +msgstr "青図" + +# Source-Hash: 142bcb2841f423c5 +# Review: human +# English: "Mediterranean" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.mediterranean" +msgstr "地中海" + +# Source-Hash: b399942ec5d7e1ba +# Review: human +# English: "Night" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.night" +msgstr "夜景" + +# Source-Hash: f512d1b947b8ae5a +# Review: human +# English: "Overcast" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.overcast" +msgstr "曇天" + +# Source-Hash: 583542b3bc837192 +# Review: human +# English: "Paper" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.paper" +msgstr "ペーパー" + +# Source-Hash: 7abc2dc6e39b1747 +# Review: human +# English: "Studio" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.studio" +msgstr "スタジオ" + +# Source-Hash: efe94d3ec8d8c44e +# Review: human +# English: "Sunset" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.sunset" +msgstr "夕景" + +# Source-Hash: 82720864e5034398 +# Review: human +# English: "Scene theme" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.title" +msgstr "シーンテーマ" + +# Source-Hash: 4c10ea0aa0e2ea41 +# Review: human +# English: "Twilight" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.twilight" +msgstr "薄暮" + +# Source-Hash: f9592fb0672740c0 +# Review: human +# English: "Verdant" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.verdant" +msgstr "緑景" + +# Source-Hash: 892d5dd34372028b +# Review: human +# English: "Guides" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.visibility.guides" +msgstr "ガイド" + +# Source-Hash: 3260bfa69cca50e5 +# Review: human +# English: "Scans" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.visibility.scans" +msgstr "3Dスキャン" + +# Source-Hash: 2cefbb554eadbb6e +# Review: human +# English: "Visibility" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.visibility.title" +msgstr "表示" + +# Source-Hash: 31afd9dee47ba943 +# Review: human +# English: "Walkthrough: {state}" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.walkthrough.label" +msgstr "ウォークスルー:{state}" + +# Source-Hash: 4da8b3678ed77b45 +# Review: human +# English: "Cutaway" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.walls.cutaway" +msgstr "カット表示" + +# Source-Hash: 131bac2467f4fdf6 +# Review: human +# English: "Full height" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.walls.fullHeight" +msgstr "全高" + +# Source-Hash: 32f3d058cf2526d2 +# Review: human +# English: "Walls: {mode}" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.walls.label" +msgstr "壁:{mode}" + +# Source-Hash: 803532407fb5962b +# Review: human +# English: "Low" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.walls.low" +msgstr "低く表示" diff --git a/packages/editor/src/i18n/locales/ja/messages.ts b/packages/editor/src/i18n/locales/ja/messages.ts new file mode 100644 index 0000000000..c213b9a5f3 --- /dev/null +++ b/packages/editor/src/i18n/locales/ja/messages.ts @@ -0,0 +1 @@ +/*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"core.level.basement\":[\"地下1階\"],\"core.level.basementNumber\":[\"地下\",[\"number\"],\"階\"],\"core.level.floorNumber\":[[\"number\"],\"階\"],\"core.level.groundFloor\":[\"1階\"],\"editor.buildPanel.advanced\":[\"詳細\"],\"editor.buildPanel.rooms.hint\":[\"壁 · 床 · 天井\"],\"editor.buildPanel.rooms.idle\":[\"形状を選んで開始します。部屋ごとに壁・床・天井が作成されます。\"],\"editor.buildPanel.rooms.polygon.caption\":[[\"lead\"],\"最後に始点をクリックして閉じます。L字型や斜めの部屋に使用します。\"],\"editor.buildPanel.rooms.polygon.label\":[\"多角形\"],\"editor.buildPanel.rooms.polygon.lead\":[\"各頂点をクリックし、\"],\"editor.buildPanel.rooms.polygon.title\":[\"多角形の部屋\"],\"editor.buildPanel.rooms.rectangle.caption\":[\"グリッド上で\",[\"lead\"],\"します。壁・床・天井をまとめて作成します。\"],\"editor.buildPanel.rooms.rectangle.label\":[\"長方形\"],\"editor.buildPanel.rooms.rectangle.lead\":[\"対角の2点をクリック\"],\"editor.buildPanel.rooms.rectangle.title\":[\"長方形の部屋\"],\"editor.buildPanel.rooms.title\":[\"部屋\"],\"editor.buildPanel.rooms.walls.caption\":[[\"lead\"],\"輪郭を閉じると部屋になり、開いたままにすると間仕切りになります。\"],\"editor.buildPanel.rooms.walls.label\":[\"壁\"],\"editor.buildPanel.rooms.walls.lead\":[\"壁を1面ずつ描画します。\"],\"editor.buildPanel.rooms.walls.title\":[\"壁\"],\"viewer.camera.orbitLeft\":[\"左に回転\"],\"viewer.camera.orbitRight\":[\"右に回転\"],\"viewer.camera.topView\":[\"上面図\"],\"viewer.common.off\":[\"オフ\"],\"viewer.common.on\":[\"オン\"],\"viewer.display.camera\":[\"カメラ\"],\"viewer.display.colored\":[\"カラー表示\"],\"viewer.display.colors\":[\"カラー\"],\"viewer.display.edges.off\":[\"オフ\"],\"viewer.display.edges.offDetail\":[\"エッジ線を表示しない\"],\"viewer.display.edges.soft\":[\"ソフト\"],\"viewer.display.edges.softDetail\":[\"主要な稜線を薄く表示\"],\"viewer.display.edges.strong\":[\"強調\"],\"viewer.display.edges.strongDetail\":[\"エッジ線をくっきり表示\"],\"viewer.display.edges.title\":[\"エッジ\"],\"viewer.display.monochrome\":[\"モノクロ\"],\"viewer.display.orthographic\":[\"平行投影\"],\"viewer.display.perspective\":[\"透視投影\"],\"viewer.display.render.rendered\":[\"レンダリング\"],\"viewer.display.render.renderedDetail\":[\"アンビエントオクルージョンを完全表示\"],\"viewer.display.render.solid\":[\"ソリッド\"],\"viewer.display.render.solidDetail\":[\"軽量表示(アンビエントオクルージョンなし)\"],\"viewer.display.render.title\":[\"レンダリング\"],\"viewer.display.shadows\":[\"影\"],\"viewer.display.title\":[\"表示設定\"],\"viewer.floorplan.alignNorth\":[\"北を上に表示\"],\"viewer.floorplan.empty\":[\"このシーンには表示できる平面図がありません。\"],\"viewer.floorplan.fit\":[\"全体を表示\"],\"viewer.floorplan.floor\":[\"階\"],\"viewer.floorplan.floorPlan\":[\"平面図\"],\"viewer.floorplan.viewLabel\":[[\"floor\"],\"の2Dビュー\"],\"viewer.floorplan.zoomIn\":[\"拡大\"],\"viewer.floorplan.zoomOut\":[\"縮小\"],\"viewer.header.back\":[\"戻る\"],\"viewer.header.levels\":[\"階層\"],\"viewer.header.rooms\":[\"部屋\"],\"viewer.header.selection\":[\"選択\"],\"viewer.header.units\":[\"住戸\"],\"viewer.header.untitled\":[\"名称未設定\"],\"viewer.header.zones\":[\"ゾーン\"],\"viewer.levels.exploded\":[\"分解\"],\"viewer.levels.label\":[\"階層:\",[\"mode\"]],\"viewer.levels.manual\":[\"手動\"],\"viewer.levels.solo\":[\"単独\"],\"viewer.levels.stacked\":[\"積層\"],\"viewer.stage.layout\":[\"ビュー配置\"],\"viewer.stage.split\":[\"分割\"],\"viewer.theme.blueprint\":[\"青図\"],\"viewer.theme.mediterranean\":[\"地中海\"],\"viewer.theme.night\":[\"夜景\"],\"viewer.theme.overcast\":[\"曇天\"],\"viewer.theme.paper\":[\"ペーパー\"],\"viewer.theme.studio\":[\"スタジオ\"],\"viewer.theme.sunset\":[\"夕景\"],\"viewer.theme.title\":[\"シーンテーマ\"],\"viewer.theme.twilight\":[\"薄暮\"],\"viewer.theme.verdant\":[\"緑景\"],\"viewer.visibility.guides\":[\"ガイド\"],\"viewer.visibility.scans\":[\"3Dスキャン\"],\"viewer.visibility.title\":[\"表示\"],\"viewer.walkthrough.label\":[\"ウォークスルー:\",[\"state\"]],\"viewer.walls.cutaway\":[\"カット表示\"],\"viewer.walls.fullHeight\":[\"全高\"],\"viewer.walls.label\":[\"壁:\",[\"mode\"]],\"viewer.walls.low\":[\"低く表示\"]}")as Messages; \ No newline at end of file diff --git a/packages/editor/src/i18n/locales/zh-CN/messages.po b/packages/editor/src/i18n/locales/zh-CN/messages.po new file mode 100644 index 0000000000..9b059286fe --- /dev/null +++ b/packages/editor/src/i18n/locales/zh-CN/messages.po @@ -0,0 +1,609 @@ +msgid "" +msgstr "" +"POT-Creation-Date: 2026-10-26 22:28+0000\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: @lingui/cli\n" +"Language: zh-CN\n" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: \n" +"Last-Translator: \n" +"Language-Team: \n" +"Plural-Forms: \n" + +# Source-Hash: 8caa959c0ae800c3 +# Review: human +# English: "Basement" +#: packages/editor/src/i18n/level-name.ts +msgid "core.level.basement" +msgstr "地下层" + +# Source-Hash: 14927085ee91ba05 +# Review: human +# English: "Basement {number}" +#: packages/editor/src/i18n/level-name.ts +msgid "core.level.basementNumber" +msgstr "地下 {number} 层" + +# Source-Hash: e59074a7b9b3f56e +# Review: human +# English: "Floor {number}" +#: packages/editor/src/i18n/level-name.ts +msgid "core.level.floorNumber" +msgstr "{number} 层" + +# Source-Hash: cde3efc9b7ddfa41 +# Review: human +# English: "Ground floor" +#: packages/editor/src/i18n/level-name.ts +msgid "core.level.groundFloor" +msgstr "首层" + +# Source-Hash: f571cb2d06ae01ae +# Review: human +# English: "Advanced" +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.advanced" +msgstr "高级" + +# Source-Hash: fb40866c2d6db5fb +# Review: human +# English: "walls · floor · ceiling" +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.hint" +msgstr "墙体 · 楼板 · 天花板" + +# Source-Hash: 271039bd919dc4c1 +# Review: human +# English: "Pick a shape to start. Every room brings its own walls, floor and ceiling." +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.idle" +msgstr "选择一种形状开始绘制。每个房间都会同时创建墙体、楼板和天花板。" + +# Source-Hash: ae2800b6c9e2b6ed +# Review: human +# English: "{lead} then the first one again to close. For L-shapes and angled rooms." +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.polygon.caption" +msgstr "{lead}然后再次点击起点闭合。适用于 L 形和斜角房间。" + +# Source-Hash: 4e186fc057733162 +# Review: human +# English: "Polygon" +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.polygon.label" +msgstr "多边形" + +# Source-Hash: 345e3aca3879b5c5 +# Review: human +# English: "Click each corner," +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.polygon.lead" +msgstr "依次点击各个角点," + +# Source-Hash: 8ec6152e139b0e3f +# Review: human +# English: "Polygon room" +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.polygon.title" +msgstr "多边形房间" + +# Source-Hash: 9b46295eedcbedae +# Review: human +# English: "{lead} on the grid. Walls, floor and ceiling are created together." +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.rectangle.caption" +msgstr "在网格上{lead}。墙体、楼板和天花板将一并创建。" + +# Source-Hash: 2c2c20272b0370cc +# Review: human +# English: "Rectangle" +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.rectangle.label" +msgstr "矩形" + +# Source-Hash: 060faa48e299d7c9 +# Review: human +# English: "Click two corners" +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.rectangle.lead" +msgstr "点击两个对角点" + +# Source-Hash: 83a45603ac7f41e8 +# Review: human +# English: "Rectangle room" +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.rectangle.title" +msgstr "矩形房间" + +# Source-Hash: 3e7408ed44676176 +# Review: human +# English: "Rooms" +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.title" +msgstr "房间" + +# Source-Hash: 8a21a03c4acf396b +# Review: human +# English: "{lead} Close a loop and it becomes a room; leave it open for partitions." +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.walls.caption" +msgstr "{lead}闭合轮廓后会形成房间;保持开口可绘制隔墙。" + +# Source-Hash: aac9a732d2159ccc +# Review: human +# English: "Walls" +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.walls.label" +msgstr "墙体" + +# Source-Hash: 9843edd68426fa2d +# Review: human +# English: "Draw one wall at a time." +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.walls.lead" +msgstr "逐面绘制墙体。" + +# Source-Hash: aac9a732d2159ccc +# Review: human +# English: "Walls" +#: packages/editor/src/components/ui/build-panel/build-panel.tsx +msgid "editor.buildPanel.rooms.walls.title" +msgstr "墙体" + +# Source-Hash: 8bf25afa3cb8baea +# Review: human +# English: "Orbit left" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.camera.orbitLeft" +msgstr "向左旋转" + +# Source-Hash: b9cc64c1d1bb0d46 +# Review: human +# English: "Orbit right" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.camera.orbitRight" +msgstr "向右旋转" + +# Source-Hash: 1ab1eb3a14f4b0e0 +# Review: human +# English: "Top view" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.camera.topView" +msgstr "俯视图" + +# Source-Hash: 50accd740fa2e736 +# Review: human +# English: "Off" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.common.off" +msgstr "关闭" + +# Source-Hash: 9cc01faad79c28da +# Review: human +# English: "On" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.common.on" +msgstr "开启" + +# Source-Hash: 14622745dbf93f4c +# Review: human +# English: "Camera" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.camera" +msgstr "相机" + +# Source-Hash: d3d478fa6c6d7ddc +# Review: human +# English: "Colored" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.colored" +msgstr "彩色" + +# Source-Hash: 82b74c4f5ef43336 +# Review: human +# English: "Colors" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.colors" +msgstr "颜色" + +# Source-Hash: 50accd740fa2e736 +# Review: human +# English: "Off" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.edges.off" +msgstr "关闭" + +# Source-Hash: 4ff58913a4492199 +# Review: human +# English: "No edge lines" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.edges.offDetail" +msgstr "不显示边线" + +# Source-Hash: d266d83ec490df4e +# Review: human +# English: "Soft" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.edges.soft" +msgstr "柔和" + +# Source-Hash: 0c583f1d4201293b +# Review: human +# English: "Faint outline of major creases" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.edges.softDetail" +msgstr "淡化显示主要折线" + +# Source-Hash: f82927a01d4c5e45 +# Review: human +# English: "Strong" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.edges.strong" +msgstr "清晰" + +# Source-Hash: 2416a48df28d6205 +# Review: human +# English: "Crisp, opaque edge lines" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.edges.strongDetail" +msgstr "清晰、不透明的边线" + +# Source-Hash: 027bbc8b7287c06a +# Review: human +# English: "Edges" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.edges.title" +msgstr "边线" + +# Source-Hash: ecde2f7c26914be3 +# Review: human +# English: "Monochrome" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.monochrome" +msgstr "单色" + +# Source-Hash: ed59dd8ce7797a66 +# Review: human +# English: "Orthographic" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.orthographic" +msgstr "正交" + +# Source-Hash: 316e25993ff72b88 +# Review: human +# English: "Perspective" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.perspective" +msgstr "透视" + +# Source-Hash: f363702a10aedaeb +# Review: human +# English: "Rendered" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.render.rendered" +msgstr "渲染效果" + +# Source-Hash: 8bb9e54fc786ca77 +# Review: human +# English: "Full ambient occlusion" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.render.renderedDetail" +msgstr "完整环境光遮蔽" + +# Source-Hash: ef3e765e692faf4c +# Review: human +# English: "Solid" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.render.solid" +msgstr "实体" + +# Source-Hash: 8827df1cdeb02ed9 +# Review: human +# English: "Flat and fast — no ambient occlusion" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.render.solidDetail" +msgstr "平整快速,不启用环境光遮蔽" + +# Source-Hash: 5e58af99d6d4fa6a +# Review: human +# English: "Render" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.render.title" +msgstr "渲染" + +# Source-Hash: 4c6bcb30b40047fc +# Review: human +# English: "Shadows" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.shadows" +msgstr "阴影" + +# Source-Hash: d0db46f206a88e2e +# Review: human +# English: "Display settings" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.display.title" +msgstr "显示设置" + +# Source-Hash: 2bdf83801d4a40af +# Review: human +# English: "Align view to north" +#: packages/editor/src/components/viewer/floorplan-compass-button.tsx +msgid "viewer.floorplan.alignNorth" +msgstr "视图对齐北向" + +# Source-Hash: 286a757d8b52bb02 +# Review: human +# English: "No floor plans are available for this scene." +#: packages/editor/src/components/viewer/floorplan-preview.tsx +msgid "viewer.floorplan.empty" +msgstr "此场景没有可用的平面图。" + +# Source-Hash: bf63c679ab94dbcd +# Review: human +# English: "Fit floor plan" +#: packages/editor/src/components/viewer/floorplan-preview.tsx +msgid "viewer.floorplan.fit" +msgstr "适合窗口" + +# Source-Hash: d7148fa065bc709b +# Review: human +# English: "Floor" +#: packages/editor/src/components/viewer/floorplan-preview.tsx +msgid "viewer.floorplan.floor" +msgstr "楼层" + +# Source-Hash: fee6203bb9046929 +# Review: human +# English: "Floor plan" +#: packages/editor/src/components/viewer/floorplan-preview.tsx +msgid "viewer.floorplan.floorPlan" +msgstr "平面图" + +# Source-Hash: 28b255b7bf5f1084 +# Review: human +# English: "{floor} 2D view" +#: packages/editor/src/components/viewer/floorplan-preview.tsx +msgid "viewer.floorplan.viewLabel" +msgstr "{floor}二维视图" + +# Source-Hash: d17804412f8dd081 +# Review: human +# English: "Zoom in" +#: packages/editor/src/components/viewer/floorplan-preview.tsx +msgid "viewer.floorplan.zoomIn" +msgstr "放大" + +# Source-Hash: 23b012a283b2c14a +# Review: human +# English: "Zoom out" +#: packages/editor/src/components/viewer/floorplan-preview.tsx +msgid "viewer.floorplan.zoomOut" +msgstr "缩小" + +# Source-Hash: 7ceade8f25b58fe9 +# Review: human +# English: "Back" +#: packages/editor/src/components/viewer/viewer-scene-header.tsx +msgid "viewer.header.back" +msgstr "返回" + +# Source-Hash: cc3d601d59e2e26c +# Review: human +# English: "Levels" +#: packages/editor/src/components/viewer/viewer-scene-header.tsx +msgid "viewer.header.levels" +msgstr "楼层" + +# Source-Hash: 3e7408ed44676176 +# Review: human +# English: "Rooms" +#: packages/editor/src/components/viewer/viewer-scene-header.tsx +msgid "viewer.header.rooms" +msgstr "房间" + +# Source-Hash: 170b2049ef0f9131 +# Review: human +# English: "Selection" +#: packages/editor/src/components/viewer/viewer-scene-header.tsx +msgid "viewer.header.selection" +msgstr "当前选择" + +# Source-Hash: b404dded35241274 +# Review: human +# English: "Units" +#: packages/editor/src/components/viewer/viewer-scene-header.tsx +msgid "viewer.header.units" +msgstr "单元" + +# Source-Hash: 6547a4c0c368042a +# Review: human +# English: "Untitled" +#: packages/editor/src/components/viewer/viewer-scene-header.tsx +msgid "viewer.header.untitled" +msgstr "未命名" + +# Source-Hash: 665db49a26f81425 +# Review: human +# English: "Zones" +#: packages/editor/src/components/viewer/viewer-scene-header.tsx +msgid "viewer.header.zones" +msgstr "分区" + +# Source-Hash: be12f8788f1f0a05 +# Review: human +# English: "Exploded" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.levels.exploded" +msgstr "分解" + +# Source-Hash: 4bf4a6861d7d633e +# Review: human +# English: "Levels: {mode}" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.levels.label" +msgstr "楼层:{mode}" + +# Source-Hash: 387082cd5f118bed +# Review: human +# English: "Manual" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.levels.manual" +msgstr "手动" + +# Source-Hash: e9f59b513f2f45ac +# Review: human +# English: "Solo" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.levels.solo" +msgstr "单层" + +# Source-Hash: c203c84eee18c0f6 +# Review: human +# English: "Stacked" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.levels.stacked" +msgstr "叠层" + +# Source-Hash: 9dd21ca47cffa3b8 +# Review: human +# English: "Viewer layout" +#: packages/editor/src/components/viewer/viewer-stage-switcher.tsx +msgid "viewer.stage.layout" +msgstr "视图布局" + +# Source-Hash: e4cd64b1680ac0d2 +# Review: human +# English: "Split" +#: packages/editor/src/components/viewer/viewer-stage-switcher.tsx +msgid "viewer.stage.split" +msgstr "分屏" + +# Source-Hash: f14b89d713d2528a +# Review: human +# English: "Blueprint" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.blueprint" +msgstr "蓝图" + +# Source-Hash: 142bcb2841f423c5 +# Review: human +# English: "Mediterranean" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.mediterranean" +msgstr "地中海" + +# Source-Hash: b399942ec5d7e1ba +# Review: human +# English: "Night" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.night" +msgstr "夜景" + +# Source-Hash: f512d1b947b8ae5a +# Review: human +# English: "Overcast" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.overcast" +msgstr "阴天" + +# Source-Hash: 583542b3bc837192 +# Review: human +# English: "Paper" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.paper" +msgstr "纸面" + +# Source-Hash: 7abc2dc6e39b1747 +# Review: human +# English: "Studio" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.studio" +msgstr "影棚" + +# Source-Hash: efe94d3ec8d8c44e +# Review: human +# English: "Sunset" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.sunset" +msgstr "日落" + +# Source-Hash: 82720864e5034398 +# Review: human +# English: "Scene theme" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.title" +msgstr "场景主题" + +# Source-Hash: 4c10ea0aa0e2ea41 +# Review: human +# English: "Twilight" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.twilight" +msgstr "暮色" + +# Source-Hash: f9592fb0672740c0 +# Review: human +# English: "Verdant" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.theme.verdant" +msgstr "绿意" + +# Source-Hash: 892d5dd34372028b +# Review: human +# English: "Guides" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.visibility.guides" +msgstr "参考图" + +# Source-Hash: 3260bfa69cca50e5 +# Review: human +# English: "Scans" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.visibility.scans" +msgstr "扫描模型" + +# Source-Hash: 2cefbb554eadbb6e +# Review: human +# English: "Visibility" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.visibility.title" +msgstr "可见性" + +# Source-Hash: 31afd9dee47ba943 +# Review: human +# English: "Walkthrough: {state}" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.walkthrough.label" +msgstr "漫游:{state}" + +# Source-Hash: 4da8b3678ed77b45 +# Review: human +# English: "Cutaway" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.walls.cutaway" +msgstr "剖切墙" + +# Source-Hash: 131bac2467f4fdf6 +# Review: human +# English: "Full height" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.walls.fullHeight" +msgstr "全高墙" + +# Source-Hash: 32f3d058cf2526d2 +# Review: human +# English: "Walls: {mode}" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.walls.label" +msgstr "墙体:{mode}" + +# Source-Hash: 803532407fb5962b +# Review: human +# English: "Low" +#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +msgid "viewer.walls.low" +msgstr "矮墙" diff --git a/packages/editor/src/i18n/locales/zh-CN/messages.ts b/packages/editor/src/i18n/locales/zh-CN/messages.ts new file mode 100644 index 0000000000..311c4550a6 --- /dev/null +++ b/packages/editor/src/i18n/locales/zh-CN/messages.ts @@ -0,0 +1 @@ +/*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"core.level.basement\":[\"地下层\"],\"core.level.basementNumber\":[\"地下 \",[\"number\"],\" 层\"],\"core.level.floorNumber\":[[\"number\"],\" 层\"],\"core.level.groundFloor\":[\"首层\"],\"editor.buildPanel.advanced\":[\"高级\"],\"editor.buildPanel.rooms.hint\":[\"墙体 · 楼板 · 天花板\"],\"editor.buildPanel.rooms.idle\":[\"选择一种形状开始绘制。每个房间都会同时创建墙体、楼板和天花板。\"],\"editor.buildPanel.rooms.polygon.caption\":[[\"lead\"],\"然后再次点击起点闭合。适用于 L 形和斜角房间。\"],\"editor.buildPanel.rooms.polygon.label\":[\"多边形\"],\"editor.buildPanel.rooms.polygon.lead\":[\"依次点击各个角点,\"],\"editor.buildPanel.rooms.polygon.title\":[\"多边形房间\"],\"editor.buildPanel.rooms.rectangle.caption\":[\"在网格上\",[\"lead\"],\"。墙体、楼板和天花板将一并创建。\"],\"editor.buildPanel.rooms.rectangle.label\":[\"矩形\"],\"editor.buildPanel.rooms.rectangle.lead\":[\"点击两个对角点\"],\"editor.buildPanel.rooms.rectangle.title\":[\"矩形房间\"],\"editor.buildPanel.rooms.title\":[\"房间\"],\"editor.buildPanel.rooms.walls.caption\":[[\"lead\"],\"闭合轮廓后会形成房间;保持开口可绘制隔墙。\"],\"editor.buildPanel.rooms.walls.label\":[\"墙体\"],\"editor.buildPanel.rooms.walls.lead\":[\"逐面绘制墙体。\"],\"editor.buildPanel.rooms.walls.title\":[\"墙体\"],\"viewer.camera.orbitLeft\":[\"向左旋转\"],\"viewer.camera.orbitRight\":[\"向右旋转\"],\"viewer.camera.topView\":[\"俯视图\"],\"viewer.common.off\":[\"关闭\"],\"viewer.common.on\":[\"开启\"],\"viewer.display.camera\":[\"相机\"],\"viewer.display.colored\":[\"彩色\"],\"viewer.display.colors\":[\"颜色\"],\"viewer.display.edges.off\":[\"关闭\"],\"viewer.display.edges.offDetail\":[\"不显示边线\"],\"viewer.display.edges.soft\":[\"柔和\"],\"viewer.display.edges.softDetail\":[\"淡化显示主要折线\"],\"viewer.display.edges.strong\":[\"清晰\"],\"viewer.display.edges.strongDetail\":[\"清晰、不透明的边线\"],\"viewer.display.edges.title\":[\"边线\"],\"viewer.display.monochrome\":[\"单色\"],\"viewer.display.orthographic\":[\"正交\"],\"viewer.display.perspective\":[\"透视\"],\"viewer.display.render.rendered\":[\"渲染效果\"],\"viewer.display.render.renderedDetail\":[\"完整环境光遮蔽\"],\"viewer.display.render.solid\":[\"实体\"],\"viewer.display.render.solidDetail\":[\"平整快速,不启用环境光遮蔽\"],\"viewer.display.render.title\":[\"渲染\"],\"viewer.display.shadows\":[\"阴影\"],\"viewer.display.title\":[\"显示设置\"],\"viewer.floorplan.alignNorth\":[\"视图对齐北向\"],\"viewer.floorplan.empty\":[\"此场景没有可用的平面图。\"],\"viewer.floorplan.fit\":[\"适合窗口\"],\"viewer.floorplan.floor\":[\"楼层\"],\"viewer.floorplan.floorPlan\":[\"平面图\"],\"viewer.floorplan.viewLabel\":[[\"floor\"],\"二维视图\"],\"viewer.floorplan.zoomIn\":[\"放大\"],\"viewer.floorplan.zoomOut\":[\"缩小\"],\"viewer.header.back\":[\"返回\"],\"viewer.header.levels\":[\"楼层\"],\"viewer.header.rooms\":[\"房间\"],\"viewer.header.selection\":[\"当前选择\"],\"viewer.header.units\":[\"单元\"],\"viewer.header.untitled\":[\"未命名\"],\"viewer.header.zones\":[\"分区\"],\"viewer.levels.exploded\":[\"分解\"],\"viewer.levels.label\":[\"楼层:\",[\"mode\"]],\"viewer.levels.manual\":[\"手动\"],\"viewer.levels.solo\":[\"单层\"],\"viewer.levels.stacked\":[\"叠层\"],\"viewer.stage.layout\":[\"视图布局\"],\"viewer.stage.split\":[\"分屏\"],\"viewer.theme.blueprint\":[\"蓝图\"],\"viewer.theme.mediterranean\":[\"地中海\"],\"viewer.theme.night\":[\"夜景\"],\"viewer.theme.overcast\":[\"阴天\"],\"viewer.theme.paper\":[\"纸面\"],\"viewer.theme.studio\":[\"影棚\"],\"viewer.theme.sunset\":[\"日落\"],\"viewer.theme.title\":[\"场景主题\"],\"viewer.theme.twilight\":[\"暮色\"],\"viewer.theme.verdant\":[\"绿意\"],\"viewer.visibility.guides\":[\"参考图\"],\"viewer.visibility.scans\":[\"扫描模型\"],\"viewer.visibility.title\":[\"可见性\"],\"viewer.walkthrough.label\":[\"漫游:\",[\"state\"]],\"viewer.walls.cutaway\":[\"剖切墙\"],\"viewer.walls.fullHeight\":[\"全高墙\"],\"viewer.walls.label\":[\"墙体:\",[\"mode\"]],\"viewer.walls.low\":[\"矮墙\"]}")as Messages; \ No newline at end of file diff --git a/packages/editor/src/i18n/published-entry.test.ts b/packages/editor/src/i18n/published-entry.test.ts new file mode 100644 index 0000000000..b2b2e7d9ad --- /dev/null +++ b/packages/editor/src/i18n/published-entry.test.ts @@ -0,0 +1,17 @@ +import { expect, test } from 'bun:test' +import { createI18n } from '@pascal-app/editor' + +test('the published editor entry registers compiled English messages', () => { + const i18n = createI18n({ locale: 'en' }) + try { + expect( + i18n._({ + id: 'viewer.levels.label', + message: 'Levels: {mode}', + values: { mode: 'Stacked' }, + }), + ).toBe('Levels: Stacked') + } finally { + i18n.destroy() + } +}) diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 5292aca737..297e7687e0 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -2,11 +2,21 @@ // own shells on top of `@pascal-app/editor` (community-app, embedders) // don't have to learn three separate package imports. The canonical // definitions still live in `@pascal-app/core` / `@pascal-app/viewer`. +import { registerCompiledMessages as registerEditorMessages } from '@pascal-app/core' +import { messages as editorEnglishMessages } from './i18n/locales/en/messages' + +// Published-package consumers need compiled English too: Lingui cannot +// interpolate a raw fallback literal in production. Other bundled locales +// remain explicitly host-registered through `@pascal-app/editor/locales`. +registerEditorMessages('en', '', editorEnglishMessages) + export { type ApplySceneSnapshotOptions, acquireSceneReadOnlyLease, applyScenePatch, applySceneSnapshot, + type CompiledTranslationMessages, + createI18n, DEFAULT_CURVED_STAIR_INNER_RADIUS, DEFAULT_CURVED_STAIR_SWEEP_ANGLE, DEFAULT_SPIRAL_SHOW_CENTER_COLUMN, @@ -22,6 +32,16 @@ export { DEFAULT_STAIR_THICKNESS, DEFAULT_STAIR_TYPE, DEFAULT_STAIR_WIDTH, + defaultI18n, + formatLength, + formatNumber, + getLocale, + normalizeDecimalInput, + type PascalI18n, + PascalI18nProvider, + parseLocalizedNumber, + registerCompiledMessages, + registerMessages, type SceneCommit, type SceneCommitListener, type SceneCommitOrigin, @@ -29,7 +49,15 @@ export { type SceneNodePatch, type ScenePatch, type SceneSnapshot, + setLocale, + subscribe, subscribeSceneCommits, + type TranslationMessage, + type TranslationMessages, + type TranslationParams, + translate, + useLocale, + usePascalI18n, useScene, } from '@pascal-app/core' export { useViewer } from '@pascal-app/viewer' @@ -695,6 +723,7 @@ export { measurementFloorplanPresentationColor, measurementPresentationColor, metersToLinearUnit, + parseLinearMeasurementInput, squareMetersToAreaUnit, } from './lib/measurements' export type { diff --git a/packages/editor/src/lib/floorplan/dimension-drive.test.ts b/packages/editor/src/lib/floorplan/dimension-drive.test.ts index 252bcc04f0..ea2818d808 100644 --- a/packages/editor/src/lib/floorplan/dimension-drive.test.ts +++ b/packages/editor/src/lib/floorplan/dimension-drive.test.ts @@ -26,6 +26,11 @@ describe('parseDimensionInput', () => { expect(parseDimensionInput('12.5', 'imperial')).toBeCloseTo(12.5 * FOOT, 9) }) + test('decimal commas follow the scoped locale', () => { + expect(parseDimensionInput('2,75', 'metric', 'fr')).toBe(2.75) + expect(parseDimensionInput('2,75', 'metric', 'en')).toBeNull() + }) + test('rejects garbage and non-positive values', () => { expect(parseDimensionInput('')).toBeNull() expect(parseDimensionInput('abc')).toBeNull() diff --git a/packages/editor/src/lib/floorplan/dimension-drive.ts b/packages/editor/src/lib/floorplan/dimension-drive.ts index f1db83f40e..bff942c6e6 100644 --- a/packages/editor/src/lib/floorplan/dimension-drive.ts +++ b/packages/editor/src/lib/floorplan/dimension-drive.ts @@ -1,5 +1,5 @@ import type { AnyNode, AnyNodeId, DoorNode, WallNode, WindowNode } from '@pascal-app/core' -import { getLinkedWallUpdates } from '@pascal-app/core' +import { getLinkedWallUpdates, normalizeDecimalInput } from '@pascal-app/core' /** * Driving dimensions — WS3. @@ -34,8 +34,9 @@ const INCHES_PER_METER = 1 / 0.0254 export function parseDimensionInput( raw: string, unit: 'metric' | 'imperial' = 'metric', + locale = 'en', ): number | null { - const text = raw.trim().toLowerCase().replace(/\s+/g, ' ') + const text = normalizeDecimalInput(raw, locale).trim().toLowerCase().replace(/\s+/g, ' ') if (!text) return null // Explicit metric suffix. diff --git a/packages/editor/src/lib/measurement-parser.ts b/packages/editor/src/lib/measurement-parser.ts index 2cafe7387b..edc18810fd 100644 --- a/packages/editor/src/lib/measurement-parser.ts +++ b/packages/editor/src/lib/measurement-parser.ts @@ -1,6 +1,7 @@ // Must precede the lingo import: lingo clones its kind table with // structuredClone while its module body evaluates. See the file's own comment. import './structured-clone-fallback' +import { normalizeDecimalInput } from '@pascal-app/core' import { type Kind, parseQuantity, quantity } from '@pascal-app/lingo' /** @@ -56,6 +57,8 @@ export interface ParseMeasurementOptions { bareUnit?: string /** Disambiguates gal/ton/cup families; harmless for length/angle. */ system?: 'metric' | 'us' | 'imperial' + /** Locale whose decimal separator is accepted in bare values and quantities. */ + locale?: string } /** @@ -68,7 +71,7 @@ export function parseMeasurement( spec: LingoUnitSpec, options: ParseMeasurementOptions = {}, ): number | null { - const result = parseQuantity(raw, { + const result = parseQuantity(normalizeDecimalInput(raw, options.locale), { kind: spec.kind, unit: options.bareUnit ?? spec.unitId, system: options.system, @@ -105,9 +108,10 @@ export function measurementHint( options: MeasurementHintOptions = {}, ): string | null { const trimmed = raw.trim() - if (!trimmed || PLAIN_NUMBER.test(trimmed)) return null + const normalized = normalizeDecimalInput(trimmed, options.locale) + if (!normalized || PLAIN_NUMBER.test(normalized)) return null - const parsed = parseMeasurement(trimmed, spec, options) + const parsed = parseMeasurement(normalized, spec, options) if (parsed === null) return null const stored = options.clamp ? options.clamp(parsed) : parsed diff --git a/packages/editor/src/lib/measurements.test.ts b/packages/editor/src/lib/measurements.test.ts index f9fa358dc2..c74e343d01 100644 --- a/packages/editor/src/lib/measurements.test.ts +++ b/packages/editor/src/lib/measurements.test.ts @@ -18,6 +18,7 @@ import { measurementFloorplanPresentationColor, measurementPresentationColor, metersToLinearUnit, + parseLinearMeasurementInput, squareMetersToAreaUnit, } from './measurements' @@ -129,6 +130,11 @@ describe('linear measurements', () => { expect(linearControlValueToMeters(3.5, 'metric')).toBe(3.5) }) + test('parses floor-plan measurement input with the scoped locale', () => { + expect(parseLinearMeasurementInput('2,75', 'metric', 'fr')).toBe(2.75) + expect(parseLinearMeasurementInput('2,75', 'metric', 'en')).toBeNull() + }) + test('clamps numeric control input after converting to meters', () => { expect(linearControlValueToMeters(0.1, 'imperial', { minMeters: 0.1 })).toBe(0.1) expect(linearControlValueToMeters(0.3, 'imperial', { minMeters: 0.1 })).toBe(0.1) @@ -212,4 +218,15 @@ describe('typed measurement fields', () => { expect(measurementHint('180cm', length)).toBe('= 1.80 m') expect(measurementHint('6', length)).toBeNull() }) + + test('only treats the active locale decimal separator as a plain decimal', () => { + const length = { kind: 'length', unitId: 'm' } as const + + expect(measurementHint('1,000', length, { locale: 'en' })).not.toBeNull() + expect(measurementHint('-1,000', length, { locale: 'en' })).not.toBeNull() + expect(measurementHint('1,5', length, { locale: 'en' })).not.toBeNull() + + expect(measurementHint('1,5', length, { locale: 'fr' })).toBeNull() + expect(parseMeasurement('2,75 m', length, { locale: 'fr' })).toBe(2.75) + }) }) diff --git a/packages/editor/src/lib/measurements.ts b/packages/editor/src/lib/measurements.ts index 2257e19329..ba63f4e345 100644 --- a/packages/editor/src/lib/measurements.ts +++ b/packages/editor/src/lib/measurements.ts @@ -1,4 +1,4 @@ -import type { MeasurementPoint } from '@pascal-app/core' +import { formatNumber, type MeasurementPoint, parseLocalizedNumber } from '@pascal-app/core' export type LinearUnit = 'metric' | 'imperial' export type MetricNotation = 'meters' | 'millimeters' @@ -119,6 +119,15 @@ export function linearUnitToMeters(value: number, unit: LinearUnit): number { return unit === 'imperial' ? value * METERS_PER_FOOT : value } +export function parseLinearMeasurementInput( + value: string, + unit: LinearUnit, + locale: string, +): number | null { + const parsed = parseLocalizedNumber(value, locale) + return parsed === null ? null : linearUnitToMeters(parsed, unit) +} + export function linearControlValueToMeters( value: number, unit: LinearUnit, @@ -153,7 +162,10 @@ export function formatAreaLabel( ): string { if (!Number.isFinite(squareMeters)) return '--' - return `${squareMetersToAreaUnit(squareMeters, unit).toFixed(fractionDigits)}${getAreaUnitLabel(unit)}` + return `${formatNumber(squareMetersToAreaUnit(squareMeters, unit), { + min: fractionDigits, + max: fractionDigits, + })}${getAreaUnitLabel(unit)}` } export function cubicMetersToVolumeUnit(cubicMeters: number, unit: LinearUnit): number { @@ -171,7 +183,10 @@ export function formatVolumeLabel( ): string { if (!Number.isFinite(cubicMeters)) return '--' - return `${cubicMetersToVolumeUnit(cubicMeters, unit).toFixed(fractionDigits)}${getVolumeUnitLabel(unit)}` + return `${formatNumber(cubicMetersToVolumeUnit(cubicMeters, unit), { + min: fractionDigits, + max: fractionDigits, + })}${getVolumeUnitLabel(unit)}` } export function formatLinearMeasurement( @@ -200,11 +215,11 @@ export function formatLinearMeasurement( if (metricNotation === 'millimeters') { const roundedMillimeters = Math.round(absoluteMeters * 1000) const sign = meters < 0 && roundedMillimeters !== 0 ? '-' : '' - return `${sign}${roundedMillimeters}mm` + return `${sign}${formatNumber(roundedMillimeters, { max: 0 })}mm` } const roundedMeters = Number.parseFloat(absoluteMeters.toFixed(2)) const sign = meters < 0 && roundedMeters !== 0 ? '-' : '' - return `${sign}${roundedMeters}m` + return `${sign}${formatNumber(roundedMeters, { max: 2 })}m` } diff --git a/packages/nodes/src/cabinet/__tests__/dimension-input.test.ts b/packages/nodes/src/cabinet/__tests__/dimension-input.test.ts new file mode 100644 index 0000000000..4f4e2773e1 --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/dimension-input.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from 'bun:test' +import { parseCabinetDimensionInput } from '../dimension-input' + +test('cabinet dimension input follows the scoped locale', () => { + expect( + parseCabinetDimensionInput('1,000', { + locale: 'fr', + metricNotation: 'meters', + unit: 'metric', + }), + ).toBe(1) + expect( + parseCabinetDimensionInput('1,000', { + locale: 'en', + metricNotation: 'meters', + unit: 'metric', + }), + ).toBe(1000) +}) diff --git a/packages/nodes/src/cabinet/dimension-input.ts b/packages/nodes/src/cabinet/dimension-input.ts new file mode 100644 index 0000000000..68f911266e --- /dev/null +++ b/packages/nodes/src/cabinet/dimension-input.ts @@ -0,0 +1,21 @@ +import { parseMeasurement } from '@pascal-app/editor' + +export function parseCabinetDimensionInput( + input: string, + options: { + locale: string + metricNotation: 'meters' | 'millimeters' + unit: 'metric' | 'imperial' + }, +): number | null { + return parseMeasurement( + input, + { kind: 'length', unitId: 'm' }, + { + bareUnit: + options.unit === 'imperial' ? 'in' : options.metricNotation === 'millimeters' ? 'mm' : 'm', + locale: options.locale, + system: options.unit === 'imperial' ? 'imperial' : 'metric', + }, + ) +} diff --git a/packages/nodes/src/cabinet/tool.tsx b/packages/nodes/src/cabinet/tool.tsx index 55fd165df0..ea4ca1b415 100644 --- a/packages/nodes/src/cabinet/tool.tsx +++ b/packages/nodes/src/cabinet/tool.tsx @@ -18,6 +18,7 @@ import { resolveSupportSlabPatch, runAsSingleSceneHistoryStep, spatialGridManager, + useLocale, useScene, type WallEvent, type WallNode, @@ -36,7 +37,6 @@ import { movementSfxStepKey, PlacementBox, PlacementDimensionGuides, - parseMeasurement, publishPlacementSurface, triggerSFX, useAlignmentGuides, @@ -78,6 +78,7 @@ import { cabinetModuleDefinition, cabinetRunFootprint, } from './definition' +import { parseCabinetDimensionInput } from './dimension-input' import { buildCabinetGeometry } from './geometry' import { applyCabinetModuleInsertion, cabinetModuleForRunInsertion } from './insertion' import { resolveCabinetNeighborPlacement } from './neighbor-placement' @@ -410,6 +411,7 @@ const CabinetPlacementTool = () => { const activeLevelId = useViewer((s) => s.selection.levelId) const unit = useViewer((s) => s.unit) const metricNotation = useViewer((s) => s.metricNotation) + const locale = useLocale() const activeDimensionId = usePlacementPreview((s) => s.activeDimensionId) const dimensionInput = usePlacementPreview((s) => s.dimensionInput) const [placement, setPlacement] = useState(null) @@ -1641,14 +1643,11 @@ const CabinetPlacementTool = () => { if (!(editor.activeDimensionId && editor.dimensionInput && current)) { return false } - const value = parseMeasurement( - editor.dimensionInput, - { kind: 'length', unitId: 'm' }, - { - bareUnit: unit === 'imperial' ? 'in' : metricNotation === 'millimeters' ? 'mm' : 'm', - system: unit === 'imperial' ? 'imperial' : 'metric', - }, - ) + const value = parseCabinetDimensionInput(editor.dimensionInput, { + locale, + metricNotation, + unit, + }) if (value === null) return false if (!current.stretch) { const sizeField = diff --git a/packages/nodes/src/measurement/tool.test.ts b/packages/nodes/src/measurement/tool.test.ts index 347d74cccd..2b077dec3a 100644 --- a/packages/nodes/src/measurement/tool.test.ts +++ b/packages/nodes/src/measurement/tool.test.ts @@ -319,6 +319,8 @@ describe('parseMeasurementExtrusionHeight', () => { test('converts numeric metric and imperial input to meters', () => { expect(parseMeasurementExtrusionHeight('2.5', 'metric')).toBeCloseTo(2.5) expect(parseMeasurementExtrusionHeight('10', 'imperial')).toBeCloseTo(3.048) + expect(parseMeasurementExtrusionHeight('2,5', 'metric', 'fr')).toBe(2.5) + expect(parseMeasurementExtrusionHeight('2,5', 'metric', 'en')).toBeNull() expect(parseMeasurementExtrusionHeight('', 'metric')).toBeNull() expect(parseMeasurementExtrusionHeight('not-a-number', 'imperial')).toBeNull() }) diff --git a/packages/nodes/src/measurement/tool.tsx b/packages/nodes/src/measurement/tool.tsx index ea429af045..fdd9bf1d6a 100644 --- a/packages/nodes/src/measurement/tool.tsx +++ b/packages/nodes/src/measurement/tool.tsx @@ -15,6 +15,7 @@ import { measurementPerimeter, measurementPrismVolume, sceneRegistry, + useLocale, useScene, } from '@pascal-app/core' import { @@ -31,7 +32,6 @@ import { isAlignmentGuideActive, isGridSnapActive, type LinearUnit, - linearUnitToMeters, MEASUREMENT_ACTIVE_COLOR, type MeasurementAxis, type MeasurementAxisGuide, @@ -41,6 +41,7 @@ import { measurementPolygonLabelAnchor, measurementPolygonMidpoints, metersToLinearUnit, + parseLinearMeasurementInput, useEditor, useInteractionScope, useMeasurementDraft, @@ -767,9 +768,12 @@ export function closestMeasurementExtrusionHeight( return Number.isFinite(height) ? height : null } -export function parseMeasurementExtrusionHeight(value: string, unit: LinearUnit): number | null { - const numericValue = Number.parseFloat(value) - return Number.isFinite(numericValue) ? linearUnitToMeters(numericValue, unit) : null +export function parseMeasurementExtrusionHeight( + value: string, + unit: LinearUnit, + locale = 'en', +): number | null { + return parseLinearMeasurementInput(value, unit, locale) } function extrusionHeightFromPointer( @@ -1224,6 +1228,7 @@ function DraftLabel({ function DraftExtrusionControl({ position }: { position: Vector3 }) { const unit = useViewer((state) => state.unit) + const locale = useLocale() const extrusionHeight = useMeasurementDraft((state) => state.extrusionHeight) const points = useMeasurementDraft((state) => state.points) const baseNormal = useMeasurementDraft((state) => state.baseNormal) @@ -1245,11 +1250,11 @@ function DraftExtrusionControl({ position }: { position: Vector3 }) { setValue(next) useMeasurementDraft .getState() - .setExtrusionHeight('3d', parseMeasurementExtrusionHeight(next, unit) ?? 0) + .setExtrusionHeight('3d', parseMeasurementExtrusionHeight(next, unit, locale) ?? 0) } const commit = () => { - const height = parseMeasurementExtrusionHeight(value, unit) + const height = parseMeasurementExtrusionHeight(value, unit, locale) if (height === null) { useMeasurementDraft.getState().setExtrusionHeight('3d', 0) return @@ -1313,7 +1318,7 @@ function DraftExtrusionControl({ position }: { position: Vector3 }) { }} placeholder="0" step="0.1" - type="number" + type="text" value={value} /> diff --git a/scripts/i18n.test.ts b/scripts/i18n.test.ts new file mode 100644 index 0000000000..5072e47db3 --- /dev/null +++ b/scripts/i18n.test.ts @@ -0,0 +1,221 @@ +import { expect, test } from 'bun:test' +import { readFileSync, writeFileSync } from 'node:fs' +import type { CatalogType } from '@lingui/conf' +import { parsePo } from 'pofile-ts' +import linguiConfig from '../lingui.config' +import { + checkCatalogs, + escapePoCommentValue, + extractCatalogs, + hashSource, + refreshTranslations, + type SourceMessage, + type TranslationEntry, + type TranslationProvider, + validateTranslation, +} from './i18n' + +test('AI review notes cannot inject physical PO lines', () => { + const escaped = escapePoCommentValue('Check wording.\nmsgid "injected"\r\nmsgstr "value"') + + expect(escaped).toBe('Check wording.\\nmsgid "injected"\\nmsgstr "value"') + expect(escaped).not.toContain('\n') + expect(escaped).not.toContain('\r') +}) + +test('PO output is stable when an unrelated source line is added', async () => { + const format = linguiConfig.format + if (!format) throw new Error('Lingui PO formatter is not configured') + const serialize = (line: number) => + format.serialize( + { + 'viewer.example': { + message: 'Example', + translation: 'Exemple', + origin: [['packages/editor/src/example.tsx', line]], + }, + } satisfies CatalogType, + { + existing: undefined, + filename: 'messages.po', + locale: 'fr', + sourceLocale: 'en', + }, + ) + + expect(await serialize(10)).toBe(await serialize(11)) +}) + +test('catalog checking does not rewrite PO or compiled catalogs', async () => { + const paths = [ + ...new Bun.Glob('packages/editor/src/i18n/locales/*/messages.po').scanSync(), + ...new Bun.Glob('packages/editor/src/i18n/locales/*/messages.ts').scanSync(), + ] + const before = new Map(paths.map((path) => [path, readFileSync(path, 'utf8')])) + + expect(await checkCatalogs()).toEqual([]) + expect(paths.map((path) => readFileSync(path, 'utf8'))).toEqual( + paths.map((path) => before.get(path)), + ) +}, 60_000) + +test('ICU plural validation follows the target locale categories', () => { + const english = '{count, plural, one {# item} other {# items}}' + + expect(validateTranslation(english, '{count, plural, other {# 個}}', 'ja')).toEqual([]) + expect(validateTranslation(english, '{count, plural, other {# 个}}', 'zh-CN')).toEqual([]) + expect(validateTranslation(english, '{count, plural, other {# items}}', 'en')).toContain( + 'plural selector "count" is missing "one"', + ) + expect(validateTranslation(english, english, 'en')).toEqual([]) +}) + +test('rich-text validation preserves attributed and self-closing tags', () => { + expect( + validateTranslation( + 'Open ', + 'Ouvrir ', + 'fr', + ), + ).toEqual([]) + expect(validateTranslation('Open', 'Ouvrir', 'fr')).toContain( + 'rich-text tags differ from English', + ) + expect(validateTranslation('Save ', 'Enregistrer', 'fr')).toContain( + 'rich-text tags differ from English', + ) +}) + +test('source drift fails checking, then extraction updates English and flags translations', async () => { + const sourcePath = 'packages/editor/src/components/viewer/viewer-controls-bar.tsx' + const catalogPaths = [ + ...new Bun.Glob('packages/editor/src/i18n/locales/*/messages.po').scanSync(), + ...new Bun.Glob('packages/editor/src/i18n/locales/*/messages.ts').scanSync(), + ] + const paths = [sourcePath, ...catalogPaths] + const before = new Map(paths.map((path) => [path, readFileSync(path, 'utf8')])) + const originalSource = before.get(sourcePath) + if (!originalSource) throw new Error(`Missing ${sourcePath}`) + + try { + writeFileSync( + sourcePath, + originalSource.replaceAll("message: 'Top view'", "message: 'Top view X'"), + ) + + const errors = await checkCatalogs() + expect(errors.some((error) => error.includes('source drift "viewer.camera.topView"'))).toBe( + true, + ) + + extractCatalogs() + + const english = parsePo(readFileSync('packages/editor/src/i18n/locales/en/messages.po', 'utf8')) + expect(english.items.find((item) => item.msgid === 'viewer.camera.topView')?.msgstr[0]).toBe( + 'Top view X', + ) + + for (const locale of ['zh-CN', 'fr', 'es', 'ja']) { + const target = parsePo( + readFileSync(`packages/editor/src/i18n/locales/${locale}/messages.po`, 'utf8'), + ) + const item = target.items.find((entry) => entry.msgid === 'viewer.camera.topView') + expect(item?.msgstr[0]).not.toBe('') + expect(item?.flags.fuzzy).toBe(true) + expect(item?.comments).toContain('Review: required') + expect( + readFileSync(`packages/editor/src/i18n/locales/${locale}/messages.ts`, 'utf8'), + ).not.toContain('\\"viewer.camera.topView\\"') + } + } finally { + for (const [path, contents] of before) writeFileSync(path, contents) + } +}, 120_000) + +test('AI translation refreshes only missing and stale entries through a mocked provider', async () => { + const sources: SourceMessage[] = [ + { id: 'viewer.current', message: 'Current', context: ['current.tsx:1'] }, + { id: 'viewer.missing', message: 'Hello {name}', context: ['missing.tsx:2'] }, + { id: 'viewer.stale', message: 'Changed', context: ['stale.tsx:3'] }, + ] + const current: Record = { + 'viewer.current': { + translation: 'Actuel', + sourceHash: hashSource('Current'), + review: 'human', + fuzzy: false, + }, + 'viewer.stale': { + translation: 'Ancien', + sourceHash: hashSource('Old'), + review: 'human', + fuzzy: false, + }, + } + const requests: string[][] = [] + const provider: TranslationProvider = { + name: 'mock', + model: 'mock-model', + async translate(request) { + requests.push(request.items.map((item) => item.id)) + return { + locale: request.locale, + entries: [ + { + id: 'viewer.missing', + translation: 'Bonjour {name}', + reviewNotes: '', + }, + { id: 'viewer.stale', translation: 'Modifié', reviewNotes: 'Check toolbar width.' }, + ], + } + }, + } + + const refreshed = await refreshTranslations('fr', sources, current, provider) + + expect(requests).toEqual([['viewer.missing', 'viewer.stale']]) + expect(refreshed['viewer.current']).toEqual(current['viewer.current']) + expect(refreshed['viewer.missing']).toEqual({ + translation: 'Bonjour {name}', + sourceHash: hashSource('Hello {name}'), + review: 'required', + fuzzy: true, + reviewNotes: '', + }) + expect(refreshed['viewer.stale']).toEqual({ + translation: 'Modifié', + sourceHash: hashSource('Changed'), + review: 'required', + fuzzy: true, + reviewNotes: 'Check toolbar width.', + }) +}) + +test('AI translation rejects broken placeholders from a provider', async () => { + const sources: SourceMessage[] = [ + { id: 'viewer.greeting', message: 'Hello {name}', context: ['greeting.tsx:1'] }, + ] + const provider: TranslationProvider = { + name: 'mock', + model: 'mock-model', + async translate(request) { + return { + locale: request.locale, + entries: [{ id: 'viewer.greeting', translation: 'Bonjour', reviewNotes: '' }], + } + }, + } + + expect(refreshTranslations('fr', sources, {}, provider)).rejects.toThrow( + 'placeholder names differ from English', + ) +}) + +test('translation validation applies the checked-in glossary', () => { + expect( + validateTranslation('Ceiling', '吊顶', 'zh-CN', { + terms: { ceiling: { 'zh-CN': '天花板' } }, + }), + ).toContain('expected glossary term "天花板" for "ceiling"') +}) diff --git a/scripts/i18n.ts b/scripts/i18n.ts new file mode 100644 index 0000000000..5878f9ab5a --- /dev/null +++ b/scripts/i18n.ts @@ -0,0 +1,787 @@ +import { createHash } from 'node:crypto' +import { readFileSync, writeFileSync } from 'node:fs' +import { dirname, join, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { createCompiledCatalog, getCatalogs } from '@lingui/cli/api' +import { getConfig } from '@lingui/conf' +import { compileMessageOrThrow } from '@lingui/message-utils/compileMessage' +import { type PoFile, type PoItem, parsePo, stringifyPo } from 'pofile-ts' +import ts from 'typescript' + +export type SourceMessage = { + id: string + message: string + context: string[] +} + +export type TranslationEntry = { + translation: string + sourceHash?: string + review: 'human' | 'required' | 'missing' + fuzzy: boolean + reviewNotes?: string +} + +export type TranslationRequest = { + locale: string + items: SourceMessage[] + glossary: Glossary +} + +export type TranslationResponse = { + locale: string + entries: Array<{ id: string; translation: string; reviewNotes: string }> +} + +export interface TranslationProvider { + name: string + model: string + translate(request: TranslationRequest): Promise +} + +type Glossary = { + productContext?: string + terms?: Record> +} + +type CompiledToken = string | [name: string, type?: string, choices?: unknown] + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const CATALOG_ROOT = join(ROOT, 'packages/editor/src/i18n/locales') +const GLOSSARY_PATH = join(ROOT, 'i18n/glossary.json') +const LINGUI_BIN = join(ROOT, 'node_modules/.bin/lingui') +const TARGET_LOCALES = ['zh-CN', 'fr', 'es', 'ja'] as const +const ALL_LOCALES = ['en', ...TARGET_LOCALES] as const +const CONVERTED_SURFACES = [ + 'packages/editor/src/components/viewer', + 'packages/editor/src/components/ui/build-panel/build-panel.tsx', +] +const SOURCE_GLOBS = [ + 'packages/core/src/**/*.{ts,tsx}', + 'packages/viewer/src/**/*.{ts,tsx}', + 'packages/editor/src/**/*.{ts,tsx}', + 'packages/nodes/src/**/*.{ts,tsx}', + 'apps/editor/**/*.{ts,tsx}', +] +const SOURCE_HASH_PREFIX = 'Source-Hash: ' +const REVIEW_PREFIX = 'Review: ' +const ENGLISH_PREFIX = 'English: ' +const AI_PROVIDER_PREFIX = 'AI-Provider: ' +const AI_MODEL_PREFIX = 'AI-Model: ' +const REVIEW_NOTES_PREFIX = 'Review-Notes: ' + +function catalogPath(locale: string): string { + return join(CATALOG_ROOT, locale, 'messages.po') +} + +function compiledCatalogPath(locale: string): string { + return join(CATALOG_ROOT, locale, 'messages.ts') +} + +function readJson(path: string): T { + return JSON.parse(readFileSync(path, 'utf8')) as T +} + +export function hashSource(message: string): string { + return createHash('sha256').update(JSON.stringify(message)).digest('hex').slice(0, 16) +} + +export function escapePoCommentValue(value: string): string { + return value.replaceAll('\r\n', '\\n').replaceAll('\r', '\\r').replaceAll('\n', '\\n') +} + +function commentValue(item: PoItem, prefix: string): string | undefined { + return item.comments.find((comment) => comment.startsWith(prefix))?.slice(prefix.length) +} + +function replaceComment(item: PoItem, prefix: string, value?: string): void { + item.comments = item.comments.filter((comment) => !comment.startsWith(prefix)) + if (value !== undefined) item.comments.push(`${prefix}${value}`) +} + +function sourceMessagesFromPo(po: PoFile): SourceMessage[] { + return po.items + .filter((item) => !item.obsolete) + .map((item) => ({ + id: item.msgid, + message: item.msgstr[0] ?? '', + context: [...item.references], + })) + .sort((left, right) => left.id.localeCompare(right.id)) +} + +function readSourceMessages(): SourceMessage[] { + return sourceMessagesFromPo(parsePo(readFileSync(catalogPath('en'), 'utf8'))) +} + +async function extractSourceMessages(): Promise { + const config = getConfig({ cwd: ROOT }) + const catalogs = await getCatalogs(config) + const collected = await Promise.all(catalogs.map((catalog) => catalog.collect())) + const messages = new Map() + + for (const catalog of collected) { + for (const [id, entry] of Object.entries(catalog ?? {})) { + const message = entry.message ?? '' + const existing = messages.get(id) + if (existing && existing.message !== message) { + throw new Error(`Lingui extracted conflicting English sources for "${id}"`) + } + messages.set(id, { + id, + message, + context: entry.origin.map(([path, line]) => (line ? `${path}:${line}` : path)), + }) + } + } + + return [...messages.values()].sort((left, right) => left.id.localeCompare(right.id)) +} + +function readTranslationState(po: PoFile): Record { + return Object.fromEntries( + po.items + .filter((item) => !item.obsolete) + .map((item) => { + const review = commentValue(item, REVIEW_PREFIX) + return [ + item.msgid, + { + translation: item.msgstr[0] ?? '', + sourceHash: commentValue(item, SOURCE_HASH_PREFIX), + review: review === 'human' || review === 'required' ? review : 'missing', + fuzzy: Boolean(item.flags.fuzzy), + reviewNotes: commentValue(item, REVIEW_NOTES_PREFIX), + } satisfies TranslationEntry, + ] + }), + ) +} + +function variableNames(tokens: CompiledToken[]): string[] { + const names = new Set() + const visit = (entries: CompiledToken[]) => { + for (const token of entries) { + if (typeof token === 'string') continue + names.add(token[0]) + const choices = token[2] + if (!(choices && typeof choices === 'object')) continue + for (const branch of Object.values(choices)) { + if (Array.isArray(branch)) visit(branch as CompiledToken[]) + } + } + } + visit(tokens) + return [...names].sort() +} + +function tags(message: string): string[] { + const found: string[] = [] + for (let start = message.indexOf('<'); start >= 0; start = message.indexOf('<', start + 1)) { + let quote: '"' | "'" | undefined + let end = start + 1 + for (; end < message.length; end += 1) { + const character = message[end] + if (quote) { + if (character === quote) quote = undefined + } else if (character === '"' || character === "'") { + quote = character + } else if (character === '>') { + break + } + } + if (end >= message.length) continue + const token = message.slice(start, end + 1) + if (/^<\s*\/?\s*[A-Za-z][\w-]*(?:\s|\/?>)/.test(token)) found.push(token) + } + return found.sort() +} + +function plurals(tokens: CompiledToken[]): Map }> { + const found = new Map }>() + const visit = (entries: CompiledToken[]) => { + for (const token of entries) { + if (typeof token === 'string') continue + const type = token[1] + const choices = token[2] + if ( + (type === 'plural' || type === 'selectordinal') && + choices && + typeof choices === 'object' + ) { + found.set(token[0], { + type, + categories: new Set(Object.keys(choices).filter((category) => category !== 'offset')), + }) + } + if (!(choices && typeof choices === 'object')) continue + for (const branch of Object.values(choices)) { + if (Array.isArray(branch)) visit(branch as CompiledToken[]) + } + } + } + visit(tokens) + return found +} + +function requiredPluralCategories(locale: string, type: string): Intl.LDMLPluralRule[] { + return new Intl.PluralRules(locale, { + type: type === 'selectordinal' ? 'ordinal' : 'cardinal', + }).resolvedOptions().pluralCategories +} + +function validateGlossary( + source: string, + translation: string, + locale: string, + glossary: Glossary, +): string[] { + const errors: string[] = [] + const normalizedSource = source + .replaceAll(/\{[^}]+\}/g, '') + .replaceAll(/[^\p{L}\p{N}\s-]/gu, '') + .trim() + .toLocaleLowerCase('en') + const normalizedTranslation = translation.toLocaleLowerCase(locale) + for (const [english, localized] of Object.entries(glossary.terms ?? {})) { + const expected = localized[locale] + const normalizedTerm = english.toLocaleLowerCase('en') + if ( + typeof expected === 'string' && + normalizedSource === normalizedTerm && + !normalizedTranslation.includes(expected.toLocaleLowerCase(locale)) + ) { + errors.push(`expected glossary term "${expected}" for "${english}"`) + } + } + return errors +} + +export function validateTranslation( + source: string, + translation: string, + locale: string, + glossary: Glossary = {}, +): string[] { + const errors: string[] = [] + let sourceTokens: CompiledToken[] + let translatedTokens: CompiledToken[] + try { + sourceTokens = compileMessageOrThrow(source) as CompiledToken[] + } catch (error) { + return [`invalid English ICU: ${error instanceof Error ? error.message : String(error)}`] + } + try { + translatedTokens = compileMessageOrThrow(translation) as CompiledToken[] + } catch (error) { + return [`invalid translated ICU: ${error instanceof Error ? error.message : String(error)}`] + } + + if ( + JSON.stringify(variableNames(sourceTokens)) !== JSON.stringify(variableNames(translatedTokens)) + ) { + errors.push('placeholder names differ from English') + } + if (JSON.stringify(tags(source)) !== JSON.stringify(tags(translation))) { + errors.push('rich-text tags differ from English') + } + + const sourcePlurals = plurals(sourceTokens) + const translatedPlurals = plurals(translatedTokens) + for (const [name, sourcePlural] of sourcePlurals) { + const translatedPlural = translatedPlurals.get(name) + if (!translatedPlural || translatedPlural.type !== sourcePlural.type) { + errors.push(`plural selector "${name}" is missing or changed`) + continue + } + for (const category of requiredPluralCategories(locale, sourcePlural.type)) { + if (!translatedPlural.categories.has(category)) { + errors.push(`plural selector "${name}" is missing "${category}"`) + } + } + } + errors.push(...validateGlossary(source, translation, locale, glossary)) + return errors +} + +function assertProviderResponse( + request: TranslationRequest, + response: TranslationResponse, + currentBatch: SourceMessage[], +): Map { + if (response.locale !== request.locale) { + throw new Error( + `Translation provider returned locale "${response.locale}" for "${request.locale}"`, + ) + } + const expectedIds = currentBatch.map((item) => item.id).sort() + const actualIds = response.entries.map((entry) => entry.id).sort() + if ( + new Set(actualIds).size !== actualIds.length || + JSON.stringify(actualIds) !== JSON.stringify(expectedIds) + ) { + throw new Error('Translation provider returned missing, duplicate, or unexpected message IDs') + } + + const byId = new Map(response.entries.map((entry) => [entry.id, entry])) + for (const source of currentBatch) { + const result = byId.get(source.id) + if (!result) throw new Error(`Translation provider omitted "${source.id}"`) + const errors = validateTranslation( + source.message, + result.translation, + request.locale, + request.glossary, + ) + if (errors.length > 0) { + throw new Error(`Translation provider returned invalid "${source.id}": ${errors.join('; ')}`) + } + } + return byId +} + +export async function refreshTranslations( + locale: string, + sources: SourceMessage[], + current: Record, + provider: TranslationProvider, + glossary: Glossary = {}, +): Promise> { + const stale = sources.filter((source) => { + const existing = current[source.id] + return !existing?.translation || existing.sourceHash !== hashSource(source.message) + }) + if (stale.length === 0) return current + + const updated = { ...current } + for (let offset = 0; offset < stale.length; offset += 40) { + const items = stale.slice(offset, offset + 40) + const request = { locale, items, glossary } + const response = await provider.translate(request) + const translated = assertProviderResponse(request, response, items) + for (const source of items) { + const result = translated.get(source.id)! + updated[source.id] = { + translation: result.translation, + sourceHash: hashSource(source.message), + review: 'required', + fuzzy: true, + reviewNotes: result.reviewNotes, + } + } + } + return updated +} + +function promptFor(request: TranslationRequest): string { + return [ + `Translate these Pascal architecture/CAD interface messages into ${request.locale}.`, + 'Return every input ID exactly once. Preserve ICU placeholders and rich-text tags exactly.', + `For ICU plurals, include the categories used by ${request.locale}: ${requiredPluralCategories(request.locale, 'plural').join(', ')}.`, + 'Use natural, concise professional UI language. Do not translate IDs.', + 'reviewNotes should briefly flag ambiguity or be an empty string.', + `Product and glossary: ${JSON.stringify(request.glossary)}`, + `Messages: ${JSON.stringify(request.items)}`, + ].join('\n\n') +} + +async function configuredProvider(): Promise { + const name = + process.env.I18N_PROVIDER ?? + (process.env.OPENAI_API_KEY ? 'openai' : process.env.ANTHROPIC_API_KEY ? 'anthropic' : '') + const modelName = process.env.I18N_MODEL + if (!modelName) throw new Error('Set I18N_MODEL to the translation model') + + if (name === 'openai' && !process.env.OPENAI_API_KEY) { + throw new Error('Set OPENAI_API_KEY for the OpenAI translation provider') + } + if (name === 'anthropic' && !process.env.ANTHROPIC_API_KEY) { + throw new Error('Set ANTHROPIC_API_KEY for the Anthropic translation provider') + } + if (name !== 'openai' && name !== 'anthropic') { + throw new Error('Set I18N_PROVIDER to "openai" or "anthropic" and provide its API key') + } + + const [{ generateText, Output }, { z }] = await Promise.all([import('ai'), import('zod')]) + const model = + name === 'openai' + ? (await import('@ai-sdk/openai')).createOpenAI({ + apiKey: process.env.OPENAI_API_KEY, + baseURL: process.env.OPENAI_BASE_URL, + })(modelName) + : (await import('@ai-sdk/anthropic')).createAnthropic({ + apiKey: process.env.ANTHROPIC_API_KEY, + baseURL: process.env.ANTHROPIC_BASE_URL, + })(modelName) + const translationOutputSchema = z.object({ + locale: z.string(), + entries: z.array( + z.object({ + id: z.string(), + translation: z.string(), + reviewNotes: z.string(), + }), + ), + }) + + return { + name, + model: modelName, + async translate(request) { + const result = await generateText({ + model, + output: Output.object({ schema: translationOutputSchema }), + prompt: promptFor(request), + }) + return result.output + }, + } +} + +function runLinguiExtract(locales: readonly string[], overwrite = false): void { + const args = [LINGUI_BIN, 'extract', '--clean', '--locale', locales.join(','), '--workers', '1'] + if (overwrite) args.push('--overwrite') + const result = Bun.spawnSync(args, { + cwd: ROOT, + stdout: 'inherit', + stderr: 'inherit', + }) + if (result.exitCode !== 0) throw new Error('Lingui extract failed') +} + +function checkLinguiCatalogSync(): string[] { + const result = Bun.spawnSync([LINGUI_BIN, 'check', 'sync', '--clean', '--workers', '1'], { + cwd: ROOT, + stdout: 'inherit', + stderr: 'inherit', + }) + return result.exitCode === 0 ? [] : ['PO catalogs are not synchronized; run bun run i18n:extract'] +} + +function writePo(path: string, po: PoFile): void { + writeFileSync(path, stringifyPo(po, { foldLength: 0 })) +} + +function annotateCatalogs( + previous: Map<(typeof TARGET_LOCALES)[number], Record>, +): void { + const sources = readSourceMessages() + const byId = new Map(sources.map((source) => [source.id, source])) + for (const locale of TARGET_LOCALES) { + const path = catalogPath(locale) + const po = parsePo(readFileSync(path, 'utf8')) + for (const item of po.items) { + if (item.obsolete) continue + const source = byId.get(item.msgid) + if (!source) continue + replaceComment(item, ENGLISH_PREFIX, JSON.stringify(source.message)) + const sourceHash = hashSource(source.message) + const prior = previous.get(locale)?.[item.msgid] + if (!item.msgstr[0]) { + replaceComment(item, SOURCE_HASH_PREFIX) + replaceComment(item, REVIEW_PREFIX, 'missing') + delete item.flags.fuzzy + } else if (prior?.sourceHash && prior.sourceHash !== sourceHash) { + replaceComment(item, SOURCE_HASH_PREFIX, prior.sourceHash) + replaceComment(item, REVIEW_PREFIX, 'required') + item.flags.fuzzy = true + } else if (!prior?.sourceHash) { + replaceComment(item, SOURCE_HASH_PREFIX, sourceHash) + replaceComment(item, REVIEW_PREFIX, 'human') + delete item.flags.fuzzy + } + } + writePo(path, po) + } +} + +function messagesForCompilation(locale: string, sources: SourceMessage[]): Record { + const sourceHashes = new Map(sources.map((source) => [source.id, hashSource(source.message)])) + const po = parsePo(readFileSync(catalogPath(locale), 'utf8')) + return Object.fromEntries( + po.items + .filter((item) => { + if (item.obsolete || !item.msgstr[0]) return false + if (locale === 'en') return true + return ( + !item.flags.fuzzy && + commentValue(item, REVIEW_PREFIX) === 'human' && + commentValue(item, SOURCE_HASH_PREFIX) === sourceHashes.get(item.msgid) + ) + }) + .map((item) => [item.msgid, item.msgstr[0] ?? '']), + ) +} + +function compiledCatalog(locale: string, sources: SourceMessage[]) { + return createCompiledCatalog(locale, messagesForCompilation(locale, sources), { + namespace: 'ts', + outputPrefix: '/*eslint-disable*/', + strict: true, + }) +} + +function writeCompiledCatalogs(strict = false): void { + const sources = readSourceMessages() + if (strict) { + const errors = checkCatalogMessages(sources, readJson(GLOSSARY_PATH)) + if (errors.length > 0) throw new Error(errors.join('\n')) + } + const compiled = ALL_LOCALES.map((locale) => [locale, compiledCatalog(locale, sources)] as const) + const errors = compiled.flatMap(([locale, result]) => + result.errors.map(({ id, error }) => `${locale}: failed to compile "${id}": ${error.message}`), + ) + if (errors.length > 0) throw new Error(errors.join('\n')) + for (const [locale, result] of compiled) { + writeFileSync(compiledCatalogPath(locale), result.source) + } +} + +export function extractCatalogs(): void { + const previous = new Map( + TARGET_LOCALES.map((locale) => [ + locale, + readTranslationState(parsePo(readFileSync(catalogPath(locale), 'utf8'))), + ]), + ) + runLinguiExtract(['en'], true) + runLinguiExtract(TARGET_LOCALES) + annotateCatalogs(previous) + writeCompiledCatalogs() +} + +function applyTranslations( + po: PoFile, + sources: SourceMessage[], + entries: Record, + provider: TranslationProvider, +): void { + const byId = new Map(sources.map((source) => [source.id, source])) + for (const item of po.items) { + const source = byId.get(item.msgid) + const entry = entries[item.msgid] + if (!(source && entry)) continue + item.msgstr = [entry.translation] + replaceComment(item, ENGLISH_PREFIX, JSON.stringify(source.message)) + replaceComment(item, SOURCE_HASH_PREFIX, entry.sourceHash) + replaceComment(item, REVIEW_PREFIX, entry.review) + replaceComment( + item, + REVIEW_NOTES_PREFIX, + entry.reviewNotes ? escapePoCommentValue(entry.reviewNotes) : undefined, + ) + replaceComment( + item, + AI_PROVIDER_PREFIX, + entry.review === 'required' ? provider.name : undefined, + ) + replaceComment(item, AI_MODEL_PREFIX, entry.review === 'required' ? provider.model : undefined) + if (entry.fuzzy) item.flags.fuzzy = true + else delete item.flags.fuzzy + } +} + +function sourceFiles(): string[] { + const files = new Set() + for (const pattern of SOURCE_GLOBS) { + const glob = new Bun.Glob(pattern) + for (const path of glob.scanSync({ cwd: ROOT, absolute: true, onlyFiles: true })) { + if ( + path.includes('/node_modules/') || + /\.test\.[tj]sx?$/.test(path) || + path.includes('/__fixtures__/') + ) { + continue + } + files.add(path) + } + } + return [...files].sort() +} + +function bypassedLiterals(): string[] { + const warnings: string[] = [] + const paths = sourceFiles().filter((path) => + CONVERTED_SURFACES.some((surface) => { + const absolute = join(ROOT, surface) + return path === absolute || path.startsWith(`${absolute}/`) + }), + ) + + for (const path of paths) { + const source = ts.createSourceFile( + path, + readFileSync(path, 'utf8'), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, + ) + const warning = (node: ts.Node, text: string) => { + if (!/[A-Za-z]{2}/.test(text)) return + const line = source.getLineAndCharacterOfPosition(node.getStart()).line + 1 + warnings.push(`${relative(ROOT, path)}:${line}: "${text.trim()}"`) + } + const visit = (node: ts.Node): void => { + if (ts.isJsxText(node)) warning(node, node.text) + if ( + ts.isJsxAttribute(node) && + ['aria-label', 'title', 'placeholder', 'label', 'alt'].includes(node.name.text) && + node.initializer && + ts.isStringLiteral(node.initializer) + ) { + warning(node, node.initializer.text) + } + ts.forEachChild(node, visit) + } + visit(source) + } + return warnings +} + +function checkCatalogMessages(sources: SourceMessage[], glossary: Glossary): string[] { + const errors: string[] = [] + const sourceById = new Map(sources.map((source) => [source.id, source])) + for (const source of sources) { + if (!source.message) errors.push(`en: missing source for "${source.id}"`) + } + + for (const locale of TARGET_LOCALES) { + const po = parsePo(readFileSync(catalogPath(locale), 'utf8')) + const entries = readTranslationState(po) + for (const source of sources) { + const entry = entries[source.id] + if (!entry?.translation) { + errors.push(`${locale}: missing "${source.id}"`) + continue + } + if (entry.sourceHash !== hashSource(source.message)) { + errors.push(`${locale}: stale "${source.id}"`) + } + if (entry.fuzzy || entry.review !== 'human') { + errors.push(`${locale}: "${source.id}" still requires human review`) + } + for (const error of validateTranslation( + source.message, + entry.translation, + locale, + glossary, + )) { + errors.push(`${locale}: invalid "${source.id}": ${error}`) + } + } + for (const id of Object.keys(entries)) { + if (!sourceById.has(id)) errors.push(`${locale}: obsolete "${id}"`) + } + } + return errors +} + +function checkEnglishCatalog( + extractedSources: SourceMessage[], + catalogSources: SourceMessage[], +): string[] { + const errors: string[] = [] + const extracted = new Map(extractedSources.map((source) => [source.id, source])) + const catalog = new Map(catalogSources.map((source) => [source.id, source])) + for (const source of extractedSources) { + const catalogSource = catalog.get(source.id) + if (!catalogSource) { + errors.push(`en: source catalog is missing "${source.id}"`) + } else if (catalogSource.message !== source.message) { + errors.push( + `en: source drift "${source.id}" (code: ${JSON.stringify(source.message)}, catalog: ${JSON.stringify(catalogSource.message)})`, + ) + } + } + for (const source of catalogSources) { + if (!extracted.has(source.id)) errors.push(`en: obsolete "${source.id}"`) + } + return errors +} + +function checkCompiledCatalogs(sources: SourceMessage[]): string[] { + return ALL_LOCALES.flatMap((locale) => { + const compiled = compiledCatalog(locale, sources) + const errors = compiled.errors.map( + ({ id, error }) => `${locale}: failed to compile "${id}": ${error.message}`, + ) + if (compiled.source !== readFileSync(compiledCatalogPath(locale), 'utf8')) { + errors.push( + `${relative(ROOT, compiledCatalogPath(locale))} is not synchronized; run bun run i18n:compile`, + ) + } + return errors + }) +} + +export async function checkCatalogs(): Promise { + const sources = await extractSourceMessages() + const catalogSources = readSourceMessages() + const glossary = readJson(GLOSSARY_PATH) + return [ + ...checkLinguiCatalogSync(), + ...checkEnglishCatalog(sources, catalogSources), + ...checkCatalogMessages(sources, glossary), + ...checkCompiledCatalogs(catalogSources), + ] +} + +async function main(): Promise { + const command = process.argv[2] + if (command === 'extract') { + extractCatalogs() + console.log(`Extracted and compiled ${readSourceMessages().length} Lingui messages`) + return + } + + if (command === 'compile') { + writeCompiledCatalogs(true) + console.log(`Compiled ${ALL_LOCALES.length} Lingui catalogs`) + return + } + + if (command === 'check') { + const errors = await checkCatalogs() + for (const literal of bypassedLiterals()) { + console.warn(`[i18n warning] user-facing literal bypasses Lingui: ${literal}`) + } + if (errors.length > 0) { + console.error(errors.map((error) => `[i18n] ${error}`).join('\n')) + process.exitCode = 1 + } else { + console.log(`Checked ${TARGET_LOCALES.length} complete bundled PO catalogs`) + } + return + } + + if (command === 'translate') { + extractCatalogs() + const requested = process.argv.slice(3) + const locales = requested.length > 0 ? requested : [...TARGET_LOCALES] + const sources = readSourceMessages() + const glossary = readJson(GLOSSARY_PATH) + const provider = await configuredProvider() + for (const locale of locales) { + if (!TARGET_LOCALES.includes(locale as (typeof TARGET_LOCALES)[number])) { + throw new Error(`Unknown bundled locale "${locale}"`) + } + const path = catalogPath(locale) + const po = parsePo(readFileSync(path, 'utf8')) + const refreshed = await refreshTranslations( + locale, + sources, + readTranslationState(po), + provider, + glossary, + ) + applyTranslations(po, sources, refreshed, provider) + writePo(path, po) + console.log(`Updated ${relative(ROOT, path)}; AI entries are fuzzy and require human review`) + } + return + } + + throw new Error('Usage: bun scripts/i18n.ts [locale...]') +} + +if (import.meta.main) await main()