diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd88e5e69d..b3105395bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,7 +57,12 @@ jobs: permissions: contents: read steps: + # Depth 2 keeps the pull request merge commit's first parent (the base + # tip), which i18n:check compares reviewed translations and the literal + # baseline against. - uses: actions/checkout@v4 + with: + fetch-depth: 2 # Read from `packageManager` in package.json, so CI runs the same bun as # contributors and a bun release cannot change the gate on its own. @@ -80,7 +85,20 @@ jobs: - name: Lint & format check run: bun run check + # A push compares with the commit before it (every commit of a + # multi-commit push); the shallow checkout lacks it, so fetch it. + - name: Fetch the pre-push commit for localization checks + if: github.event_name == 'push' && !startsWith(github.event.before, '0000000') + env: + BEFORE: ${{ github.event.before }} + run: git fetch --no-tags --depth=1 origin "$BEFORE" + + # Includes the review-regression check against the PR base (or the + # pre-push commit) and the scan of the compiled packs for drafts; draft + # counts go to the job summary. - name: Check localization catalogs + env: + I18N_BASE_REF: ${{ github.event_name == 'push' && github.event.before || '' }} run: bun run i18n:check - name: Validate agent skills and plugin packages diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b269e4de9f..d2f1da1972 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -102,6 +102,14 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + # Drafts ship as English until reviewed. A release fails only when an + # entry reviewed at the latest @pascal-app/editor@* tag (the package + # that ships the catalogs; tagged below) became a draft with the same + # English, or when a compiled pack contains a draft. The full-history + # checkout above makes the tags available. + - name: Check translation review regressions and shipped packs + run: bun scripts/i18n.ts release-check + - name: Configure git run: | git config user.name "github-actions[bot]" diff --git a/.gitignore b/.gitignore index e41ddbb868..8d9dbf9930 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,4 @@ og-test .claude/launch.json .claude/settings.local.json .vscode/launch.json +.i18n-audit/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d5f928dbb3..b438dba567 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,6 +60,22 @@ and reports inflated counts. `bun run test` goes through Turborepo, which builds workspace dependencies first (several packages import theirs from `dist/`) and runs each package's own scoped test script. +### User-visible text + +Every string a person reads in the editor (labels, tooltips, `aria-label`, +`title`, placeholders, menus, dialogs, errors) goes through the i18n catalog: + +```tsx +const i18n = usePascalI18n() + +``` + +`bun run i18n:check` (part of `bun run ci`) fails when a file gains a +hardcoded user-visible literal. Run `bun run i18n:extract` after changing copy. +Scene data, MCP/agent text and developer errors stay English. See +[`i18n/README.md`](i18n/README.md) for catalogs, translation review, the +migration codemod and the pseudo-locale screenshot audit. + ### Project structure | Package | What it does | diff --git a/apps/editor/app/client-bootstrap.tsx b/apps/editor/app/client-bootstrap.tsx index 0c307cd874..ac33b9f387 100644 --- a/apps/editor/app/client-bootstrap.tsx +++ b/apps/editor/app/client-bootstrap.tsx @@ -12,8 +12,10 @@ import '../lib/bootstrap' import { createI18n, defaultI18n, PascalI18nProvider } from '@pascal-app/core' import { registerBundledLocales } from '@pascal-app/editor/locales' import { type ReactNode, useEffect, useMemo } from 'react' +import { registerDevLocales } from '@/lib/dev-locales' registerBundledLocales() +registerDevLocales() export function ClientBootstrap({ children, diff --git a/apps/editor/app/layout.tsx b/apps/editor/app/layout.tsx index 1107368272..6af66b7ac6 100644 --- a/apps/editor/app/layout.tsx +++ b/apps/editor/app/layout.tsx @@ -4,11 +4,13 @@ import { GeistPixelSquare } from 'geist/font/pixel' import { Barlow } from 'next/font/google' import localFont from 'next/font/local' import { headers } from 'next/headers' +import { registerDevLocales } from '@/lib/dev-locales' import { PASCAL_LOCALE_HEADER, resolveUiLocale } from '@/lib/ui-locale' import { ClientBootstrap } from './client-bootstrap' import './globals.css' registerBundledLocales() +registerDevLocales() const geistSans = localFont({ src: './fonts/GeistVF.woff', diff --git a/apps/editor/components/viewer-toolbar.tsx b/apps/editor/components/viewer-toolbar.tsx index 901bb044db..a9ee1183db 100644 --- a/apps/editor/components/viewer-toolbar.tsx +++ b/apps/editor/components/viewer-toolbar.tsx @@ -1,6 +1,7 @@ 'use client' import { Icon as IconifyIcon } from '@iconify/react' +import { type MessageDescriptor, usePascalI18n } from '@pascal-app/core' import { DropdownMenu, DropdownMenuContent, @@ -10,6 +11,7 @@ import { DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, + sceneThemeName, useEditor, useFloorplanAnnotationVisibility, useFloorplanMode, @@ -84,56 +86,143 @@ function ToolbarTooltip({ children, label }: { children: ReactNode; label: strin } const levelModeOrder = ['stacked', 'exploded', 'solo'] as const -const levelModeLabels: Record = { - manual: 'Stack', - stacked: 'Stack', - exploded: 'Exploded', - solo: 'Solo', +const levelModeLabels: Record = { + manual: /* i18n */ { id: 'app.viewerToolbar.manual', message: 'Manual' }, + stacked: /* i18n */ { id: 'app.viewerToolbar.stack', message: 'Stack' }, + exploded: /* i18n */ { id: 'app.viewerToolbar.exploded', message: 'Exploded' }, + solo: /* i18n */ { id: 'app.viewerToolbar.solo', message: 'Solo' }, } const wallModeOrder = ['cutaway', 'up', 'down', 'translucent'] as const -const wallModeConfig: Record = { - up: { icon: '/icons/room.webp', label: 'Full height' }, - cutaway: { icon: '/icons/wallcut.webp', label: 'Cutaway' }, - down: { icon: '/icons/walllow.webp', label: 'Low' }, - translucent: { icon: '/icons/wall.webp', label: 'Translucent' }, +const wallModeConfig: Record = { + up: { + icon: '/icons/room.webp', + label: /* i18n */ { id: 'app.viewerToolbar.fullHeight', message: 'Full height' }, + }, + cutaway: { + icon: '/icons/wallcut.webp', + label: /* i18n */ { id: 'app.viewerToolbar.cutaway', message: 'Cutaway' }, + }, + down: { + icon: '/icons/walllow.webp', + label: /* i18n */ { id: 'app.viewerToolbar.low', message: 'Low' }, + }, + translucent: { + icon: '/icons/wall.webp', + label: /* i18n */ { id: 'app.viewerToolbar.translucent', message: 'Translucent' }, + }, } const SHADING_OPTIONS = [ - { id: 'solid', name: 'Solid', detail: 'Flat and fast — no ambient occlusion', icon: Box }, - { id: 'rendered', name: 'Rendered', detail: 'Full ambient occlusion', icon: Sparkles }, + { + id: 'solid', + name: /* i18n */ { id: 'app.viewerToolbar.solid', message: 'Solid' }, + detail: /* i18n */ { + id: 'app.viewerToolbar.flatAndFastNoAmbientOcclusion', + message: 'Flat and fast — no ambient occlusion', + }, + icon: Box, + }, + { + id: 'rendered', + name: /* i18n */ { id: 'app.viewerToolbar.rendered', message: 'Rendered' }, + detail: /* i18n */ { + id: 'app.viewerToolbar.fullAmbientOcclusion', + message: 'Full ambient occlusion', + }, + icon: Sparkles, + }, ] as const const FLOORPLAN_ANNOTATION_OPTIONS = [ - { id: 'automaticDimensions', name: 'Automatic dimensions', icon: Ruler }, - { id: 'manualDimensions', name: 'Manual dimensions', icon: Ruler }, - { id: 'measurements', name: 'Measurements', icon: ScanLine }, - { id: 'openingMarks', name: 'Door/window marks', icon: Tag }, - { id: 'structuralGrids', name: 'Structural grids & column centers', icon: Grid2X2 }, - { id: 'roomLabels', name: 'Room labels', icon: SquareUserRound }, - { id: 'stairAnnotations', name: 'Stair annotations', icon: Footprints }, + { + id: 'automaticDimensions', + name: /* i18n */ { + id: 'app.viewerToolbar.automaticDimensions', + message: 'Automatic dimensions', + }, + icon: Ruler, + }, + { + id: 'manualDimensions', + name: /* i18n */ { id: 'app.viewerToolbar.manualDimensions', message: 'Manual dimensions' }, + icon: Ruler, + }, + { + id: 'measurements', + name: /* i18n */ { id: 'app.viewerToolbar.measurements', message: 'Measurements' }, + icon: ScanLine, + }, + { + id: 'openingMarks', + name: /* i18n */ { id: 'app.viewerToolbar.doorWindowMarks', message: 'Door/window marks' }, + icon: Tag, + }, + { + id: 'structuralGrids', + name: /* i18n */ { + id: 'app.viewerToolbar.structuralGridsColumnCenters', + message: 'Structural grids & column centers', + }, + icon: Grid2X2, + }, + { + id: 'roomLabels', + name: /* i18n */ { id: 'app.viewerToolbar.roomLabels', message: 'Room labels' }, + icon: SquareUserRound, + }, + { + id: 'stairAnnotations', + name: /* i18n */ { id: 'app.viewerToolbar.stairAnnotations', message: 'Stair annotations' }, + icon: Footprints, + }, ] as const const FLOORPLAN_MODE_OPTIONS = [ { id: 'default', - name: 'Default', - detail: 'Clean plan; dimensions appear with selection', + name: /* i18n */ { id: 'app.viewerToolbar.default', message: 'Default' }, + detail: /* i18n */ { + id: 'app.viewerToolbar.cleanPlanDimensionsAppearWithSelection', + message: 'Clean plan; dimensions appear with selection', + }, }, { id: 'expert', - name: 'Expert', - detail: 'Full documentation and annotation controls', + name: /* i18n */ { id: 'app.viewerToolbar.expert', message: 'Expert' }, + detail: /* i18n */ { + id: 'app.viewerToolbar.fullDocumentationAndAnnotationControls', + message: 'Full documentation and annotation controls', + }, }, ] as const const FLOORPLAN_WALL_DIMENSION_REFERENCE_OPTIONS = [ - { id: 'finished-faces', name: 'Finished faces', detail: 'Full wall thickness' }, - { id: 'centerline', name: 'Wall centerline', detail: 'Single wall axis' }, - { id: 'stud-faces', name: 'Face of stud', detail: 'Structural core face' }, + { + id: 'finished-faces', + name: /* i18n */ { id: 'app.viewerToolbar.finishedFaces', message: 'Finished faces' }, + detail: /* i18n */ { + id: 'app.viewerToolbar.fullWallThickness', + message: 'Full wall thickness', + }, + }, + { + id: 'centerline', + name: /* i18n */ { id: 'app.viewerToolbar.wallCenterline', message: 'Wall centerline' }, + detail: /* i18n */ { id: 'app.viewerToolbar.singleWallAxis', message: 'Single wall axis' }, + }, + { + id: 'stud-faces', + name: /* i18n */ { id: 'app.viewerToolbar.faceOfStud', message: 'Face of stud' }, + detail: /* i18n */ { + id: 'app.viewerToolbar.structuralCoreFace', + message: 'Structural core face', + }, + }, ] as const function CollapseSidebarButton() { + const i18n = usePascalI18n() const isCollapsed = useSidebarStore((state) => state.isCollapsed) const setIsCollapsed = useSidebarStore((state) => state.setIsCollapsed) @@ -143,9 +232,19 @@ function CollapseSidebarButton() { return ( - + state.levelMode) const setLevelMode = useViewer((state) => state.setLevelMode) const isDefault = levelMode === 'stacked' || levelMode === 'manual' @@ -177,7 +277,12 @@ function LevelModeToggle() { if (next) setLevelMode(next) } - const label = `Levels: ${levelMode === 'manual' ? 'Manual' : (levelModeLabels[levelMode] ?? 'Stack')}` + const modeLabel = i18n._(levelModeLabels[levelMode] ?? levelModeLabels.stacked!) + const label = i18n._({ + id: 'app.viewerToolbar.levelsMode', + message: 'Levels: {mode}', + values: { mode: modeLabel }, + }) return ( @@ -197,13 +302,18 @@ function LevelModeToggle() { ) : ( )} - {levelModeLabels[levelMode] ?? 'Stack'} + + {levelMode === 'manual' + ? i18n._({ id: 'app.viewerToolbar.stack', message: 'Stack' }) + : modeLabel} + ) } function WallModeToggle() { + const i18n = usePascalI18n() const wallMode = useViewer((state) => state.wallMode) const setWallMode = useViewer((state) => state.setWallMode) const config = wallModeConfig[wallMode] ?? wallModeConfig.cutaway! @@ -215,7 +325,13 @@ function WallModeToggle() { } return ( - + - {config.label} + {i18n._(config.label)} ) @@ -238,14 +354,33 @@ function WallModeToggle() { // camera projection, units, render mode, edges and scene theme. const EDGE_OPTIONS = [ - { id: 'off', name: 'Off', detail: 'No edge lines' }, - { id: 'soft', name: 'Soft', detail: 'Faint outline of major creases' }, - { id: 'strong', name: 'Strong', detail: 'Crisp, opaque edge lines' }, -] as const satisfies readonly { id: EdgeMode; name: string; detail: string }[] + { + id: 'off', + name: /* i18n */ { id: 'app.viewerToolbar.off', message: 'Off' }, + detail: /* i18n */ { id: 'app.viewerToolbar.noEdgeLines', message: 'No edge lines' }, + }, + { + id: 'soft', + name: /* i18n */ { id: 'app.viewerToolbar.soft', message: 'Soft' }, + detail: /* i18n */ { + id: 'app.viewerToolbar.faintOutlineOfMajorCreases', + message: 'Faint outline of major creases', + }, + }, + { + id: 'strong', + name: /* i18n */ { id: 'app.viewerToolbar.strong', message: 'Strong' }, + detail: /* i18n */ { + id: 'app.viewerToolbar.crispOpaqueEdgeLines', + message: 'Crisp, opaque edge lines', + }, + }, +] as const satisfies readonly { id: EdgeMode; name: MessageDescriptor; detail: MessageDescriptor }[] const SUBMENU_CONTENT_CLASS = 'min-w-56 rounded-xl border-border/45 bg-popover/95 backdrop-blur-xl' function DisplayMenu() { + const i18n = usePascalI18n() const scene = useEditor(visibleScene) const showGrid = useViewer((state) => state.showGrid) const setShowGrid = useViewer((state) => state.setShowGrid) @@ -291,27 +426,34 @@ function DisplayMenu() { return ( - + - Display + + {i18n._({ id: 'app.viewerToolbar.display', message: 'Display' })} + keepOpen(e, () => setShowGrid(!showGrid))}> - Grid + {i18n._({ id: 'app.viewerToolbar.grid', message: 'Grid' })} {showGrid ? ( ) : ( @@ -323,7 +465,11 @@ function DisplayMenu() { onSelect={(e) => keepOpen(e, () => setShowMeasurements(!showMeasurements))} > - {scene === 'split' ? '3D measurements' : 'Measurements'} + + {scene === 'split' + ? i18n._({ id: 'app.viewerToolbar.n3dMeasurements', message: '3D measurements' }) + : i18n._({ id: 'app.viewerToolbar.measurements', message: 'Measurements' })} + {showMeasurements ? ( ) : ( @@ -336,17 +482,21 @@ function DisplayMenu() { - Floor plan mode + + {i18n._({ id: 'app.viewerToolbar.floorPlanMode', message: 'Floor plan mode' })} + - {floorplanMode === 'default' ? 'Default' : 'Expert'} + {floorplanMode === 'default' + ? i18n._({ id: 'app.viewerToolbar.default', message: 'Default' }) + : i18n._({ id: 'app.viewerToolbar.expert', message: 'Expert' })} {FLOORPLAN_MODE_OPTIONS.map((option) => ( setFloorplanMode(option.id)}> - {option.name} - {option.detail} + {i18n._(option.name)} + {i18n._(option.detail)} {floorplanMode === option.id ? ( @@ -360,7 +510,12 @@ function DisplayMenu() { - Floor plan annotations + + {i18n._({ + id: 'app.viewerToolbar.floorPlanAnnotations', + message: 'Floor plan annotations', + })} + {FLOORPLAN_ANNOTATION_OPTIONS.map((option) => { @@ -374,7 +529,7 @@ function DisplayMenu() { } > - {option.name} + {i18n._(option.name)} {visible ? ( ) : ( @@ -388,13 +543,20 @@ function DisplayMenu() { - Wall dimensions + + {i18n._({ + id: 'app.viewerToolbar.wallDimensions', + message: 'Wall dimensions', + })} + - { - FLOORPLAN_WALL_DIMENSION_REFERENCE_OPTIONS.find( - (option) => option.id === wallDimensionReference, - )?.name - } + {i18n._( + ( + FLOORPLAN_WALL_DIMENSION_REFERENCE_OPTIONS.find( + (option) => option.id === wallDimensionReference, + ) ?? FLOORPLAN_WALL_DIMENSION_REFERENCE_OPTIONS[0] + ).name, + )} @@ -406,8 +568,10 @@ function DisplayMenu() { } > - {option.name} - {option.detail} + {i18n._(option.name)} + + {i18n._(option.detail)} + {wallDimensionReference === option.id ? ( @@ -422,15 +586,21 @@ function DisplayMenu() { ) : null} keepOpen(e, () => setMagneticSnap(!magneticSnap))}> - Magnetic snap + {i18n._({ id: 'app.viewerToolbar.magneticSnap', message: 'Magnetic snap' })} - {magneticSnap ? 'On' : 'Off'} + {magneticSnap + ? i18n._({ id: 'app.viewerToolbar.on', message: 'On' }) + : i18n._({ id: 'app.viewerToolbar.off', message: 'Off' })} keepOpen(e, () => setShadows(!shadows))}> - Shadows - {shadows ? 'On' : 'Off'} + {i18n._({ id: 'app.viewerToolbar.shadows', message: 'Shadows' })} + + {shadows + ? i18n._({ id: 'app.viewerToolbar.on', message: 'On' }) + : i18n._({ id: 'app.viewerToolbar.off', message: 'Off' })} + @@ -444,9 +614,11 @@ function DisplayMenu() { icon={cameraMode === 'perspective' ? 'icon-park-outline:perspective' : 'vaadin:grid'} width={16} /> - Camera + {i18n._({ id: 'app.viewerToolbar.camera', message: 'Camera' })} - {cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'} + {cameraMode === 'perspective' + ? i18n._({ id: 'app.viewerToolbar.perspective', message: 'Perspective' }) + : i18n._({ id: 'app.viewerToolbar.orthographic', message: 'Orthographic' })} @@ -454,13 +626,13 @@ function DisplayMenu() { {unit === 'imperial' ? 'ft' : metricNotation === 'millimeters' ? 'mm' : 'm'} - Units + {i18n._({ id: 'app.viewerToolbar.units', message: 'Units' })} {unit === 'imperial' - ? 'Feet & inches' + ? i18n._({ id: 'app.viewerToolbar.feetInches', message: 'Feet & inches' }) : metricNotation === 'millimeters' - ? 'Millimeters' - : 'Meters'} + ? i18n._({ id: 'app.viewerToolbar.millimeters', message: 'Millimeters' }) + : i18n._({ id: 'app.viewerToolbar.meters', message: 'Meters' })} @@ -468,7 +640,7 @@ function DisplayMenu() { m - Meters + {i18n._({ id: 'app.viewerToolbar.meters', message: 'Meters' })} {unit === 'metric' && metricNotation === 'meters' ? ( ) : null} @@ -477,7 +649,7 @@ function DisplayMenu() { mm - Millimeters + {i18n._({ id: 'app.viewerToolbar.millimeters', message: 'Millimeters' })} {unit === 'metric' && metricNotation === 'millimeters' ? ( ) : null} @@ -486,7 +658,9 @@ function DisplayMenu() { ft - Feet & inches + + {i18n._({ id: 'app.viewerToolbar.feetInches', message: 'Feet & inches' })} + {unit === 'imperial' ? : null} @@ -497,8 +671,10 @@ function DisplayMenu() { - Render - {activeShading.name} + {i18n._({ id: 'app.viewerToolbar.render', message: 'Render' })} + + {i18n._(activeShading.name)} + {SHADING_OPTIONS.map((option) => { @@ -507,8 +683,8 @@ function DisplayMenu() { setShading(option.id)}> - {option.name} - {option.detail} + {i18n._(option.name)} + {i18n._(option.detail)} {shading === option.id ? ( @@ -522,15 +698,17 @@ function DisplayMenu() { - Edges - {activeEdges.name} + {i18n._({ id: 'app.viewerToolbar.edges', message: 'Edges' })} + + {i18n._(activeEdges.name)} + {EDGE_OPTIONS.map((option) => ( setEdges(option.id)}> - {option.name} - {option.detail} + {i18n._(option.name)} + {i18n._(option.detail)} {edges === option.id ? : null} @@ -541,9 +719,9 @@ function DisplayMenu() { - Scene theme + {i18n._({ id: 'app.viewerToolbar.sceneTheme', message: 'Scene theme' })} - {activeTheme.name} + {sceneThemeName(activeTheme.id, activeTheme.name, i18n)} @@ -561,7 +739,9 @@ function DisplayMenu() { ))} - {theme.name} + + {sceneThemeName(theme.id, theme.name, i18n)} + {sceneTheme === theme.id ? ( ) : null} @@ -576,6 +756,7 @@ function DisplayMenu() { } function WalkthroughButton() { + const i18n = usePascalI18n() const isFirstPersonMode = useEditor((state) => state.isFirstPersonMode) const setFirstPersonMode = useEditor((state) => state.setFirstPersonMode) const handleClick = useCallback(() => { @@ -589,7 +770,7 @@ function WalkthroughButton() { }, [isFirstPersonMode, setFirstPersonMode]) return ( - + + useEditor.getState().setPreviewMode(true)} type="button" > - Preview + {i18n._({ id: 'app.viewerToolbar.preview', message: 'Preview' })} ) @@ -662,7 +846,7 @@ export function EditorViewerToolbarRight({ vrButton, vrActive = false, vrDisabled = false, - vrLabel = vrActive ? 'Exit VR' : 'Enter VR', + vrLabel, }: { onVRToggle?: () => void vrButton?: ReactNode @@ -670,6 +854,12 @@ export function EditorViewerToolbarRight({ vrDisabled?: boolean vrLabel?: string }) { + const i18n = usePascalI18n() + const label = + vrLabel ?? + (vrActive + ? i18n._({ id: 'app.viewerToolbar.exitVr', message: 'Exit VR' }) + : i18n._({ id: 'app.viewerToolbar.enterVr', message: 'Enter VR' })) return ( @@ -681,12 +871,7 @@ export function EditorViewerToolbarRight({ {onVRToggle || vrButton ? (vrButton ?? (onVRToggle ? ( - + ) : null)) : null} diff --git a/apps/editor/lib/dev-locales.ts b/apps/editor/lib/dev-locales.ts new file mode 100644 index 0000000000..10fe9716c9 --- /dev/null +++ b/apps/editor/lib/dev-locales.ts @@ -0,0 +1,11 @@ +/** + * `en-XA` is a test locale. The require sits inside a `NODE_ENV` branch that + * bundlers drop from production builds, so its catalog never ships. + */ +export function registerDevLocales(): void { + if (process.env.NODE_ENV !== 'production') { + const { registerPseudoLocale } = + require('@pascal-app/editor/locales/pseudo') as typeof import('@pascal-app/editor/locales/pseudo') + registerPseudoLocale() + } +} diff --git a/bun.lock b/bun.lock index 9afeaaf62a..f83dfe428e 100644 --- a/bun.lock +++ b/bun.lock @@ -16,7 +16,9 @@ "@typescript/typescript6": "6.0.2", "ai": "^7.0.154", "dotenv-cli": "^11.0.0", + "entities": "^8.1.0", "fast-xml-parser": "^5.11.2", + "playwright-core": "1.64.1", "pofile-ts": "^4.0.3", "turbo": "^2.11.10", "typescript": "npm:@typescript/typescript6@6.0.2", @@ -1448,6 +1450,8 @@ "enhanced-resolve": ["enhanced-resolve@5.26.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw=="], + "entities": ["entities@8.1.0", "", {}, "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA=="], + "env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="], "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], diff --git a/i18n/README.md b/i18n/README.md index 0d317721fc..91f4bf245a 100644 --- a/i18n/README.md +++ b/i18n/README.md @@ -36,17 +36,144 @@ 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. +2. Translate every new entry in each bundled locale, by hand or with + `bun run i18n:translate` (below). +3. Keep the `Source-Hash` comment in sync. An entry a fluent reviewer approved + is `Review: human` without the `fuzzy` flag; an AI or otherwise unreviewed + draft is `fuzzy` with `Review: required`. 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. +missing or stale translations, malformed ICU, changed placeholders or tags, +invalid target-locale plural branches, glossary violations, an unreviewed entry +that is not flagged `fuzzy` + `Review: required`, and new hardcoded UI +literals (see the literal guard below). It is read-only: use `i18n:extract` or +`i18n:compile` when catalogs need to be written. + +CI and releases allow drafts, never regressions: + +- In a pull request, drafts awaiting review pass `i18n:check` but are never + shown: compiled packs omit them, so that locale shows the English source + until a reviewer removes `fuzzy` and sets `Review: human`. `i18n:check` + prints how many entries each locale still has awaiting review. +- A translation that is `Review: human` on the base branch must stay reviewed + while its English is unchanged. A PR may edit it, but turning it back into a + draft fails CI. +- To rename a reviewed ID, declare it on the new descriptor: + `i18n._({ id: 'editor.panel.advancedSection', message: 'Advanced', comment: 'renamed-from: editor.panel.advanced' })`. + `bun run i18n:extract` then carries the reviewed translation over while the + English is unchanged, and CI fails if a declared rename arrives without it. + An undeclared new ID that shares English with a removed reviewed ID is only + reported as a possible rename (console and job summary): it may be an + unrelated addition, and a missed carry-over just shows English. CI compares with the + pull request's base commit, or on a push with the commit before the push; + locally `i18n:check` compares with the merge base of `origin/main`, or + `I18N_BASE_REF`. +- Releases ship drafts as English until they are reviewed. +- `bun scripts/i18n.ts release-check` runs in the release workflow and, as part + of `i18n:check`, in CI. It compares the committed catalogs with a base + revision. In CI that is the pull request base. In a release it is the latest + `@pascal-app/editor@` tag reachable from HEAD (the release workflow + tags every package `@pascal-app/@`, and the catalogs ship in + the editor package), or `--base `. When that tag predates the catalogs + it falls back to `origin/main` if that is another commit with catalogs, then + to the commit that introduced the catalogs. It never compares with a base + that has no catalogs: with none available it fails and asks for `--base`. + It fails when a reviewed entry with the same English became a draft, or when + a compiled pack contains any draft. Both jobs write the per-locale draft + count, and the base used, to the job summary. +- `bun scripts/i18n.ts check --reviewed` is the manual sign-off for a locale: + it fails on every remaining draft. + +## Literal guard and migration codemod + +`bun run i18n:literals -- check` (run by `i18n:check`) scans the TSX in +`apps/editor` and `packages/{editor,viewer,nodes,core,ui}` for user-visible +literals: JSX text, string-valued `aria-*`, `title`, `placeholder`, `alt`, +`label`, `tooltip`, `description`… props, display keys (`label`, `title`, +`placeholder`, `tooltip`…) in objects, and `toast`/`alert`/`confirm`/`setError` +payloads (also in `.ts` modules), including `const` strings rendered by name +in JSX, a UI prop or inside a sentence, and copy built with `+`. Hyphenated +words ("Read-only") count as copy; icon names, paths and snake_case keys do +not. JSX text and attribute values are recorded as they render (whitespace rules, +entities, `{' '}` joins; a no-break space is content), so equivalent spellings +match, and the allowlist applies to the joined text. `i18n/literal-baseline.json` lists, per +file, the legacy candidates by text. A file may only contain copy its entry +lists, so adding or swapping a string fails, and a file missing from the +baseline (every new file) must have none. When a PR converts copy, run +`bun run i18n:literals -- update` to remove it from the baseline. The baseline +may only shrink, measured across the repository: CI fails when it lists a text +more often than the base branch's baseline does. Copy that moves with a +renamed, split or extracted file therefore passes after `update`. + +Not every literal is copy. Exemptions are explicit and reviewed; the allowlist, +like the baseline, may not gain entries relative to the base: + +- `i18n/literal-allowlist.json` lists exact strings that are never translated + (brands, file formats, unit symbols, modifier key names) and developer-only + paths, each path with its reason. +- `// i18n-ignore: ` (or `{/* i18n-ignore: */}` in JSX) on a + literal's own line, or alone on the line before it, exempts it, e.g. an + authored or scene value. A trailing ignore does not reach the next line. A + bare `i18n-ignore` without a reason exempts nothing. + +`bun run i18n:literals -- report [--files] [path…]` lists the remaining +candidates per area or per line. The one-time migration codemod rewrites them: + +```bash +bun run i18n:literals -- convert packages/editor/src/components/ui/sidebar # dry run +bun run i18n:literals -- convert --apply packages/editor/src/components/ui/sidebar +``` + +It turns JSX text, UI props, conditional and template-literal copy, and +notification payloads into `i18n._({ id, message, values })`. Adjacent text and +simple interpolations become one message (`Shared with {count} people`). It +adds `const i18n = usePascalI18n()` to the owning component or hook and the +import. IDs follow the owning package and path (`app.editorNavbar.share`, +`editor.sidebar.sitePanel.addLevel`, `node.roofSegment.inspector.pitch`). +Copy is ICU-encoded so the message renders exactly the original text. The +codemod leaves these for a person and lists them as `MANUAL`: module-level data +(unless asked, below), complex template interpolations, concatenated copy, +string constants, copy inside named callbacks or async functions, and modules a +host can render on the server (app modules and editor package entry exports +without `'use client'`). A second run changes nothing, including a repeated +`--vars` pass. + +Module data (option arrays, label maps) cannot call a hook. Name its keys or +variables and the codemod turns those values into lazy descriptors that Lingui +extracts: + +```bash +bun run i18n:literals -- convert --apply --keys=name,detail --vars=levelModeLabels apps/editor/components/viewer-toolbar.tsx +# { name: 'Solid' } → { name: /* i18n */ { id: 'app.viewerToolbar.solid', message: 'Solid' } } +``` + +Type the field as `MessageDescriptor` (exported by `@pascal-app/core`) and +render it with `i18n._(option.name)` inside the component; the type checker +lists every consumer to update. Review the diff, run `bun run i18n:extract`, and translate. + +## Pseudo-locale audit + +`en-XA` is generated from English on every extract/compile: letters are +accented, text is ~30 % longer and bracketed (`[Šĥáŕé ~~]`), and ICU arguments +and tags are kept. The standalone app registers it in development and test +builds only (`apps/editor/lib/dev-locales.ts`; production bundles never include +the catalog), so `?lang=en-XA` shows at a glance +which copy bypasses the catalog and which controls clip longer text. + +With the app running (`bun dev`), the audit opens every surface in +`i18n/pseudo-surfaces.ts` in Chrome, saves a screenshot per surface, and lists +any visible text or accessible name that still contains plain ASCII words: + +```bash +bun run i18n:pseudo-audit -- --base http://localhost:3002 --out .i18n-audit [surface…] +``` + +Set `PLAYWRIGHT_CHROMIUM_EXECUTABLE` if Chrome is not at +`/usr/local/bin/google-chrome`. A PR that localizes a surface adds it to the +manifest (with the steps that open menus or dialogs) and includes the +screenshots. Copy from external plugins (`@pascal-app/plugin-*`) is the +plugin's own to translate through `registerMessages`. `bun run i18n:compile` runs Lingui’s strict compiler directly when only the generated runtime catalogs need refreshing. diff --git a/i18n/literal-allowlist.json b/i18n/literal-allowlist.json new file mode 100644 index 0000000000..354688c61a --- /dev/null +++ b/i18n/literal-allowlist.json @@ -0,0 +1,39 @@ +{ + "texts": [ + "Pascal", + "Pascal Editor", + "GitHub", + "Discord", + "IFC", + "GLB", + "glTF", + "OBJ", + "STL", + "DXF", + "SVG", + "PNG", + "JPG", + "JPEG", + "PDF", + "JSON", + "MCP", + "HVAC", + "mm", + "cm", + "m²", + "ft", + "ft²", + "in", + "px", + "Ctrl", + "Cmd", + "Alt", + "Shift", + "Esc", + "OK" + ], + "paths": { + "apps/editor/app/dev/": "developer-only diagnostics routes, never shown to product users", + "packages/editor/src/components/debug/": "developer-only debug overlays" + } +} diff --git a/i18n/literal-baseline.json b/i18n/literal-baseline.json new file mode 100644 index 0000000000..30b9b64ca8 --- /dev/null +++ b/i18n/literal-baseline.json @@ -0,0 +1,3275 @@ +{ + "version": 3, + "files": { + "apps/editor/app/embed/[id]/page.tsx": [ + "{name} · Pascal" + ], + "apps/editor/app/import/import-client.tsx": [ + " · {?} m² of floor", + "Contents", + "Creating the scene…", + "Fetching the scene…", + "Import as a new scene", + "Scene name", + "Try again", + "node" + ], + "apps/editor/app/import/page.tsx": [ + "Home", + "Import", + "Import a scene", + "Review the file before it becomes a scene. Nothing is created until you confirm." + ], + "apps/editor/app/login/login-form.tsx": [ + "Could not reach the editor.", + "Password", + "Sign in", + "Signing in…", + "That password is not right.", + "Too many attempts. Try again in a minute." + ], + "apps/editor/app/login/page.tsx": [ + "Enter the admin password set for this instance.", + "Sign in to this editor", + "Sign in · Pascal" + ], + "apps/editor/app/page.tsx": [ + "Projects", + "Settings", + "Settings", + "Your projects live here. Start one to begin designing." + ], + "apps/editor/app/project/[id]/page.tsx": [ + "All projects", + "Project not found", + "There is no project with id", + "on this editor." + ], + "apps/editor/app/settings/page.tsx": [ + "About", + "Account", + "Agent token", + "Agents and scripts send the PASCAL_SCENE_API_TOKEN value in an Authorization: Bearer header.", + "All projects", + "Configured", + "Connect an agent", + "Data directory", + "Documentation", + "Instance", + "Not configured", + "Public URL", + "Scene API", + "Set PASCAL_SCENE_API_TOKEN on this instance, then send it in an Authorization: Bearer header from agents and scripts.", + "Settings", + "Settings · Pascal", + "Sign out", + "Signed in as the instance admin.", + "Source code", + "Version", + "development", + "{publicUrl}/api/scenes", + "~/.pascal/data" + ], + "apps/editor/app/setup/page.tsx": [ + "Set an admin password in the environment and restart. Until then, nothing is served from this address.", + "Set up · Pascal", + "This editor is reachable but not protected yet" + ], + "apps/editor/components/app-shell.tsx": [ + "Pascal home" + ], + "apps/editor/components/build-tab.tsx": [ + "Add Gate", + "Add Open Passage", + "Add to rooms", + "Choose Gate or Open Passage, then click its position on any fence. Find placed gates and openings under their fence in the scene graph.", + "Click a lineset to lay the line beside it.", + "Double gate", + "Features & extensions", + "Fence features", + "Follow lineset", + "Gate", + "Gate style", + "Horizontal boards", + "Hover a fence to preview. Click to place. Esc cancels. Leave room between openings.", + "Leaves", + "Liquid Line", + "MEP", + "Match fence style", + "Match fence style", + "Match fence style", + "Off", + "On", + "Open rails", + "Opening", + "Opening width (m)", + "Outdoor", + "Picket", + "Roof type", + "Rooms already create their floor and ceiling. Use these for platforms and one-off structure.", + "Search build tools and kitchen items", + "Search tools, fridge, sink…", + "Select a curved wall to match its radius and arc.", + "Single gate", + "Slab, ceiling, column…", + "Solid privacy", + "Trace a line alongside an existing lineset (F).", + "Vertical slats", + "← All build tools" + ], + "apps/editor/components/create-project-button.tsx": [ + "Creating…", + "Failed to create project", + "Failed to create project ({status})", + "New project" + ], + "apps/editor/components/editor-navbar.tsx": [ + "All projects", + "All projects", + "Preview", + "Project name", + "Rename project", + "Share" + ], + "apps/editor/components/embed-viewer.tsx": [ + "Made with Pascal", + "This project could not be shown in 3D here. Try a browser with hardware acceleration turned on." + ], + "apps/editor/components/kitchen-browser.tsx": [ + "Add to kitchen", + "Add {label}", + "Choose an item, then click to place it. Appliances include their cabinetry.", + "Find in this project · {length}", + "Find {?}", + "Kitchen cabinet", + "No matching kitchen items placed yet.", + "Select and show in scene" + ], + "apps/editor/components/scene-card.tsx": [ + "Actions for {name}", + "Cancel", + "Cancel", + "Could not reach the editor.", + "Could not reach the editor.", + "Delete", + "Delete", + "Delete failed ({status})", + "Delete project?", + "Deleting…", + "Project name", + "Rename", + "Rename failed ({status})", + "Rename project", + "Save", + "Saving…", + "This project changed since the page loaded. Reload and try again.", + "This project changed since the page loaded. Reload and try again.", + "node", + "nodes", + "· Updated", + "“{name}” will be removed from this editor, along with everything in it." + ], + "apps/editor/components/scene-loader.tsx": [ + "Another session saved first — refresh?", + "Build", + "Dismiss", + "Items", + "Paint", + "Reload", + "Scene", + "Settings", + "Your changes haven't been saved. Reload to pick up the latest version." + ], + "apps/editor/components/share-panel.tsx": [ + "Anyone with the link can view this project. They cannot edit it.", + "Copied", + "Copy", + "Could not read the sharing state.", + "Could not turn off the public link.", + "Could not turn on the public link.", + "Embed code", + "Embed code", + "Embed link", + "Hide controls", + "Link", + "Only the 3D view, no header or toolbar.", + "Open", + "Public link", + "Share project" + ], + "packages/editor/src/components/editor-2d/floorplan-measurement-tool-layer.tsx": [ + "ALIGN ", + "Create", + "Extrusion height", + "Extrusion height" + ], + "packages/editor/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx": [ + "Wall end not joined", + "{?}{opacity:0.4}" + ], + "packages/editor/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx": [ + "Cannot read \"{?}\"" + ], + "packages/editor/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx": [ + "override" + ], + "packages/editor/src/components/editor/capture-level-picker.tsx": [ + "Levels in the shot", + "Whole building" + ], + "packages/editor/src/components/editor/delete-confirmation-dialog.tsx": [ + "Add walls", + "Add walls to {room}?", + "Adds {?} · the room loses {?} m² of floor.", + "Cancel", + "Delete", + "Delete", + "Delete all", + "Delete items", + "Delete {?}?", + "Delete {?}?", + "Remove", + "Remove", + "Remove all", + "Remove the ceiling from {room}?", + "Remove the floor from {room}?", + "Remove walls", + "Remove walls from {room}?", + "Removes the ceiling.", + "Removes the floor.", + "This removes every selected element. You can undo the deletion while it remains in the editor history.", + "{hosted} {?} on it. Keep {it} at {its} height, or delete {it} too." + ], + "packages/editor/src/components/editor/first-person-controls.tsx": [ + "Lights", + "Place a spawn point from the Build tab to control where walkthrough starts.", + "door", + "door button", + "elevator button", + "item", + "window" + ], + "packages/editor/src/components/editor/floating-action-menu.tsx": [ + "Axis", + "R/T rotate", + "System", + "register", + "registers", + "run", + "runs", + "⌥ axis", + "⚠ no equipment" + ], + "packages/editor/src/components/editor/floorplan-mode-coordinator.tsx": [ + "Dismiss", + "Switch to Expert" + ], + "packages/editor/src/components/editor/floorplan-panel.tsx": [ + "Any decimal works. Use the known real length, not the drawn value.", + "Cancel", + "Centimeters", + "Click one end of a distance you know — e.g. a dimension printed on the plan", + "Click the other end of that distance", + "Drawn line", + "Enter a valid length to preview the scale.", + "Enter the real-world length of the line you just drew. The image will resize to match it.", + "Feet", + "Image will scale {?}x from the first point.", + "Inches", + "Meters", + "Real length", + "Ref {?}", + "Save Scale", + "Set overlay scale", + "Switch to a building level to view and edit the floorplan.", + "ctrl", + "e.g. 3.5, 180cm or 5'11\"", + "panel", + "resize", + "rotate", + "set scale" + ], + "packages/editor/src/components/editor/footprint-height-handle.tsx": [ + "Floor top", + "Foundation", + "footprint-height" + ], + "packages/editor/src/components/editor/index.tsx": [ + "Back to home", + "Camera controls hint", + "Dismiss", + "Dismiss camera controls hint", + "Expand sidebar", + "Left click", + "Middle click", + "Reload editor", + "Right click", + "Scroll wheel", + "Space", + "The editor scene failed to render", + "You can retry the scene or return home without reloading the whole app shell." + ], + "packages/editor/src/components/editor/join-walls-pill.tsx": [ + "Join walls" + ], + "packages/editor/src/components/editor/node-action-menu.tsx": [ + "Curve", + "Cut out", + "Duplicate", + "Edit mesh", + "Find in catalog", + "Group selection", + "Move", + "Rotate left", + "Rotate right", + "Ungroup selection" + ], + "packages/editor/src/components/editor/open-wall-ends-3d-layer.tsx": [ + "Wall end not joined" + ], + "packages/editor/src/components/editor/open-wall-ends-hint.tsx": [ + "Show" + ], + "packages/editor/src/components/editor/quick-measurement-card.tsx": [ + "Click another surface to replace", + "Click surface to pin", + "Live lens", + "Pinned", + "{?} {?} measurements" + ], + "packages/editor/src/components/editor/riser-diagram-panel.tsx": [ + "DWV plumbing isometric", + "DWV riser diagram", + "No drain, waste, or vent pipes yet. Draw plumbing to see the riser diagram.", + "Riser Diagram", + "Vent", + "Waste" + ], + "packages/editor/src/components/editor/room-controls.tsx": [ + "Add stairs", + "Divide room" + ], + "packages/editor/src/components/editor/room-handles-2d.tsx": [ + "Floor height", + "Lower the floor", + "Raise the floor" + ], + "packages/editor/src/components/editor/room-handles.tsx": [ + "Floor" + ], + "packages/editor/src/components/editor/snapshot-capture-overlay.tsx": [ + " freezes this exact camera angle as a reusable reference for renders & videos.", + "Area", + "Capture", + "Capture", + "Capturing…", + "Close capture mode", + "Crop", + "Drag the area you want to capture", + "Drone", + "Esc to cancel", + "Field of view", + "Format", + "Lens", + "Orbit", + "Preset · square", + "Reset field of view", + "Saved", + "Standard", + "Switch back to orbit to drag a capture area", + "Take snapshot", + "Take snapshot", + "Viewport", + "Walk", + "snapshot" + ], + "packages/editor/src/components/editor/views/host-view.tsx": [ + "\"{label}\" crashed", + "Loading…", + "This view isn't available" + ], + "packages/editor/src/components/editor/views/plugins-view.tsx": [ + "About", + "Add focused tools and content to", + "Adds a new tool panel to the editor.", + "Adds a new tool panel to the editor.", + "All plugins", + "All plugins", + "Browse", + "By", + "By {name}", + "Can access", + "Category", + "Close details", + "Create a Pascal plugin", + "Credits", + "Doesn't use credits", + "Install", + "Install", + "Install in {projectName}", + "Installed", + "Installed in {projectName}", + "Manage", + "No plugins here yet.", + "No plugins match “{?}”{?}.", + "Not installed", + "Open {label}", + "Plugin categories", + "Plugins", + "Runs in the editor. No Pascal services, no outside accounts.", + "Search plugins", + "Search plugins", + "Undo", + "Uninstall", + "Uses this project's credits", + "View plugin", + "installed", + "this project", + "uninstalled", + "{label} details" + ], + "packages/editor/src/components/editor/views/view-bar.tsx": [ + "Auto", + "Browse plugins", + "Close split (\\)", + "Close {label}", + "Ground", + "Keep the ground solid", + "More views", + "Pane {?} views", + "Pin to the view bar", + "Pin {label}", + "Solid", + "Split", + "Split (\\)", + "Swap panes", + "Swap panes", + "Unpin from the view bar", + "Unpin {label}", + "Veil the ground over a level below grade", + "Views" + ], + "packages/editor/src/components/point-ask/bubble.tsx": [ + "Ask Pascal", + "Ask Pascal", + "Dictate", + "Drag to move it aside", + "No picture", + "Queue", + "Queue", + "Remove the area", + "Remove {name}", + "Send", + "Stop dictating", + "What Pascal will see" + ], + "packages/editor/src/components/point-ask/hover-chip.tsx": [ + "Pascal is working on this", + "⌥ the wall" + ], + "packages/editor/src/components/point-ask/mode-pill.tsx": [ + "Before", + "Point at anything to ask Pascal", + "· Esc to leave" + ], + "packages/editor/src/components/point-ask/pins.tsx": [ + "After", + "After", + "Change more", + "Hold to see before", + "Keep", + "Open in chat", + "Open in chat", + "Redo", + "See why", + "Sent to Pascal", + "Showing before", + "Try again", + "Undo", + "Undo anyway", + "You've changed the scene since. Undoing also removes those changes." + ], + "packages/editor/src/components/point-ask/point-ask-layer.tsx": [ + "{?}{?}\n.pa-layer[data-calm] .pa-hlabel{transform:none;transition-property:opacity}\n.pa-layer[data-calm] .pa-send,.pa-layer[data-calm] .pa-dict,.pa-layer[data-calm] .pa-btn,.pa-layer[data-calm] .pa-hold{transform:none;transition-property:opacity,color,background}\n.pa-layer[data-calm] .pa-pk,.pa-layer[data-calm] .pa-edgemark{transition:none}\n" + ], + "packages/editor/src/components/procedural-items/inspector.tsx": [ + "Adjust design", + "Back", + "Edit parts", + "Invalid attachment", + "Invalid edit", + "Invalid placement", + "Invalid position", + "Invalid reset", + "Position", + "Reset this part", + "pos" + ], + "packages/editor/src/components/tools/distribution/distribution-run-tool.tsx": [ + "Length:", + "Tab: direction", + "type a length" + ], + "packages/editor/src/components/ui/action-menu/camera-actions.tsx": [ + "Orbit Left", + "Orbit Left", + "Orbit Right", + "Orbit Right", + "Top View", + "Top View" + ], + "packages/editor/src/components/ui/action-menu/control-modes.tsx": [ + "Delete", + "Select" + ], + "packages/editor/src/components/ui/action-menu/follow-pascal-pill.tsx": [ + "Resume following" + ], + "packages/editor/src/components/ui/action-menu/follow-pascal-toggle.tsx": [ + "Follow Pascal: {?}" + ], + "packages/editor/src/components/ui/action-menu/furnish-tools.tsx": [ + "Appliance", + "Bathroom", + "Furniture", + "Kitchen", + "Outdoor" + ], + "packages/editor/src/components/ui/action-menu/measurement-control.tsx": [ + "Angle", + "Angular dimension", + "Arc length", + "Area", + "Center mark", + "Chord dimension", + "Continuous dimension", + "Coordinate dimensions", + "Diameter dimension", + "Distance", + "Floor plan", + "Linear dimension", + "Measure: {selectedLabel}", + "Measure: {selectedLabel}", + "Measurement options", + "Measurement type", + "Perimeter", + "Radius dimension", + "Smart", + "Volume" + ], + "packages/editor/src/components/ui/action-menu/point-ask-button.tsx": [ + "Point and ask", + "Point and ask (C)", + "Point and ask works in the 3D view" + ], + "packages/editor/src/components/ui/action-menu/structure-tools.tsx": [ + "Ceiling", + "Column", + "DWV Pipe", + "Door", + "Duct", + "Duct Fitting", + "Elevator", + "Fence", + "Gable Roof", + "HVAC Unit", + "Lineset", + "Liquid Line", + "Pipe Fitting", + "Register", + "Shelf", + "Slab", + "Spawn Point", + "Stairs", + "Trap", + "Wall", + "Window", + "Zone" + ], + "packages/editor/src/components/ui/action-menu/view-toggles.tsx": [ + "Delete guide image", + "Delete scan", + "Delete {noun}", + "Guide image settings", + "Guide image {?}", + "Guide images", + "Guide images", + "Guides", + "Guides: {?}", + "Hide reference floor", + "Hide {?}", + "No lower floor available.", + "Opacity", + "Opacity", + "Opacity", + "Opacity", + "Reference floor", + "Reference floor", + "Reference floor settings", + "Reference floor: {selectedLevelName}", + "Reference settings", + "References", + "References: {?}", + "Riser diagram", + "Scan settings", + "Scan {?}", + "Scans", + "Scans", + "Scans", + "Scans: {?}", + "Show reference floor", + "Show {?}", + "Upload GLB meshes as scan references or blueprint images as guide references.", + "Upload GLB meshes as scan references or blueprint images as guide references.", + "Upload GLB meshes as scan references or blueprint images as guide references.", + "Upload GLB meshes as scan references or blueprint images as guide references.", + "Upload scan or guide image", + "below", + "guide image", + "on this level", + "on this level", + "on this level", + "scan" + ], + "packages/editor/src/components/ui/command-palette/editor-commands.tsx": [ + "Add Level", + "Ceiling Tool", + "Copy Share Link", + "Delete Level", + "Delete Selection", + "Door Tool", + "Exit unit focus", + "Export 3D Model (GLB)", + "Export Scene (JSON)", + "Focus unit: {?}", + "Go to Level", + "Item Tool", + "Material Paint", + "New unit", + "Redo", + "Rename Level", + "Sculpt Terrain", + "Slab Tool", + "Stair Tool", + "Switch to Rendered", + "Switch to Solid", + "Take Screenshot", + "Take Snapshot", + "Toggle Fullscreen", + "Undo", + "Wall Mode", + "Wall Tool", + "Window Tool", + "Zone Tool" + ], + "packages/editor/src/components/ui/command-palette/index.tsx": [ + "Command Palette", + "Filter options…", + "Go to Level", + "No commands found.", + "Rename Level", + "Rename to", + "Search actions…", + "Type a new name above…", + "Type a new name…", + "Wall Mode", + "back", + "close", + "navigate", + "select" + ], + "packages/editor/src/components/ui/controls/finish-swatch.tsx": [ + "Default", + "Default" + ], + "packages/editor/src/components/ui/controls/material-paint-panel.tsx": [ + "Add material", + "Add material", + "Click a painted part of a wall, floor or ceiling to remove it, or a painted surface to reset it to its default.", + "Erase", + "Erase: click a painted part to remove it, or a painted surface to reset it", + "No custom materials yet — add one with +.", + "Paint", + "Pick material", + "Pick: click a surface to paint with its material (or hold Alt / Option)", + "Polygon: click the points of a shape on a floor or ceiling", + "Rectangle: press anywhere on a wall, floor or ceiling, drag to the opposite corner", + "Scene materials", + "Whole surface: a click paints the surface under it" + ], + "packages/editor/src/components/ui/controls/material-picker.tsx": [ + "New material" + ], + "packages/editor/src/components/ui/controls/material-properties-editor.tsx": [ + "Back", + "Color", + "Double", + "Front", + "Metalness", + "Opacity", + "Roughness", + "Side" + ], + "packages/editor/src/components/ui/controls/scene-material-list.tsx": [ + "Delete", + "Delete", + "Duplicate", + "Duplicate", + "Edit", + "Edit", + "Paint with", + "Paint with", + "Used by", + "part", + "parts" + ], + "packages/editor/src/components/ui/controls/slider-control.tsx": [ + "Mixed" + ], + "packages/editor/src/components/ui/controls/terrain-sculpt-panel.tsx": [ + "Clear terrain", + "Click the ground to pick its height as the target.", + "Every flatten stroke levels toward this height.", + "Flatten", + "Level lot", + "Lower", + "No target yet — the first click samples the ground under it.", + "Pick target height from the ground", + "Raise", + "Round", + "Size", + "Smooth", + "Softness", + "Square", + "Strength", + "Target" + ], + "packages/editor/src/components/ui/floating-level-selector.tsx": [ + "10 ft", + "8 ft", + "9 ft", + "? All walls, floors, and objects on this level will be permanently removed.", + "Add a floor under the roof", + "Add level above", + "Add level below", + "Are you sure you want to delete", + "Cancel", + "Delete", + "Delete level", + "Delete level", + "Delete level", + "Drag to reorder", + "Duplicate level", + "Duplicate with options...", + "Exit unit focus", + "Exit unit focus", + "Focused unit has zones here", + "Ground line: the levels under it are below grade", + "Insert level here", + "Level height", + "Level height", + "Paste copied selection", + "Reorder {levelName}", + "Seat the building so this floor stands at grade; the floors under it are below grade", + "Set as ground floor", + "The ground floor stands {?} {displayUnit} {?} grade. Seat the ground floor 8 in over grade", + "The ground level cannot be deleted", + "This floor stands at grade", + "Unit" + ], + "packages/editor/src/components/ui/helpers/building-helper.tsx": [ + "Cancel", + "Place building", + "Rotate" + ], + "packages/editor/src/components/ui/helpers/contextual-helper-panel.tsx": [ + "Cancel placement", + "Continuation — click or press C to cycle", + "Drag to draw fence", + "Fence type — click or press T to switch between straight, curved and freehand", + "Fewer shortcuts", + "Finish curve (or double-click)", + "Grid step — click or tap Ctrl to cycle", + "Grid: {?} m", + "Hover a painted surface to erase", + "Hover a surface to paint", + "Hover a surface to pick its material", + "More shortcuts", + "Place gate on a fence", + "Place passage on a fence", + "Select a material to paint", + "Snapping mode — click or press Shift to cycle", + "Snapping: {?}", + "Straight continuation is unavailable for curved or freehand fences", + "Straight fence continuation — click or press C to toggle", + "Straight: Continuous", + "Straight: Single", + "{verb} scope — click or press Shift to cycle" + ], + "packages/editor/src/components/ui/helpers/helper-manager.tsx": [ + "Add point", + "Add point · click the first to close", + "Add point · click the first to close", + "Add point · click the first to close", + "Brush size", + "Cancel", + "Cancel", + "Cancel", + "Cancel", + "Cancel", + "Cancel", + "Cancel", + "Cancel", + "Cancel", + "Cancel", + "Cancel", + "Cancel", + "Cancel picking", + "Cancel stroke", + "Done", + "Done", + "Draw the mezzanine", + "Draw the opening", + "Draw the terrace", + "Exit smart measure", + "Finish", + "Finish", + "Finish at the edge", + "Free move", + "Free move · slide doors and windows", + "Hold to pick a material", + "Inspect surface dimensions", + "Paint with this material", + "Pick target height", + "Pin measurement lens", + "Place", + "Place inside the room", + "Press anywhere, drag to the opposite corner", + "Raise or lower", + "Remove a painted part, or reset a surface", + "Remove last point", + "Remove last point", + "Remove last point", + "Remove last point", + "Resize", + "Resize", + "Rotate the selection ±45°", + "Rotate ±45°", + "Rotate ±45°", + "Set one corner, then the opposite", + "Set one corner, then the opposite" + ], + "packages/editor/src/components/ui/helpers/item-helper.tsx": [ + "Cancel", + "Force place", + "Place", + "Rotate" + ], + "packages/editor/src/components/ui/helpers/registered-tool-helper.tsx": [ + "Guided constraints bypassed" + ], + "packages/editor/src/components/ui/item-catalog/catalog-items.tsx": [ + "Intensity", + "Intensity", + "Intensity" + ], + "packages/editor/src/components/ui/level-duplicate-dialog.tsx": [ + "Cancel", + "Choose what to copy from", + "Duplicate", + "Duplicate Level", + "Everything", + "Structure + furniture", + "Structure + materials", + "Structure only", + "Structure with the current material and finish assignments.", + "Structure, finishes, and placed items, without guide references.", + "Structure, materials, furniture, and references.", + "Walls, slabs, roofs, stairs, windows, and doors without finishes." + ], + "packages/editor/src/components/ui/panels/collections/collections-popover.tsx": [ + "Cancel", + "Collection name…", + "Collections", + "Delete", + "Delete", + "Delete \"{name}\"?", + "New", + "No collections yet. Create one to group elements together.", + "Rename" + ], + "packages/editor/src/components/ui/panels/floor-foundation-panel.tsx": [ + "A thicker floor grows upward: the rooms below keep their height.", + "Edge finish", + "Floor plate", + "Floor plate {thicknessLabel} thick, resting on the walls below", + "Floor thickness", + "Floor top", + "Floor top +", + "Foundation finish", + "Foundation height", + "Not to scale", + "On the ground", + "Raised on a foundation", + "Slab", + "Slab thickness", + "Slab {thicknessLabel} thick on a {foundationLabel} foundation, floor top {?} above the ground", + "Slab {thicknessLabel} thick on the ground, floor top {?} above the ground", + "The floor's outside edge, all around the footprint.", + "The foundation stands on the ground; the slab sits on it.", + "The slab sits on the foundation (or the ground): a thicker slab raises the floor top and everything on it.", + "Under the floor" + ], + "packages/editor/src/components/ui/panels/floor-opening-panel.tsx": [ + " · Drag its corners to change the shape.", + " · Shape follows its owner.", + "A hatch in the mezzanine: it cuts only the mezzanine’s floor.", + "Delete", + "Done", + "Elevator", + "On {?}, as one void with this opening.", + "Owned by", + "Staircase", + "There is no floor above this storey.", + "There is no storey below." + ], + "packages/editor/src/components/ui/panels/mobile-panel-sheet.tsx": [ + "Close" + ], + "packages/editor/src/components/ui/panels/mobile-selection-bar.tsx": [ + "Delete", + "Duplicate", + "Edit properties", + "Edit {resolvedLabel}", + "Move" + ], + "packages/editor/src/components/ui/panels/multi-height-mode.tsx": [ + "Currently {currentLabel}", + "Custom height", + "Follows level", + "Height", + "Top" + ], + "packages/editor/src/components/ui/panels/multi-parametric-inspector.tsx": [ + "{label} (session only). Plain click reselects all members. Not saved with the project." + ], + "packages/editor/src/components/ui/panels/multi-selection-panel.tsx": [ + "Delete", + "Duplicate", + "Group", + "Group (Ctrl/Cmd+G)", + "Merge windows", + "Merge windows into a corner window: both take the height, sill and type of the one you selected first", + "Merge windows into one across both: it keeps the type and style of the one you selected first", + "Select building", + "Select the whole building to move all its levels", + "Ungroup", + "Ungroup (Ctrl/Cmd+Shift+G)", + "Unwrap the corner", + "Unwrap the corner: both windows stay, each framed at the corner", + "{label} (session only). Plain click reselects all members. Not saved with the project.", + "{length} selected" + ], + "packages/editor/src/components/ui/panels/panel-manager.tsx": [ + "Selection" + ], + "packages/editor/src/components/ui/panels/panel-wrapper.tsx": [ + "Close {title}", + "Collapse panel", + "Collections", + "Expand panel", + "Manage collections…", + "Open {title}", + "“{title}” hit an error and was unloaded for this session." + ], + "packages/editor/src/components/ui/panels/parametric-field-control.tsx": [ + "Mixed" + ], + "packages/editor/src/components/ui/panels/parametric-inspector.tsx": [ + "Actions", + "Delete", + "Delete", + "Delete with contents", + "Move" + ], + "packages/editor/src/components/ui/panels/reference-panel.tsx": [ + "Cancel", + "Capture", + "Capture", + "Center", + "Clear Scale", + "Click both ends of a known distance on the plan, then type its real length.", + "Delete", + "Draw a line over a known dimension on the plan, then type its real length to scale the image exactly.", + "Edit Scale", + "Guide Image", + "Hide", + "Hide", + "Hide Scale", + "Image", + "Lock", + "Move", + "Opacity", + "Overlay image unavailable. Replace the image to restore it.", + "Position", + "Quick Actions", + "Reference Scale", + "Replace", + "Replacing...", + "Reset Image Scale", + "Reset Rotation", + "Rotation", + "Scale & Opacity", + "Set Scale", + "Show", + "Show", + "Show Scale", + "Unlock", + "XYZ", + "pos", + "pos", + "pos", + "rot", + "scale" + ], + "packages/editor/src/components/ui/panels/room-construction-rows.tsx": [ + " (shared with {?})", + "+ Add mezzanine", + "+ New floor", + "A drawn slab: set its thickness and finishes here. It has no footprint height or foundation.", + "Built on", + "Built on", + "Built with", + "Ceiling", + "Close with walls", + "Edit shape", + "Floor", + "Floor is a drawn slab: ", + "Floor is several drawn slabs, mostly ", + "Floor thickness", + "Hand-drawn ceiling", + "Height", + "Height", + "Include floor", + "Include walls", + "Include {?}", + "Keep outside dimensions", + "Keep outside dimensions", + "No floor", + "Not included", + "Replace", + "Select", + "Shape", + "Sits on:", + "This floor is a drawn slab: select it to edit or delete it.", + "Turning walls off keeps the ones shared with", + "Use existing", + "Walls", + "Walls", + "Walls thicken inward, so the building's outer size stays the same." + ], + "packages/editor/src/components/ui/panels/room-openings-field.tsx": [ + "Cut opening", + "Done", + "Edit floor", + "Elevator opening", + "No openings", + "No openings yet. Cut one for a stair void or a hatch.", + "Opening {?}", + "Stair opening" + ], + "packages/editor/src/components/ui/panels/room-panel.tsx": [ + "Add stairs", + "Add to a unit", + "Area", + "Built with", + "Ceiling", + "Delete room", + "Edit shape", + "Floor", + "Height", + "Hide code", + "Loading…", + "Mezzanine", + "Mezzanine", + "Mezzanine name", + "Mezzanine names", + "New unit", + "None", + "Perimeter", + "Picking it adds a number", + "Remove", + "Remove from", + "Rename", + "Room", + "Room", + "Room", + "Room colour — used in the plan and labels", + "Room name", + "Room names", + "Scripted finish", + "Shape", + "Show mezzanine names", + "Show room names", + "Stairs", + "Unit", + "Unit", + "Unit", + "Unit", + "Use", + "View code", + "unit", + "{?} · Rename", + "{used} on this level", + "· Close its walls to build its floor and ceiling." + ], + "packages/editor/src/components/ui/panels/shape-choice.tsx": [ + "Polygon", + "Rectangle" + ], + "packages/editor/src/components/ui/panels/wall-region-list.tsx": [ + "Delete region", + "Delete region", + "Paint regions" + ], + "packages/editor/src/components/ui/primitives/dialog.tsx": [ + "Close" + ], + "packages/editor/src/components/ui/primitives/error-boundary.tsx": [ + "Something went wrong", + "Try again" + ], + "packages/editor/src/components/ui/primitives/opacity-control.tsx": [ + "Opacity" + ], + "packages/editor/src/components/ui/primitives/sheet.tsx": [ + "Close" + ], + "packages/editor/src/components/ui/primitives/shortcut-token.tsx": [ + "Left click", + "Left click", + "Middle click", + "Right click", + "Scroll" + ], + "packages/editor/src/components/ui/primitives/sidebar.tsx": [ + "Displays the mobile sidebar.", + "Sidebar", + "Toggle Sidebar", + "Toggle Sidebar", + "Toggle Sidebar" + ], + "packages/editor/src/components/ui/scene-loader.tsx": [ + "Nothing was changed. Check your connection and try again.", + "This project couldn't be loaded", + "Try again" + ], + "packages/editor/src/components/ui/sidebar/icon-rail.tsx": [ + "Settings", + "Site" + ], + "packages/editor/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx": [ + "All", + "Community", + "Library", + "Mine", + "No results for “{search}”", + "Search..." + ], + "packages/editor/src/components/ui/sidebar/panels/items-panel/index.tsx": [ + "All", + "Community", + "Library", + "Mine", + "No results for “{search}”", + "Search..." + ], + "packages/editor/src/components/ui/sidebar/panels/settings-panel/animation-speed-setting.tsx": [ + "Animation speed", + "Animation speed", + "Builds play ×{speed} as fast.", + "Higher plays a build faster; it applies to the next build.", + "The pace builds were made at.", + "Turn the construction animation on to use this." + ], + "packages/editor/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx": [ + "Audio settings", + "Audio settings", + "Master volume", + "Mute this tab", + "Radio volume", + "Sound effects", + "Unmute this tab", + "Volume levels apply to every tab. Muting applies to this tab only." + ], + "packages/editor/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx": [ + "Construction animation", + "Construction animation", + "Framing", + "Framing needs the Architect plugin.", + "Full", + "Full, plus the framing members dropping in first, where the framing plugin draws them.", + "Off", + "Pieces drop into place with dust, the roof assembles, one sound per phase.", + "Simple", + "Walls rise and the rest grows in, piece by piece. No falls, no dust, no sound.", + "What the AI builds appears at once." + ], + "packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx": [ + "(GLB/USDZ only)", + "3D model", + "Add basement", + "Add floor", + "Anyone with the link can view it.", + "Cast shadows from lights.", + "Choose which procedural content is baked into model files. GLB and USDZ use the textured portable path; STL, OBJ and IFC remain geometry-only.", + "Clear and start new", + "Control who can open this project.", + "Copied", + "Copy", + "Copy project ID", + "Danger zone", + "Dark", + "Default mode", + "Download the scene or hand it to another app.", + "Exclude hidden furniture and other hidden scene nodes", + "Expert mode", + "Explore scene graph", + "Export", + "Export options", + "Export visible nodes only", + "Export {label}", + "Exporting {label}…", + "Floor plan", + "Floors", + "Full floor plan", + "Generate thumbnail", + "Generating…", + "Include in file", + "Include {label} in GLB and USDZ", + "Include {label} in model files", + "Inspect how nodes are nested in this scene.", + "Interface theme", + "Keep a JSON copy of the scene, or restore one.", + "Light", + "Load build", + "Needs the", + "No floors yet", + "No optional procedural content is present.", + "Only you can view it.", + "Optional static surroundings are included only in GLB and USDZ.", + "Pascal add-on for Blender", + "Preferences", + "Preparing", + "Preparing floor-plan PDF…", + "Project", + "Project ID", + "Project ID copied", + "Public project", + "Reference this project from the API, MCP tools, or support.", + "Refresh the preview shown in your project list.", + "Remove floor", + "Remove {?}", + "Removes every node from this scene. This cannot be undone.", + "Rendering options for this editor session.", + "Save and load", + "Save build", + "Scene Graph", + "Scene graph", + "Select a floor to make it active. The ground floor is always kept as the building datum.", + "Send to Blender", + "Shadows", + "Sharing", + "Show 3D scans", + "Show floor plans", + "Sound levels, the construction animation and keyboard shortcuts.", + "Structure only", + "System", + "System follows your device’s appearance.", + "The ground floor cannot be removed", + "Thumbnail", + "Try again", + "USDZ", + "Viewer", + "Viewer surroundings", + "Viewer surroundings are excluded unless selected separately below.", + "Visible in the public viewer.", + "Visible in the public viewer.", + "Visible nodes only", + "Warning: {modelExportWarning}", + "file…", + "running in an open Blender." + ], + "packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx": [ + "Camera", + "Direct Manipulation", + "Drawing Tools", + "Editor Navigation", + "Item Placement", + "Keyboard shortcuts", + "Keyboard shortcuts", + "Modes & History", + "Selection", + "Shortcuts are context-aware. Guided constraints are enabled by default; hold Shift during an active gesture to build freely." + ], + "packages/editor/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx": [ + "Buildings", + "Cancel", + "Cannot import this file", + "Ceilings", + "Doors", + "Floor area", + "Hide", + "Items", + "Levels", + "Ready to import", + "Replace current scene", + "Scans", + "Schema details (", + "Show", + "Show {hiddenWarningCount} more", + "Sites", + "Slabs", + "Structure", + "The file contains no recognised nodes.", + "Walls", + "Windows", + "Zones", + "error", + "node", + "node", + "warning" + ], + "packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx": [ + "3D print export failed.", + "Export 3D print 3MF", + "Export 3D print STL" + ], + "packages/editor/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx": [ + "Add new level", + "Building" + ], + "packages/editor/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx": [ + "Collection colour", + "Collections", + "Delete collection" + ], + "packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx": [ + "Add level", + "Add point", + "Area:", + "Base elevation", + "Building", + "Building", + "Camera snapshot", + "Camera snapshot", + "Camera snapshot", + "Camera snapshot", + "Capture", + "Clear selection", + "Clear snapshot", + "Clear snapshot", + "Clear snapshot", + "Clear snapshot", + "Close walls around a space to make a room.", + "Delete", + "Delete", + "Delete level", + "Draw a zone", + "Duplicate", + "Duplicate level", + "Duplicate level with options", + "Duplicate with options...", + "Elements", + "Guide", + "Hide", + "Level", + "New unit", + "New unit", + "No buildings yet", + "No elements on this level", + "No levels yet", + "Other zones", + "Perimeter:", + "Property Line", + "Rooms", + "Select a level to view content", + "Show", + "Site", + "Site", + "Take snapshot", + "Take snapshot", + "Take snapshot", + "Take snapshot", + "The ground level cannot be deleted", + "Units", + "Update snapshot", + "Update snapshot", + "Update snapshot", + "Update snapshot", + "Upload scan/floorplan", + "Uploading {uploadingType}... {progress}%", + "View snapshot", + "View snapshot", + "View snapshot", + "View snapshot", + "{length} objects selected" + ], + "packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx": [ + "Camera snapshot", + "Clear snapshot", + "Hide", + "Show", + "Take snapshot", + "Update snapshot", + "View snapshot" + ], + "packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node-drag.tsx": [ + "* { cursor: grabbing !important; }" + ], + "packages/editor/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx": [ + "Delete unit", + "Focused", + "Remove from unit", + "Room", + "Zone" + ], + "packages/editor/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx": [ + "In {unitName}" + ], + "packages/editor/src/components/ui/sidebar/panels/zone-panel/index.tsx": [ + "Actions", + "Add one", + "Camera snapshot", + "Clear snapshot", + "Delete", + "Delete with contents", + "No zones on this level.", + "Save to catalog", + "Select a level to view and create zones", + "Take snapshot", + "Update snapshot", + "View snapshot" + ], + "packages/editor/src/components/ui/sidebar/use-plugin-panels.tsx": [ + "\"{label}\" plugin crashed", + "Loading…", + "Plugins", + "This panel hit an error and was unloaded for this session. The rest of the editor is unaffected — reload to try again." + ], + "packages/editor/src/components/ui/slider-demo.tsx": [ + "Temperature", + "Temperature" + ], + "packages/editor/src/components/walkthrough-hud.tsx": [ + "Click", + "free cursor", + "or", + "or click to", + "to exit", + "to resume" + ], + "packages/editor/src/lib/floor-region-session.ts": [ + "Pick a material first", + "Pick a material first", + "Pick a material first" + ], + "packages/nodes/src/block/panel.tsx": [ + "Actions", + "Add slot", + "Apply {label} to selected faces", + "Block", + "Delete", + "Delete material slot and use Body on its faces", + "Delete {label} slot", + "Move", + "Position", + "Rename {label} slot", + "Select one or more faces first", + "Slots", + "face", + "faces" + ], + "packages/nodes/src/block/selection.tsx": [ + "Adjust Last Operation · F9", + "Adjust {label}", + "Adjust {label} (F9)", + "BEVEL", + "Bevel selected edges", + "Clear selection", + "Close last operation panel", + "Could not preview loop cut", + "Delete components", + "Dissolve selection", + "Edge select (2)", + "Extrude selected faces", + "Face select (3)", + "Finish edit mode (Tab)", + "Inset selected faces", + "Invert selection", + "LOOP CUT", + "Loop Cut and Slide", + "Loop cut count", + "Loop cut requires a connected ring of quad faces", + "Merge vertices", + "Mesh operations", + "Move selection", + "Operations", + "Repeat {label}", + "Rotate selection", + "Select all", + "Selection actions", + "Selection and more", + "Shift+R", + "Transform selected components (G / R / S)", + "Vertex select (1)", + "X-ray selection" + ], + "packages/nodes/src/box-vent/panel.tsx": [ + "Actions", + "Base Flange", + "Base Height", + "Base Inset", + "Box", + "Box Vent", + "Cap", + "Cap Height", + "Corner Bevel", + "Delete", + "Depth", + "Dimensions", + "Dome", + "Dome Curvature", + "Duplicate", + "Gap Height", + "Height", + "Hood Overhang", + "Move", + "Position", + "Rotation", + "Style", + "Top Taper", + "Width" + ], + "packages/nodes/src/cabinet/compartment-card.tsx": [ + "Baskets", + "Bottom", + "Bottom Freezer", + "Bowls", + "Burners on", + "Chimney", + "Chimney", + "Door", + "Door", + "Double", + "Double", + "Double", + "Drawer", + "Drawer", + "Drawers", + "Flip hinge", + "Flip the door hinge to the opposite side", + "Fridge", + "Gas", + "Glass", + "Glass", + "Height", + "Hob", + "Induction", + "Layout", + "Left", + "Micro", + "Oven", + "Pullout", + "Rack", + "Right", + "Shelf", + "Shelf", + "Shelves", + "Shelves inside", + "Single", + "Single", + "Sink", + "Style", + "Style", + "Surface", + "Top", + "Top Freezer", + "Top grate", + "Tray", + "Type", + "Washer", + "Wire" + ], + "packages/nodes/src/cabinet/essential-controls.tsx": [ + "Bottom freezer", + "Cooktop type", + "Door direction", + "Double", + "Double", + "Double", + "Fridge type", + "Gas", + "Glass", + "Induction", + "Item options", + "Left hinge", + "Right hinge", + "Single", + "Single", + "Sink layout", + "Top freezer" + ], + "packages/nodes/src/cabinet/kitchen-collection.tsx": [ + "Choose {label}", + "No kitchen items match. Try “fridge”, “sink”, or “cabinet”.", + "Search fridge, sink, cabinets…", + "Search kitchen items" + ], + "packages/nodes/src/cabinet/kitchen-module-actions.tsx": [ + "Add beside", + "Add beside selected item", + "Add {label}", + "An upper cabinet attached to this item will be removed to make room.", + "Cabinet", + "Cancel", + "Inserts on the {action} of the selected item.", + "Kitchen run preview, highlighted item is the proposed change", + "Left side", + "Replace selected item", + "Replace with {label}", + "Replace with…", + "Replaces only the selected item.", + "Right side", + "This", + "Unable to update this kitchen.", + "item cannot fit within the current run and its wall or corner limits. Move an end cabinet or free space, then try again.", + "moves {?}" + ], + "packages/nodes/src/cabinet/panel.tsx": [ + "Add chimney", + "Add compartment", + "Add wall cabinet", + "Advanced", + "Appliance", + "Appliance front", + "Auto", + "Bar", + "Base Cabinet", + "Cabinet Type", + "Cabinet construction, compartments, and opening preview.", + "Cabinet panel", + "Center", + "Close", + "Close cabinet", + "Close cabinet", + "Common wall-cabinet heights. Use the slider below for a custom height.", + "Compartments", + "Cutout", + "Depth", + "Dimensions", + "Dimensions", + "Edit kitchen run", + "Fill to ceiling", + "Finish", + "Finish", + "Finished height extends", + "Front construction", + "Handles", + "Height", + "Height", + "Height preset", + "Hole", + "Inset", + "Kitchen", + "Kitchen run", + "Knob", + "Mounting", + "None", + "None", + "Open", + "Open Animation", + "Open cabinet", + "Overlay", + "Panel-ready", + "Planning checks", + "Play", + "Play animation", + "Position", + "Raised Arch", + "Remove wall cabinet", + "Replace with…", + "Reveal gap", + "Shaker", + "Shared countertop, plinth, and finishes", + "Slab", + "Solid matching front between the cabinet and wall. Countertop and toe-kick continue across this filler.", + "Standard width", + "Stop", + "Stop animation", + "Stop animation", + "Style", + "Style", + "Tall Cabinet", + "Top", + "Top / Ceiling", + "Top Cabinet", + "Top depth", + "Top height", + "Trim / Soffit", + "Uses the cabinet front style, reveal, handle, and front material settings below.", + "Wall filler", + "Width", + "Width", + "mm above the ceiling." + ], + "packages/nodes/src/cabinet/run-panel.tsx": [ + "Add left", + "Add right", + "Align cabinets & handles", + "Applies depth, carcass, plinth, and countertop thickness to this run.", + "Auto", + "Back", + "Balances standard base cabinets while keeping appliance and corner-filler widths fixed.", + "Bar", + "Bar counter", + "Bar depth", + "Bar height", + "Carcass height", + "Center", + "Common wall-cabinet heights. Use the slider below for a custom height.", + "Copies", + "Copies include the source cabinet structure and any attached wall cabinet. Existing modules stay fixed; the requested array must fit in the available run space.", + "Corner fillers cannot be used as array sources", + "Countertop depth", + "Countertop height", + "Create array", + "Cutout", + "Depth", + "Direction", + "Duplicate along run", + "Equalize widths", + "Finish & align", + "Finished back", + "Finished exposed ends", + "Fit wall fillers", + "Fits 50–150 mm gaps at run ends beside perpendicular walls.", + "Fronts", + "Handles", + "Height preset", + "Hole", + "Inset", + "Island & Bar", + "Kitchen run", + "Knob", + "Left", + "Left", + "Match base heights, backs, reveals and handles. Align upper cabinets; preserve tall appliances and custom compartments.", + "Module {?}", + "Modules", + "Mounting", + "No eligible module", + "None", + "Overlay", + "Plinth height", + "Position", + "Raised Arch", + "Reveal gap", + "Right", + "Right", + "Seating overhang", + "Shaker", + "Shared Plinth & Countertop", + "Show countertop", + "Show plinth", + "Slab", + "Source module", + "Spacing", + "Standard dimensions", + "Style", + "Style", + "Top", + "Use this module as the array source", + "Use {?} as array source", + "Waterfall ends" + ], + "packages/nodes/src/ceiling/panel.tsx": [ + "(Editing)", + "Add Hole", + "Area", + "Auto", + "Ceiling", + "Currently", + "Custom height", + "Cut opening", + "Done", + "Follows level", + "Height", + "Height", + "High (3.0m)", + "High (9'0\")", + "Hole", + "Holes", + "Info", + "Limited by the level to", + "Low (2.4m)", + "Low (8'0\")", + "Manual", + "Move", + "No holes", + "Standard (2.5m)", + "Standard (8'6\")", + "Taller than this level ({?} available). Raise the level height first.", + "pts ·", + "— raise the level height for a taller ceiling." + ], + "packages/nodes/src/chimney/panel.tsx": [ + "Above Ridge", + "Actions", + "Back", + "Bands", + "Cap", + "Chimney", + "Chimney Type", + "Corbeled", + "Corner Bevel", + "Count", + "Cricket", + "Cutout Offset", + "Delete", + "Depth", + "Depth", + "Diameter", + "Diameter", + "Double", + "Extent", + "Extent", + "Flat", + "Flues", + "Footprint", + "Front", + "Height", + "Height", + "Height", + "Height", + "Height", + "Hollow Depth", + "Length", + "None", + "None", + "None", + "None", + "None", + "Offset", + "Overhang", + "Panels", + "Position", + "Rectangular", + "Rotation", + "Round", + "Round", + "Shoulder", + "Side Margin", + "Simple", + "Single", + "Sloped", + "Spacing", + "Square", + "Square", + "Stepped", + "Style", + "Tapered", + "Thickness", + "Thickness", + "Top Offset", + "Wall Thickness", + "Wall Thickness", + "Width" + ], + "packages/nodes/src/column/panel.tsx": [ + "A-Frame", + "Actions", + "Apply preset...", + "Apply proportion...", + "Bottom Depth", + "Bottom Height", + "Bottom Spread", + "Bottom Step Spread", + "Bottom Tiers", + "Bottom Width", + "Bottom Width", + "Box Frame", + "Brace Depth", + "Brace Width", + "Bulge", + "Bulged", + "Column", + "Connector Plates", + "Delete", + "Depth", + "Dimensions", + "Edge Softness", + "End Width", + "End Width", + "Ends", + "Fork Spread", + "Heavy", + "Height", + "Height and footprint are controlled by the lean-to extension. Rotate or change the support style here; resize from the parent lean-to.", + "Hourglass", + "K Brace", + "Move", + "Neck Width", + "No Bottom", + "No Top", + "Plinth Thickness", + "Portal Frame", + "Preset", + "Rectangular", + "Ring Pairs", + "Ring Spread", + "Ring Thickness", + "Round", + "Round Band Width", + "Rounded Bottom", + "Rounded Top", + "Segment Twist", + "Shaft", + "Shaft Corner Radius", + "Shaft Width", + "Shape", + "Short / Stout", + "Simple Block Bottom", + "Simple Top", + "Single Strut", + "Slender", + "Square", + "Square Plinth Bottom", + "Standard", + "Stepped Bottom", + "Stepped Top", + "Straight", + "Taper", + "Tapered", + "Top Depth", + "Top Height", + "Top Spread", + "Top Step Spread", + "Top Tiers", + "Top Width", + "Top Width", + "Transform", + "Trestle", + "Tripod", + "Twist Segments", + "V Support", + "Vertical", + "Waist", + "Width", + "X Brace", + "Y Support", + "Yaw" + ], + "packages/nodes/src/construction-dimension/panel.tsx": [ + "Above line", + "Actions", + "Architectural tick", + "Center mark", + "Centered on line", + "Centerline", + "Construction Dimension", + "Controlled by foundation", + "Datum policy", + "Delete", + "Dimension", + "Dot", + "Drawing coordination", + "Extension gap", + "Extension overshoot", + "Feature count", + "Filled arrow", + "Finish face", + "Foundation controller", + "Foundation dimension", + "Imperial precision", + "Linked dimensions reuse the controller's associative anchors and update with it.", + "Meters", + "Metric notation", + "Millimeters", + "Mode", + "Nearest 1/16 inch", + "Nearest 1/2 inch", + "Nearest 1/4 inch", + "Nearest 1/8 inch", + "Nearest inch", + "No foundation dimensions", + "Notation", + "Omitted", + "Open arrow", + "Prefix", + "Primary drawing", + "Segment numbers are one-based and apply only in this drawing view.", + "Shown", + "Standards", + "Structural face", + "Suffix", + "Terminator", + "Text override", + "Text position", + "Use measured value", + "Wall face", + "{activeDrawingLabel} presentation", + "{activeDrawingLabel} suppressed segments" + ], + "packages/nodes/src/cupola/panel.tsx": [ + "Actions", + "Cupola", + "Delete", + "Depth", + "Dimensions", + "Dome", + "Duplicate", + "Finial", + "Height", + "Move", + "No Finial", + "Position", + "Pyramid", + "Rotation", + "Style", + "Width" + ], + "packages/nodes/src/door/panel.tsx": [ + "+ Add Segment", + "- Remove", + "Actions", + "All", + "All", + "Arch", + "Arch", + "Arch Height", + "Arch Height", + "Bar Height", + "Columns", + "Content Padding", + "Corner Radius", + "Corner Radius", + "Custom: the segments match no style.", + "Delete", + "Depth", + "Depth", + "Dimensions", + "Direction", + "Divider", + "Door", + "Door", + "Door Closer", + "Duplicate", + "Empty", + "Enable Handle", + "Enable Threshold", + "Flip Side", + "Fold", + "Frame", + "Garage", + "Garage", + "Glass", + "Handle", + "Handle Side", + "Hardware", + "Height", + "Height", + "Height", + "Height", + "Hinges Side", + "Horizontal", + "Individual", + "Individual", + "Inset", + "Inward", + "Left", + "Left", + "Left", + "Move", + "Open", + "Open", + "Open", + "Opening", + "Opening Shape", + "Outward", + "Panel", + "Panel", + "Panels", + "Panels", + "Panic Bar", + "Pocket", + "Position", + "Rail", + "Rect", + "Rect", + "Reveal Radius", + "Reveal Radius", + "Right", + "Right", + "Right", + "Roll-up", + "Rounded", + "Rounded", + "Sectional", + "Segment", + "Segments", + "Size is limited to the wall, including clearance for the opening frame.", + "Slide", + "Style", + "Swing", + "Thickness", + "Threshold", + "Tilt-up", + "Top Shape", + "Type", + "Vertical", + "Width", + "wall" + ], + "packages/nodes/src/dormer/panel-actions-section.tsx": [ + "Actions", + "Delete", + "Duplicate", + "Move" + ], + "packages/nodes/src/dormer/panel-position-section.tsx": [ + "Position", + "Rotation" + ], + "packages/nodes/src/dormer/panel-windows-section.tsx": [ + "Add Window", + "Edit window", + "Edit {?}", + "Increase the dormer width to add another window.", + "Move", + "Move window", + "Move {?}", + "No windows", + "Window {?}", + "Windows ({length})", + "front" + ], + "packages/nodes/src/dormer/panel.tsx": [ + "Depth", + "Dimensions", + "Dormer", + "Dormer", + "Dutch", + "Flat", + "Gable", + "Gambrel", + "Hip", + "Mansard", + "Pitch Direction", + "Pitch Rise", + "Rise Back", + "Rise Front", + "Roof Height", + "Roof Type", + "Section", + "Shed", + "Wall Height", + "Width", + "Windows" + ], + "packages/nodes/src/downspout/inspector-editors.tsx": [ + "Position" + ], + "packages/nodes/src/duct-fitting/inspector-editors.tsx": [ + "Swap W/H", + "Swap width and height" + ], + "packages/nodes/src/duct-fitting/tool.tsx": [ + "Axis", + "Hover a duct face that fits the door", + "Place access door", + "R/T rotate", + "⌥ axis" + ], + "packages/nodes/src/duct-terminal/tool.tsx": [ + "M surface", + "Mount {mountLabel}", + "R/T rotate", + "Snapped to duct" + ], + "packages/nodes/src/elevator/panel.tsx": [ + "Access", + "Actions", + "Cab", + "Cab Height", + "Center opening", + "Default Floor", + "Delete", + "Depth", + "Destination", + "Disabled", + "Disabled", + "Door Height", + "Door Time", + "Door Type", + "Door Width", + "Doors", + "Duplicate", + "Dwell", + "Elevator", + "From", + "Glass", + "Glass frame", + "Motion", + "Move", + "Opening Style", + "Position", + "Rotation", + "Segmented panel", + "Service", + "Service", + "Service", + "Shaft", + "Shaft Depth", + "Shaft Style", + "Shaft Width", + "Single left", + "Single right", + "Solid", + "Solid panel", + "Speed", + "Stop {stopOrder}", + "To", + "Wall Thickness", + "Width", + "Yaw" + ], + "packages/nodes/src/eyebrow-vent/panel.tsx": [ + "Actions", + "Back height", + "Delete", + "Depth", + "Dimensions", + "Duplicate", + "Eyebrow Vent", + "Half-round", + "Height", + "Louvers", + "Move", + "Position", + "Rotation", + "Scoop", + "Slant-box", + "Style", + "Width" + ], + "packages/nodes/src/fence-feature/actions.tsx": [ + "Close", + "Open" + ], + "packages/nodes/src/fence-feature/inspector.tsx": [ + "Board width", + "Bracing", + "Distance from start", + "Fence unavailable.", + "Frame width", + "Gate height", + "Gate style", + "Ground clearance", + "Hinge side", + "Hinges and latch", + "Horizontal boards", + "Infill spacing", + "Jamb posts", + "Keep the opening inside the fence and clear of other gates or passages.", + "Leaf thickness", + "Leaves", + "Left leaf proportion", + "Match fence height", + "Match fence style", + "Open rails", + "Opening angle", + "Opening width", + "Picket", + "Post height", + "Remove gate", + "Remove opening", + "Solid privacy", + "Swing direction", + "Vertical slats" + ], + "packages/nodes/src/fence/inspector-editors.tsx": [ + "Add point after", + "Choose a slab or shaped surface on this level.", + "Choose a surface", + "Corner", + "Curve", + "Curved", + "Distribution controls {subject}. Spacing is measured between centers. The available width limits the count so pieces do not overlap.", + "Edit path", + "Extend path", + "Follow highest surface", + "Follow selected surface", + "Hold starting height", + "Join", + "Length", + "Picket rail projection", + "Point", + "Remove point", + "Set each span to straight or curved, then drag its points and curve handles in either view.", + "Smooth", + "Span to point", + "Straight", + "Surface", + "Surface behavior", + "points ·" + ], + "packages/nodes/src/fence/move-endpoint-tool.tsx": [ + "Detach endpoint", + "Drag endpoint" + ], + "packages/nodes/src/gutter/downspouts-panel.tsx": [ + "Add Downspout", + "Downspout {?}", + "Downspouts", + "Remove downspout" + ], + "packages/nodes/src/hvac-equipment/tool.tsx": [ + "R/T rotate" + ], + "packages/nodes/src/item/authored-params.tsx": [ + "Couldn't rebuild with these values.", + "Parameters", + "Rebuilding in Blender…", + "Rebuilding…", + "Reset parameters", + "Reset to default", + "Reset {?}" + ], + "packages/nodes/src/item/panel.tsx": [ + "Actions", + "Delete", + "Dimensions", + "Duplicate", + "Info", + "Move", + "Position", + "Rotation", + "Scale", + "Uniform Scale", + "XYZ", + "pos", + "pos", + "pos", + "rot", + "rot", + "scale", + "scale", + "scale", + "scale" + ], + "packages/nodes/src/liquid-line/tool.tsx": [ + "Click to trace this lineset run", + "Follow: hover a lineset" + ], + "packages/nodes/src/measurement/selection.tsx": [ + "Align " + ], + "packages/nodes/src/measurement/tool.tsx": [ + "Create", + "Extrusion height", + "Extrusion height" + ], + "packages/nodes/src/pipe-fitting/tool.tsx": [ + "Axis", + "R/T rotate", + "⌥ axis" + ], + "packages/nodes/src/pipe-trap/tool.tsx": [ + "R/T rotate", + "{diameter}\" Trap" + ], + "packages/nodes/src/ridge-vent/panel.tsx": [ + "Actions", + "Delete", + "Dimensions", + "Duplicate", + "End Caps", + "Flanged", + "Height", + "Length", + "Move", + "Open", + "Position", + "Ridge Vent", + "Rotation", + "Shingled", + "Standard", + "Style", + "Width" + ], + "packages/nodes/src/roof-segment/panel.tsx": [ + "Actions", + "Angle", + "Arc", + "Auto gutters", + "Auto ridge vent", + "Barrel", + "Clipped version", + "Conical", + "Conical Shape", + "Deck Thick.", + "Delete", + "Depth", + "Diameter", + "Done editing", + "Drainage", + "Duplicate", + "Dutch", + "Eave overhang", + "Edit footprint", + "End overhang", + "Fascia", + "Fascia height", + "Flat", + "Footprint", + "Gable", + "Gable overhang", + "Gambrel", + "Hip", + "Kink Depth", + "Kink Height", + "Length", + "Mansard", + "Move", + "Pitch", + "Position", + "Reset", + "Roof Segment", + "Roof Type", + "Rotation", + "Shape", + "Shape", + "Shape", + "Shed", + "Shingle Thick.", + "Span", + "Start Angle", + "Structure", + "Top Rake Length", + "Top Rake Thick.", + "Trim", + "Waist Height", + "Waist Height", + "Waist Length", + "Waist Width", + "Waist Width", + "Wall", + "Wall Height", + "Wall Thick.", + "Width" + ], + "packages/nodes/src/roof/barrel-panel.tsx": [ + "Arch bays", + "Barrel arch", + "Flat panes follow a circular arch. Rise is measured from the eaves.", + "Rise" + ], + "packages/nodes/src/roof/glazing-controls.tsx": [ + "Alignment", + "Center", + "Choose a layout to edit the grid.", + "Empty", + "End", + "Fixed spacing", + "Glass", + "Layout", + "Max spacing", + "Maximum spacing", + "Mixed", + "Panel count", + "Panels", + "Solid", + "Spacing", + "Start", + "{title} alignment" + ], + "packages/nodes/src/roof/glazing-panel-editor.tsx": [ + "Choose a panel to override its default infill.", + "Column", + "Custom", + "Default", + "Individual panels", + "Panel infill", + "Reset to default", + "Roof face", + "Row", + "Selected panel infill", + "column", + "columns", + "row", + "rows" + ], + "packages/nodes/src/roof/glazing-panel.tsx": [ + "Changes apply to all {length} roof segments.", + "Choose one roof system to edit settings for all selected segments.", + "Close the roof ends", + "Default panel infill", + "Default panel infill", + "Divides each roof face into columns.", + "Divides the length of each arch bay into columns.", + "End infill fills the space above the walls. Edit room walls and ceilings separately.", + "Frame color", + "Frame depth", + "Frame width", + "Framing", + "Glass", + "Glass tint", + "Glazed", + "Glazing is available for gable, straight shed, and barrel roofs.", + "Grid across the roof", + "Grid along the length", + "Grid along the slope", + "Grid within each arch bay", + "Infill", + "Opacity", + "Pane thickness", + "Roof system", + "Roughness", + "Spacing is measured on the slope, not its horizontal projection.", + "Standard", + "Subdivides each flat arch bay. Arch bays controls the divisions around the curve." + ], + "packages/nodes/src/roof/panel.tsx": [ + " The floor's {roofs} roofs move together.", + "Actions", + "Add Chimney", + "Add Cupola", + "Add Dormer", + "Add Eyebrow Vent", + "Add Gutter", + "Add Skylight", + "Add Solar Panel", + "Add Vent", + "Box", + "Box Vent {?}", + "Chimney {?}", + "Currently", + "Custom", + "Delete", + "Dormer {?}", + "Draw Segment", + "Duplicate", + "Elements", + "Follows walls", + "Gutter {?}", + "Level", + "Move", + "Move to the roof level", + "Move to {?}", + "On", + "Position", + "Ridge", + "Ridge Vent {?}", + "Roof", + "Rotation", + "Segment {?}", + "Segments", + "Skylight {?}", + "Solar Panel {?}", + "Turbine", + "Turbine Vent {?}", + "box vent", + "chimney", + "dormer", + "gutter", + "ridge vent", + "skylight", + "solar panel", + "turbine vent", + "with its walls, so it hides that floor's plan in solo and exploded views." + ], + "packages/nodes/src/shared/item-interaction-actions.tsx": [ + "Turn light off", + "Turn light on" + ], + "packages/nodes/src/shared/run-hanger-inspector.tsx": [ + "Automatic support", + "Hanger", + "Hanger {?} position in metres", + "Hanger {?} support", + "Individual hangers", + "Missing support", + "No support within reach. Move the hanger or choose another host.", + "Position (m)", + "Positions are measured along each path segment. Changing spacing regenerates the numbered slots.", + "Reveal support", + "Skip", + "· segment" + ], + "packages/nodes/src/shared/system-check-panel.tsx": [ + "About these checks", + "All findings", + "All systems", + "Connections", + "Drainage", + "Error", + "Errors", + "Filter system checks", + "Hangers", + "Intersections", + "Locate {?} ({id})", + "Missing item", + "No findings", + "No findings in this category", + "Open ends", + "Open ends and separate branches may be intentional. Intersection checks use bounding boxes; inspect openings and clearances.", + "Selected connected system", + "Selected:", + "Separate branches", + "Show all findings", + "System checks", + "System findings", + "Warning", + "Warnings", + "run", + "runs" + ], + "packages/nodes/src/site/panel.tsx": [ + "1200 W Cass St, Tampa, FL", + "APN", + "Address", + "Auto — most north-facing edge", + "Contour interval (site plan and 3D)", + "County", + "Find parcel", + "Finding…", + "Front (street) edge", + "Lot area", + "No surveyed lines — contours are drawn from the sculpted ground.", + "North rotation", + "Orientation", + "Parcel", + "Project look", + "Save current look", + "Save the current lighting and display as this project's default. Personal display changes stay in this browser.", + "Setbacks", + "Show ground", + "Show the contour lines in 3D", + "Site", + "Source", + "Source / citation", + "Street address", + "Terrain", + "Use project look", + "Zone", + "e.g. RS-60", + "e.g. Tampa LDC §27-156", + "every 12 in", + "every 24 in", + "every 6 in", + "ft (", + "none", + "side", + "survey", + "surveyed lines at" + ], + "packages/nodes/src/skylight/panel.tsx": [ + "Actions", + "Along X", + "Along Z", + "Bottom", + "Curb", + "Cutout Offset", + "Delete", + "Depth", + "Dimensions", + "Frame", + "Glass Thickness", + "Height", + "Height", + "Lantern Height", + "Left", + "Motor", + "Motor Housing", + "No", + "No Motor", + "Open", + "Open", + "Opening Angle", + "Position", + "Right", + "Rotation", + "Skylight", + "Thickness", + "Top", + "Top Scale", + "Track Width", + "Type", + "Width", + "Yes" + ], + "packages/nodes/src/slab/panel.tsx": [ + "(Editing)", + "Add Hole", + "Area", + "Auto", + "Base", + "Cut hatch", + "Cut opening", + "Depth", + "Done", + "Elevation", + "Extends the perimeter down to terrain. The flat surface, base, and thickness stay unchanged.", + "Fixed", + "Floor", + "Follows terrain", + "Foundation", + "Hole", + "Holes", + "Info", + "Manual", + "Move", + "No holes", + "Rim", + "Slab", + "Standard (2\")", + "Standard (5cm)", + "Sunken (15cm)", + "Sunken (6\")", + "Surface", + "Thick (15cm)", + "Thick (6\")", + "Thickness", + "Thin (1\")", + "Thin (2cm)", + "pts ·" + ], + "packages/nodes/src/solar-panel/panel.tsx": [ + "Actions", + "Array", + "Auto-fit to roof", + "Columns", + "Custom — dimensions don't match any preset", + "Delete", + "Flip orientation", + "Flush", + "Frame depth", + "Frame thickness", + "Gap X", + "Gap Y", + "Height", + "Mounting", + "Panel", + "Preset", + "Rows", + "Solar Panel", + "Standoff", + "Tilt angle", + "Tilted", + "Width" + ], + "packages/nodes/src/spawn/panel.tsx": [ + "Actions", + "Delete", + "Facing", + "Move", + "Position", + "Spawn Point", + "Yaw" + ], + "packages/nodes/src/stair-segment/panel.tsx": [ + "Actions", + "Attachment", + "Delete", + "Dimensions", + "Duplicate", + "Editing switches the stair to Custom rise", + "Equal angle", + "Equal going", + "Fill to floor", + "Flight", + "Flight shape", + "Front", + "Height", + "Inner gap", + "Landing", + "Left", + "Left turn", + "Length", + "Move", + "Position", + "Right", + "Right turn", + "Rotation", + "Stair Segment", + "Steps", + "Straight", + "Structure", + "Thickness", + "Type", + "Walking line offset", + "Width", + "Winder" + ], + "packages/nodes/src/stair/construction-controls.tsx": [ + "Body", + "Center stringer", + "Closed risers", + "Inherit", + "Nosing", + "Open", + "Original", + "Riser thickness", + "Side stringers", + "Solid", + "Stringer depth", + "Stringer width", + "Tread finish", + "Tread thickness", + "Waist", + "Waist thickness" + ], + "packages/nodes/src/stair/panel.tsx": [ + "Actions", + "Add flight", + "Add landing", + "Apply {label} layout", + "Auto Cutout", + "Balusters", + "Boards", + "Both", + "Cable", + "Center Column", + "Clockwise", + "Connected to {?}. Move the stair to connect to another edge.", + "Construction", + "Counterclockwise", + "Currently", + "Curved", + "Custom rise", + "Deck", + "Delete", + "Design targets", + "Destination", + "Drag near a deck, patio, concrete slab, or landing edge to fit the stair.", + "Duplicate", + "Equal angle", + "Equal going", + "Fit To Floor", + "Fit risers and going", + "Fit uniform risers", + "Flight", + "Floors, ceilings and stairs only.", + "Follows deck", + "Follows level", + "From Level", + "Geometry", + "Glass", + "Headroom:", + "Height", + "Inner Radius", + "Inner gap", + "Integrated", + "Landing", + "Landing", + "Layout", + "Left", + "Left turn", + "Measurements", + "Metal", + "Move", + "Moving this stair keeps its current rise, steps, and run.", + "No overhead surface", + "None", + "None", + "None", + "Not evaluated", + "Opening", + "Opening Offset", + "Position", + "Post & rail", + "Railing", + "Right", + "Right turn", + "Rise", + "Rise", + "Rotation", + "Segment {?}", + "Segments", + "Sizing", + "Snap to nearby surfaces", + "Spiral", + "Staircase", + "Step Supports", + "Steps", + "Straight", + "Style", + "Surface connection", + "Sweep", + "Target going", + "Target headroom", + "Target max riser", + "Target minimum going", + "Thickness", + "To", + "Top Landing", + "Type", + "Unavailable", + "Walking line offset", + "Width", + "Winder", + "risers · R" + ], + "packages/nodes/src/stair/railing-controls.tsx": [ + "Both", + "Continuous", + "Floor", + "Handrail diameter", + "Handrail height", + "Handrail inset", + "Left", + "No return", + "None", + "Original layout", + "Post-facing", + "Return length", + "Right", + "Separate handrail", + "Set the length to reach the wall or post.", + "Wall-facing", + "{?} extension" + ], + "packages/nodes/src/turbine-vent/panel.tsx": [ + "Actions", + "Cylinder", + "Delete", + "Diameter", + "Dimensions", + "Duplicate", + "Globe", + "Height", + "Motion", + "Move", + "Neck Height", + "Pause", + "Play", + "Position", + "Rotation", + "Spin Speed", + "Style", + "Turbine Vent", + "Vanes" + ], + "packages/nodes/src/unit/unit-panel.tsx": [ + "Actions", + "Color", + "Delete", + "Focus", + "Gross area", + "Kind", + "Levels", + "Members", + "Members", + "Name", + "No rooms yet. Pick this unit from a room's panel, or draw zones while it is focused.", + "Remove {?} from unit", + "Report", + "Unit", + "Unit" + ], + "packages/nodes/src/wall/actions.tsx": [ + "Merge walls", + "Merge walls", + "Split wall", + "Split wall" + ], + "packages/nodes/src/wall/curtain-wall-panel.tsx": [ + ", limited by frame depth.", + ", row", + "Apply tint", + "Border / opening frame", + "Bottom", + "Bottom row", + "Cancel tint", + "Center", + "Choose a cell or enter its column and row. Use arrow keys to move between cells. Columns run from the wall start; rows count upward.", + "Clear hidden overrides", + "Column", + "Column", + "Column layout", + "Column {?}, row {?}: {type}", + "Construction", + "Continuous mullions and transoms assembled along the wall.", + "Curtain wall panel selection", + "Custom infill", + "Default panel", + "Edit individual panels", + "Effective thickness:", + "Empty", + "Enable panel editing", + "End", + "Fixed spacing", + "Following wall defaults", + "Frame dimensions", + "Framing", + "Fully capped", + "Glass", + "Glass appearance", + "Glass appearance is controlled by the paint tool.", + "Glass tint", + "Glass tint", + "Glazing gap", + "Grid limited to 32 panels per axis. Spacing expands to fit this wall.", + "Horizontal caps", + "Horizontal frame width", + "Layout", + "Low roughness is smooth; high roughness gives a matte surface in rendered shading.", + "Maximum spacing", + "Module joint width", + "None", + "Opacity", + "Opaque band", + "Opaque bands replace a full row with solid panels. Apply their finish with the paint tool.", + "Panel count", + "Panel infill", + "Panel infill", + "Panel thickness", + "Panels", + "Reset this panel", + "Row", + "Row layout", + "Separate framed modules with joints between units.", + "Solid", + "Spacing", + "Start", + "Stick-built", + "Structural glazing", + "Surface roughness", + "Top", + "Top row", + "Uncapped members sit behind the glazing.", + "Unitized", + "Use glass settings instead", + "Use the Door or Window tool to add an opening through the wall.", + "Vertical caps", + "Vertical frame width", + "Wall system", + "{length} custom panels are outside this grid. They return if the grid expands." + ], + "packages/nodes/src/wall/move-endpoint-tool.tsx": [ + "Alt to detach", + "Detaching corner" + ], + "packages/nodes/src/wall/panel.tsx": [ + "Actions", + "Assembly", + "Auto", + "Both", + "Bottom", + "Brick veneer", + "CMU", + "Chair rail", + "Colonial", + "Cove", + "Craft", + "Crown molding", + "Currently", + "Curtain wall", + "Curve", + "Curve", + "Custom", + "Custom height", + "Depth", + "Dimensions", + "Drywall", + "Extends downward to meet the terrain. Height and top stay unchanged.", + "Exterior", + "Fiber cement", + "Fill to terrain", + "Flat", + "Flat", + "Flat", + "Follows level", + "Frame depth", + "Framing", + "Gypsum", + "Height", + "Height", + "Hide code", + "Hide {?}", + "ICF", + "Interior", + "Lap siding", + "Layer thicknesses follow the 2021 IRC assembly data. Drafting aid, not engineering — verify with the authority having jurisdiction.", + "Layered", + "Length", + "Loading…", + "Modern", + "None", + "None", + "None", + "None (single layer)", + "OSB", + "Offset", + "Ogee", + "Ogee", + "Ogee", + "Partition: the interior finish is applied to both faces.", + "Picture", + "Plaster", + "Plywood", + "Preset", + "Reference", + "Remove", + "Round", + "Scripted finish", + "Sheathing", + "Shoe", + "Show {?}", + "Side", + "Side A", + "Side B", + "Skirting", + "Standard", + "Steel studs", + "Step", + "Stone veneer", + "Stucco", + "Thickness", + "Thickness", + "Top", + "Total thickness", + "Total thickness", + "Unverified: {presetNote}", + "View code", + "Wall", + "Wall type", + "Which face is outside is undetermined (no room detected on either side) — the exterior layers are drawn on side B.", + "Wood studs", + "· from assembly" + ], + "packages/nodes/src/window/panel.tsx": [ + "Actions", + "All", + "All", + "Arch", + "Arch", + "Arch Height", + "Arch Height", + "Col Widths", + "Columns", + "Corner", + "Corner Radius", + "Corner Radius", + "Corner post", + "Custom: the panes match no style.", + "Delete", + "Depth", + "Depth", + "Dimensions", + "Divider", + "Divider", + "Down", + "Duplicate", + "Enable Sill", + "Flip Side", + "Frame", + "French", + "Fused glass", + "Grid", + "Height", + "Individual", + "Individual", + "Left", + "Move", + "Opening", + "Opening Shape", + "Other side", + "Position", + "Rect", + "Rect", + "Reveal Radius", + "Reveal Radius", + "Right", + "Rounded", + "Rounded", + "Row Heights", + "Rows", + "Sill", + "Single", + "Size is limited to the wall, including clearance for the opening frame.", + "Style", + "Thickness", + "Thickness", + "This side", + "Top Shape", + "Type", + "Unwrap", + "Up", + "Width", + "Window", + "Window", + "Window Type", + "Wrap the corner", + "Wraps the corner with", + "its partner", + "pos", + "pos" + ], + "packages/nodes/src/zone/quantities-panel.tsx": [ + "Enclosed room", + "Floor surface", + "Footprint only", + "None", + "Not proven", + "Room quantities", + "Top view with zone edge dimensions", + "Unit", + "Unit", + "Unit", + "Volume", + "Wall surface", + "Zone boundary unavailable", + "Zone quantities" + ], + "packages/ui/src/button.tsx": [ + "Hello from your {appName} app!" + ], + "packages/viewer/src/components/viewer/glb-scene.tsx": [ + "Door", + "Item" + ], + "packages/viewer/src/components/viewer/perf-panel.tsx": [ + "Dock panel to the side", + "Performance", + "drag {?} / settle {?} ({settleFrames} frames)", + "last action", + "no timestamp-query", + "perf", + "waiting for samples…", + "{?}ms", + "{?}ms", + "{?}ms (max {?})", + "{?}ms (max {?})", + "{?}ms (max {?})", + "{?}ms ({count}×)", + "{?}ms ({count}×, max {?})", + "{?}ms cpu (max {?})", + "{?}ms {outcome}", + "{fps} fps", + "{fps} fps", + "{geometries} geo {textures} tex {?}", + "{items} items · {instances} inst · {containers} mesh", + "{meshes} mesh {lines} line {lights} light" + ], + "packages/viewer/src/components/viewer/unsupported-gpu-fallback.tsx": [ + "3D viewer unavailable", + "This browser or environment could not initialize WebGPU or WebGL, so Pascal cannot render the 3D scene here. Try opening the editor in a browser with hardware acceleration enabled." + ], + "packages/viewer/src/systems/interactive/control-widget.tsx": [ + "Off", + "On" + ] + } +} diff --git a/i18n/pseudo-surfaces.ts b/i18n/pseudo-surfaces.ts new file mode 100644 index 0000000000..6e78aa7ca2 --- /dev/null +++ b/i18n/pseudo-surfaces.ts @@ -0,0 +1,57 @@ +import type { Page } from 'playwright-core' +import { pseudoLocalize } from '../scripts/i18n' + +/** + * The surface manifest for `bun run i18n:pseudo-audit`. Each migration PR adds + * the surfaces it localized, with the steps that open them, so the audit keeps + * proving them afterwards. `data` lists exact authored or generated values the + * surface shows (project names, numbers with units) that are not UI copy. + */ +export type PseudoSurface = { + name: string + /** A route, or a function that prepares state and returns one. */ + path: string | ((base: string) => Promise) + steps?: (page: Page) => Promise + /** CSS selector limiting the audit; the screenshot always shows the whole viewport. */ + scope?: string + viewport?: { width: number; height: number } + data?: string[] +} + +let projectId: Promise | undefined + +/** One empty project per audit run, named like the app's own default. */ +export function auditProject(base: string): Promise { + projectId ??= fetch(new URL('/api/scenes', base), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Project #1', graph: { nodes: {}, rootNodeIds: [] } }), + }).then(async (response) => { + if (!response.ok) throw new Error(`Could not create the audit project (${response.status})`) + return ((await response.json()) as { id: string }).id + }) + return projectId +} + +export const editorPath = async (base: string) => `/project/${await auditProject(base)}` + +/** Waits for the editor chrome and the 3D canvas to settle. */ +export async function openEditor(page: Page): Promise { + await page.locator('canvas').first().waitFor({ timeout: 120_000 }) + await page.waitForTimeout(1500) +} + +/** The accessible name a control has in en-XA, for locating it by its translated label. */ +export const pseudo = (english: string) => pseudoLocalize(english) + +export async function openInEditor(page: Page, name: string): Promise { + await openEditor(page) + await page.getByRole('button', { name: pseudo(name), exact: true }).first().click() + await page.waitForTimeout(500) +} + +export const surfaces: PseudoSurface[] = [ + { name: 'home', path: '/', data: ['Project #1'] }, + { name: 'settings', path: '/settings' }, + { name: 'editor', path: editorPath, steps: openEditor, data: ['Project #1'] }, +] diff --git a/package.json b/package.json index c28b819148..a4861ae976 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,10 @@ "i18n:check": "bun scripts/i18n.ts check", "i18n:compile": "bun scripts/i18n.ts compile", "i18n:extract": "bun scripts/i18n.ts extract", + "i18n:literals": "bun scripts/i18n-literals.ts", + "i18n:pseudo-audit": "bun scripts/i18n-pseudo-audit.ts", "i18n:translate": "bun scripts/i18n.ts translate", - "test": "turbo run test && bun test scripts/i18n.test.ts apps/editor/proxy.test.ts", + "test": "turbo run test && bun test scripts/i18n.test.ts scripts/i18n-literals.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", @@ -41,7 +43,9 @@ "@typescript/typescript6": "6.0.2", "ai": "^7.0.154", "dotenv-cli": "^11.0.0", + "entities": "^8.1.0", "fast-xml-parser": "^5.11.2", + "playwright-core": "1.64.1", "pofile-ts": "^4.0.3", "turbo": "^2.11.10", "typescript": "npm:@typescript/typescript6@6.0.2", diff --git a/packages/core/src/i18n/index.ts b/packages/core/src/i18n/index.ts index 82f51409a7..e1c81ca903 100644 --- a/packages/core/src/i18n/index.ts +++ b/packages/core/src/i18n/index.ts @@ -1,3 +1,4 @@ +export type { MessageDescriptor } from '@lingui/core' export { formatLength, formatNumber, diff --git a/packages/core/src/i18n/registry.ts b/packages/core/src/i18n/registry.ts index e476e6226c..6e98c675fa 100644 --- a/packages/core/src/i18n/registry.ts +++ b/packages/core/src/i18n/registry.ts @@ -178,8 +178,7 @@ export class PascalI18n { _(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 translated = this.locale === 'en' || Object.hasOwn(this.i18n.messages, id) const runtime = translated ? this.i18n : this.englishI18n if (!translated) warnMissing(id, this.locale) return typeof idOrDescriptor === 'string' diff --git a/packages/editor/package.json b/packages/editor/package.json index 97bf5e8909..d19b1838ab 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -8,6 +8,7 @@ ".": "./src/index.tsx", "./catalog": "./src/components/ui/item-catalog/catalog-items.tsx", "./locales": "./src/i18n/locales.ts", + "./locales/pseudo": "./src/i18n/pseudo-locale.ts", "./procedural-items": "./src/components/procedural-items/inspector.tsx", "./distribution": "./src/components/tools/distribution/distribution-public.ts" }, diff --git a/packages/editor/src/components/viewer/viewer-controls-bar.tsx b/packages/editor/src/components/viewer/viewer-controls-bar.tsx index 9129e7521f..10f6e20f87 100644 --- a/packages/editor/src/components/viewer/viewer-controls-bar.tsx +++ b/packages/editor/src/components/viewer/viewer-controls-bar.tsx @@ -26,6 +26,7 @@ import { Square, SwatchBook, } from 'lucide-react' +import { sceneThemeName } from '../../i18n/scene-theme-name' import { cn } from '../../lib/utils' import { ActionButton } from '../ui/action-menu/action-button' import { FollowPascalToggle } from '../ui/action-menu/follow-pascal-toggle' @@ -69,20 +70,6 @@ function wallModeLabel(mode: keyof typeof wallModeConfig, i18n: PascalI18n): str return i18n._({ id: 'viewer.walls.cutaway', message: 'Cutaway' }) } -function sceneThemeName(id: string, fallback: string, i18n: PascalI18n): string { - if (id === 'studio') return i18n._({ id: 'viewer.theme.studio', message: 'Studio' }) - if (id === 'paper') return i18n._({ id: 'viewer.theme.paper', message: 'Paper' }) - if (id === 'sunset') return i18n._({ id: 'viewer.theme.sunset', message: 'Sunset' }) - if (id === 'overcast') return i18n._({ id: 'viewer.theme.overcast', message: 'Overcast' }) - if (id === 'blueprint') return i18n._({ id: 'viewer.theme.blueprint', message: 'Blueprint' }) - if (id === 'mediterranean') - return i18n._({ id: 'viewer.theme.mediterranean', message: 'Mediterranean' }) - if (id === 'twilight') return i18n._({ id: 'viewer.theme.twilight', message: 'Twilight' }) - if (id === 'night') return i18n._({ id: 'viewer.theme.night', message: 'Night' }) - if (id === 'verdant') return i18n._({ id: 'viewer.theme.verdant', message: 'Verdant' }) - return fallback -} - // Keep the dropdown open when flipping an in-place toggle row. const keepOpen = (event: Event, fn: () => void) => { event.preventDefault() diff --git a/packages/editor/src/i18n/locales/en-XA/messages.ts b/packages/editor/src/i18n/locales/en-XA/messages.ts new file mode 100644 index 0000000000..27e83c36c6 --- /dev/null +++ b/packages/editor/src/i18n/locales/en-XA/messages.ts @@ -0,0 +1 @@ +/*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"app.viewerToolbar.automaticDimensions\":[\"[Åûţöɱáţîç ðîɱéñšîöñš ~~~~~~]\"],\"app.viewerToolbar.camera\":[\"[Çáɱéŕá ~~]\"],\"app.viewerToolbar.cleanPlanDimensionsAppearWithSelection\":[\"[Çļéáñ þļáñ; ðîɱéñšîöñš áþþéáŕ ŵîţĥ šéļéçţîöñ ~~~~~~~~~~~~]\"],\"app.viewerToolbar.collapseSidebar\":[\"[Çöļļáþšé šîðéƀáŕ ~~~~~]\"],\"app.viewerToolbar.crispOpaqueEdgeLines\":[\"[Çŕîšþ, öþáǫûé éðĝé ļîñéš ~~~~~~]\"],\"app.viewerToolbar.cutaway\":[\"[Çûţáŵáý ~~~]\"],\"app.viewerToolbar.default\":[\"[Ðéƒáûļţ ~~~]\"],\"app.viewerToolbar.display\":[\"[Ðîšþļáý ~~~]\"],\"app.viewerToolbar.displaySettings\":[\"[Ðîšþļáý šéţţîñĝš ~~~~~]\"],\"app.viewerToolbar.doorWindowMarks\":[\"[Ðööŕ/ŵîñðöŵ ɱáŕķš ~~~~~]\"],\"app.viewerToolbar.edges\":[\"[Éðĝéš ~~]\"],\"app.viewerToolbar.enterVr\":[\"[Éñţéŕ ṼŔ ~~~]\"],\"app.viewerToolbar.exitVr\":[\"[Éẋîţ ṼŔ ~~]\"],\"app.viewerToolbar.expandSidebar\":[\"[Éẋþáñð šîðéƀáŕ ~~~~]\"],\"app.viewerToolbar.expert\":[\"[Éẋþéŕţ ~~]\"],\"app.viewerToolbar.exploded\":[\"[Éẋþļöðéð ~~~]\"],\"app.viewerToolbar.faceOfStud\":[\"[Ƒáçé öƒ šţûð ~~~]\"],\"app.viewerToolbar.faintOutlineOfMajorCreases\":[\"[Ƒáîñţ öûţļîñé öƒ ɱáĵöŕ çŕéášéš ~~~~~~~~]\"],\"app.viewerToolbar.feetInches\":[\"[Ƒééţ & îñçĥéš ~~~]\"],\"app.viewerToolbar.finishedFaces\":[\"[Ƒîñîšĥéð ƒáçéš ~~~~]\"],\"app.viewerToolbar.flatAndFastNoAmbientOcclusion\":[\"[Ƒļáţ áñð ƒášţ — ñö áɱƀîéñţ öççļûšîöñ ~~~~~~~~~]\"],\"app.viewerToolbar.floorPlanAnnotations\":[\"[Ƒļööŕ þļáñ áññöţáţîöñš ~~~~~~]\"],\"app.viewerToolbar.floorPlanMode\":[\"[Ƒļööŕ þļáñ ɱöðé ~~~~]\"],\"app.viewerToolbar.fullAmbientOcclusion\":[\"[Ƒûļļ áɱƀîéñţ öççļûšîöñ ~~~~~~]\"],\"app.viewerToolbar.fullDocumentationAndAnnotationControls\":[\"[Ƒûļļ ðöçûɱéñţáţîöñ áñð áññöţáţîöñ çöñţŕöļš ~~~~~~~~~~~~]\"],\"app.viewerToolbar.fullHeight\":[\"[Ƒûļļ ĥéîĝĥţ ~~~]\"],\"app.viewerToolbar.fullWallThickness\":[\"[Ƒûļļ ŵáļļ ţĥîçķñéšš ~~~~~~]\"],\"app.viewerToolbar.grid\":[\"[Ĝŕîð ~~]\"],\"app.viewerToolbar.levelsMode\":[\"[Ļéṽéļš: \",[\"mode\"],\" ~~]\"],\"app.viewerToolbar.low\":[\"[Ļöŵ ~]\"],\"app.viewerToolbar.magneticSnap\":[\"[Ṁáĝñéţîç šñáþ ~~~~]\"],\"app.viewerToolbar.manual\":[\"[Ṁáñûáļ ~~]\"],\"app.viewerToolbar.manualDimensions\":[\"[Ṁáñûáļ ðîɱéñšîöñš ~~~~~]\"],\"app.viewerToolbar.measurements\":[\"[Ṁéášûŕéɱéñţš ~~~~]\"],\"app.viewerToolbar.meters\":[\"[Ṁéţéŕš ~~]\"],\"app.viewerToolbar.millimeters\":[\"[Ṁîļļîɱéţéŕš ~~~~]\"],\"app.viewerToolbar.n3dMeasurements\":[\"[3Ð ɱéášûŕéɱéñţš ~~~~]\"],\"app.viewerToolbar.noEdgeLines\":[\"[Ñö éðĝé ļîñéš ~~~~]\"],\"app.viewerToolbar.off\":[\"[Öƒƒ ~]\"],\"app.viewerToolbar.on\":[\"[Öñ ~]\"],\"app.viewerToolbar.orthographic\":[\"[Öŕţĥöĝŕáþĥîç ~~~~]\"],\"app.viewerToolbar.perspective\":[\"[Þéŕšþéçţîṽé ~~~~]\"],\"app.viewerToolbar.preview\":[\"[Þŕéṽîéŵ ~~~]\"],\"app.viewerToolbar.previewMode\":[\"[Þŕéṽîéŵ ɱöðé ~~~~]\"],\"app.viewerToolbar.render\":[\"[Ŕéñðéŕ ~~]\"],\"app.viewerToolbar.rendered\":[\"[Ŕéñðéŕéð ~~~]\"],\"app.viewerToolbar.roomLabels\":[\"[Ŕööɱ ļáƀéļš ~~~]\"],\"app.viewerToolbar.sceneTheme\":[\"[Šçéñé ţĥéɱé ~~~]\"],\"app.viewerToolbar.shadows\":[\"[Šĥáðöŵš ~~~]\"],\"app.viewerToolbar.singleWallAxis\":[\"[Šîñĝļé ŵáļļ áẋîš ~~~~~]\"],\"app.viewerToolbar.soft\":[\"[Šöƒţ ~~]\"],\"app.viewerToolbar.solid\":[\"[Šöļîð ~~]\"],\"app.viewerToolbar.solo\":[\"[Šöļö ~~]\"],\"app.viewerToolbar.stack\":[\"[Šţáçķ ~~]\"],\"app.viewerToolbar.stairAnnotations\":[\"[Šţáîŕ áññöţáţîöñš ~~~~~]\"],\"app.viewerToolbar.strong\":[\"[Šţŕöñĝ ~~]\"],\"app.viewerToolbar.structuralCoreFace\":[\"[Šţŕûçţûŕáļ çöŕé ƒáçé ~~~~~~]\"],\"app.viewerToolbar.structuralGridsColumnCenters\":[\"[Šţŕûçţûŕáļ ĝŕîðš & çöļûɱñ çéñţéŕš ~~~~~~~~~]\"],\"app.viewerToolbar.translucent\":[\"[Ţŕáñšļûçéñţ ~~~~]\"],\"app.viewerToolbar.units\":[\"[Ûñîţš ~~]\"],\"app.viewerToolbar.walkthrough\":[\"[Ŵáļķţĥŕöûĝĥ ~~~~]\"],\"app.viewerToolbar.wallCenterline\":[\"[Ŵáļļ çéñţéŕļîñé ~~~~~]\"],\"app.viewerToolbar.wallDimensions\":[\"[Ŵáļļ ðîɱéñšîöñš ~~~~~]\"],\"app.viewerToolbar.wallsLabel\":[\"[Ŵáļļš: \",[\"label\"],\" ~~]\"],\"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\"],\" ţĥéñ ţĥé ƒîŕšţ öñé áĝáîñ ţö çļöšé. Ƒöŕ Ļ-šĥáþéš áñð áñĝļéð ŕööɱš. ~~~~~~~~~~~~~~~~]\"],\"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\"],\" 2Ð ṽîéŵ ~~]\"],\"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/locales/en/messages.po b/packages/editor/src/i18n/locales/en/messages.po index 96b505b693..feef521746 100644 --- a/packages/editor/src/i18n/locales/en/messages.po +++ b/packages/editor/src/i18n/locales/en/messages.po @@ -7,6 +7,262 @@ msgstr "" "X-Generator: @lingui/cli\n" "Language: en\n" +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.automaticDimensions" +msgstr "Automatic dimensions" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.camera" +msgstr "Camera" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.cleanPlanDimensionsAppearWithSelection" +msgstr "Clean plan; dimensions appear with selection" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.collapseSidebar" +msgstr "Collapse sidebar" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.crispOpaqueEdgeLines" +msgstr "Crisp, opaque edge lines" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.cutaway" +msgstr "Cutaway" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.default" +msgstr "Default" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.display" +msgstr "Display" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.displaySettings" +msgstr "Display settings" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.doorWindowMarks" +msgstr "Door/window marks" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.edges" +msgstr "Edges" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.enterVr" +msgstr "Enter VR" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.exitVr" +msgstr "Exit VR" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.expandSidebar" +msgstr "Expand sidebar" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.expert" +msgstr "Expert" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.exploded" +msgstr "Exploded" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.faceOfStud" +msgstr "Face of stud" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.faintOutlineOfMajorCreases" +msgstr "Faint outline of major creases" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.feetInches" +msgstr "Feet & inches" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.finishedFaces" +msgstr "Finished faces" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.flatAndFastNoAmbientOcclusion" +msgstr "Flat and fast — no ambient occlusion" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.floorPlanAnnotations" +msgstr "Floor plan annotations" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.floorPlanMode" +msgstr "Floor plan mode" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.fullAmbientOcclusion" +msgstr "Full ambient occlusion" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.fullDocumentationAndAnnotationControls" +msgstr "Full documentation and annotation controls" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.fullHeight" +msgstr "Full height" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.fullWallThickness" +msgstr "Full wall thickness" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.grid" +msgstr "Grid" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.levelsMode" +msgstr "Levels: {mode}" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.low" +msgstr "Low" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.magneticSnap" +msgstr "Magnetic snap" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.manual" +msgstr "Manual" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.manualDimensions" +msgstr "Manual dimensions" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.measurements" +msgstr "Measurements" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.meters" +msgstr "Meters" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.millimeters" +msgstr "Millimeters" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.n3dMeasurements" +msgstr "3D measurements" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.noEdgeLines" +msgstr "No edge lines" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.off" +msgstr "Off" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.on" +msgstr "On" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.orthographic" +msgstr "Orthographic" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.perspective" +msgstr "Perspective" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.preview" +msgstr "Preview" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.previewMode" +msgstr "Preview mode" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.render" +msgstr "Render" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.rendered" +msgstr "Rendered" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.roomLabels" +msgstr "Room labels" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.sceneTheme" +msgstr "Scene theme" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.shadows" +msgstr "Shadows" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.singleWallAxis" +msgstr "Single wall axis" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.soft" +msgstr "Soft" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.solid" +msgstr "Solid" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.solo" +msgstr "Solo" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.stack" +msgstr "Stack" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.stairAnnotations" +msgstr "Stair annotations" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.strong" +msgstr "Strong" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.structuralCoreFace" +msgstr "Structural core face" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.structuralGridsColumnCenters" +msgstr "Structural grids & column centers" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.translucent" +msgstr "Translucent" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.units" +msgstr "Units" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.walkthrough" +msgstr "Walkthrough" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.wallCenterline" +msgstr "Wall centerline" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.wallDimensions" +msgstr "Wall dimensions" + +#: apps/editor/components/viewer-toolbar.tsx +msgid "app.viewerToolbar.wallsLabel" +msgstr "Walls: {label}" + #: packages/editor/src/i18n/level-name.ts msgid "core.level.basement" msgstr "Basement" @@ -275,31 +531,31 @@ msgstr "Viewer layout" msgid "viewer.stage.split" msgstr "Split" -#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +#: packages/editor/src/i18n/scene-theme-name.ts msgid "viewer.theme.blueprint" msgstr "Blueprint" -#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +#: packages/editor/src/i18n/scene-theme-name.ts msgid "viewer.theme.mediterranean" msgstr "Mediterranean" -#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +#: packages/editor/src/i18n/scene-theme-name.ts msgid "viewer.theme.night" msgstr "Night" -#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +#: packages/editor/src/i18n/scene-theme-name.ts msgid "viewer.theme.overcast" msgstr "Overcast" -#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +#: packages/editor/src/i18n/scene-theme-name.ts msgid "viewer.theme.paper" msgstr "Paper" -#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +#: packages/editor/src/i18n/scene-theme-name.ts msgid "viewer.theme.studio" msgstr "Studio" -#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +#: packages/editor/src/i18n/scene-theme-name.ts msgid "viewer.theme.sunset" msgstr "Sunset" @@ -307,11 +563,11 @@ msgstr "Sunset" msgid "viewer.theme.title" msgstr "Scene theme" -#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +#: packages/editor/src/i18n/scene-theme-name.ts msgid "viewer.theme.twilight" msgstr "Twilight" -#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +#: packages/editor/src/i18n/scene-theme-name.ts msgid "viewer.theme.verdant" msgstr "Verdant" diff --git a/packages/editor/src/i18n/locales/en/messages.ts b/packages/editor/src/i18n/locales/en/messages.ts index 0cd37f56df..4848613578 100644 --- a/packages/editor/src/i18n/locales/en/messages.ts +++ b/packages/editor/src/i18n/locales/en/messages.ts @@ -1 +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 +/*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"app.viewerToolbar.automaticDimensions\":[\"Automatic dimensions\"],\"app.viewerToolbar.camera\":[\"Camera\"],\"app.viewerToolbar.cleanPlanDimensionsAppearWithSelection\":[\"Clean plan; dimensions appear with selection\"],\"app.viewerToolbar.collapseSidebar\":[\"Collapse sidebar\"],\"app.viewerToolbar.crispOpaqueEdgeLines\":[\"Crisp, opaque edge lines\"],\"app.viewerToolbar.cutaway\":[\"Cutaway\"],\"app.viewerToolbar.default\":[\"Default\"],\"app.viewerToolbar.display\":[\"Display\"],\"app.viewerToolbar.displaySettings\":[\"Display settings\"],\"app.viewerToolbar.doorWindowMarks\":[\"Door/window marks\"],\"app.viewerToolbar.edges\":[\"Edges\"],\"app.viewerToolbar.enterVr\":[\"Enter VR\"],\"app.viewerToolbar.exitVr\":[\"Exit VR\"],\"app.viewerToolbar.expandSidebar\":[\"Expand sidebar\"],\"app.viewerToolbar.expert\":[\"Expert\"],\"app.viewerToolbar.exploded\":[\"Exploded\"],\"app.viewerToolbar.faceOfStud\":[\"Face of stud\"],\"app.viewerToolbar.faintOutlineOfMajorCreases\":[\"Faint outline of major creases\"],\"app.viewerToolbar.feetInches\":[\"Feet & inches\"],\"app.viewerToolbar.finishedFaces\":[\"Finished faces\"],\"app.viewerToolbar.flatAndFastNoAmbientOcclusion\":[\"Flat and fast — no ambient occlusion\"],\"app.viewerToolbar.floorPlanAnnotations\":[\"Floor plan annotations\"],\"app.viewerToolbar.floorPlanMode\":[\"Floor plan mode\"],\"app.viewerToolbar.fullAmbientOcclusion\":[\"Full ambient occlusion\"],\"app.viewerToolbar.fullDocumentationAndAnnotationControls\":[\"Full documentation and annotation controls\"],\"app.viewerToolbar.fullHeight\":[\"Full height\"],\"app.viewerToolbar.fullWallThickness\":[\"Full wall thickness\"],\"app.viewerToolbar.grid\":[\"Grid\"],\"app.viewerToolbar.levelsMode\":[\"Levels: \",[\"mode\"]],\"app.viewerToolbar.low\":[\"Low\"],\"app.viewerToolbar.magneticSnap\":[\"Magnetic snap\"],\"app.viewerToolbar.manual\":[\"Manual\"],\"app.viewerToolbar.manualDimensions\":[\"Manual dimensions\"],\"app.viewerToolbar.measurements\":[\"Measurements\"],\"app.viewerToolbar.meters\":[\"Meters\"],\"app.viewerToolbar.millimeters\":[\"Millimeters\"],\"app.viewerToolbar.n3dMeasurements\":[\"3D measurements\"],\"app.viewerToolbar.noEdgeLines\":[\"No edge lines\"],\"app.viewerToolbar.off\":[\"Off\"],\"app.viewerToolbar.on\":[\"On\"],\"app.viewerToolbar.orthographic\":[\"Orthographic\"],\"app.viewerToolbar.perspective\":[\"Perspective\"],\"app.viewerToolbar.preview\":[\"Preview\"],\"app.viewerToolbar.previewMode\":[\"Preview mode\"],\"app.viewerToolbar.render\":[\"Render\"],\"app.viewerToolbar.rendered\":[\"Rendered\"],\"app.viewerToolbar.roomLabels\":[\"Room labels\"],\"app.viewerToolbar.sceneTheme\":[\"Scene theme\"],\"app.viewerToolbar.shadows\":[\"Shadows\"],\"app.viewerToolbar.singleWallAxis\":[\"Single wall axis\"],\"app.viewerToolbar.soft\":[\"Soft\"],\"app.viewerToolbar.solid\":[\"Solid\"],\"app.viewerToolbar.solo\":[\"Solo\"],\"app.viewerToolbar.stack\":[\"Stack\"],\"app.viewerToolbar.stairAnnotations\":[\"Stair annotations\"],\"app.viewerToolbar.strong\":[\"Strong\"],\"app.viewerToolbar.structuralCoreFace\":[\"Structural core face\"],\"app.viewerToolbar.structuralGridsColumnCenters\":[\"Structural grids & column centers\"],\"app.viewerToolbar.translucent\":[\"Translucent\"],\"app.viewerToolbar.units\":[\"Units\"],\"app.viewerToolbar.walkthrough\":[\"Walkthrough\"],\"app.viewerToolbar.wallCenterline\":[\"Wall centerline\"],\"app.viewerToolbar.wallDimensions\":[\"Wall dimensions\"],\"app.viewerToolbar.wallsLabel\":[\"Walls: \",[\"label\"]],\"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 index 9d86e70e75..1cc9827893 100644 --- a/packages/editor/src/i18n/locales/es/messages.po +++ b/packages/editor/src/i18n/locales/es/messages.po @@ -13,6 +13,646 @@ msgstr "" "Language-Team: \n" "Plural-Forms: \n" +# Source-Hash: 3d3f438d5a49eba7 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Automatic dimensions" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.automaticDimensions" +msgstr "Cotas automáticas" + +# Source-Hash: 14622745dbf93f4c +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Camera" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.camera" +msgstr "Cámara" + +# Source-Hash: e6bce525c47ba3ac +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Clean plan; dimensions appear with selection" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.cleanPlanDimensionsAppearWithSelection" +msgstr "Plano limpio; las cotas aparecen al seleccionar" + +# Source-Hash: 61ce356577cfb04a +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Collapse sidebar" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.collapseSidebar" +msgstr "Contraer barra lateral" + +# Source-Hash: 2416a48df28d6205 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Crisp, opaque edge lines" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.crispOpaqueEdgeLines" +msgstr "Aristas nítidas y opacas" + +# Source-Hash: 4da8b3678ed77b45 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Cutaway" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.cutaway" +msgstr "Corte" + +# Source-Hash: 4ca0e0e73229540c +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Default" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.default" +msgstr "Predeterminado" + +# Source-Hash: a313661ef9dd0a65 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Display" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.display" +msgstr "Visualización" + +# Source-Hash: d0db46f206a88e2e +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Display settings" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.displaySettings" +msgstr "Ajustes de visualización" + +# Source-Hash: 0286962ab8e9cfcd +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Door/window marks" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.doorWindowMarks" +msgstr "Marcas de puertas y ventanas" + +# Source-Hash: 027bbc8b7287c06a +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Edges" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.edges" +msgstr "Aristas" + +# Source-Hash: d86126c5d16c6238 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Enter VR" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.enterVr" +msgstr "Entrar en RV" + +# Source-Hash: eb99fb0b382e58cd +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Exit VR" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.exitVr" +msgstr "Salir de RV" + +# Source-Hash: aeac665749d3101d +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Expand sidebar" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.expandSidebar" +msgstr "Expandir barra lateral" + +# Source-Hash: 08c178ee733ff009 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Expert" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.expert" +msgstr "Experto" + +# Source-Hash: be12f8788f1f0a05 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Exploded" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.exploded" +msgstr "Despiece" + +# Source-Hash: c6f865a366ef5755 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Face of stud" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.faceOfStud" +msgstr "Cara del montante" + +# Source-Hash: 0c583f1d4201293b +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Faint outline of major creases" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.faintOutlineOfMajorCreases" +msgstr "Contorno tenue de los pliegues principales" + +# Source-Hash: 08ecd5878b08139d +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Feet & inches" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.feetInches" +msgstr "Pies y pulgadas" + +# Source-Hash: 43b8017fa821c46c +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Finished faces" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.finishedFaces" +msgstr "Caras acabadas" + +# Source-Hash: 8827df1cdeb02ed9 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Flat and fast — no ambient occlusion" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.flatAndFastNoAmbientOcclusion" +msgstr "Plano y rápido: sin oclusión ambiental" + +# Source-Hash: 060a718cdcff61b3 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Floor plan annotations" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.floorPlanAnnotations" +msgstr "Anotaciones del plano de planta" + +# Source-Hash: 483171f79dc32ae5 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Floor plan mode" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.floorPlanMode" +msgstr "Modo plano de planta" + +# Source-Hash: 8bb9e54fc786ca77 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Full ambient occlusion" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.fullAmbientOcclusion" +msgstr "Oclusión ambiental completa" + +# Source-Hash: 91fd3544d9ce16bf +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Full documentation and annotation controls" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.fullDocumentationAndAnnotationControls" +msgstr "Documentación y anotaciones completas" + +# Source-Hash: 131bac2467f4fdf6 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Full height" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.fullHeight" +msgstr "Altura completa" + +# Source-Hash: c9838bc98cb35125 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Full wall thickness" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.fullWallThickness" +msgstr "Espesor total del muro" + +# Source-Hash: 7e4a0a38f694be6b +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Grid" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.grid" +msgstr "Cuadrícula" + +# Source-Hash: 4bf4a6861d7d633e +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Levels: {mode}" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.levelsMode" +msgstr "Plantas: {mode}" + +# Source-Hash: 803532407fb5962b +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Low" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.low" +msgstr "Bajo" + +# Source-Hash: 32f9a404c0bdce10 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Magnetic snap" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.magneticSnap" +msgstr "Ajuste magnético" + +# Source-Hash: 387082cd5f118bed +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Manual" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.manual" +msgstr "Manual" + +# Source-Hash: 2fb69122dd996c0a +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Manual dimensions" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.manualDimensions" +msgstr "Cotas manuales" + +# Source-Hash: c6aa74075f37313a +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Measurements" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.measurements" +msgstr "Medidas" + +# Source-Hash: 0a39470be3b84536 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Meters" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.meters" +msgstr "Metros" + +# Source-Hash: 83cce6c521e71d01 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Millimeters" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.millimeters" +msgstr "Milímetros" + +# Source-Hash: b784527bb90b9c5e +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "3D measurements" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.n3dMeasurements" +msgstr "Medidas 3D" + +# Source-Hash: 4ff58913a4492199 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "No edge lines" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.noEdgeLines" +msgstr "Sin aristas" + +# Source-Hash: 50accd740fa2e736 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Off" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.off" +msgstr "Desactivado" + +# Source-Hash: 9cc01faad79c28da +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "On" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.on" +msgstr "Activado" + +# Source-Hash: ed59dd8ce7797a66 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Orthographic" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.orthographic" +msgstr "Ortográfica" + +# Source-Hash: 316e25993ff72b88 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Perspective" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.perspective" +msgstr "Perspectiva" + +# Source-Hash: 20226b68d5467072 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Preview" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.preview" +msgstr "Vista previa" + +# Source-Hash: 34c20b72a42ca618 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Preview mode" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.previewMode" +msgstr "Modo de vista previa" + +# Source-Hash: 5e58af99d6d4fa6a +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Render" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.render" +msgstr "Renderizado" + +# Source-Hash: f363702a10aedaeb +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Rendered" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.rendered" +msgstr "Renderizado" + +# Source-Hash: 4959d5538efc595c +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Room labels" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.roomLabels" +msgstr "Etiquetas de estancias" + +# Source-Hash: 82720864e5034398 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Scene theme" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.sceneTheme" +msgstr "Tema de la escena" + +# Source-Hash: 4c6bcb30b40047fc +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Shadows" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.shadows" +msgstr "Sombras" + +# Source-Hash: 59dc62417b44931d +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Single wall axis" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.singleWallAxis" +msgstr "Eje único del muro" + +# Source-Hash: d266d83ec490df4e +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Soft" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.soft" +msgstr "Suaves" + +# Source-Hash: ef3e765e692faf4c +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Solid" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.solid" +msgstr "Sólido" + +# Source-Hash: e9f59b513f2f45ac +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Solo" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.solo" +msgstr "Aislado" + +# Source-Hash: 481b7608ce12139b +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Stack" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.stack" +msgstr "Apilado" + +# Source-Hash: 61ad94ca68b0458e +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Stair annotations" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.stairAnnotations" +msgstr "Anotaciones de escaleras" + +# Source-Hash: f82927a01d4c5e45 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Strong" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.strong" +msgstr "Marcadas" + +# Source-Hash: e1be031e67d95382 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Structural core face" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.structuralCoreFace" +msgstr "Cara del núcleo estructural" + +# Source-Hash: efc50964d7b0ec47 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Structural grids & column centers" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.structuralGridsColumnCenters" +msgstr "Ejes estructurales y centros de pilares" + +# Source-Hash: 5ce4f6b80cd30f86 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Translucent" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.translucent" +msgstr "Translúcido" + +# Source-Hash: b404dded35241274 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Units" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.units" +msgstr "Unidades" + +# Source-Hash: faf51e28b748e0d9 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Walkthrough" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.walkthrough" +msgstr "Recorrido" + +# Source-Hash: 62281975e51da7e3 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Wall centerline" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.wallCenterline" +msgstr "Eje del muro" + +# Source-Hash: ff145d7003118ec6 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Wall dimensions" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.wallDimensions" +msgstr "Cotas de muros" + +# Source-Hash: 13969d1d6e310aa4 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Walls: {label}" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.wallsLabel" +msgstr "Muros: {label}" + # Source-Hash: 8caa959c0ae800c3 # Review: human # English: "Basement" @@ -485,49 +1125,49 @@ msgstr "Dividida" # Source-Hash: f14b89d713d2528a # Review: human # English: "Blueprint" -#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +#: packages/editor/src/i18n/scene-theme-name.ts msgid "viewer.theme.blueprint" msgstr "Plano técnico" # Source-Hash: 142bcb2841f423c5 # Review: human # English: "Mediterranean" -#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +#: packages/editor/src/i18n/scene-theme-name.ts msgid "viewer.theme.mediterranean" msgstr "Mediterráneo" # Source-Hash: b399942ec5d7e1ba # Review: human # English: "Night" -#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +#: packages/editor/src/i18n/scene-theme-name.ts msgid "viewer.theme.night" msgstr "Noche" # Source-Hash: f512d1b947b8ae5a # Review: human # English: "Overcast" -#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +#: packages/editor/src/i18n/scene-theme-name.ts msgid "viewer.theme.overcast" msgstr "Nublado" # Source-Hash: 583542b3bc837192 # Review: human # English: "Paper" -#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +#: packages/editor/src/i18n/scene-theme-name.ts msgid "viewer.theme.paper" msgstr "Papel" # Source-Hash: 7abc2dc6e39b1747 # Review: human # English: "Studio" -#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +#: packages/editor/src/i18n/scene-theme-name.ts msgid "viewer.theme.studio" msgstr "Estudio" # Source-Hash: efe94d3ec8d8c44e # Review: human # English: "Sunset" -#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +#: packages/editor/src/i18n/scene-theme-name.ts msgid "viewer.theme.sunset" msgstr "Atardecer" @@ -541,14 +1181,14 @@ msgstr "Ambiente" # Source-Hash: 4c10ea0aa0e2ea41 # Review: human # English: "Twilight" -#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +#: packages/editor/src/i18n/scene-theme-name.ts msgid "viewer.theme.twilight" msgstr "Crepúsculo" # Source-Hash: f9592fb0672740c0 # Review: human # English: "Verdant" -#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +#: packages/editor/src/i18n/scene-theme-name.ts msgid "viewer.theme.verdant" msgstr "Verde" diff --git a/packages/editor/src/i18n/locales/fr/messages.po b/packages/editor/src/i18n/locales/fr/messages.po index 89431ce1c1..ccc26c5cd7 100644 --- a/packages/editor/src/i18n/locales/fr/messages.po +++ b/packages/editor/src/i18n/locales/fr/messages.po @@ -13,6 +13,646 @@ msgstr "" "Language-Team: \n" "Plural-Forms: \n" +# Source-Hash: 3d3f438d5a49eba7 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Automatic dimensions" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.automaticDimensions" +msgstr "Cotes automatiques" + +# Source-Hash: 14622745dbf93f4c +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Camera" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.camera" +msgstr "Caméra" + +# Source-Hash: e6bce525c47ba3ac +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Clean plan; dimensions appear with selection" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.cleanPlanDimensionsAppearWithSelection" +msgstr "Plan épuré ; les cotes apparaissent à la sélection" + +# Source-Hash: 61ce356577cfb04a +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Collapse sidebar" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.collapseSidebar" +msgstr "Réduire la barre latérale" + +# Source-Hash: 2416a48df28d6205 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Crisp, opaque edge lines" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.crispOpaqueEdgeLines" +msgstr "Arêtes nettes et opaques" + +# Source-Hash: 4da8b3678ed77b45 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Cutaway" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.cutaway" +msgstr "Coupe" + +# Source-Hash: 4ca0e0e73229540c +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Default" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.default" +msgstr "Par défaut" + +# Source-Hash: a313661ef9dd0a65 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Display" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.display" +msgstr "Affichage" + +# Source-Hash: d0db46f206a88e2e +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Display settings" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.displaySettings" +msgstr "Paramètres d’affichage" + +# Source-Hash: 0286962ab8e9cfcd +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Door/window marks" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.doorWindowMarks" +msgstr "Repères de portes et fenêtres" + +# Source-Hash: 027bbc8b7287c06a +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Edges" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.edges" +msgstr "Arêtes" + +# Source-Hash: d86126c5d16c6238 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Enter VR" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.enterVr" +msgstr "Entrer en VR" + +# Source-Hash: eb99fb0b382e58cd +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Exit VR" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.exitVr" +msgstr "Quitter la VR" + +# Source-Hash: aeac665749d3101d +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Expand sidebar" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.expandSidebar" +msgstr "Développer la barre latérale" + +# Source-Hash: 08c178ee733ff009 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Expert" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.expert" +msgstr "Expert" + +# Source-Hash: be12f8788f1f0a05 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Exploded" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.exploded" +msgstr "Éclatée" + +# Source-Hash: c6f865a366ef5755 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Face of stud" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.faceOfStud" +msgstr "Nu de l’ossature" + +# Source-Hash: 0c583f1d4201293b +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Faint outline of major creases" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.faintOutlineOfMajorCreases" +msgstr "Contour léger des arêtes principales" + +# Source-Hash: 08ecd5878b08139d +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Feet & inches" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.feetInches" +msgstr "Pieds et pouces" + +# Source-Hash: 43b8017fa821c46c +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Finished faces" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.finishedFaces" +msgstr "Faces finies" + +# Source-Hash: 8827df1cdeb02ed9 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Flat and fast — no ambient occlusion" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.flatAndFastNoAmbientOcclusion" +msgstr "Plat et rapide — sans occlusion ambiante" + +# Source-Hash: 060a718cdcff61b3 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Floor plan annotations" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.floorPlanAnnotations" +msgstr "Annotations du plan d’étage" + +# Source-Hash: 483171f79dc32ae5 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Floor plan mode" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.floorPlanMode" +msgstr "Mode plan d’étage" + +# Source-Hash: 8bb9e54fc786ca77 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Full ambient occlusion" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.fullAmbientOcclusion" +msgstr "Occlusion ambiante complète" + +# Source-Hash: 91fd3544d9ce16bf +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Full documentation and annotation controls" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.fullDocumentationAndAnnotationControls" +msgstr "Documentation et annotations complètes" + +# Source-Hash: 131bac2467f4fdf6 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Full height" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.fullHeight" +msgstr "Pleine hauteur" + +# Source-Hash: c9838bc98cb35125 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Full wall thickness" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.fullWallThickness" +msgstr "Épaisseur totale du mur" + +# Source-Hash: 7e4a0a38f694be6b +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Grid" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.grid" +msgstr "Grille" + +# Source-Hash: 4bf4a6861d7d633e +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Levels: {mode}" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.levelsMode" +msgstr "Niveaux : {mode}" + +# Source-Hash: 803532407fb5962b +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Low" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.low" +msgstr "Bas" + +# Source-Hash: 32f9a404c0bdce10 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Magnetic snap" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.magneticSnap" +msgstr "Aimantation" + +# Source-Hash: 387082cd5f118bed +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Manual" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.manual" +msgstr "Manuel" + +# Source-Hash: 2fb69122dd996c0a +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Manual dimensions" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.manualDimensions" +msgstr "Cotes manuelles" + +# Source-Hash: c6aa74075f37313a +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Measurements" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.measurements" +msgstr "Mesures" + +# Source-Hash: 0a39470be3b84536 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Meters" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.meters" +msgstr "Mètres" + +# Source-Hash: 83cce6c521e71d01 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Millimeters" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.millimeters" +msgstr "Millimètres" + +# Source-Hash: b784527bb90b9c5e +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "3D measurements" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.n3dMeasurements" +msgstr "Mesures 3D" + +# Source-Hash: 4ff58913a4492199 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "No edge lines" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.noEdgeLines" +msgstr "Aucune arête" + +# Source-Hash: 50accd740fa2e736 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Off" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.off" +msgstr "Désactivé" + +# Source-Hash: 9cc01faad79c28da +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "On" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.on" +msgstr "Activé" + +# Source-Hash: ed59dd8ce7797a66 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Orthographic" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.orthographic" +msgstr "Orthographique" + +# Source-Hash: 316e25993ff72b88 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Perspective" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.perspective" +msgstr "Perspective" + +# Source-Hash: 20226b68d5467072 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Preview" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.preview" +msgstr "Aperçu" + +# Source-Hash: 34c20b72a42ca618 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Preview mode" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.previewMode" +msgstr "Mode aperçu" + +# Source-Hash: 5e58af99d6d4fa6a +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Render" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.render" +msgstr "Rendu" + +# Source-Hash: f363702a10aedaeb +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Rendered" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.rendered" +msgstr "Rendu" + +# Source-Hash: 4959d5538efc595c +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Room labels" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.roomLabels" +msgstr "Étiquettes des pièces" + +# Source-Hash: 82720864e5034398 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Scene theme" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.sceneTheme" +msgstr "Thème de la scène" + +# Source-Hash: 4c6bcb30b40047fc +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Shadows" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.shadows" +msgstr "Ombres" + +# Source-Hash: 59dc62417b44931d +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Single wall axis" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.singleWallAxis" +msgstr "Axe unique du mur" + +# Source-Hash: d266d83ec490df4e +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Soft" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.soft" +msgstr "Douces" + +# Source-Hash: ef3e765e692faf4c +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Solid" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.solid" +msgstr "Plein" + +# Source-Hash: e9f59b513f2f45ac +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Solo" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.solo" +msgstr "Isolé" + +# Source-Hash: 481b7608ce12139b +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Stack" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.stack" +msgstr "Empilé" + +# Source-Hash: 61ad94ca68b0458e +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Stair annotations" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.stairAnnotations" +msgstr "Annotations d’escalier" + +# Source-Hash: f82927a01d4c5e45 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Strong" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.strong" +msgstr "Marquées" + +# Source-Hash: e1be031e67d95382 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Structural core face" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.structuralCoreFace" +msgstr "Nu du noyau structurel" + +# Source-Hash: efc50964d7b0ec47 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Structural grids & column centers" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.structuralGridsColumnCenters" +msgstr "Trames structurelles et axes des poteaux" + +# Source-Hash: 5ce4f6b80cd30f86 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Translucent" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.translucent" +msgstr "Translucide" + +# Source-Hash: b404dded35241274 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Units" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.units" +msgstr "Unités" + +# Source-Hash: faf51e28b748e0d9 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Walkthrough" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.walkthrough" +msgstr "Visite" + +# Source-Hash: 62281975e51da7e3 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Wall centerline" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.wallCenterline" +msgstr "Axe du mur" + +# Source-Hash: ff145d7003118ec6 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Wall dimensions" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.wallDimensions" +msgstr "Cotes des murs" + +# Source-Hash: 13969d1d6e310aa4 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Walls: {label}" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.wallsLabel" +msgstr "Murs : {label}" + # Source-Hash: 8caa959c0ae800c3 # Review: human # English: "Basement" @@ -485,49 +1125,49 @@ msgstr "Partagé" # Source-Hash: f14b89d713d2528a # Review: human # English: "Blueprint" -#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +#: packages/editor/src/i18n/scene-theme-name.ts msgid "viewer.theme.blueprint" msgstr "Plan" # Source-Hash: 142bcb2841f423c5 # Review: human # English: "Mediterranean" -#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +#: packages/editor/src/i18n/scene-theme-name.ts msgid "viewer.theme.mediterranean" msgstr "Méditerranéen" # Source-Hash: b399942ec5d7e1ba # Review: human # English: "Night" -#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +#: packages/editor/src/i18n/scene-theme-name.ts msgid "viewer.theme.night" msgstr "Nuit" # Source-Hash: f512d1b947b8ae5a # Review: human # English: "Overcast" -#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +#: packages/editor/src/i18n/scene-theme-name.ts msgid "viewer.theme.overcast" msgstr "Ciel couvert" # Source-Hash: 583542b3bc837192 # Review: human # English: "Paper" -#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +#: packages/editor/src/i18n/scene-theme-name.ts msgid "viewer.theme.paper" msgstr "Papier" # Source-Hash: 7abc2dc6e39b1747 # Review: human # English: "Studio" -#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +#: packages/editor/src/i18n/scene-theme-name.ts msgid "viewer.theme.studio" msgstr "Studio" # Source-Hash: efe94d3ec8d8c44e # Review: human # English: "Sunset" -#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +#: packages/editor/src/i18n/scene-theme-name.ts msgid "viewer.theme.sunset" msgstr "Coucher de soleil" @@ -541,14 +1181,14 @@ msgstr "Ambiance" # Source-Hash: 4c10ea0aa0e2ea41 # Review: human # English: "Twilight" -#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +#: packages/editor/src/i18n/scene-theme-name.ts msgid "viewer.theme.twilight" msgstr "Crépuscule" # Source-Hash: f9592fb0672740c0 # Review: human # English: "Verdant" -#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +#: packages/editor/src/i18n/scene-theme-name.ts msgid "viewer.theme.verdant" msgstr "Verdoyant" diff --git a/packages/editor/src/i18n/locales/ja/messages.po b/packages/editor/src/i18n/locales/ja/messages.po index 7fe420abfb..84094cdcf6 100644 --- a/packages/editor/src/i18n/locales/ja/messages.po +++ b/packages/editor/src/i18n/locales/ja/messages.po @@ -13,6 +13,646 @@ msgstr "" "Language-Team: \n" "Plural-Forms: \n" +# Source-Hash: 3d3f438d5a49eba7 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Automatic dimensions" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.automaticDimensions" +msgstr "自動寸法" + +# Source-Hash: 14622745dbf93f4c +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Camera" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.camera" +msgstr "カメラ" + +# Source-Hash: e6bce525c47ba3ac +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Clean plan; dimensions appear with selection" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.cleanPlanDimensionsAppearWithSelection" +msgstr "すっきりした図面。寸法は選択時に表示" + +# Source-Hash: 61ce356577cfb04a +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Collapse sidebar" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.collapseSidebar" +msgstr "サイドバーを折りたたむ" + +# Source-Hash: 2416a48df28d6205 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Crisp, opaque edge lines" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.crispOpaqueEdgeLines" +msgstr "くっきりした不透明なエッジ線" + +# Source-Hash: 4da8b3678ed77b45 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Cutaway" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.cutaway" +msgstr "カットアウェイ" + +# Source-Hash: 4ca0e0e73229540c +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Default" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.default" +msgstr "標準" + +# Source-Hash: a313661ef9dd0a65 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Display" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.display" +msgstr "表示" + +# Source-Hash: d0db46f206a88e2e +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Display settings" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.displaySettings" +msgstr "表示設定" + +# Source-Hash: 0286962ab8e9cfcd +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Door/window marks" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.doorWindowMarks" +msgstr "建具記号" + +# Source-Hash: 027bbc8b7287c06a +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Edges" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.edges" +msgstr "エッジ" + +# Source-Hash: d86126c5d16c6238 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Enter VR" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.enterVr" +msgstr "VR を開始" + +# Source-Hash: eb99fb0b382e58cd +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Exit VR" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.exitVr" +msgstr "VR を終了" + +# Source-Hash: aeac665749d3101d +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Expand sidebar" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.expandSidebar" +msgstr "サイドバーを展開" + +# Source-Hash: 08c178ee733ff009 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Expert" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.expert" +msgstr "エキスパート" + +# Source-Hash: be12f8788f1f0a05 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Exploded" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.exploded" +msgstr "分解" + +# Source-Hash: c6f865a366ef5755 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Face of stud" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.faceOfStud" +msgstr "下地面" + +# Source-Hash: 0c583f1d4201293b +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Faint outline of major creases" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.faintOutlineOfMajorCreases" +msgstr "主な折れ目の淡い輪郭" + +# Source-Hash: 08ecd5878b08139d +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Feet & inches" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.feetInches" +msgstr "フィート・インチ" + +# Source-Hash: 43b8017fa821c46c +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Finished faces" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.finishedFaces" +msgstr "仕上げ面" + +# Source-Hash: 8827df1cdeb02ed9 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Flat and fast — no ambient occlusion" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.flatAndFastNoAmbientOcclusion" +msgstr "フラットで高速 — アンビエントオクルージョンなし" + +# Source-Hash: 060a718cdcff61b3 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Floor plan annotations" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.floorPlanAnnotations" +msgstr "平面図の注記" + +# Source-Hash: 483171f79dc32ae5 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Floor plan mode" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.floorPlanMode" +msgstr "平面図モード" + +# Source-Hash: 8bb9e54fc786ca77 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Full ambient occlusion" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.fullAmbientOcclusion" +msgstr "フルアンビエントオクルージョン" + +# Source-Hash: 91fd3544d9ce16bf +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Full documentation and annotation controls" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.fullDocumentationAndAnnotationControls" +msgstr "図面化と注記の全コントロール" + +# Source-Hash: 131bac2467f4fdf6 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Full height" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.fullHeight" +msgstr "全高" + +# Source-Hash: c9838bc98cb35125 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Full wall thickness" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.fullWallThickness" +msgstr "壁の全厚" + +# Source-Hash: 7e4a0a38f694be6b +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Grid" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.grid" +msgstr "グリッド" + +# Source-Hash: 4bf4a6861d7d633e +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Levels: {mode}" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.levelsMode" +msgstr "階層: {mode}" + +# Source-Hash: 803532407fb5962b +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Low" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.low" +msgstr "低" + +# Source-Hash: 32f9a404c0bdce10 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Magnetic snap" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.magneticSnap" +msgstr "マグネットスナップ" + +# Source-Hash: 387082cd5f118bed +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Manual" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.manual" +msgstr "手動" + +# Source-Hash: 2fb69122dd996c0a +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Manual dimensions" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.manualDimensions" +msgstr "手動寸法" + +# Source-Hash: c6aa74075f37313a +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Measurements" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.measurements" +msgstr "計測" + +# Source-Hash: 0a39470be3b84536 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Meters" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.meters" +msgstr "メートル" + +# Source-Hash: 83cce6c521e71d01 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Millimeters" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.millimeters" +msgstr "ミリメートル" + +# Source-Hash: b784527bb90b9c5e +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "3D measurements" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.n3dMeasurements" +msgstr "3D 計測" + +# Source-Hash: 4ff58913a4492199 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "No edge lines" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.noEdgeLines" +msgstr "エッジ線なし" + +# Source-Hash: 50accd740fa2e736 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Off" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.off" +msgstr "オフ" + +# Source-Hash: 9cc01faad79c28da +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "On" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.on" +msgstr "オン" + +# Source-Hash: ed59dd8ce7797a66 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Orthographic" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.orthographic" +msgstr "平行投影" + +# Source-Hash: 316e25993ff72b88 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Perspective" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.perspective" +msgstr "透視投影" + +# Source-Hash: 20226b68d5467072 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Preview" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.preview" +msgstr "プレビュー" + +# Source-Hash: 34c20b72a42ca618 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Preview mode" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.previewMode" +msgstr "プレビューモード" + +# Source-Hash: 5e58af99d6d4fa6a +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Render" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.render" +msgstr "レンダリング" + +# Source-Hash: f363702a10aedaeb +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Rendered" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.rendered" +msgstr "レンダリング" + +# Source-Hash: 4959d5538efc595c +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Room labels" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.roomLabels" +msgstr "部屋ラベル" + +# Source-Hash: 82720864e5034398 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Scene theme" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.sceneTheme" +msgstr "シーンテーマ" + +# Source-Hash: 4c6bcb30b40047fc +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Shadows" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.shadows" +msgstr "影" + +# Source-Hash: 59dc62417b44931d +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Single wall axis" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.singleWallAxis" +msgstr "壁の中心線" + +# Source-Hash: d266d83ec490df4e +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Soft" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.soft" +msgstr "ソフト" + +# Source-Hash: ef3e765e692faf4c +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Solid" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.solid" +msgstr "ソリッド" + +# Source-Hash: e9f59b513f2f45ac +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Solo" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.solo" +msgstr "単独" + +# Source-Hash: 481b7608ce12139b +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Stack" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.stack" +msgstr "積み重ね" + +# Source-Hash: 61ad94ca68b0458e +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Stair annotations" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.stairAnnotations" +msgstr "階段の注記" + +# Source-Hash: f82927a01d4c5e45 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Strong" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.strong" +msgstr "強" + +# Source-Hash: e1be031e67d95382 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Structural core face" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.structuralCoreFace" +msgstr "構造躯体面" + +# Source-Hash: efc50964d7b0ec47 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Structural grids & column centers" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.structuralGridsColumnCenters" +msgstr "構造通り芯と柱芯" + +# Source-Hash: 5ce4f6b80cd30f86 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Translucent" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.translucent" +msgstr "半透明" + +# Source-Hash: b404dded35241274 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Units" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.units" +msgstr "単位" + +# Source-Hash: faf51e28b748e0d9 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Walkthrough" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.walkthrough" +msgstr "ウォークスルー" + +# Source-Hash: 62281975e51da7e3 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Wall centerline" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.wallCenterline" +msgstr "壁芯" + +# Source-Hash: ff145d7003118ec6 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Wall dimensions" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.wallDimensions" +msgstr "壁の寸法" + +# Source-Hash: 13969d1d6e310aa4 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Walls: {label}" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.wallsLabel" +msgstr "壁: {label}" + # Source-Hash: 8caa959c0ae800c3 # Review: human # English: "Basement" @@ -485,49 +1125,49 @@ msgstr "分割" # Source-Hash: f14b89d713d2528a # Review: human # English: "Blueprint" -#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +#: packages/editor/src/i18n/scene-theme-name.ts msgid "viewer.theme.blueprint" msgstr "青図" # Source-Hash: 142bcb2841f423c5 # Review: human # English: "Mediterranean" -#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +#: packages/editor/src/i18n/scene-theme-name.ts msgid "viewer.theme.mediterranean" msgstr "地中海" # Source-Hash: b399942ec5d7e1ba # Review: human # English: "Night" -#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +#: packages/editor/src/i18n/scene-theme-name.ts msgid "viewer.theme.night" msgstr "夜景" # Source-Hash: f512d1b947b8ae5a # Review: human # English: "Overcast" -#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +#: packages/editor/src/i18n/scene-theme-name.ts msgid "viewer.theme.overcast" msgstr "曇天" # Source-Hash: 583542b3bc837192 # Review: human # English: "Paper" -#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +#: packages/editor/src/i18n/scene-theme-name.ts msgid "viewer.theme.paper" msgstr "ペーパー" # Source-Hash: 7abc2dc6e39b1747 # Review: human # English: "Studio" -#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +#: packages/editor/src/i18n/scene-theme-name.ts msgid "viewer.theme.studio" msgstr "スタジオ" # Source-Hash: efe94d3ec8d8c44e # Review: human # English: "Sunset" -#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +#: packages/editor/src/i18n/scene-theme-name.ts msgid "viewer.theme.sunset" msgstr "夕景" @@ -541,14 +1181,14 @@ msgstr "シーンテーマ" # Source-Hash: 4c10ea0aa0e2ea41 # Review: human # English: "Twilight" -#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +#: packages/editor/src/i18n/scene-theme-name.ts msgid "viewer.theme.twilight" msgstr "薄暮" # Source-Hash: f9592fb0672740c0 # Review: human # English: "Verdant" -#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +#: packages/editor/src/i18n/scene-theme-name.ts msgid "viewer.theme.verdant" msgstr "緑景" diff --git a/packages/editor/src/i18n/locales/zh-CN/messages.po b/packages/editor/src/i18n/locales/zh-CN/messages.po index 9b059286fe..1eee85b56d 100644 --- a/packages/editor/src/i18n/locales/zh-CN/messages.po +++ b/packages/editor/src/i18n/locales/zh-CN/messages.po @@ -13,6 +13,646 @@ msgstr "" "Language-Team: \n" "Plural-Forms: \n" +# Source-Hash: 3d3f438d5a49eba7 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Automatic dimensions" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.automaticDimensions" +msgstr "自动尺寸" + +# Source-Hash: 14622745dbf93f4c +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Camera" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.camera" +msgstr "相机" + +# Source-Hash: e6bce525c47ba3ac +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Clean plan; dimensions appear with selection" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.cleanPlanDimensionsAppearWithSelection" +msgstr "简洁平面;选中时显示尺寸" + +# Source-Hash: 61ce356577cfb04a +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Collapse sidebar" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.collapseSidebar" +msgstr "收起侧边栏" + +# Source-Hash: 2416a48df28d6205 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Crisp, opaque edge lines" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.crispOpaqueEdgeLines" +msgstr "清晰、不透明的边线" + +# Source-Hash: 4da8b3678ed77b45 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Cutaway" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.cutaway" +msgstr "剖切" + +# Source-Hash: 4ca0e0e73229540c +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Default" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.default" +msgstr "默认" + +# Source-Hash: a313661ef9dd0a65 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Display" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.display" +msgstr "显示" + +# Source-Hash: d0db46f206a88e2e +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Display settings" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.displaySettings" +msgstr "显示设置" + +# Source-Hash: 0286962ab8e9cfcd +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Door/window marks" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.doorWindowMarks" +msgstr "门窗标记" + +# Source-Hash: 027bbc8b7287c06a +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Edges" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.edges" +msgstr "边线" + +# Source-Hash: d86126c5d16c6238 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Enter VR" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.enterVr" +msgstr "进入 VR" + +# Source-Hash: eb99fb0b382e58cd +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Exit VR" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.exitVr" +msgstr "退出 VR" + +# Source-Hash: aeac665749d3101d +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Expand sidebar" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.expandSidebar" +msgstr "展开侧边栏" + +# Source-Hash: 08c178ee733ff009 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Expert" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.expert" +msgstr "专家" + +# Source-Hash: be12f8788f1f0a05 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Exploded" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.exploded" +msgstr "分解" + +# Source-Hash: c6f865a366ef5755 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Face of stud" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.faceOfStud" +msgstr "龙骨面" + +# Source-Hash: 0c583f1d4201293b +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Faint outline of major creases" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.faintOutlineOfMajorCreases" +msgstr "主要折边的淡轮廓" + +# Source-Hash: 08ecd5878b08139d +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Feet & inches" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.feetInches" +msgstr "英尺和英寸" + +# Source-Hash: 43b8017fa821c46c +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Finished faces" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.finishedFaces" +msgstr "完成面" + +# Source-Hash: 8827df1cdeb02ed9 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Flat and fast — no ambient occlusion" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.flatAndFastNoAmbientOcclusion" +msgstr "平面且快速 — 无环境光遮蔽" + +# Source-Hash: 060a718cdcff61b3 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Floor plan annotations" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.floorPlanAnnotations" +msgstr "平面图标注" + +# Source-Hash: 483171f79dc32ae5 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Floor plan mode" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.floorPlanMode" +msgstr "平面图模式" + +# Source-Hash: 8bb9e54fc786ca77 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Full ambient occlusion" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.fullAmbientOcclusion" +msgstr "完整环境光遮蔽" + +# Source-Hash: 91fd3544d9ce16bf +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Full documentation and annotation controls" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.fullDocumentationAndAnnotationControls" +msgstr "完整的图档与标注控件" + +# Source-Hash: 131bac2467f4fdf6 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Full height" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.fullHeight" +msgstr "全高" + +# Source-Hash: c9838bc98cb35125 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Full wall thickness" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.fullWallThickness" +msgstr "墙体全厚" + +# Source-Hash: 7e4a0a38f694be6b +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Grid" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.grid" +msgstr "网格" + +# Source-Hash: 4bf4a6861d7d633e +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Levels: {mode}" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.levelsMode" +msgstr "楼层:{mode}" + +# Source-Hash: 803532407fb5962b +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Low" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.low" +msgstr "矮墙" + +# Source-Hash: 32f9a404c0bdce10 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Magnetic snap" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.magneticSnap" +msgstr "磁性吸附" + +# Source-Hash: 387082cd5f118bed +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Manual" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.manual" +msgstr "手动" + +# Source-Hash: 2fb69122dd996c0a +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Manual dimensions" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.manualDimensions" +msgstr "手动尺寸" + +# Source-Hash: c6aa74075f37313a +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Measurements" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.measurements" +msgstr "测量" + +# Source-Hash: 0a39470be3b84536 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Meters" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.meters" +msgstr "米" + +# Source-Hash: 83cce6c521e71d01 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Millimeters" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.millimeters" +msgstr "毫米" + +# Source-Hash: b784527bb90b9c5e +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "3D measurements" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.n3dMeasurements" +msgstr "3D 测量" + +# Source-Hash: 4ff58913a4492199 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "No edge lines" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.noEdgeLines" +msgstr "无边线" + +# Source-Hash: 50accd740fa2e736 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Off" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.off" +msgstr "关" + +# Source-Hash: 9cc01faad79c28da +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "On" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.on" +msgstr "开" + +# Source-Hash: ed59dd8ce7797a66 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Orthographic" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.orthographic" +msgstr "正交" + +# Source-Hash: 316e25993ff72b88 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Perspective" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.perspective" +msgstr "透视" + +# Source-Hash: 20226b68d5467072 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Preview" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.preview" +msgstr "预览" + +# Source-Hash: 34c20b72a42ca618 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Preview mode" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.previewMode" +msgstr "预览模式" + +# Source-Hash: 5e58af99d6d4fa6a +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Render" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.render" +msgstr "渲染" + +# Source-Hash: f363702a10aedaeb +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Rendered" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.rendered" +msgstr "渲染" + +# Source-Hash: 4959d5538efc595c +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Room labels" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.roomLabels" +msgstr "房间标签" + +# Source-Hash: 82720864e5034398 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Scene theme" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.sceneTheme" +msgstr "场景主题" + +# Source-Hash: 4c6bcb30b40047fc +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Shadows" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.shadows" +msgstr "阴影" + +# Source-Hash: 59dc62417b44931d +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Single wall axis" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.singleWallAxis" +msgstr "单一墙体轴线" + +# Source-Hash: d266d83ec490df4e +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Soft" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.soft" +msgstr "柔和" + +# Source-Hash: ef3e765e692faf4c +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Solid" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.solid" +msgstr "实体" + +# Source-Hash: e9f59b513f2f45ac +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Solo" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.solo" +msgstr "单独" + +# Source-Hash: 481b7608ce12139b +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Stack" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.stack" +msgstr "堆叠" + +# Source-Hash: 61ad94ca68b0458e +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Stair annotations" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.stairAnnotations" +msgstr "楼梯标注" + +# Source-Hash: f82927a01d4c5e45 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Strong" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.strong" +msgstr "强烈" + +# Source-Hash: e1be031e67d95382 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Structural core face" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.structuralCoreFace" +msgstr "结构核心面" + +# Source-Hash: efc50964d7b0ec47 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Structural grids & column centers" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.structuralGridsColumnCenters" +msgstr "结构轴网与柱中心" + +# Source-Hash: 5ce4f6b80cd30f86 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Translucent" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.translucent" +msgstr "半透明" + +# Source-Hash: b404dded35241274 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Units" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.units" +msgstr "单位" + +# Source-Hash: faf51e28b748e0d9 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Walkthrough" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.walkthrough" +msgstr "漫游" + +# Source-Hash: 62281975e51da7e3 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Wall centerline" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.wallCenterline" +msgstr "墙体中心线" + +# Source-Hash: ff145d7003118ec6 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Wall dimensions" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.wallDimensions" +msgstr "墙体尺寸" + +# Source-Hash: 13969d1d6e310aa4 +# Review: required +# AI-Provider: cursor-agent +# AI-Model: claude-opus +# English: "Walls: {label}" +#: apps/editor/components/viewer-toolbar.tsx +#, fuzzy +msgid "app.viewerToolbar.wallsLabel" +msgstr "墙体:{label}" + # Source-Hash: 8caa959c0ae800c3 # Review: human # English: "Basement" @@ -485,49 +1125,49 @@ msgstr "分屏" # Source-Hash: f14b89d713d2528a # Review: human # English: "Blueprint" -#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +#: packages/editor/src/i18n/scene-theme-name.ts msgid "viewer.theme.blueprint" msgstr "蓝图" # Source-Hash: 142bcb2841f423c5 # Review: human # English: "Mediterranean" -#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +#: packages/editor/src/i18n/scene-theme-name.ts msgid "viewer.theme.mediterranean" msgstr "地中海" # Source-Hash: b399942ec5d7e1ba # Review: human # English: "Night" -#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +#: packages/editor/src/i18n/scene-theme-name.ts msgid "viewer.theme.night" msgstr "夜景" # Source-Hash: f512d1b947b8ae5a # Review: human # English: "Overcast" -#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +#: packages/editor/src/i18n/scene-theme-name.ts msgid "viewer.theme.overcast" msgstr "阴天" # Source-Hash: 583542b3bc837192 # Review: human # English: "Paper" -#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +#: packages/editor/src/i18n/scene-theme-name.ts msgid "viewer.theme.paper" msgstr "纸面" # Source-Hash: 7abc2dc6e39b1747 # Review: human # English: "Studio" -#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +#: packages/editor/src/i18n/scene-theme-name.ts msgid "viewer.theme.studio" msgstr "影棚" # Source-Hash: efe94d3ec8d8c44e # Review: human # English: "Sunset" -#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +#: packages/editor/src/i18n/scene-theme-name.ts msgid "viewer.theme.sunset" msgstr "日落" @@ -541,14 +1181,14 @@ msgstr "场景主题" # Source-Hash: 4c10ea0aa0e2ea41 # Review: human # English: "Twilight" -#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +#: packages/editor/src/i18n/scene-theme-name.ts msgid "viewer.theme.twilight" msgstr "暮色" # Source-Hash: f9592fb0672740c0 # Review: human # English: "Verdant" -#: packages/editor/src/components/viewer/viewer-controls-bar.tsx +#: packages/editor/src/i18n/scene-theme-name.ts msgid "viewer.theme.verdant" msgstr "绿意" diff --git a/packages/editor/src/i18n/pseudo-locale.ts b/packages/editor/src/i18n/pseudo-locale.ts new file mode 100644 index 0000000000..64dfb4d682 --- /dev/null +++ b/packages/editor/src/i18n/pseudo-locale.ts @@ -0,0 +1,17 @@ +import { registerCompiledMessages } from '@pascal-app/core' +import { messages } from './locales/en-XA/messages' + +export const PSEUDO_LOCALE = 'en-XA' + +let registered = false + +/** + * Registers `en-XA`, the accented and ~30 % longer pseudo-locale generated + * from English. It finds untranslated copy and clipping; it is not a language, + * so hosts enable it only in development or test builds. + */ +export function registerPseudoLocale(): void { + if (registered) return + registered = true + registerCompiledMessages(PSEUDO_LOCALE, '', messages) +} diff --git a/packages/editor/src/i18n/scene-theme-name.ts b/packages/editor/src/i18n/scene-theme-name.ts new file mode 100644 index 0000000000..2d9001c154 --- /dev/null +++ b/packages/editor/src/i18n/scene-theme-name.ts @@ -0,0 +1,16 @@ +import type { PascalI18n } from '@pascal-app/core' + +/** Display name of a built-in scene theme; unknown (plugin) themes keep their own name. */ +export function sceneThemeName(id: string, fallback: string, i18n: PascalI18n): string { + if (id === 'studio') return i18n._({ id: 'viewer.theme.studio', message: 'Studio' }) + if (id === 'paper') return i18n._({ id: 'viewer.theme.paper', message: 'Paper' }) + if (id === 'sunset') return i18n._({ id: 'viewer.theme.sunset', message: 'Sunset' }) + if (id === 'overcast') return i18n._({ id: 'viewer.theme.overcast', message: 'Overcast' }) + if (id === 'blueprint') return i18n._({ id: 'viewer.theme.blueprint', message: 'Blueprint' }) + if (id === 'mediterranean') + return i18n._({ id: 'viewer.theme.mediterranean', message: 'Mediterranean' }) + if (id === 'twilight') return i18n._({ id: 'viewer.theme.twilight', message: 'Twilight' }) + if (id === 'night') return i18n._({ id: 'viewer.theme.night', message: 'Night' }) + if (id === 'verdant') return i18n._({ id: 'viewer.theme.verdant', message: 'Verdant' }) + return fallback +} diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 297e7687e0..f81c953885 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -394,6 +394,7 @@ export { usePlacementNotice } from './hooks/use-placement-notice' export { useReducedMotion } from './hooks/use-reduced-motion' export { useSelectedRoom } from './hooks/use-selected-room' export { type Selection, useSelection } from './hooks/use-selection' +export { sceneThemeName } from './i18n/scene-theme-name' export { clearPlacementSurface, getPlacementSurface, diff --git a/scripts/i18n-codemod.ts b/scripts/i18n-codemod.ts new file mode 100644 index 0000000000..55046e5dd7 --- /dev/null +++ b/scripts/i18n-codemod.ts @@ -0,0 +1,675 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { parsePo } from 'pofile-ts' +import ts from 'typescript' +import { + ASCII_EDGE_WHITESPACE, + attributeText, + componentOwner, + enclosingComponent, + parseSource, + placeholderName, + type SentenceChild, + scannedFiles, + visitUiLiterals, +} from './i18n-literals' + +/** + * One-time migration: rewrites hardcoded UI copy into the runtime descriptor + * form the packages already use, `i18n._({ id, message, values })`, and adds + * `const i18n = usePascalI18n()` to the owning component. Dry run by default; + * `--apply` writes. A second run over converted files changes nothing because + * the scanner skips existing `i18n._(...)` calls. + */ + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const EN_CATALOG = join(ROOT, 'packages/editor/src/i18n/locales/en/messages.po') +const ALLOWLIST_PATH = join(ROOT, 'i18n/literal-allowlist.json') +const HOOK = 'usePascalI18n' + +type Edit = { start: number; end: number; text: string } + +type Planned = { + path: string + line: number + id?: string + message: string + manual?: string +} + +/** + * Encodes literal copy as an ICU MessageFormat message that renders the same + * text: braces (with any apostrophes next to them) are quoted, and an + * apostrophe is doubled only where ICU would read it as syntax. + */ +export function icuLiteral(text: string, following?: string): string { + return text.replace(/[{}']*[{}][{}']*|'/g, (run, offset: number) => { + if (run !== "'") return `'${run.replace(/'/g, "''")}'` + // `following` is what comes after this piece, e.g. the `{` of a placeholder. + const next = offset + 1 < text.length ? text[offset + 1] : following + const special = + text[offset - 1] === "'" || next === "'" || next === '#' || next === '|' || next === '{' + return special ? "''" : "'" + }) +} + +function camel(words: string[]): string { + return words + .map((word, index) => { + const lower = word.toLowerCase() + return index === 0 ? lower : lower.charAt(0).toUpperCase() + lower.slice(1) + }) + .join('') +} + +function segmentName(segment: string): string { + return camel(segment.split(/[-_.\s]+/).filter(Boolean)) +} + +/** `packages/editor/src/components/ui/sidebar/site-panel.tsx` → `editor.sidebar.sitePanel`. */ +export function namespaceFor(path: string): string { + const parts = path.replace(/\.[jt]sx?$/, '').split('/') + let prefix: string[] + let rest: string[] + if (parts[0] === 'apps') { + prefix = ['app'] + rest = parts.slice(2) + } else if (parts[1] === 'nodes') { + prefix = ['node', segmentName(parts[3] ?? 'shared')] + rest = parts.slice(4) + } else { + prefix = [parts[1] ?? 'app'] + rest = parts.slice(3) + } + const segments = rest + .filter((segment) => !['components', 'ui', 'app', 'src', 'index'].includes(segment)) + .map(segmentName) + return [...prefix, ...segments] + .filter((segment, index, all) => segment && segment !== all[index - 1]) + .join('.') +} + +export function slugFor(message: string): string { + const words = message + .replace(/\{(\w+)[^}]*\}/g, ' $1 ') + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .match(/[A-Za-z0-9]+/g) + if (!words?.length) { + let hash = 0 + for (const character of message) hash = (hash * 31 + (character.codePointAt(0) ?? 0)) >>> 0 + return `text${hash.toString(36).slice(0, 6)}` + } + const slug = camel(words.slice(0, 6)) + return /^[0-9]/.test(slug) ? `n${slug}` : slug +} + +export class IdRegistry { + private readonly messages = new Map() + + constructor(existing: Record = {}) { + for (const [id, message] of Object.entries(existing)) this.messages.set(id, message) + } + + idFor(path: string, message: string): string { + const base = `${namespaceFor(path)}.${slugFor(message)}` + for (let suffix = 1; ; suffix += 1) { + const id = suffix === 1 ? base : `${base}${suffix}` + const existing = this.messages.get(id) + if (existing === undefined) { + this.messages.set(id, message) + return id + } + if (existing === message) return id + } + } +} + +function existingMessages(): Record { + const po = parsePo(readFileSync(EN_CATALOG, 'utf8')) + return Object.fromEntries(po.items.map((item) => [item.msgid, item.msgstr[0] ?? ''])) +} + +/** A single-quoted JavaScript string literal for any text. */ +function quote(text: string): string { + return `'${text.replace(/[\\'\n\r\u2028\u2029]/g, (character) => + character === '\\' + ? '\\\\' + : character === "'" + ? "\\'" + : character === '\n' + ? '\\n' + : character === '\r' + ? '\\r' + : `\\u${character.charCodeAt(0).toString(16)}`, + )}'` +} + +function descriptor(id: string, message: string, values: Array<[string, string]>): string { + const parts = [`id: ${quote(id)}`, `message: ${quote(message)}`] + if (values.length > 0) { + const entries = values.map(([name, expression]) => + name === expression ? name : `${name}: ${expression}`, + ) + parts.push(`values: { ${entries.join(', ')} }`) + } + return `i18n._({ ${parts.join(', ')} })` +} + +function uniqueValues( + source: ts.SourceFile, + items: Array<{ name: string; expression: ts.Expression }>, +) { + const values: Array<[string, string]> = [] + const names = new Map() + const placeholders: string[] = [] + for (const item of items) { + const expression = item.expression.getText(source) + let name = item.name + for (let suffix = 2; names.has(name) && names.get(name) !== expression; suffix += 1) { + name = `${item.name}${suffix}` + } + if (!names.has(name)) { + names.set(name, expression) + values.push([name, expression]) + } + placeholders.push(name) + } + return { placeholders, values } +} + +function bindsI18n(component: ts.FunctionLikeDeclaration): boolean { + const declaresI18n = (name: ts.BindingName): boolean => + ts.isIdentifier(name) + ? name.text === 'i18n' + : name.elements.some( + (element) => !ts.isOmittedExpression(element) && declaresI18n(element.name), + ) + if (component.parameters.some((parameter) => declaresI18n(parameter.name))) return true + const body = component.body + if (!(body && ts.isBlock(body))) return false + return body.statements.some( + (statement) => + ts.isVariableStatement(statement) && + statement.declarationList.declarations.some((declaration) => declaresI18n(declaration.name)), + ) +} + +function hookEdits(source: ts.SourceFile, component: ts.FunctionLikeDeclaration): Edit[] { + if (bindsI18n(component)) return [] + const body = component.body + if (!body) return [] + if (ts.isBlock(body)) { + const at = body.getStart(source) + 1 + // Keep the body's own first statement on a line of its own. + const separator = /^[ \t]*\r?\n/.test(source.text.slice(at)) ? '' : '\n' + return [{ start: at, end: at, text: `\n const i18n = ${HOOK}()${separator}` }] + } + return [ + { + start: body.getStart(source), + end: body.getStart(source), + text: `{\n const i18n = ${HOOK}()\n return (`, + }, + { start: body.end, end: body.end, text: ')\n}' }, + ] +} + +function importEdit(source: ts.SourceFile): Edit | undefined { + for (const statement of source.statements) { + if ( + ts.isImportDeclaration(statement) && + ts.isStringLiteral(statement.moduleSpecifier) && + statement.moduleSpecifier.text === '@pascal-app/core' + ) { + const bindings = statement.importClause?.namedBindings + if (bindings && ts.isNamedImports(bindings)) { + if (bindings.elements.some((element) => element.name.text === HOOK)) return undefined + if (!statement.importClause?.isTypeOnly) { + const last = bindings.elements[bindings.elements.length - 1] + if (last) return { start: last.end, end: last.end, text: `, ${HOOK}` } + } + } + } + } + const firstImport = source.statements.find(ts.isImportDeclaration) + if (firstImport) { + const at = firstImport.getStart(source) + return { start: at, end: at, text: `import { ${HOOK} } from '@pascal-app/core'\n` } + } + const directive = source.statements.find( + (statement) => + ts.isExpressionStatement(statement) && + ts.isStringLiteral(statement.expression) && + statement.expression.text.startsWith('use '), + ) + const at = directive ? directive.end : 0 + return { + start: at, + end: at, + text: `${directive ? '\n\n' : ''}import { ${HOOK} } from '@pascal-app/core'\n`, + } +} + +/** + * In the Next app a module without `'use client'` may render inside a server + * component (pages, layouts, shared shells), where hooks crash the request. + */ +function hasUseClient(source: ts.SourceFile): boolean { + return source.statements.some( + (statement) => + ts.isExpressionStatement(statement) && + ts.isStringLiteral(statement.expression) && + statement.expression.text === 'use client', + ) +} + +function resolveModule(from: string, specifier: string): string | undefined { + const base = join(dirname(from), specifier) + for (const candidate of [ + base, + `${base}.tsx`, + `${base}.ts`, + join(base, 'index.tsx'), + join(base, 'index.ts'), + ]) { + if (/\.tsx?$/.test(candidate) && existsSync(join(ROOT, candidate))) return candidate + } + return undefined +} + +let editorEntryModules: Set | undefined + +/** + * Modules a host imports straight from `@pascal-app/editor` (the package + * `exports` and everything the entry barrel re-exports). A host may render + * those from a server component, so a module without `'use client'` cannot + * take a hook. + */ +export function editorEntryModuleSet(): Set { + if (editorEntryModules) return editorEntryModules + const modules = new Set() + const manifest = JSON.parse(readFileSync(join(ROOT, 'packages/editor/package.json'), 'utf8')) as { + exports: Record> + } + for (const target of Object.values(manifest.exports)) { + if (typeof target === 'string' && /\.tsx?$/.test(target)) { + modules.add(join('packages/editor', target)) + } + } + const barrel = 'packages/editor/src/index.tsx' + const source = parseSource(barrel, readFileSync(join(ROOT, barrel), 'utf8')) + for (const statement of source.statements) { + if ( + ts.isExportDeclaration(statement) && + statement.moduleSpecifier && + ts.isStringLiteral(statement.moduleSpecifier) && + statement.moduleSpecifier.text.startsWith('.') + ) { + const resolved = resolveModule(barrel, statement.moduleSpecifier.text) + if (resolved) modules.add(resolved) + } + } + editorEntryModules = modules + return modules +} + +function isServerOnly(path: string, source: ts.SourceFile): boolean { + if (hasUseClient(source)) return false + if (path.startsWith('apps/editor/')) return true + return editorEntryModuleSet().has(path) +} + +/** Edge ASCII spaces become `{' '}` beside the call; a no-break space stays in the message. */ +function spacing(normalized: string): { lead: string; trail: string } { + return { + lead: /^[ \t\r\n]/.test(normalized) ? "{' '}" : '', + trail: /[ \t\r\n]$/.test(normalized) ? "{' '}" : '', + } +} + +export type DescriptorTargets = { + /** Object keys whose module-level string values become lazy descriptors. */ + keys?: ReadonlySet + /** Variables whose every string property value becomes a lazy descriptor. */ + variables?: ReadonlySet +} + +function declaredIn(node: ts.Node): string | undefined { + for (let current = node.parent; current; current = current.parent) { + if (ts.isVariableDeclaration(current) && ts.isIdentifier(current.name)) return current.name.text + } + return undefined +} + +/** + * Module data cannot call a hook, so its copy becomes `/* i18n *\/ { id, message }`, + * which Lingui extracts and components render with `i18n._(descriptor)`. The + * value's type changes to `MessageDescriptor`; the type checker then points at + * every consumer to update. + */ +function isDescriptorObject(node: ts.Node): boolean { + if (!ts.isObjectLiteralExpression(node)) return false + const names = new Set( + node.properties.flatMap((property) => + ts.isPropertyAssignment(property) && ts.isIdentifier(property.name) + ? [property.name.text] + : [], + ), + ) + return names.has('id') && names.has('message') +} + +function isTranslationCall(node: ts.Node): boolean { + return ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + node.expression.name.text === '_' + ) +} + +function descriptorEdits( + source: ts.SourceFile, + path: string, + registry: IdRegistry, + allow: Set, + targets: DescriptorTargets, + planned: Planned[], +): Edit[] { + const edits: Edit[] = [] + const visit = (node: ts.Node): void => { + // Already translated: an existing descriptor or i18n._ call is left alone. + if (isDescriptorObject(node) || isTranslationCall(node)) return + if ( + ts.isPropertyAssignment(node) && + (ts.isIdentifier(node.name) || ts.isStringLiteral(node.name)) && + (ts.isStringLiteral(node.initializer) || + ts.isNoSubstitutionTemplateLiteral(node.initializer)) && + !enclosingComponent(node) + ) { + const variable = declaredIn(node) + const wanted = + targets.keys?.has(node.name.text) || + (variable !== undefined && targets.variables?.has(variable)) + const value = node.initializer.text + if (wanted && /\p{L}{2}/u.test(value) && !allow.has(value.trim())) { + const message = icuLiteral(value) + const id = registry.idFor(path, message) + planned.push({ + path, + line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1, + id, + message, + }) + edits.push({ + start: node.initializer.getStart(source), + end: node.initializer.end, + text: `/* i18n */ { id: ${quote(id)}, message: ${quote(message)} }`, + }) + } + } + ts.forEachChild(node, visit) + } + visit(source) + return edits +} + +export function convertSource( + path: string, + text: string, + registry: IdRegistry, + allow: Set, + targets: DescriptorTargets = {}, + allKinds = path.endsWith('.tsx'), +): { output: string; planned: Planned[] } { + const source = parseSource(path, text) + const planned: Planned[] = [] + const edits: Edit[] = descriptorEdits(source, path, registry, allow, targets, planned) + const described = new Set(edits.map((edit) => edit.start)) + const components = new Set() + const serverOnly = isServerOnly(path, source) + + const owner = (node: ts.Node, message: string): ts.FunctionLikeDeclaration | undefined => { + const line = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1 + const { owner: component, problem } = componentOwner(node) + const manual = serverOnly + ? 'may render in a server component: move the copy into a client leaf or pass the request scope' + : component + ? undefined + : `${problem}: needs a lazy descriptor, an i18n parameter or the enclosing component's hook` + if (manual) { + planned.push({ path, line, message, manual }) + return undefined + } + return component + } + + const record = (node: ts.Node, message: string): string => { + const id = registry.idFor(path, message) + planned.push({ + path, + line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1, + id, + message, + }) + return id + } + + visitUiLiterals( + source, + { + jsxText(node, normalized) { + const message = icuLiteral(normalized.replace(ASCII_EDGE_WHITESPACE, '')) + const component = owner(node, message) + if (!component) return + components.add(component) + const { lead, trail } = spacing(normalized) + const raw = node.text + const before = raw.match(/^\s*/)?.[0] ?? '' + const after = raw.match(/\s*$/)?.[0] ?? '' + edits.push({ + start: node.pos, + end: node.end, + text: `${before.includes('\n') ? before : ''}${lead}{${descriptor(record(node, message), message, [])}}${trail}${after.includes('\n') ? after : ''}`, + }) + }, + sentence(element, children: SentenceChild[]) { + const values = uniqueValues( + source, + children.flatMap((child) => (child.type === 'value' ? [child] : [])), + ) + // Adjacent text and static pieces render as one run, so they are + // encoded together (`{'{'}{'}'}` is the literal `{}`), and a run before + // a placeholder is encoded knowing a `{` follows. + const runs: Array<{ text: string } | { name: string }> = [] + let index = 0 + for (const child of children) { + if (child.type === 'value') { + runs.push({ name: values.placeholders[index]! }) + index += 1 + continue + } + const previous = runs.at(-1) + if (previous && 'text' in previous) previous.text += child.text + else runs.push({ text: child.text }) + } + const edges = runs.map((run, runIndex) => { + if (!('text' in run)) return run + let text = run.text + if (runIndex === 0) text = text.replace(/^[ \t\r\n]+/, '') + if (runIndex === runs.length - 1) text = text.replace(/[ \t\r\n]+$/, '') + return { text } + }) + const message = edges + .map((run, runIndex) => + 'text' in run + ? icuLiteral(run.text, runIndex + 1 < edges.length ? '{' : undefined) + : `{${run.name}}`, + ) + .join('') + const normalized = children + .map((child) => (child.type === 'value' ? 'x' : child.text)) + .join('') + const component = owner(element, message) + if (!component) return + components.add(component) + const { lead, trail } = spacing(normalized) + const first = children[0]!.node + const last = children[children.length - 1]!.node + edits.push({ + start: first.getStart(source), + end: last.end, + text: `${lead}{${descriptor(record(element, message), message, values.values)}}${trail}`, + }) + }, + literal(node) { + if (described.has(node.getStart(source))) return + const message = icuLiteral(attributeText(node)) + const component = owner(node, message) + if (!component) return + components.add(component) + const call = descriptor(record(node, message), message, []) + const directAttribute = ts.isJsxAttribute(node.parent) + edits.push({ + start: node.getStart(source), + end: node.end, + text: directAttribute ? `{${call}}` : call, + }) + }, + concatenation(node, value) { + planned.push({ + path, + line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1, + message: value, + manual: 'concatenated copy: write one message with placeholders', + }) + }, + constant(node, value) { + planned.push({ + path, + line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1, + message: value, + manual: `string constant ${node.text}: make it a descriptor or translate at the use`, + }) + }, + template(node) { + const names = node.templateSpans.map((span) => placeholderName(span.expression)) + const preview = [ + node.head.text, + ...node.templateSpans.map((span) => span.literal.text), + ].join('{…}') + if (names.includes(undefined)) { + planned.push({ + path, + line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1, + message: preview, + manual: 'complex template interpolation: name the values by hand', + }) + return + } + const values = uniqueValues( + source, + node.templateSpans.map((span, spanIndex) => ({ + name: names[spanIndex]!, + expression: span.expression, + })), + ) + const message = [ + icuLiteral(node.head.text, '{'), + ...node.templateSpans.map( + (span, spanIndex) => + `{${values.placeholders[spanIndex]}}${icuLiteral( + span.literal.text, + spanIndex + 1 < node.templateSpans.length ? '{' : undefined, + )}`, + ), + ].join('') + const component = owner(node, message) + if (!component) return + components.add(component) + edits.push({ + start: node.getStart(source), + end: node.end, + text: descriptor(record(node, message), message, values.values), + }) + }, + }, + allow, + allKinds, + ) + + if (edits.length === 0) return { output: text, planned } + for (const component of components) edits.push(...hookEdits(source, component)) + const needsImport = [...components].some((component) => !bindsI18n(component)) + const importChange = needsImport ? importEdit(source) : undefined + if (importChange) edits.push(importChange) + + let output = text + for (const edit of edits.sort((a, b) => b.start - a.start || b.end - a.end)) { + output = output.slice(0, edit.start) + edit.text + output.slice(edit.end) + } + return { output, planned } +} + +export async function convertFiles(args: string[]): Promise { + const apply = args.includes('--apply') + const list = (name: string) => + new Set( + args + .find((arg) => arg.startsWith(`--${name}=`)) + ?.slice(name.length + 3) + .split(',') + .filter(Boolean) ?? [], + ) + const targets: DescriptorTargets = { keys: list('keys'), variables: list('vars') } + const prefixes = args.filter((arg) => !arg.startsWith('--')) + if (prefixes.length === 0) throw new Error('Pass the files or directories to convert') + const allowlist = JSON.parse(readFileSync(ALLOWLIST_PATH, 'utf8')) as { texts: string[] } + const allow = new Set(allowlist.texts) + const registry = new IdRegistry(existingMessages()) + const changed: string[] = [] + let converted = 0 + let manual = 0 + const explicit = prefixes.filter( + (prefix) => /\.tsx?$/.test(prefix) && existsSync(join(ROOT, prefix)), + ) + const files = new Set([ + ...explicit, + ...scannedFiles().filter((path) => + prefixes.some((prefix) => path === prefix || path.startsWith(prefix.replace(/\/?$/, '/'))), + ), + ]) + for (const path of [...files].sort()) { + const text = readFileSync(join(ROOT, path), 'utf8') + const { output, planned } = convertSource( + path, + text, + registry, + allow, + targets, + path.endsWith('.tsx') || explicit.includes(path), + ) + for (const item of planned) { + if (item.manual) manual += 1 + else converted += 1 + console.log( + `${item.path}:${item.line}\t${item.id ?? 'MANUAL'}\t${JSON.stringify(item.message)}${item.manual ? `\t${item.manual}` : ''}`, + ) + } + if (output !== text) { + changed.push(path) + if (apply) writeFileSync(join(ROOT, path), output) + } + } + console.log( + `\n${converted} literals ${apply ? 'converted' : 'convertible'} in ${changed.length} files; ${manual} need manual work`, + ) + if (apply && changed.length > 0) { + Bun.spawnSync([join(ROOT, 'node_modules/.bin/biome'), 'check', '--write', ...changed], { + cwd: ROOT, + stdout: 'inherit', + stderr: 'inherit', + }) + } +} diff --git a/scripts/i18n-git.ts b/scripts/i18n-git.ts new file mode 100644 index 0000000000..e7188d7e8a --- /dev/null +++ b/scripts/i18n-git.ts @@ -0,0 +1,77 @@ +import { dirname, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +/** `I18N_ROOT` points the checks at another checkout (tests use a temporary repository). */ +const ROOT = process.env.I18N_ROOT + ? resolve(process.env.I18N_ROOT) + : resolve(dirname(fileURLToPath(import.meta.url)), '..') + +function git(args: string[]): string | undefined { + const result = Bun.spawnSync(['git', ...args], { cwd: ROOT, stdout: 'pipe', stderr: 'ignore' }) + return result.exitCode === 0 ? result.stdout.toString() : undefined +} + +/** + * The revision a change is compared with. In a pull_request CI run the + * checkout is GitHub's merge commit, whose first parent is the base tip; on a + * push CI sets `I18N_BASE_REF` to `github.event.before`, covering every commit + * of the push; locally it is the merge base with origin/main. + */ +export function baseRevision(): string | undefined { + const configured = process.env.I18N_BASE_REF?.trim() + // A push that creates a branch reports an all-zero "before" commit. + const requested = + (configured && !/^0+$/.test(configured) ? configured : undefined) ?? + (process.env.GITHUB_EVENT_NAME === 'pull_request' + ? 'HEAD^1' + : git(['merge-base', 'HEAD', 'origin/main'])?.trim()) + if (!requested) return undefined + const revision = git(['rev-parse', '--verify', '--quiet', `${requested}^{commit}`])?.trim() + if (!revision) { + if (process.env.GITHUB_EVENT_NAME === 'pull_request') { + throw new Error(`Base revision ${requested} is not available; fetch it before i18n:check`) + } + return undefined + } + return revision === git(['rev-parse', 'HEAD'])?.trim() ? undefined : revision +} + +export function readAtRevision(revision: string, path: string): string | undefined { + return git(['show', `${revision}:${relative(ROOT, path)}`]) +} + +/** + * The newest release tag of `pkg` reachable from HEAD. The release workflow + * tags every package `@pascal-app/@`. + */ +export function lastReleaseTag(pkg: string): { tag: string; revision: string } | undefined { + const tag = git([ + 'describe', + '--tags', + '--abbrev=0', + '--match', + `@pascal-app/${pkg}@*`, + 'HEAD', + ])?.trim() + const revision = tag + ? git(['rev-parse', '--verify', '--quiet', `${tag}^{commit}`])?.trim() + : undefined + return tag && revision ? { tag, revision } : undefined +} + +export function resolveRevision(ref: string): string | undefined { + return git(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`])?.trim() +} + +export function headRevision(): string | undefined { + return resolveRevision('HEAD') +} + +/** The oldest commit on HEAD's history that added `path`. */ +export function firstCommitWith(path: string): string | undefined { + const added = git(['log', '--diff-filter=A', '--format=%H', 'HEAD', '--', relative(ROOT, path)]) + ?.trim() + .split('\n') + .filter(Boolean) + return added?.at(-1) +} diff --git a/scripts/i18n-literals.test.ts b/scripts/i18n-literals.test.ts new file mode 100644 index 0000000000..7627482d28 --- /dev/null +++ b/scripts/i18n-literals.test.ts @@ -0,0 +1,785 @@ +import { expect, test } from 'bun:test' +import { cpSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { compileMessageOrThrow } from '@lingui/message-utils/compileMessage' +import { parsePo, stringifyPo } from 'pofile-ts' +import ts from 'typescript' +import { + annotatedState, + checkTranslationEntries, + hashSource, + type LocaleEntries, + possibleRenames, + pseudoLocalize, + reviewRegressions, + type SourceMessage, +} from './i18n' +import { + convertSource, + editorEntryModuleSet, + IdRegistry, + icuLiteral, + namespaceFor, +} from './i18n-codemod' +import { type Candidate, checkAllowlistGrowth, checkLiterals, scanSource } from './i18n-literals' +import { untranslatedWords } from './i18n-pseudo-audit' + +const allowlist = { texts: ['Pascal'], paths: {} } +const convert = (path: string, source: string) => + convertSource(path, source, new IdRegistry(), new Set(allowlist.texts)) + +const panel = `'use client' + +import { useState } from 'react' + +export function SharePanel({ count, name }: { count: number; name: string }) { + const [open] = useState(false) + return ( + + Share + + Shared with {count} people + + {name} + Pascal + + + ) +} +` + +test('the codemod wraps visible copy in runtime descriptors owned by the component', () => { + const { output, planned } = convert('apps/editor/components/share-panel.tsx', panel) + + expect(output).toContain("import { usePascalI18n } from '@pascal-app/core'") + expect(output).toContain('const i18n = usePascalI18n()') + expect(output).toContain( + `aria-label={i18n._({ id: 'app.sharePanel.shareProject', message: 'Share project' })}`, + ) + expect(output).toContain( + `open ? i18n._({ id: 'app.sharePanel.close', message: 'Close' }) : i18n._({ id: 'app.sharePanel.open', message: 'Open' })`, + ) + expect(output).toContain( + `{i18n._({ id: 'app.sharePanel.sharedWithCountPeople', message: 'Shared with {count} people', values: { count } })}`, + ) + expect(output).toContain('Pascal') + expect(output).toContain('alt=""') + expect(output).toContain('className="size-4 bg-muted"') + expect(planned.every((item) => !item.manual)).toBe(true) + + expect(convert('apps/editor/components/share-panel.tsx', output).output).toBe(output) + expect(scanSource('apps/editor/components/share-panel.tsx', output, allowlist)).toEqual([]) +}) + +test('the codemod leaves module-level copy for a person and never edits it', () => { + const source = `const ITEMS = [{ label: 'Delete' }] + +export const Menu = () => {ITEMS.map((item) => {item.label})} +` + const { output, planned } = convert('packages/editor/src/components/ui/menu.tsx', source) + + expect(output).toBe(source) + expect(planned).toEqual([ + expect.objectContaining({ + message: 'Delete', + manual: expect.stringContaining('outside a component'), + }), + ]) +}) + +test('module data becomes lazy descriptors only for the requested keys or variables', () => { + const source = `const MODES = [{ id: 'solid', name: 'Solid', icon: 'box' }] +const LABELS: Record = { up: 'Full height' } +const ROUTES = [{ name: 'home' }] +` + const { output } = convertSource( + 'apps/editor/components/viewer-toolbar.tsx', + source, + new IdRegistry(), + new Set(), + { keys: new Set(['name']), variables: new Set(['LABELS']) }, + ) + + expect(output).toContain( + "name: /* i18n */ { id: 'app.viewerToolbar.solid', message: 'Solid' }, icon: 'box'", + ) + expect(output).toContain( + "up: /* i18n */ { id: 'app.viewerToolbar.fullHeight', message: 'Full height' }", + ) + expect(output).toContain("id: 'solid'") + expect(output).toContain('ROUTES = [{ name: /* i18n */') +}) + +test('hooks are never placed in named callbacks or async functions', () => { + const source = `'use client' +export function List({ items }: { items: string[] }) { + return {items.map(function Row(item) { return Remove item })} +} +export async function Page() { + return Loading projects +} +` + const { output, planned } = convert('packages/editor/src/components/list.tsx', source) + expect(output).toBe(source) + expect(planned.map((item) => item.manual)).toEqual([ + expect.stringContaining('inside a callback function'), + expect.stringContaining('inside an async function'), + ]) +}) + +test('package entry modules without use client are left for a person', () => { + const [entry] = [...editorEntryModuleSet()].filter((path) => path.endsWith('.tsx')) + if (!entry) throw new Error('the editor entry exports no .tsx module') + const source = `export function Badge() {\n return New feature\n}\n` + expect(convert(entry, source).planned[0]?.manual).toContain('server component') + expect(convert(entry, `'use client'\n${source}`).planned[0]?.manual).toBeUndefined() +}) + +test('a second --vars pass leaves existing descriptors alone', () => { + const source = `const LABELS: Record = { up: 'Full height' }\n` + const targets = { variables: new Set(['LABELS']) } + const once = convertSource( + 'apps/editor/components/t.tsx', + source, + new IdRegistry(), + new Set(), + targets, + ).output + const twice = convertSource( + 'apps/editor/components/t.tsx', + once, + new IdRegistry(), + new Set(), + targets, + ).output + expect(once).toContain("/* i18n */ { id: 'app.t.fullHeight', message: 'Full height' }") + expect(twice).toBe(once) +}) + +test('ICU encoding and entity decoding render the original text', () => { + for (const text of [ + 'Use {}', + "Don't", + "a '{'", + "''", + 'x}{y', + "{'}", + "it's {x} isn't", + "a'#b", + "'|'", + ]) { + expect((compileMessageOrThrow(icuLiteral(text)) as string[]).join('')).toBe(text) + } + const source = `'use client'\nexport function T() {\n return Use {'{}'} & it's done…\n}\n` + const message = convert('packages/editor/src/components/t.tsx', source).planned[0]?.message ?? '' + expect((compileMessageOrThrow(message) as string[]).join('')).toBe("Use {} & it's\u00a0done…") +}) + +test('an allowlisted text joined with static pieces stays exempt', () => { + const scan = (body: string) => + scanSource( + 'packages/editor/src/components/brand.tsx', + `export function Brand() {\n return ${body}\n}\n`, + allowlist, + ) + expect(scan("Pascal{' '}")).toEqual([]) + expect(scan('Pascal')).toEqual([]) +}) + +test('a parenthesized named callback is not a hook owner', () => { + const source = `'use client' +export function List({ items }: { items: string[] }) { + return {items.map((function Row(item) { return Remove item }))} +} +` + const { output, planned } = convert('packages/editor/src/components/list.tsx', source) + expect(output).toBe(source) + expect(planned[0]?.manual).toContain('inside a callback function') +}) + +test('a constant concatenated with a value is concatenated copy', () => { + const source = `const LABEL = 'Save' +export function Row({ name }: { name: string }) { + return {LABEL + ' ' + name} +} +` + const [found] = scanSource('packages/editor/src/components/row.tsx', source, allowlist) + expect(found?.text).toBe('Save {?}') + expect(found?.manual).toContain('concatenated copy') +}) + +const render = (message: string, values: Record = {}) => + (compileMessageOrThrow(message) as Array) + .map((token) => (typeof token === 'string' ? token : values[token[0]])) + .join('') + +test('converted messages render exactly the original text', () => { + const messageOf = (body: string, attributes = '') => + convert( + 'packages/editor/src/components/t.tsx', + `'use client'\nexport function T({ name }: { name: string }) {\n return ${body}\n}\n`, + ).planned.map((item) => item.message) + const [braces] = messageOf("Use {'{'}{'}'}") + expect(render(braces!)).toBe('Use {}') + const [spaced] = messageOf(' Copy ') + expect(render(spaced!)).toBe('\u00a0Copy\u00a0') + const [quoted] = messageOf("Rename '{name}'") + expect(render(quoted!, { name: 'Lobby' })).toBe("Rename 'Lobby'") + const titled = messageOf('Open', ' title="Tom & Jerry"').find((message) => message !== 'Open') + expect(render(titled ?? '')).toBe('Tom & Jerry') + const template = convert( + 'packages/editor/src/components/t.tsx', + `'use client'\nexport function T({ name }: { name: string }) {\n return \n}\n`, + ).planned[0]?.message + expect(render(template!, { name: 'Lobby' })).toBe("Rename 'Lobby'") +}) + +test('inlined constants with line breaks are emitted as valid string literals', () => { + const source = `'use client' +const LABEL = 'Save\\r\\nnow' +export function T({ name }: { name: string }) { + return {LABEL} for {name} +} +` + const { output } = convert('packages/editor/src/components/t.tsx', source) + const result = ts.transpileModule(output, { + reportDiagnostics: true, + compilerOptions: { jsx: ts.JsxEmit.Preserve }, + fileName: 't.tsx', + }) + expect(result.diagnostics ?? []).toEqual([]) + expect(output).toContain("message: 'Save\\r\\nnow for {name}'") +}) + +test('app modules without use client are left for a person', () => { + const source = `export function AppHeader() { + return +} +` + const { output, planned } = convert('apps/editor/components/app-shell.tsx', source) + expect(output).toBe(source) + expect(planned[0]?.manual).toContain('server component') +}) + +test('an expression-bodied component gains a block body for the hook', () => { + const source = `export const Empty = () => No layers yet\n` + const { output } = convert('packages/editor/src/components/ui/sidebar/empty.tsx', source) + + expect(output).toContain('const i18n = usePascalI18n()') + expect(output).toContain( + "return ({i18n._({ id: 'editor.sidebar.empty.noLayersYet', message: 'No layers yet' })})", + ) +}) + +test('message IDs follow the owning package and path, disambiguating different copy', () => { + expect(namespaceFor('packages/editor/src/components/ui/sidebar/sidebar.tsx')).toBe( + 'editor.sidebar', + ) + expect(namespaceFor('packages/nodes/src/roof-segment/inspector/panel.tsx')).toBe( + 'node.roofSegment.inspector.panel', + ) + const registry = new IdRegistry({ 'app.navbar.share': 'Share' }) + expect(registry.idFor('apps/editor/components/navbar.tsx', 'Share')).toBe('app.navbar.share') + expect(registry.idFor('apps/editor/components/navbar.tsx', 'Share…')).toBe('app.navbar.share2') +}) + +const candidate = (text: string): Candidate => ({ kind: 'jsx-text', text, line: 1 }) + +test('the guard fails on copy the baseline does not list and reports progress', () => { + const scan = new Map([ + ['a.tsx', [candidate('Share'), candidate('Share')]], + ['new.tsx', [candidate('Hello')]], + ]) + const { errors, improvable } = checkLiterals(scan, { + version: 3, + files: { 'a.tsx': ['Share', 'Share', 'Copy'], 'gone.tsx': ['Old'] }, + }) + + expect(errors).toHaveLength(1) + expect(errors[0]).toContain('new.tsx: untranslated user-visible literals not in the baseline') + expect(improvable).toEqual(['a.tsx: 1 fewer', 'gone.tsx: 1 fewer']) +}) + +test('swapping baselined copy for new copy fails even when the count is unchanged', () => { + const { errors } = checkLiterals(new Map([['a.tsx', [candidate('Delete everything now')]]]), { + version: 3, + files: { 'a.tsx': ['Share'] }, + }) + expect(errors[0]).toContain('"Delete everything now"') +}) + +test('the baseline may only shrink relative to the base branch', () => { + const { errors } = checkLiterals( + new Map([['a.tsx', [candidate('Share'), candidate('Copy')]]]), + { version: 3, files: { 'a.tsx': ['Copy', 'Share'] } }, + { version: 3, files: { 'a.tsx': ['Share'] } }, + ) + expect(errors).toHaveLength(1) + expect(errors[0]).toContain('"Copy" (a.tsx)') +}) + +test('copy that moves with a git mv, a split or an extraction does not grow the baseline', () => { + const scan = new Map([ + ['new.tsx', [candidate('Share'), candidate('Copy')]], + ['extracted.tsx', [candidate('Delete')]], + ]) + const baseline = { + version: 3 as const, + files: { 'new.tsx': ['Copy', 'Share'], 'extracted.tsx': ['Delete'] }, + } + const base = { version: 3 as const, files: { 'old.tsx': ['Copy', 'Delete', 'Share'] } } + expect(checkLiterals(scan, baseline, base).errors).toEqual([]) + + const duplicated = { version: 3 as const, files: { ...baseline.files, 'copy.tsx': ['Share'] } } + expect(checkLiterals(scan, duplicated, base).errors[0]).toContain('"Share" (new.tsx, copy.tsx)') +}) + +test('the allowlist may not gain exemptions relative to the base', () => { + expect( + checkAllowlistGrowth( + { + texts: ['Pascal', 'Delete'], + paths: { 'apps/editor/app/dev/': 'dev', 'packages/x/': 'why' }, + }, + { texts: ['Pascal'], paths: { 'apps/editor/app/dev/': 'dev' } }, + ), + ).toEqual([ + 'i18n/literal-allowlist.json adds the text "Delete"; exempt one use with i18n-ignore: instead', + 'i18n/literal-allowlist.json adds the path "packages/x/"; exempting a path needs a reviewed exception', + ]) + expect(checkAllowlistGrowth({ texts: ['Pascal'], paths: {} }, undefined)).toEqual([]) +}) + +test('equivalent JSX spellings scan to the same rendered text', () => { + const texts = (body: string) => + scanSource( + 'packages/editor/src/components/share.tsx', + `export function Share() {\n return ${body}\n}\n`, + allowlist, + ).map((found) => found.text) + expect(texts("Share{' '}project")).toEqual(['Share project']) + expect(texts('Share project')).toEqual(['Share project']) + expect(texts('Tom &…')).toEqual(['Tom\u00a0&…']) +}) + +test('constants rendered inside a sentence and concatenated copy are scanned', () => { + const scan = (label: string) => + scanSource( + 'packages/editor/src/components/when.tsx', + `const LABEL = '${label}'\nexport function When({ name }: { name: string }) {\n return (\n \n {LABEL} now\n {'Delete ' + name}\n \n )\n}\n`, + allowlist, + ) + expect(scan('Save').map((found) => found.text)).toEqual(['Save now', 'Delete {?}']) + expect(scan('Publish')[0]?.text).toBe('Publish now') + expect(scan('Save')[1]?.manual).toContain('concatenated copy') +}) + +test('an i18n-ignore comment exempts data that only looks like copy', () => { + const source = `export function Credits() { + return ( + + {/* i18n-ignore: product name */} + Made with Three.js + + ) +} +` + expect(scanSource('packages/editor/src/components/credits.tsx', source, allowlist)).toEqual([]) +}) + +test('a trailing i18n-ignore exempts only its own line', () => { + const source = `export function Credits() { + return ( + + {/* i18n-ignore: library name */} + + + ) +} +` + expect( + scanSource('packages/editor/src/components/credits.tsx', source, allowlist).map( + (found) => found.text, + ), + ).toEqual(['Delete everything']) +}) + +test('an i18n-ignore without a reason does not exempt anything', () => { + const source = `export function Credits() { + // i18n-ignore + return +} +` + expect(scanSource('packages/editor/src/components/credits.tsx', source, allowlist)).toHaveLength( + 1, + ) +}) + +test('hyphenated labels and string constants rendered in JSX are copy', () => { + const source = `const DELETE_LABEL = 'Delete everything' +const ICON = 'lucide:trash' + +export function Toolbar() { + return ( + + + Read-only + {DELETE_LABEL} + + ) +} +` + expect( + scanSource('packages/editor/src/components/toolbar.tsx', source, allowlist).map( + (found) => found.text, + ), + ).toEqual(['zoom-in', 'Read-only', 'Delete everything']) +}) + +test('a rendered constant resolves through scope, not just its name', () => { + const source = `const LABEL = 'Shown label' + +function Other() { + const LABEL = 'Never rendered' + return LABEL.length +} + +export function Panel({ title }: { title: string }) { + const LABEL = title + return ( + + {LABEL} + + + ) +} + +function Inner() { + return {LABEL} +} +` + expect( + scanSource('packages/editor/src/components/panel.tsx', source, allowlist).map( + (found) => found.text, + ), + ).toEqual(['Shown label']) +}) + +test('a notification description is one candidate and one edit', () => { + const source = `export function Save() { + return toast.error('Failed', { description: 'Try again' })} /> +} +` + const path = 'packages/editor/src/components/save.tsx' + expect(scanSource(path, source, allowlist).map((found) => found.text)).toEqual([ + 'Failed', + 'Try again', + ]) + const { output } = convert(path, source) + expect(output.match(/message: 'Try again'/g)).toHaveLength(1) +}) + +test('a one-line block body keeps its statement after the inserted hook', () => { + const source = `export function Empty() { return No layers yet }\n` + const { output } = convert('packages/editor/src/components/ui/sidebar/empty.tsx', source) + expect(output).toContain('const i18n = usePascalI18n()\n return ') + expect(() => + scanSource('packages/editor/src/components/ui/sidebar/empty.tsx', output, allowlist), + ).not.toThrow() + expect(convert('packages/editor/src/components/ui/sidebar/empty.tsx', output).output).toBe(output) +}) + +test('the audit allowlist removes whole words only', () => { + expect(untranslatedWords('Find · 2 in', ['in'])).toEqual(['Find']) + expect(untranslatedWords('Pin', ['in'])).toEqual(['Pin']) + expect(untranslatedWords('[Ƒîñð] 2 in', ['in'])).toEqual([]) +}) + +test('pseudo-localization accents every letter and keeps ICU structure', () => { + const message = 'Delete {count, plural, one {# wall} other {# walls}} from <0>{level}0>?' + const pseudo = pseudoLocalize(message) + + expect(pseudo).toBe( + '[Ðéļéţé {count, plural, one {# ŵáļļ} other {# ŵáļļš}} ƒŕöɱ <0>{level}0>? ~~~~~~]', + ) + expect(() => compileMessageOrThrow(pseudo)).not.toThrow() + expect(pseudoLocalize("It''s '{'literal'}'")).toBe("[Îţ''š '{'ļîţéŕáļ'}' ~~~]") +}) + +const advanced: SourceMessage = { + id: 'editor.buildPanel.advanced', + message: 'Advanced', + context: [], +} +const entry = (overrides: Partial) => ({ + translation: 'Avancé', + sourceHash: hashSource('Advanced'), + review: 'human' as const, + fuzzy: false, + ...overrides, +}) +const everyLocale = (value: ReturnType): LocaleEntries => + Object.fromEntries( + ['zh-CN', 'fr', 'es', 'ja'].map((locale) => [locale, { [advanced.id]: value }]), + ) + +test('AI drafts pass ordinary checks but block the reviewed release gate', () => { + const drafts = everyLocale(entry({ review: 'required', fuzzy: true })) + expect(checkTranslationEntries([advanced], drafts, {}, {})).toEqual([]) + expect(checkTranslationEntries([advanced], drafts, {}, { requireReviewed: true })).toContain( + 'fr: "editor.buildPanel.advanced" still requires human review', + ) + expect( + checkTranslationEntries([advanced], everyLocale(entry({ review: 'missing' })), {}, {}), + ).toContain( + 'fr: "editor.buildPanel.advanced" is unreviewed but not marked fuzzy + Review: required', + ) +}) + +test('after a wording change, extract leaves a passing draft and a reviewed stale entry fails', () => { + const reworded = { ...advanced, message: 'Advanced tools' } + const staleDraft = everyLocale(entry({ review: 'required', fuzzy: true })) + expect(checkTranslationEntries([reworded], staleDraft, {}, {})).toEqual([]) + expect(checkTranslationEntries([reworded], staleDraft, {}, { requireReviewed: true })).toContain( + 'fr: stale "editor.buildPanel.advanced"', + ) + expect(checkTranslationEntries([reworded], everyLocale(entry({})), {}, {})).toContain( + 'fr: stale "editor.buildPanel.advanced"', + ) +}) + +test('a reviewed translation cannot fall back to draft while its English is unchanged', () => { + const base = everyLocale(entry({})) + expect( + reviewRegressions([advanced], everyLocale(entry({ review: 'required', fuzzy: true })), base), + ).toContain( + 'fr: "editor.buildPanel.advanced" is reviewed on the base branch with the same English and must stay reviewed', + ) + expect( + reviewRegressions([advanced], everyLocale(entry({ translation: 'Avancés' })), base), + ).toEqual([]) + const reworded = { ...advanced, message: 'Advanced tools' } + expect( + reviewRegressions([reworded], everyLocale(entry({ review: 'required', fuzzy: true })), base), + ).toEqual([]) +}) + +test('an unrelated addition sharing English with a removed reviewed ID only warns', () => { + const off = { id: 'viewer.common.off', message: 'Off', context: [] } + const added = { id: 'viewer.audio.off', message: 'Off', context: [] } + const reviewed = (translation: string) => ({ + translation, + sourceHash: hashSource('Off'), + review: 'human' as const, + fuzzy: false, + }) + const base: LocaleEntries = Object.fromEntries( + ['zh-CN', 'fr', 'es', 'ja'].map((locale) => [locale, { [off.id]: reviewed('Désactivé') }]), + ) + const current: LocaleEntries = Object.fromEntries( + ['zh-CN', 'fr', 'es', 'ja'].map((locale) => [ + locale, + { [added.id]: { ...reviewed('Désactivé'), review: 'required' as const, fuzzy: true } }, + ]), + ) + expect(reviewRegressions([added], current, base)).toEqual([]) + expect(possibleRenames([added], current, base)).toEqual([ + `possible rename "viewer.common.off" → "viewer.audio.off" (same English): carry over the translation if intended with comment: 'renamed-from: ' and bun run i18n:extract`, + ]) +}) + +test('a declared rename must carry the reviewed translation over', () => { + const renamed = { ...advanced, id: 'editor.buildPanel.advancedTools' } + const base = everyLocale(entry({})) + const declared = (overrides: Partial): LocaleEntries => + Object.fromEntries( + ['zh-CN', 'fr', 'es', 'ja'].map((locale) => [ + locale, + { [renamed.id]: entry({ renamedFrom: advanced.id, ...overrides }) }, + ]), + ) + expect( + reviewRegressions([renamed], declared({ review: 'required', fuzzy: true }), base), + ).toContain( + 'fr: "editor.buildPanel.advancedTools" is declared renamed-from "editor.buildPanel.advanced", whose reviewed translation was not carried over; run bun run i18n:extract', + ) + expect(reviewRegressions([renamed], declared({}), base)).toEqual([]) + expect(possibleRenames([renamed], declared({ review: 'required', fuzzy: true }), base)).toEqual( + [], + ) +}) + +test('an explicit base that cannot be resolved fails the release job', () => { + const result = Bun.spawnSync( + ['bun', 'scripts/i18n.ts', 'release-check', '--base', 'nonexistent-ref'], + { env: { ...process.env, GITHUB_STEP_SUMMARY: '' }, stdout: 'pipe', stderr: 'pipe' }, + ) + expect(result.exitCode).toBe(1) + expect(`${result.stdout}${result.stderr}`).toContain('--base nonexistent-ref is not a commit') +}) + +test('extraction never approves a newly filled translation by itself', () => { + const hash = hashSource('Advanced') + const pasted = { translation: 'Avancé', review: 'missing' as const, fuzzy: false } + expect(annotatedState(pasted, 'Avancé', hash)).toEqual({ + sourceHash: hash, + review: 'required', + fuzzy: true, + }) + expect(annotatedState({ ...pasted, review: 'human' }, 'Avancé', hash)).toEqual({ + sourceHash: hash, + review: 'human', + fuzzy: false, + }) + expect(annotatedState({ ...pasted, review: 'human', sourceHash: hash }, 'Avancé', hash)).toBe( + undefined, + ) +}) + +const LOCALES_DIR = 'packages/editor/src/i18n/locales' + +/** A copy of the real catalogs to run the release job against, edited per case. */ +function catalogFixture(edit: (root: string) => void): { current: string; base: string } { + const root = mkdtempSync(join(tmpdir(), 'pascal-i18n-')) + const base = join(root, 'base') + const current = join(root, 'current') + cpSync(LOCALES_DIR, base, { recursive: true }) + cpSync(LOCALES_DIR, current, { recursive: true }) + edit(current) + return { current, base } +} + +function releaseCheckJob(fixture: { current: string; base: string }) { + const result = Bun.spawnSync( + ['bun', 'scripts/i18n.ts', 'release-check', '--base-dir', fixture.base], + { + env: { ...process.env, I18N_CATALOG_ROOT: fixture.current, GITHUB_STEP_SUMMARY: '' }, + stdout: 'pipe', + stderr: 'pipe', + }, + ) + return { exitCode: result.exitCode, output: `${result.stdout}${result.stderr}` } +} + +function markDraft(root: string, id: string): void { + const path = join(root, 'fr/messages.po') + const po = parsePo(readFileSync(path, 'utf8')) + const item = po.items.find((entry) => entry.msgid === id) + if (!item) throw new Error(`fixture entry ${id} moved`) + item.comments = item.comments.map((comment) => + comment.startsWith('Review: ') ? 'Review: required' : comment, + ) + item.flags.fuzzy = true + writeFileSync(path, stringifyPo(po, { foldLength: 0 })) +} + +test('the release job passes on unchanged catalogs', () => { + expect(releaseCheckJob(catalogFixture(() => {})).exitCode).toBe(0) +}) + +test('the release job fails when a reviewed entry becomes a draft with the same English', () => { + const { exitCode, output } = releaseCheckJob( + catalogFixture((root) => markDraft(root, 'editor.buildPanel.advanced')), + ) + expect(exitCode).toBe(1) + expect(output).toContain( + 'fr: "editor.buildPanel.advanced" is reviewed on the base branch with the same English and must stay reviewed', + ) +}) + +test('the release job fails when a compiled pack ships a draft', () => { + const { exitCode, output } = releaseCheckJob( + catalogFixture((root) => { + const path = join(root, 'fr/messages.ts') + const pack = readFileSync(path, 'utf8') + const draftId = parsePo(readFileSync(join(root, 'fr/messages.po'), 'utf8')).items.find( + (item) => item.flags.fuzzy, + )?.msgid + if (!draftId) throw new Error('fixture has no draft') + writeFileSync( + path, + pack.replace('JSON.parse("{', `JSON.parse("{\\"${draftId}\\":[\\"Brouillon\\"],`), + ) + }), + ) + expect(exitCode).toBe(1) + expect(output).toMatch(/fr: compiled pack ships unreviewed "[^"]+"/) +}) + +/** A throwaway repository with the real catalogs, for the default base lookup. */ +function releaseRepo(steps: (git: (...args: string[]) => void, root: string) => void): string { + const root = mkdtempSync(join(tmpdir(), 'pascal-release-')) + const git = (...args: string[]) => { + const result = Bun.spawnSync( + ['git', '-c', 'user.name=Test', '-c', 'user.email=test@example.com', ...args], + { cwd: root, stdout: 'ignore', stderr: 'pipe' }, + ) + if (result.exitCode !== 0) throw new Error(`git ${args.join(' ')}: ${result.stderr}`) + } + git('init', '-q', '-b', 'main') + steps(git, root) + return root +} + +const addCatalogs = (root: string) => + cpSync(LOCALES_DIR, join(root, LOCALES_DIR), { recursive: true }) + +function defaultReleaseCheck(root: string) { + const result = Bun.spawnSync(['bun', 'scripts/i18n.ts', 'release-check'], { + env: { ...process.env, I18N_ROOT: root, GITHUB_STEP_SUMMARY: '' }, + stdout: 'pipe', + stderr: 'pipe', + }) + return { exitCode: result.exitCode, output: `${result.stdout}${result.stderr}` } +} + +test('the release job compares with the latest editor release tag and fails on a reviewed-to-draft change', () => { + const root = releaseRepo((git, root) => { + addCatalogs(root) + git('add', '.') + git('commit', '-q', '-m', 'release') + git('tag', '@pascal-app/editor@9.9.9') + git('tag', 'v0.0.1') + markDraft(join(root, LOCALES_DIR), 'editor.buildPanel.advanced') + }) + const { exitCode, output } = defaultReleaseCheck(root) + expect(output).toContain('Comparing with @pascal-app/editor@9.9.9') + expect(exitCode).toBe(1) + expect(output).toContain( + 'fr: "editor.buildPanel.advanced" is reviewed on the base branch with the same English and must stay reviewed', + ) +}) + +test('a release tag without catalogs falls back to the commit that introduced them', () => { + const root = releaseRepo((git, root) => { + writeFileSync(join(root, 'README.md'), 'before i18n\n') + git('add', '.') + git('commit', '-q', '-m', 'old release') + git('tag', '@pascal-app/editor@1.0.0') + addCatalogs(root) + git('add', '.') + git('commit', '-q', '-m', 'catalogs') + writeFileSync(join(root, 'README.md'), 'after i18n\n') + git('add', '.') + git('commit', '-q', '-m', 'later work') + markDraft(join(root, LOCALES_DIR), 'editor.buildPanel.advanced') + }) + const { exitCode, output } = defaultReleaseCheck(root) + expect(output).toContain( + 'the commit that introduced the catalogs (@pascal-app/editor@1.0.0 has no catalogs)', + ) + expect(exitCode).toBe(1) + expect(output).toContain('fr: "editor.buildPanel.advanced" is reviewed on the base branch') +}) + +test('with no base that has catalogs the release job fails loudly', () => { + const root = releaseRepo((git, root) => { + addCatalogs(root) + git('add', '.') + git('commit', '-q', '-m', 'catalogs at HEAD only') + }) + const { exitCode, output } = defaultReleaseCheck(root) + expect(exitCode).toBe(1) + expect(output).toContain('release-check: no base with bundled PO catalogs') +}) diff --git a/scripts/i18n-literals.ts b/scripts/i18n-literals.ts new file mode 100644 index 0000000000..96531c5c8a --- /dev/null +++ b/scripts/i18n-literals.ts @@ -0,0 +1,931 @@ +import { readFileSync, writeFileSync } from 'node:fs' +import { dirname, join, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { decodeHTMLStrict } from 'entities' +import ts from 'typescript' +import { baseRevision, readAtRevision } from './i18n-git' + +/** + * User-visible literal scanner shared by the one-time codemod (`convert`) and + * the CI ratchet (`check`). Both see exactly the same candidates, so a file + * the codemod finished is a file the guard keeps finished. + */ + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const BASELINE_PATH = join(ROOT, 'i18n/literal-baseline.json') +const ALLOWLIST_PATH = join(ROOT, 'i18n/literal-allowlist.json') + +export const SCANNED_GLOBS = [ + 'apps/editor/**/*.tsx', + 'packages/editor/src/**/*.tsx', + 'packages/viewer/src/**/*.tsx', + 'packages/nodes/src/**/*.tsx', + 'packages/core/src/**/*.tsx', + 'packages/ui/src/**/*.tsx', + // Plain modules only contribute notification payloads (toast/alert/confirm). + 'apps/editor/**/*.ts', + 'packages/editor/src/**/*.ts', +] + +const SKIPPED_PATH = + /(?:\.test|\.spec|\.stories|\.bench|\.d)\.tsx?$|\/(?:__fixtures__|__tests__|__bench__|test-utils|node_modules|vendor)\// + +/** JSX props whose string value is read by people or assistive technology. */ +const UI_ATTRIBUTES = new Set([ + 'alt', + 'aria-description', + 'aria-label', + 'aria-placeholder', + 'aria-roledescription', + 'aria-valuetext', + 'cancelLabel', + 'caption', + 'confirmLabel', + 'description', + 'emptyLabel', + 'emptyMessage', + 'emptyText', + 'heading', + 'helperText', + 'hint', + 'label', + 'placeholder', + 'subtitle', + 'title', + 'tooltip', +]) + +/** Object keys that carry display copy when built inside a component. */ +const UI_PROPERTIES = new Set([ + 'ariaLabel', + 'cancelLabel', + 'confirmLabel', + 'description', + 'detail', + 'emptyMessage', + 'heading', + 'hint', + 'label', + 'placeholder', + 'subtitle', + 'title', + 'tooltip', +]) + +const NOTIFY_CALLEES = new Set([ + 'alert', + 'confirm', + 'prompt', + 'setError', + 'setErrorMessage', + 'setNotice', + 'setStatusMessage', + 'toast', +]) +const NOTIFY_METHODS = new Set(['success', 'error', 'info', 'warning', 'message', 'loading']) + +export type CandidateKind = + | 'jsx-text' + | 'jsx-sentence' + | 'attribute' + | 'expression' + | 'property' + | 'notify' + +export type Candidate = { + kind: CandidateKind + /** English copy as rendered (JSX whitespace collapsed, placeholders as `{name}`). */ + text: string + line: number + /** Enclosing React component or hook that can own `usePascalI18n()`. */ + component?: string + /** Why the codemod leaves this one for a person. */ + manual?: string +} + +export type Allowlist = { + /** Exact strings that are not translatable copy anywhere (brands, units, symbols). */ + texts: string[] + /** Path prefixes whose literals are not product UI, each with a reason. */ + paths: Record +} + +/** Legacy candidates per file, by text (a multiset: repeated copy appears once per use). */ +export type Baseline = { version: 3; files: Record } + +function readAllowlist(): Allowlist { + return JSON.parse(readFileSync(ALLOWLIST_PATH, 'utf8')) as Allowlist +} + +/** Mirrors React's JSX text whitespace rules (`jsx-runtime` collapses per line). */ +export function normalizeJsxText(raw: string): string { + const lines = raw.split(/\r\n|\n|\r/) + if (lines.length === 1) return raw + const parts: string[] = [] + lines.forEach((line, index) => { + let value = line.replace(/\t/g, ' ') + if (index > 0) value = value.replace(/^ +/, '') + if (index < lines.length - 1) value = value.replace(/ +$/, '') + if (value) parts.push(value) + }) + return parts.join(' ') +} + +/** Leading/trailing ASCII whitespace; a no-break space (` `) is content. */ +export const ASCII_EDGE_WHITESPACE = /^[ \t\r\n]+|[ \t\r\n]+$/g + +/** What one JSX text node renders: React's whitespace rules, then HTML entities. */ +export function renderJsxText(raw: string): string { + return decodeHTMLStrict(normalizeJsxText(raw)) +} + +/** A JSX attribute's string renders with HTML entities decoded; other literals are as written. */ +export function attributeText(node: ts.StringLiteral | ts.NoSubstitutionTemplateLiteral): string { + return ts.isJsxAttribute(node.parent) ? decodeHTMLStrict(node.text) : node.text +} + +function looksTranslatable(text: string, allow: Set): boolean { + const trimmed = text.trim() + if (!/\p{L}{2}/u.test(trimmed)) return false + if (allow.has(trimmed)) return false + // Icon names, snake_case keys, paths and URLs. Hyphenated words ("Read-only", "zoom-in") are copy. + if (/^[a-z0-9-]+(?:[_:./#][a-z0-9-]+)+$/i.test(trimmed)) return false + if (/^(?:https?:|mailto:|\/|#|\.)/.test(trimmed)) return false + if (/^[a-z][a-zA-Z0-9]*$/.test(trimmed) && /[A-Z]/.test(trimmed)) return false + return true +} + +function lineOf(source: ts.SourceFile, node: ts.Node): number { + return source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1 +} + +const IGNORE = /i18n-ignore:\s*\S/ +// A line holding only a comment: a line comment, a block comment, or a JSX {/* comment */}. +const COMMENT_ONLY_LINE = /^\s*(?:\/\/|\/\*|\{\s*\/\*)/ + +/** + * `i18n-ignore: ` exempts a literal on its own line, or on the line + * after a comment-only line. A trailing ignore that exempts one line's literal + * does not reach the next line. + */ +function isIgnored(source: ts.SourceFile, node: ts.Node): boolean { + const text = source.text + const start = node.getStart(source) + const lineStart = text.lastIndexOf('\n', start - 1) + 1 + const lineEnd = text.indexOf('\n', start) === -1 ? text.length : text.indexOf('\n', start) + if (IGNORE.test(text.slice(lineStart, lineEnd))) return true + if (lineStart === 0) return false + const previousLineStart = text.lastIndexOf('\n', lineStart - 2) + 1 + const previousLine = text.slice(previousLineStart, lineStart - 1) + return COMMENT_ONLY_LINE.test(previousLine) && IGNORE.test(previousLine) +} + +function isTranslationCall(node: ts.Node): boolean { + return ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + node.expression.name.text === '_' + ) +} + +function insideTranslationCall(node: ts.Node): boolean { + for (let current = node.parent; current; current = current.parent) { + if (isTranslationCall(current)) return true + } + return false +} + +export function functionName(node: ts.Node): string | undefined { + if (ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node)) { + if (node.name) return node.name.text + } + if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) { + let parent = node.parent + // memo(...) / forwardRef(...) wrappers + while (parent && ts.isCallExpression(parent)) parent = parent.parent + if (parent && ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) { + return parent.name.text + } + } + return undefined +} + +export function isComponentName(name: string | undefined): boolean { + return Boolean(name && (/^[A-Z]/.test(name) || /^use[A-Z]/.test(name))) +} + +const COMPONENT_WRAPPERS = new Set(['memo', 'forwardRef']) + +/** A function passed as an argument, other than to `memo` / `forwardRef`. */ +function isCallbackArgument(node: ts.Node): boolean { + let argument: ts.Node = node + while (argument.parent && ts.isParenthesizedExpression(argument.parent)) + argument = argument.parent + const call = argument.parent + if (!(call && ts.isCallExpression(call) && call.arguments.includes(argument as ts.Expression))) { + return false + } + const callee = call.expression + const name = ts.isIdentifier(callee) + ? callee.text + : ts.isPropertyAccessExpression(callee) + ? callee.name.text + : undefined + return !(name && COMPONENT_WRAPPERS.has(name)) +} + +function isAsync(node: ts.FunctionLikeDeclaration): boolean { + return Boolean( + ts.canHaveModifiers(node) && + ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword), + ) +} + +/** + * The nearest enclosing function named like a component or hook, and why it + * cannot own a hook: an async function, or a named callback such as + * `.map(function Row() …)`, is not rendered by React as a component. + */ +export function componentOwner(node: ts.Node): { + owner?: ts.FunctionLikeDeclaration + problem?: string +} { + for (let current = node.parent; current; current = current.parent) { + if ( + (ts.isFunctionDeclaration(current) || + ts.isArrowFunction(current) || + ts.isFunctionExpression(current)) && + isComponentName(functionName(current)) + ) { + if (isAsync(current)) return { problem: 'inside an async function: hooks cannot run there' } + if (isCallbackArgument(current)) { + return { problem: 'inside a callback function: hooks cannot run there' } + } + return { owner: current } + } + } + return { problem: 'outside a component or hook' } +} + +/** The nearest enclosing component or hook that can own `usePascalI18n()`. */ +export function enclosingComponent(node: ts.Node): ts.FunctionLikeDeclaration | undefined { + return componentOwner(node).owner +} + +function stringValue(node: ts.Expression | undefined): string | undefined { + if (!node) return undefined + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text + return undefined +} + +/** Placeholder name for a simple interpolated expression, or undefined when it needs a person. */ +export function placeholderName(expression: ts.Expression): string | undefined { + if (ts.isIdentifier(expression)) return expression.text + if (ts.isPropertyAccessExpression(expression) && !expression.questionDotToken) { + return expression.name.text + } + if (ts.isNonNullExpression(expression)) return placeholderName(expression.expression) + return undefined +} + +export type SentenceChild = + /** `text` is what the node renders. */ + | { type: 'text'; node: ts.JsxText; text: string } + /** A string literal or `const` string rendered inline, e.g. `{' '}` or `{LABEL}`. */ + | { type: 'static'; node: ts.JsxExpression; text: string } + | { type: 'value'; node: ts.JsxExpression; expression: ts.Expression; name: string } + +/** + * Children of an element that render as one sentence: text, static strings and + * simple interpolations, no nested elements. Static parts are folded into the + * rendered text, so `Share{' '}project` and `Share project` read the same and a + * changed constant changes the sentence. + */ +export function sentenceChildren(element: ts.JsxElement): SentenceChild[] | undefined { + const children = element.children.filter( + (child) => !(ts.isJsxText(child) && child.containsOnlyTriviaWhiteSpaces), + ) + if (children.length < 2) return undefined + const result: SentenceChild[] = [] + let hasText = false + let hasJoin = false + for (const child of children) { + if (ts.isJsxText(child)) { + const text = renderJsxText(child.text) + hasText ||= /\p{L}{2}/u.test(text) + result.push({ type: 'text', node: child, text }) + continue + } + if (ts.isJsxExpression(child) && child.expression && !child.dotDotDotToken) { + const expression = child.expression + const literal = + ts.isStringLiteral(expression) || ts.isNoSubstitutionTemplateLiteral(expression) + ? expression + : ts.isIdentifier(expression) + ? constantValue(expression) + : undefined + if (literal) { + hasText ||= /\p{L}{2}/u.test(literal.text) + hasJoin = true + result.push({ type: 'static', node: child, text: literal.text }) + continue + } + const name = placeholderName(expression) + if (!name) return undefined + hasJoin = true + result.push({ type: 'value', node: child, expression, name }) + continue + } + return undefined + } + return hasText && hasJoin ? result : undefined +} + +export function sentenceMessage(children: SentenceChild[]): string { + return children + .map((child) => (child.type === 'value' ? `{${child.name}}` : child.text)) + .join('') + .replace(ASCII_EDGE_WHITESPACE, '') +} + +function notifyCallee(node: ts.CallExpression): string | undefined { + const callee = node.expression + if (ts.isIdentifier(callee) && NOTIFY_CALLEES.has(callee.text)) return callee.text + if ( + ts.isPropertyAccessExpression(callee) && + ts.isIdentifier(callee.expression) && + callee.expression.text === 'toast' && + NOTIFY_METHODS.has(callee.name.text) + ) { + return `toast.${callee.name.text}` + } + if ( + ts.isPropertyAccessExpression(callee) && + ts.isIdentifier(callee.expression) && + callee.expression.text === 'window' && + NOTIFY_CALLEES.has(callee.name.text) + ) { + return `window.${callee.name.text}` + } + return undefined +} + +export type Visitor = { + jsxText(node: ts.JsxText, text: string): void + sentence(element: ts.JsxElement, children: SentenceChild[], text: string): void + literal(node: ts.StringLiteral | ts.NoSubstitutionTemplateLiteral, kind: CandidateKind): void + template(node: ts.TemplateExpression, kind: CandidateKind): void + /** A `const` holding copy, rendered by name in JSX or a UI prop. */ + constant(node: ts.Identifier, text: string, kind: CandidateKind): void + /** Copy built with `+`, e.g. `'Delete ' + name`. */ + concatenation(node: ts.BinaryExpression, text: string, kind: CandidateKind): void +} + +function bindsName(name: ts.BindingName, wanted: string): boolean { + if (ts.isIdentifier(name)) return name.text === wanted + return name.elements.some( + (element) => !ts.isOmittedExpression(element) && bindsName(element.name, wanted), + ) +} + +type Binding = + | { found: false } + | { found: true; value?: ts.StringLiteral | ts.NoSubstitutionTemplateLiteral } + +/** The declaration of `name` directly owned by one scope node, if any. */ +function bindingIn(scope: ts.Node, name: string): Binding { + if (ts.isFunctionLike(scope)) { + if (scope.parameters.some((parameter) => bindsName(parameter.name, name))) + return { found: true } + return { found: false } + } + if ( + (ts.isForStatement(scope) || ts.isForOfStatement(scope) || ts.isForInStatement(scope)) && + scope.initializer && + ts.isVariableDeclarationList(scope.initializer) && + scope.initializer.declarations.some((declaration) => bindsName(declaration.name, name)) + ) { + return { found: true } + } + if ( + ts.isCatchClause(scope) && + scope.variableDeclaration && + bindsName(scope.variableDeclaration.name, name) + ) { + return { found: true } + } + if ( + !( + ts.isSourceFile(scope) || + ts.isBlock(scope) || + ts.isModuleBlock(scope) || + ts.isCaseClause(scope) + ) + ) { + return { found: false } + } + for (const statement of scope.statements) { + if (ts.isVariableStatement(statement)) { + const isConst = (statement.declarationList.flags & ts.NodeFlags.Const) !== 0 + for (const declaration of statement.declarationList.declarations) { + if (!bindsName(declaration.name, name)) continue + const value = declaration.initializer + return isConst && + ts.isIdentifier(declaration.name) && + value && + (ts.isStringLiteral(value) || ts.isNoSubstitutionTemplateLiteral(value)) + ? { found: true, value } + : { found: true } + } + } + if ( + (ts.isFunctionDeclaration(statement) || + ts.isClassDeclaration(statement) || + ts.isEnumDeclaration(statement)) && + statement.name?.text === name + ) { + return { found: true } + } + if (ts.isImportDeclaration(statement)) { + const clause = statement.importClause + const bindings = clause?.namedBindings + if ( + clause?.name?.text === name || + (bindings && ts.isNamespaceImport(bindings) && bindings.name.text === name) || + (bindings && + ts.isNamedImports(bindings) && + bindings.elements.some((element) => element.name.text === name)) + ) { + return { found: true } + } + } + } + return { found: false } +} + +/** The `const` string an identifier refers to, resolved through the nearest scope that declares it. */ +function constantValue( + identifier: ts.Identifier, +): ts.StringLiteral | ts.NoSubstitutionTemplateLiteral | undefined { + for (let scope = identifier.parent; scope; scope = scope.parent) { + const binding = bindingIn(scope, identifier.text) + if (binding.found) return binding.value + } + return undefined +} + +function isNotifyOptions(node: ts.Node): boolean { + const call = node.parent + return ( + ts.isObjectLiteralExpression(node) && + !!call && + ts.isCallExpression(call) && + call.arguments[1] === node && + notifyCallee(call) !== undefined + ) +} + +/** Walks every user-visible literal in one file. */ +export function visitUiLiterals( + source: ts.SourceFile, + visitor: Visitor, + allow: Set, + /** Plain modules are scanned for notifications only unless the codemod was pointed at them. */ + allKinds = source.fileName.endsWith('.tsx'), +): void { + const notifyOnly = !allKinds + const literalSink = (expression: ts.Expression | undefined, kind: CandidateKind): void => { + if (!expression || (notifyOnly && kind !== 'notify')) return + if (ts.isParenthesizedExpression(expression)) { + literalSink(expression.expression, kind) + return + } + if (ts.isConditionalExpression(expression)) { + literalSink(expression.whenTrue, kind) + literalSink(expression.whenFalse, kind) + return + } + if ( + ts.isBinaryExpression(expression) && + (expression.operatorToken.kind === ts.SyntaxKind.BarBarToken || + expression.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken || + expression.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) + ) { + if (expression.operatorToken.kind !== ts.SyntaxKind.AmpersandAmpersandToken) { + literalSink(expression.left, kind) + } + literalSink(expression.right, kind) + return + } + if ( + ts.isBinaryExpression(expression) && + expression.operatorToken.kind === ts.SyntaxKind.PlusToken + ) { + const parts: ts.Expression[] = [] + const flatten = (part: ts.Expression): void => { + if (ts.isParenthesizedExpression(part)) flatten(part.expression) + else if ( + ts.isBinaryExpression(part) && + part.operatorToken.kind === ts.SyntaxKind.PlusToken + ) { + flatten(part.left) + flatten(part.right) + } else parts.push(part) + } + flatten(expression) + // A `const` string operand is copy like a literal one. + const literalOf = (part: ts.Expression) => + ts.isStringLiteral(part) || ts.isNoSubstitutionTemplateLiteral(part) + ? part + : ts.isIdentifier(part) + ? constantValue(part) + : undefined + const text = parts.map((part) => literalOf(part)?.text ?? '{?}').join('') + const hasCopy = parts.some((part) => /\p{L}{2}/u.test(literalOf(part)?.text ?? '')) + if (hasCopy && looksTranslatable(text, allow) && !isIgnored(source, expression)) { + visitor.concatenation(expression, text, kind) + } + return + } + if (ts.isIdentifier(expression)) { + const value = constantValue(expression) + if ( + value && + looksTranslatable(value.text, allow) && + !isIgnored(source, value) && + !isIgnored(source, expression) + ) { + visitor.constant(expression, value.text, kind) + } + return + } + if (ts.isStringLiteral(expression) || ts.isNoSubstitutionTemplateLiteral(expression)) { + if (looksTranslatable(expression.text, allow) && !isIgnored(source, expression)) { + visitor.literal(expression, kind) + } + return + } + if (ts.isTemplateExpression(expression)) { + const text = [ + expression.head.text, + ...expression.templateSpans.map((span) => span.literal.text), + ].join(' ') + if (looksTranslatable(text, allow) && !isIgnored(source, expression)) { + visitor.template(expression, kind) + } + } + } + + const visit = (node: ts.Node): void => { + if (isTranslationCall(node)) return + + if (ts.isJsxElement(node)) { + const sentence = sentenceChildren(node) + if (sentence) { + const text = sentenceMessage(sentence) + // The whole joined text is what renders, so exemptions apply to it. + if (looksTranslatable(text, allow) && !isIgnored(source, sentence[0]!.node)) { + visitor.sentence(node, sentence, text) + } + ts.forEachChild(node.openingElement, visit) + return + } + } + + if (ts.isJsxText(node) && !node.containsOnlyTriviaWhiteSpaces) { + const text = renderJsxText(node.text) + if (looksTranslatable(text, allow) && !isIgnored(source, node)) visitor.jsxText(node, text) + return + } + + if ( + ts.isJsxExpression(node) && + node.parent && + (ts.isJsxElement(node.parent) || ts.isJsxFragment(node.parent)) + ) { + literalSink(node.expression, 'expression') + } + + if ( + ts.isJsxAttribute(node) && + ts.isIdentifier(node.name) && + UI_ATTRIBUTES.has(node.name.text) + ) { + const initializer = node.initializer + if (initializer && ts.isStringLiteral(initializer)) literalSink(initializer, 'attribute') + else if (initializer && ts.isJsxExpression(initializer)) { + literalSink(initializer.expression, 'attribute') + } + } + + if ( + ts.isPropertyAssignment(node) && + (ts.isIdentifier(node.name) || ts.isStringLiteral(node.name)) && + UI_PROPERTIES.has(node.name.text) && + !insideTranslationCall(node) && + !isNotifyOptions(node.parent) + ) { + literalSink(node.initializer, 'property') + } + + if (ts.isCallExpression(node) && notifyCallee(node)) { + literalSink(node.arguments[0], 'notify') + const options = node.arguments[1] + if (options && ts.isObjectLiteralExpression(options)) { + for (const property of options.properties) { + if ( + ts.isPropertyAssignment(property) && + ts.isIdentifier(property.name) && + property.name.text === 'description' + ) { + literalSink(property.initializer, 'notify') + } + } + } + } + + ts.forEachChild(node, visit) + } + visit(source) +} + +export function parseSource(path: string, text: string): ts.SourceFile { + return ts.createSourceFile( + path, + text, + ts.ScriptTarget.Latest, + true, + path.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ) +} + +export function scanSource( + path: string, + text: string, + allowlist: Allowlist = readAllowlist(), +): Candidate[] { + const source = parseSource(path, text) + const allow = new Set(allowlist.texts) + const candidates: Candidate[] = [] + const add = (node: ts.Node, kind: CandidateKind, value: string, manual?: string) => { + const { owner, problem } = componentOwner(node) + candidates.push({ + kind, + text: value, + line: lineOf(source, node), + component: owner ? functionName(owner) : undefined, + manual: manual ?? problem, + }) + } + visitUiLiterals( + source, + { + jsxText: (node, value) => add(node, 'jsx-text', value.replace(ASCII_EDGE_WHITESPACE, '')), + sentence: (element, _children, value) => add(element, 'jsx-sentence', value), + literal: (node, kind) => add(node, kind, attributeText(node)), + template: (node, kind) => { + const names = node.templateSpans.map((span) => placeholderName(span.expression)) + const value = [ + node.head.text, + ...node.templateSpans.map( + (span, index) => `{${names[index] ?? '?'}}${span.literal.text}`, + ), + ].join('') + add( + node, + kind, + value, + names.includes(undefined) ? 'complex template interpolation' : undefined, + ) + }, + constant: (node, value, kind) => + add(node, kind, value, 'string constant: make it a descriptor or translate at the use'), + concatenation: (node, value, kind) => + add(node, kind, value, 'concatenated copy: write one message with placeholders'), + }, + allow, + ) + return candidates +} + +export function scannedFiles(): string[] { + const allowlist = readAllowlist() + const files = new Set() + for (const pattern of SCANNED_GLOBS) { + for (const path of new Bun.Glob(pattern).scanSync({ cwd: ROOT, onlyFiles: true })) { + if (SKIPPED_PATH.test(`/${path}`)) continue + if (Object.keys(allowlist.paths).some((prefix) => path.startsWith(prefix))) continue + files.add(path) + } + } + return [...files].sort() +} + +export function scanRepository(): Map { + const allowlist = readAllowlist() + const result = new Map() + for (const path of scannedFiles()) { + const candidates = scanSource(path, readFileSync(join(ROOT, path), 'utf8'), allowlist) + if (candidates.length > 0) result.set(path, candidates) + } + return result +} + +export function parseBaseline(text: string | undefined): Baseline | undefined { + if (!text) return undefined + const parsed = JSON.parse(text) as { version?: number; files?: Record } + // Version 3 records rendered text (entities, joins); older baselines are not comparable. + return parsed.version === 3 ? (parsed as Baseline) : undefined +} + +function readBaseline(): Baseline { + return parseBaseline(readFileSync(BASELINE_PATH, 'utf8')) ?? { version: 3, files: {} } +} + +/** The base branch's baseline, when the base has one (see `baseRevision`). */ +function readBaseBaseline(): Baseline | undefined { + const revision = baseRevision() + return revision ? parseBaseline(readAtRevision(revision, BASELINE_PATH)) : undefined +} + +function writeBaseline(scan: Map): void { + const files = Object.fromEntries( + [...scan.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([path, list]) => [path, list.map((candidate) => candidate.text).sort()]), + ) + writeFileSync( + BASELINE_PATH, + `${JSON.stringify({ version: 3, files } satisfies Baseline, null, 2)}\n`, + ) +} + +/** Items of `values` left after removing one occurrence per item of `allowed`. */ +function beyond(values: readonly string[], allowed: readonly string[]): string[] { + const remaining = new Map() + for (const value of allowed) remaining.set(value, (remaining.get(value) ?? 0) + 1) + return values.filter((value) => { + const left = remaining.get(value) ?? 0 + if (left === 0) return true + remaining.set(value, left - 1) + return false + }) +} + +/** + * The ratchet: a file may only keep copy its baseline already lists, so new + * or swapped copy fails, and a file absent from the baseline must have none. + * The baseline itself may only shrink relative to the base branch; exempting + * copy goes through `i18n/literal-allowlist.json` or an `i18n-ignore: `. + */ +export function checkLiterals( + scan = scanRepository(), + baseline = readBaseline(), + base?: Baseline, +): { + errors: string[] + improvable: string[] + total: number +} { + const errors: string[] = [] + const improvable: string[] = [] + let total = 0 + for (const [path, candidates] of scan) { + total += candidates.length + const unknown = new Set( + beyond( + candidates.map((candidate) => candidate.text), + baseline.files[path] ?? [], + ), + ) + if (unknown.size === 0) continue + const listed = candidates + .filter((candidate) => unknown.has(candidate.text)) + .map((candidate) => ` ${path}:${candidate.line} ${JSON.stringify(candidate.text)}`) + errors.push( + `${path}: untranslated user-visible literals not in the baseline. Wrap them with i18n._({ id, message }) or add an "i18n-ignore: " comment.\n${listed.join('\n')}`, + ) + } + for (const [path, allowed] of Object.entries(baseline.files)) { + const left = beyond( + allowed, + (scan.get(path) ?? []).map((candidate) => candidate.text), + ) + if (left.length > 0) improvable.push(`${path}: ${left.length} fewer`) + } + if (base) { + // Growth is measured across the repository, so copy that moves with a + // renamed, split or extracted file passes; each added text must consume an + // equal text removed elsewhere, duplicates counted. + const all = (files: Baseline['files']) => Object.values(files).flat() + const added = beyond(all(baseline.files), all(base.files)) + if (added.length > 0) { + const where = (text: string) => + Object.entries(baseline.files) + .filter(([, texts]) => texts.includes(text)) + .map(([path]) => path) + errors.push( + `i18n/literal-baseline.json adds copy the base branch's baseline does not have; the baseline may only shrink:\n${[ + ...new Set(added), + ] + .map((text) => ` ${JSON.stringify(text)} (${where(text).join(', ')})`) + .join('\n')}`, + ) + } + } + return { errors, improvable, total } +} + +/** Exemptions are reviewed like the baseline: the allowlist may not gain entries relative to the base. */ +export function checkAllowlistGrowth(current: Allowlist, base: Allowlist | undefined): string[] { + if (!base) return [] + const texts = current.texts.filter((text) => !base.texts.includes(text)) + const paths = Object.keys(current.paths).filter((path) => !(path in base.paths)) + return [ + ...texts.map( + (text) => + `i18n/literal-allowlist.json adds the text ${JSON.stringify(text)}; exempt one use with i18n-ignore: instead`, + ), + ...paths.map( + (path) => + `i18n/literal-allowlist.json adds the path ${JSON.stringify(path)}; exempting a path needs a reviewed exception`, + ), + ] +} + +function readBaseAllowlist(): Allowlist | undefined { + const revision = baseRevision() + const text = revision ? readAtRevision(revision, ALLOWLIST_PATH) : undefined + return text ? (JSON.parse(text) as Allowlist) : undefined +} + +export function checkRepositoryLiterals() { + const result = checkLiterals(scanRepository(), readBaseline(), readBaseBaseline()) + result.errors.push(...checkAllowlistGrowth(readAllowlist(), readBaseAllowlist())) + return result +} + +function report(scan: Map): void { + const byArea = new Map() + for (const [path, candidates] of scan) { + const area = path + .split('/') + .slice(0, path.startsWith('packages/editor/src/components/ui/') ? 6 : 4) + .join('/') + byArea.set(area, (byArea.get(area) ?? 0) + candidates.length) + } + for (const [area, count] of [...byArea.entries()].sort((a, b) => b[1] - a[1])) { + console.log(`${String(count).padStart(6)} ${area}`) + } +} + +async function main(): Promise { + const [command, ...args] = process.argv.slice(2) + if (command === 'check') { + const { errors, improvable, total } = checkRepositoryLiterals() + if (improvable.length > 0) { + console.log( + `[i18n literals] ${improvable.length} files are below their baseline; run bun run i18n:literals -- update to lock in the progress`, + ) + } + if (errors.length > 0) { + console.error(errors.map((error) => `[i18n literals] ${error}`).join('\n')) + process.exitCode = 1 + return + } + console.log(`[i18n literals] no new untranslated literals (${total} legacy candidates remain)`) + return + } + if (command === 'update') { + writeBaseline(scanRepository()) + console.log(`Updated ${relative(ROOT, BASELINE_PATH)}`) + return + } + if (command === 'report') { + const scan = scanRepository() + const filter = args.filter((arg) => !arg.startsWith('--')) + if (args.includes('--files')) { + for (const [path, candidates] of scan) { + if (filter.length && !filter.some((prefix) => path.startsWith(prefix))) continue + for (const candidate of candidates) { + console.log( + `${path}:${candidate.line}\t${candidate.kind}\t${candidate.component ?? '-'}\t${JSON.stringify(candidate.text)}${candidate.manual ? `\tMANUAL: ${candidate.manual}` : ''}`, + ) + } + } + return + } + report(scan) + return + } + if (command === 'convert') { + const { convertFiles } = await import('./i18n-codemod') + await convertFiles(args) + return + } + throw new Error( + 'Usage: bun scripts/i18n-literals.ts >', + ) +} + +if (import.meta.main) await main() diff --git a/scripts/i18n-pseudo-audit.ts b/scripts/i18n-pseudo-audit.ts new file mode 100644 index 0000000000..f10dec99fa --- /dev/null +++ b/scripts/i18n-pseudo-audit.ts @@ -0,0 +1,180 @@ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium, type Page } from 'playwright-core' +import { type PseudoSurface, surfaces } from '../i18n/pseudo-surfaces' + +/** + * Pseudo-locale audit. Opens each surface of a running editor with + * `?lang=en-XA`, where every translated letter is accented and the text is + * ~30 % longer and bracketed, then reports any visible text or accessible + * name that still contains plain ASCII words: that copy bypassed the catalog. + * Visible controls without an accessible name are reported too, so narrow + * layouts that hide a button's text still name it. + * Screenshots of each surface go to the output directory for clipping review. + * + * bun run i18n:pseudo-audit -- --base http://localhost:3002 [--out dir] [surface...] + * + * Needs a Chrome/Chromium binary (`PLAYWRIGHT_CHROMIUM_EXECUTABLE`, defaulting + * to the system Google Chrome). It is a contributor and PR tool, not a CI job. + */ + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const ALLOWLIST = JSON.parse(readFileSync(join(ROOT, 'i18n/literal-allowlist.json'), 'utf8')) as { + texts: string[] +} + +export type Finding = { surface: string; where: string; text: string } + +function argument(name: string): string | undefined { + const index = process.argv.indexOf(`--${name}`) + return index === -1 ? undefined : process.argv[index + 1] +} + +/** ASCII words left after removing allowed data (authored names, brands, units). */ +export function untranslatedWords(text: string, allowed: readonly string[]): string[] { + let rest = text + for (const value of [...allowed].sort((a, b) => b.length - a.length)) { + if (!value) continue + // Whole words only: allowing the unit "in" must not hide "Find" or "Pin". + const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + rest = rest.replace(new RegExp(`(? { + const roots = selector ? [...document.querySelectorAll(selector)] : [document.body] + const results: Array<{ where: string; text: string }> = [] + const describe = (element: Element) => { + const parts: string[] = [] + for ( + let current: Element | null = element; + current && parts.length < 3; + current = current.parentElement + ) { + const id = current.id ? `#${current.id}` : '' + const testId = current.getAttribute('data-testid') + parts.unshift( + `${current.tagName.toLowerCase()}${id}${testId ? `[data-testid=${testId}]` : ''}`, + ) + } + return parts.join(' > ') + } + const visible = (element: Element) => + element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }) && + !element.closest('[aria-hidden="true"], script, style, noscript, svg title') + for (const root of roots) { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT) + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + const text = node.textContent?.trim() + const parent = node.parentElement + if (text && parent && visible(parent)) results.push({ where: describe(parent), text }) + } + for (const element of root.querySelectorAll( + '[aria-label], [title], [placeholder], img[alt]', + )) { + if (!visible(element)) continue + for (const name of ['aria-label', 'title', 'placeholder', 'alt']) { + const value = element.getAttribute(name)?.trim() + if (value) results.push({ where: `${describe(element)} @${name}`, text: value }) + } + } + } + const unnamed: string[] = [] + for (const root of roots) { + for (const control of root.querySelectorAll( + 'button, a[href], [role="button"], input, select', + )) { + if (!visible(control)) continue + const labelledBy = control + .getAttribute('aria-labelledby') + ?.split(/\s+/) + .map((id) => document.getElementById(id)?.textContent ?? '') + .join(' ') + const name = [ + control.getAttribute('aria-label'), + labelledBy, + control.getAttribute('title'), + // biome-ignore lint/performance/useDomNodeTextContent: text hidden at this width must not count as a name + (control as HTMLElement).innerText, + // biome-ignore lint/performance/useDomNodeTextContent: text hidden at this width must not count as a name + (control as HTMLInputElement).labels?.[0]?.innerText, + ...[...control.querySelectorAll('img[alt]')].map((image) => image.getAttribute('alt')), + ] + .map((value) => value?.trim()) + .find(Boolean) + if (!name) unnamed.push(describe(control)) + } + } + return { results, unnamed } + }, scope) +} + +export async function auditSurface(page: Page, base: string, surface: PseudoSurface, out: string) { + const findings: Finding[] = [] + const path = typeof surface.path === 'string' ? surface.path : await surface.path(base) + const url = new URL(path, base) + url.searchParams.set('lang', 'en-XA') + await page.setViewportSize(surface.viewport ?? { width: 1440, height: 900 }) + await page.goto(url.toString(), { waitUntil: 'domcontentloaded' }) + await page.waitForLoadState('networkidle', { timeout: 60_000 }).catch(() => undefined) + await surface.steps?.(page) + await page.waitForTimeout(400) + const allowed = [...ALLOWLIST.texts, ...(surface.data ?? [])] + const { results, unnamed } = await collect(page, surface.scope) + for (const where of unnamed) { + findings.push({ surface: surface.name, where, text: 'control has no accessible name' }) + } + for (const entry of results) { + const words = untranslatedWords(entry.text, allowed) + if (words.length > 0) + findings.push({ surface: surface.name, where: entry.where, text: entry.text }) + } + const shot = join(out, `${surface.name}.png`) + if (surface.scope && (await page.locator(surface.scope).count()) === 0) { + findings.push({ surface: surface.name, where: surface.scope, text: 'surface did not open' }) + } + await page.screenshot({ path: shot }) + return { findings, shot } +} + +async function main(): Promise { + const base = argument('base') ?? 'http://localhost:3002' + const out = resolve(argument('out') ?? join(ROOT, '.i18n-audit')) + const wanted = process.argv + .slice(2) + .filter((arg, index, all) => !arg.startsWith('--') && !all[index - 1]?.startsWith('--')) + const selected = surfaces.filter( + (surface) => wanted.length === 0 || wanted.includes(surface.name), + ) + if (selected.length === 0) throw new Error(`No surface matches ${wanted.join(', ')}`) + mkdirSync(out, { recursive: true }) + + const browser = await chromium.launch({ + executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE ?? '/usr/local/bin/google-chrome', + args: ['--use-angle=swiftshader', '--enable-unsafe-swiftshader'], + }) + const findings: Finding[] = [] + try { + const context = await browser.newContext({ deviceScaleFactor: 1 }) + const page = await context.newPage() + for (const surface of selected) { + const result = await auditSurface(page, base, surface, out) + findings.push(...result.findings) + console.log( + `${result.findings.length === 0 ? 'PASS' : 'FAIL'} ${surface.name} (${result.findings.length} untranslated) → ${result.shot}`, + ) + } + } finally { + await browser.close() + } + writeFileSync(join(out, 'findings.json'), `${JSON.stringify(findings, null, 2)}\n`) + for (const finding of findings) { + console.log(` ${finding.surface}: ${JSON.stringify(finding.text)} (${finding.where})`) + } + if (findings.length > 0) process.exitCode = 1 +} + +if (import.meta.main) await main() diff --git a/scripts/i18n.ts b/scripts/i18n.ts index 5878f9ab5a..20b2274ddd 100644 --- a/scripts/i18n.ts +++ b/scripts/i18n.ts @@ -1,12 +1,20 @@ import { createHash } from 'node:crypto' -import { readFileSync, writeFileSync } from 'node:fs' +import { appendFileSync, existsSync, mkdirSync, 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' +import { + baseRevision, + firstCommitWith, + headRevision, + lastReleaseTag, + readAtRevision, + resolveRevision, +} from './i18n-git' +import { checkRepositoryLiterals } from './i18n-literals' export type SourceMessage = { id: string @@ -20,6 +28,8 @@ export type TranslationEntry = { review: 'human' | 'required' | 'missing' fuzzy: boolean reviewNotes?: string + /** Declared with a `renamed-from: ` descriptor comment (`#.` in the PO). */ + renamedFrom?: string } export type TranslationRequest = { @@ -46,23 +56,18 @@ type Glossary = { 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 ROOT = process.env.I18N_ROOT + ? resolve(process.env.I18N_ROOT) + : resolve(dirname(fileURLToPath(import.meta.url)), '..') +const CATALOG_ROOT = process.env.I18N_CATALOG_ROOT + ? resolve(process.env.I18N_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}', -] +/** Generated from English for layout and coverage testing; never shipped as a language. */ +export const PSEUDO_LOCALE = 'en-XA' const SOURCE_HASH_PREFIX = 'Source-Hash: ' const REVIEW_PREFIX = 'Review: ' const ENGLISH_PREFIX = 'English: ' @@ -138,6 +143,13 @@ async function extractSourceMessages(): Promise { return [...messages.values()].sort((left, right) => left.id.localeCompare(right.id)) } +const RENAMED_FROM_PREFIX = 'renamed-from:' + +function renamedFrom(item: PoItem): string | undefined { + const comment = item.extractedComments.find((value) => value.startsWith(RENAMED_FROM_PREFIX)) + return comment?.slice(RENAMED_FROM_PREFIX.length).trim() || undefined +} + function readTranslationState(po: PoFile): Record { return Object.fromEntries( po.items @@ -152,6 +164,7 @@ function readTranslationState(po: PoFile): Record { review: review === 'human' || review === 'required' ? review : 'missing', fuzzy: Boolean(item.flags.fuzzy), reviewNotes: commentValue(item, REVIEW_NOTES_PREFIX), + renamedFrom: renamedFrom(item), } satisfies TranslationEntry, ] }), @@ -462,6 +475,26 @@ function writePo(path: string, po: PoFile): void { writeFileSync(path, stringifyPo(po, { foldLength: 0 })) } +/** + * Review state extraction writes for one target entry, or undefined to keep + * it. A newly filled translation is a draft unless a person already marked it + * `Review: human` without `fuzzy`; extraction never approves copy itself. + */ +export function annotatedState( + prior: TranslationEntry | undefined, + translation: string, + sourceHash: string, +): Pick | undefined { + if (!translation) return { sourceHash: undefined, review: 'missing', fuzzy: false } + if (prior?.sourceHash && prior.sourceHash !== sourceHash) { + return { sourceHash: prior.sourceHash, review: 'required', fuzzy: true } + } + if (prior?.sourceHash) return undefined + return prior?.review === 'human' && !prior.fuzzy + ? { sourceHash, review: 'human', fuzzy: false } + : { sourceHash, review: 'required', fuzzy: true } +} + function annotateCatalogs( previous: Map<(typeof TARGET_LOCALES)[number], Record>, ): void { @@ -477,18 +510,28 @@ function annotateCatalogs( 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) { + const declared = renamedFrom(item) + const origin = declared ? previous.get(locale)?.[declared] : undefined + if ( + !item.msgstr[0] && + origin?.translation && + origin.review === 'human' && + !origin.fuzzy && + origin.sourceHash === sourceHash + ) { + // A declared rename with unchanged English keeps its reviewed translation. + item.msgstr = [origin.translation] replaceComment(item, SOURCE_HASH_PREFIX, sourceHash) replaceComment(item, REVIEW_PREFIX, 'human') delete item.flags.fuzzy + continue + } + const state = annotatedState(prior, item.msgstr[0] ?? '', sourceHash) + if (state) { + replaceComment(item, SOURCE_HASH_PREFIX, state.sourceHash) + replaceComment(item, REVIEW_PREFIX, state.review) + if (state.fuzzy) item.flags.fuzzy = true + else delete item.flags.fuzzy } } writePo(path, po) @@ -524,7 +567,12 @@ function compiledCatalog(locale: string, sources: SourceMessage[]) { function writeCompiledCatalogs(strict = false): void { const sources = readSourceMessages() if (strict) { - const errors = checkCatalogMessages(sources, readJson(GLOSSARY_PATH)) + const errors = checkTranslationEntries( + sources, + readLocaleEntries((path) => readFileSync(path, 'utf8')), + 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) @@ -535,6 +583,147 @@ function writeCompiledCatalogs(strict = false): void { for (const [locale, result] of compiled) { writeFileSync(compiledCatalogPath(locale), result.source) } + writePseudoCatalog(sources) +} + +const PSEUDO_LETTERS: Record = Object.fromEntries( + [...'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'].map((letter, index) => [ + letter, + [...'áƀçðéƒĝĥîĵķļɱñöþǫŕšţûṽŵẋýžÅƁÇÐÉƑĜĤÎĴĶĻṀÑÖÞǪŔŠŢÛṼŴẊÝŽ'][index]!, + ]), +) + +/** + * en-XA: every letter accented and the text padded by about 30 %, wrapped in + * brackets so clipping shows. ICU arguments, plural/select keys, `#`, quoted + * literals and rich-text tags are copied unchanged. + */ +export function pseudoLocalize(message: string): string { + let index = 0 + let letters = 0 + + const text = (end: (character: string) => boolean, inPlural: boolean): string => { + let out = '' + while (index < message.length) { + const character = message[index]! + if (end(character)) return out + if (character === "'") { + const next = message[index + 1] + if (next === "'") { + out += "''" + index += 2 + continue + } + if (next === '{' || next === '}' || next === '#' || next === '<') { + const close = message.indexOf("'", index + 1) + const stop = close === -1 ? message.length : close + 1 + out += message.slice(index, stop) + index = stop + continue + } + } + if (character === '{') { + out += argument() + continue + } + if (character === '<') { + const close = message.indexOf('>', index) + const stop = close === -1 ? message.length : close + 1 + out += message.slice(index, stop) + index = stop + continue + } + if (character === '#' && inPlural) { + out += character + index += 1 + continue + } + const mapped = PSEUDO_LETTERS[character] + if (mapped) letters += 1 + out += mapped ?? character + index += 1 + } + return out + } + + const argument = (): string => { + const start = index + index += 1 + const header = message.slice(index).match(/^\s*([^,}\s]+)\s*(?:,\s*(\w+)\s*)?/) + const type = header?.[2] + if (!(type === 'plural' || type === 'select' || type === 'selectordinal')) { + let depth = 1 + while (index < message.length && depth > 0) { + if (message[index] === '{') depth += 1 + if (message[index] === '}') depth -= 1 + index += 1 + } + return message.slice(start, index) + } + index += header![0].length + let out = message.slice(start, index) + if (message[index] === ',') { + out += ',' + index += 1 + } + while (index < message.length && message[index] !== '}') { + const open = message.indexOf('{', index) + if (open === -1) break + out += message.slice(index, open + 1) + index = open + 1 + out += text((character) => character === '}', type !== 'select') + out += '}' + index += 1 + const spaces = message.slice(index).match(/^\s*/)?.[0] ?? '' + if (message[index + spaces.length] === '}') { + out += spaces + index += spaces.length + } + } + out += '}' + index += 1 + return out + } + + const body = text(() => false, false) + if (letters === 0) return body + return `[${body} ${'~'.repeat(Math.max(1, Math.ceil(letters * 0.3)))}]` +} + +function pseudoCatalog(sources: SourceMessage[]) { + const english = messagesForCompilation('en', sources) + return createCompiledCatalog( + PSEUDO_LOCALE, + Object.fromEntries( + Object.entries(english).map(([id, message]) => [id, pseudoLocalize(message)]), + ), + { namespace: 'ts', outputPrefix: '/*eslint-disable*/', strict: true }, + ) +} + +function writePseudoCatalog(sources: SourceMessage[]): void { + const compiled = pseudoCatalog(sources) + if (compiled.errors.length > 0) { + throw new Error( + compiled.errors + .map(({ id, error }) => `${PSEUDO_LOCALE}: "${id}": ${error.message}`) + .join('\n'), + ) + } + mkdirSync(dirname(compiledCatalogPath(PSEUDO_LOCALE)), { recursive: true }) + writeFileSync(compiledCatalogPath(PSEUDO_LOCALE), compiled.source) +} + +function checkPseudoCatalog(sources: SourceMessage[]): string[] { + const path = compiledCatalogPath(PSEUDO_LOCALE) + const compiled = pseudoCatalog(sources) + const errors = compiled.errors.map( + ({ id, error }) => `${PSEUDO_LOCALE}: failed to compile "${id}": ${error.message}`, + ) + if (!existsSync(path) || compiled.source !== readFileSync(path, 'utf8')) { + errors.push(`${relative(ROOT, path)} is not synchronized; run bun run i18n:compile`) + } + return errors } export function extractCatalogs(): void { @@ -581,64 +770,46 @@ function applyTranslations( } } -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() +export type CatalogCheckOptions = { + /** + * Release sign-off: every bundled entry must be human-reviewed. Ordinary CI + * accepts AI drafts that are `fuzzy` + `Review: required`; they are kept out + * of the compiled packs, so the UI shows English until a person approves. + */ + requireReviewed?: boolean } -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}/`) +export function pendingReview(sources: SourceMessage[]): Record { + return Object.fromEntries( + TARGET_LOCALES.map((locale) => { + const entries = readTranslationState(parsePo(readFileSync(catalogPath(locale), 'utf8'))) + const pending = sources.filter((source) => { + const entry = entries[source.id] + return entry && (entry.fuzzy || entry.review !== 'human') + }).length + return [locale, pending] }), ) +} - 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) +export type LocaleEntries = Record> + +function readLocaleEntries(read: (path: string) => string | undefined): LocaleEntries { + const entries: LocaleEntries = {} + for (const locale of TARGET_LOCALES) { + const text = read(catalogPath(locale)) + if (text !== undefined) entries[locale] = readTranslationState(parsePo(text)) } - return warnings + return entries } -function checkCatalogMessages(sources: SourceMessage[], glossary: Glossary): string[] { +/** Pure completeness/validity/review rules for the bundled target catalogs. */ +export function checkTranslationEntries( + sources: SourceMessage[], + entriesByLocale: LocaleEntries, + glossary: Glossary, + options: CatalogCheckOptions = { requireReviewed: true }, +): string[] { const errors: string[] = [] const sourceById = new Map(sources.map((source) => [source.id, source])) for (const source of sources) { @@ -646,20 +817,28 @@ function checkCatalogMessages(sources: SourceMessage[], glossary: Glossary): str } for (const locale of TARGET_LOCALES) { - const po = parsePo(readFileSync(catalogPath(locale), 'utf8')) - const entries = readTranslationState(po) + const entries = entriesByLocale[locale] ?? {} 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)) { + const awaitingReview = entry.fuzzy || entry.review !== 'human' + const stale = entry.sourceHash !== hashSource(source.message) + if (stale && !(awaitingReview && !options.requireReviewed)) { errors.push(`${locale}: stale "${source.id}"`) } - if (entry.fuzzy || entry.review !== 'human') { + if (awaitingReview && options.requireReviewed) { errors.push(`${locale}: "${source.id}" still requires human review`) + } else if (awaitingReview && !(entry.fuzzy && entry.review === 'required')) { + errors.push( + `${locale}: "${source.id}" is unreviewed but not marked fuzzy + Review: required`, + ) } + // A stale draft translates the previous English; it is never shipped and + // is compared with the current English when it is translated again. + if (stale && awaitingReview) continue for (const error of validateTranslation( source.message, entry.translation, @@ -676,6 +855,263 @@ function checkCatalogMessages(sources: SourceMessage[], glossary: Glossary): str return errors } +/** + * A translation a person approved on the base branch stays approved while its + * English is unchanged: a PR may edit it (still `Review: human`) but never + * send it back to draft, which would silently drop it from the shipped pack. + */ +export function reviewRegressions( + sources: SourceMessage[], + current: LocaleEntries, + base: LocaleEntries, +): string[] { + const errors: string[] = [] + for (const locale of TARGET_LOCALES) { + for (const source of sources) { + const before = base[locale]?.[source.id] + if ( + !before?.translation || + before.fuzzy || + before.review !== 'human' || + before.sourceHash !== hashSource(source.message) + ) { + continue + } + const after = current[locale]?.[source.id] + if (!after?.translation || after.fuzzy || after.review !== 'human') { + errors.push( + `${locale}: "${source.id}" is reviewed on the base branch with the same English and must stay reviewed`, + ) + } + } + // A declared rename must arrive with the reviewed translation; + // `i18n:extract` carries it over when the English is unchanged. + const sourceIds = new Set(sources.map((source) => source.id)) + for (const source of sources) { + const after = current[locale]?.[source.id] + const removedId = after?.renamedFrom + if (!removedId || sourceIds.has(removedId)) continue + const before = base[locale]?.[removedId] + if ( + !before?.translation || + before.fuzzy || + before.review !== 'human' || + before.sourceHash !== hashSource(source.message) + ) { + continue + } + const carried = + after.translation === before.translation && after.review === 'human' && !after.fuzzy + if (!carried) { + errors.push( + `${locale}: "${source.id}" is declared renamed-from "${removedId}", whose reviewed translation was not carried over; run bun run i18n:extract`, + ) + } + } + } + return errors +} + +/** + * Reviewed IDs removed while a new, undeclared ID has the same English. That + * may be a rename or an unrelated addition; a missed carry-over only falls back + * to English, so it is reported, not enforced. + */ +export function possibleRenames( + sources: SourceMessage[], + current: LocaleEntries, + base: LocaleEntries, +): string[] { + const sourceIds = new Set(sources.map((source) => source.id)) + const pairs = new Set() + for (const locale of TARGET_LOCALES) { + const baseEntries = base[locale] ?? {} + for (const [removedId, before] of Object.entries(baseEntries)) { + if (sourceIds.has(removedId) || before.fuzzy || before.review !== 'human') continue + for (const source of sources) { + if (source.id in baseEntries || hashSource(source.message) !== before.sourceHash) continue + const after = current[locale]?.[source.id] + if (after?.renamedFrom || (after?.review === 'human' && !after.fuzzy)) continue + pairs.add(`"${removedId}" → "${source.id}"`) + } + } + } + return [...pairs].map( + (pair) => + `possible rename ${pair} (same English): carry over the translation if intended with comment: 'renamed-from: ' and bun run i18n:extract`, + ) +} + +function isApproved(entry: TranslationEntry | undefined, source: SourceMessage): boolean { + return Boolean( + entry?.translation && + !entry.fuzzy && + entry.review === 'human' && + entry.sourceHash === hashSource(source.message), + ) +} + +/** The compiled module a browser loads for one locale, as message IDs. */ +export function readCompiledPack(locale: string): Record { + const text = readFileSync(compiledCatalogPath(locale), 'utf8') + const literal = text.match(/JSON\.parse\(("(?:[^"\\]|\\.)*")\)/)?.[1] + if (!literal) + throw new Error(`${relative(ROOT, compiledCatalogPath(locale))} has no compiled messages`) + return JSON.parse(JSON.parse(literal) as string) as Record +} + +/** + * Inspects the shipped packs themselves: a draft (fuzzy, `Review: required`, + * unreviewed or stale) must never be compiled in, whatever produced the file. + */ +export function packLeaks( + sources: SourceMessage[], + current: LocaleEntries, + packs: Record>, +): string[] { + const byId = new Map(sources.map((source) => [source.id, source])) + const errors: string[] = [] + for (const locale of TARGET_LOCALES) { + for (const id of Object.keys(packs[locale] ?? {})) { + const source = byId.get(id) + if (!source || !isApproved(current[locale]?.[id], source)) { + errors.push(`${locale}: compiled pack ships unreviewed "${id}"`) + } + } + } + return errors +} + +export function draftCounts( + sources: SourceMessage[], + current: LocaleEntries, +): Record { + return Object.fromEntries( + TARGET_LOCALES.map((locale) => [ + locale, + sources.filter((source) => !isApproved(current[locale]?.[source.id], source)).length, + ]), + ) +} + +function writeJobSummary( + sources: SourceMessage[], + current: LocaleEntries, + base?: string, + warnings: string[] = [], +): void { + const path = process.env.GITHUB_STEP_SUMMARY + if (!path) return + const counts = draftCounts(sources, current) + const rows = TARGET_LOCALES.map((locale) => `| ${locale} | ${counts[locale]} |`) + appendFileSync( + path, + [ + '### Translations awaiting review', + '', + `Drafts ship as English until reviewed. ${sources.length} messages${base ? `; compared with ${base}` : ''}.`, + '', + '| Locale | Drafts |', + '| --- | --- |', + ...rows, + '', + ...(warnings.length > 0 + ? ['**Warnings**', '', ...warnings.map((warning) => `- ${warning}`), ''] + : []), + ].join('\n'), + ) +} + +function currentEntries(): LocaleEntries { + return readLocaleEntries((path) => readFileSync(path, 'utf8')) +} + +function compiledPacks(): Record> { + return Object.fromEntries(TARGET_LOCALES.map((locale) => [locale, readCompiledPack(locale)])) +} + +/** Regression and leak checks shared by `check` (CI) and `release-check` (release). */ +export function releaseCheck( + sources: SourceMessage[], + base: LocaleEntries | undefined, +): { errors: string[]; warnings: string[] } { + const current = currentEntries() + return { + errors: [ + ...(base ? reviewRegressions(sources, current, base) : []), + ...packLeaks(sources, current, compiledPacks()), + ], + warnings: base ? possibleRenames(sources, current, base) : [], + } +} + +/** The package whose release carries the bundled catalogs. */ +const CATALOG_PACKAGE = 'editor' + +function hasCatalogs(revision: string): boolean { + return TARGET_LOCALES.every( + (locale) => readAtRevision(revision, catalogPath(locale)) !== undefined, + ) +} + +/** + * What a release is compared with: the latest `@pascal-app/editor@*` tag + * whose tree has the catalogs; else `origin/main` when it is another commit + * with catalogs; else the commit that introduced the catalogs, whose reviewed + * entries must survive. Never the working tree, and never a base without + * catalogs: with none available this throws. + */ +export function releaseBase(requested?: string): { revision: string; label: string } { + const head = headRevision() + if (requested) { + const revision = resolveRevision(requested) + if (!revision) throw new Error(`release-check: --base ${requested} is not a commit`) + if (!hasCatalogs(revision)) { + throw new Error( + `release-check: --base ${requested} has no bundled PO catalogs to compare with`, + ) + } + return { revision, label: requested } + } + const tag = lastReleaseTag(CATALOG_PACKAGE) + if (tag && hasCatalogs(tag.revision)) return { revision: tag.revision, label: tag.tag } + const reason = tag ? `${tag.tag} has no catalogs` : 'no release tag' + const main = resolveRevision('origin/main') + if (main && main !== head && hasCatalogs(main)) { + return { revision: main, label: `origin/main (${reason})` } + } + const introduced = firstCommitWith(catalogPath('fr')) + if (introduced && introduced !== head && hasCatalogs(introduced)) { + return { + revision: introduced, + label: `${introduced.slice(0, 12)}, the commit that introduced the catalogs (${reason})`, + } + } + throw new Error( + `release-check: no base with bundled PO catalogs (${reason}; origin/main and the catalogs' first commit are unavailable or HEAD); pass --base `, + ) +} + +function checkCatalogMessages( + sources: SourceMessage[], + glossary: Glossary, + options: CatalogCheckOptions = { requireReviewed: true }, +): string[] { + const current = currentEntries() + const base = baseRevision() + const release = releaseCheck( + sources, + base ? readLocaleEntries((path) => readAtRevision(base, path)) : undefined, + ) + reportWarnings(release.warnings) + writeJobSummary(sources, current, base?.slice(0, 12), release.warnings) + return [...checkTranslationEntries(sources, current, glossary, options), ...release.errors] +} + +function reportWarnings(warnings: string[]): void { + for (const warning of warnings) console.warn(`[i18n warning] ${warning}`) +} + function checkEnglishCatalog( extractedSources: SourceMessage[], catalogSources: SourceMessage[], @@ -714,15 +1150,16 @@ function checkCompiledCatalogs(sources: SourceMessage[]): string[] { }) } -export async function checkCatalogs(): Promise { +export async function checkCatalogs(options: CatalogCheckOptions = {}): Promise { const sources = await extractSourceMessages() const catalogSources = readSourceMessages() const glossary = readJson(GLOSSARY_PATH) return [ ...checkLinguiCatalogSync(), ...checkEnglishCatalog(sources, catalogSources), - ...checkCatalogMessages(sources, glossary), + ...checkCatalogMessages(sources, glossary, options), ...checkCompiledCatalogs(catalogSources), + ...checkPseudoCatalog(catalogSources), ] } @@ -740,16 +1177,66 @@ async function main(): Promise { return } - if (command === 'check') { - const errors = await checkCatalogs() - for (const literal of bypassedLiterals()) { - console.warn(`[i18n warning] user-facing literal bypasses Lingui: ${literal}`) + if (command === 'release-check') { + const flag = (name: string) => { + const index = process.argv.indexOf(`--${name}`) + return index === -1 ? undefined : process.argv[index + 1] + } + const baseDir = flag('base-dir') + let resolved: { revision: string; label: string } | undefined + try { + resolved = baseDir ? undefined : releaseBase(flag('base')) + } catch (error) { + console.error(`[i18n] ${error instanceof Error ? error.message : String(error)}`) + process.exitCode = 1 + return + } + const revision = resolved?.revision + if (resolved) console.log(`Comparing with ${resolved.label}`) + const base = baseDir + ? readLocaleEntries((path) => { + const file = join(resolve(baseDir), relative(CATALOG_ROOT, path)) + return existsSync(file) ? readFileSync(file, 'utf8') : undefined + }) + : readLocaleEntries((path) => readAtRevision(revision!, path)) + const sources = readSourceMessages() + const { errors, warnings } = releaseCheck(sources, base) + reportWarnings(warnings) + writeJobSummary(sources, currentEntries(), resolved?.label ?? baseDir, warnings) + if (errors.length > 0) { + console.error(errors.map((error) => `[i18n] ${error}`).join('\n')) + process.exitCode = 1 + return } + console.log( + `No reviewed translation regressed against ${baseDir ?? resolved?.label} and no draft is compiled in`, + ) + return + } + + if (command === 'check') { + const requireReviewed = process.argv.includes('--reviewed') + const errors = await checkCatalogs({ requireReviewed }) + const literals = checkRepositoryLiterals() + errors.push(...literals.errors.map((error) => `literal guard: ${error}`)) 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 + } + const pending = Object.entries(pendingReview(readSourceMessages())) + .filter(([, count]) => count > 0) + .map(([locale, count]) => `${locale} ${count}`) + console.log( + `Checked ${TARGET_LOCALES.length} bundled PO catalogs and ${literals.total} legacy literal candidates` + + (pending.length > 0 + ? `; awaiting human review (shown in English until approved): ${pending.join(', ')}` + : ''), + ) + if (literals.improvable.length > 0) { + console.log( + `[i18n] ${literals.improvable.length} files are below their literal baseline; run bun run i18n:literals -- update`, + ) } return } @@ -781,7 +1268,9 @@ async function main(): Promise { return } - throw new Error('Usage: bun scripts/i18n.ts [locale...]') + throw new Error( + 'Usage: bun scripts/i18n.ts |--base-dir ]|translate> [locale...]', + ) } if (import.meta.main) await main()
+ Shared with {count} people +
Loading projects
Use {'{}'} & it's done…
${body}
{LABEL} for {name}
No layers yet
{i18n._({ id: 'editor.sidebar.empty.noLayersYet', message: 'No layers yet' })}
{LABEL} now
+ {/* i18n-ignore: product name */} + Made with Three.js +
+ {/* i18n-ignore: library name */} + +
') + expect(() => + scanSource('packages/editor/src/components/ui/sidebar/empty.tsx', output, allowlist), + ).not.toThrow() + expect(convert('packages/editor/src/components/ui/sidebar/empty.tsx', output).output).toBe(output) +}) + +test('the audit allowlist removes whole words only', () => { + expect(untranslatedWords('Find · 2 in', ['in'])).toEqual(['Find']) + expect(untranslatedWords('Pin', ['in'])).toEqual(['Pin']) + expect(untranslatedWords('[Ƒîñð] 2 in', ['in'])).toEqual([]) +}) + +test('pseudo-localization accents every letter and keeps ICU structure', () => { + const message = 'Delete {count, plural, one {# wall} other {# walls}} from <0>{level}0>?' + const pseudo = pseudoLocalize(message) + + expect(pseudo).toBe( + '[Ðéļéţé {count, plural, one {# ŵáļļ} other {# ŵáļļš}} ƒŕöɱ <0>{level}0>? ~~~~~~]', + ) + expect(() => compileMessageOrThrow(pseudo)).not.toThrow() + expect(pseudoLocalize("It''s '{'literal'}'")).toBe("[Îţ''š '{'ļîţéŕáļ'}' ~~~]") +}) + +const advanced: SourceMessage = { + id: 'editor.buildPanel.advanced', + message: 'Advanced', + context: [], +} +const entry = (overrides: Partial) => ({ + translation: 'Avancé', + sourceHash: hashSource('Advanced'), + review: 'human' as const, + fuzzy: false, + ...overrides, +}) +const everyLocale = (value: ReturnType): LocaleEntries => + Object.fromEntries( + ['zh-CN', 'fr', 'es', 'ja'].map((locale) => [locale, { [advanced.id]: value }]), + ) + +test('AI drafts pass ordinary checks but block the reviewed release gate', () => { + const drafts = everyLocale(entry({ review: 'required', fuzzy: true })) + expect(checkTranslationEntries([advanced], drafts, {}, {})).toEqual([]) + expect(checkTranslationEntries([advanced], drafts, {}, { requireReviewed: true })).toContain( + 'fr: "editor.buildPanel.advanced" still requires human review', + ) + expect( + checkTranslationEntries([advanced], everyLocale(entry({ review: 'missing' })), {}, {}), + ).toContain( + 'fr: "editor.buildPanel.advanced" is unreviewed but not marked fuzzy + Review: required', + ) +}) + +test('after a wording change, extract leaves a passing draft and a reviewed stale entry fails', () => { + const reworded = { ...advanced, message: 'Advanced tools' } + const staleDraft = everyLocale(entry({ review: 'required', fuzzy: true })) + expect(checkTranslationEntries([reworded], staleDraft, {}, {})).toEqual([]) + expect(checkTranslationEntries([reworded], staleDraft, {}, { requireReviewed: true })).toContain( + 'fr: stale "editor.buildPanel.advanced"', + ) + expect(checkTranslationEntries([reworded], everyLocale(entry({})), {}, {})).toContain( + 'fr: stale "editor.buildPanel.advanced"', + ) +}) + +test('a reviewed translation cannot fall back to draft while its English is unchanged', () => { + const base = everyLocale(entry({})) + expect( + reviewRegressions([advanced], everyLocale(entry({ review: 'required', fuzzy: true })), base), + ).toContain( + 'fr: "editor.buildPanel.advanced" is reviewed on the base branch with the same English and must stay reviewed', + ) + expect( + reviewRegressions([advanced], everyLocale(entry({ translation: 'Avancés' })), base), + ).toEqual([]) + const reworded = { ...advanced, message: 'Advanced tools' } + expect( + reviewRegressions([reworded], everyLocale(entry({ review: 'required', fuzzy: true })), base), + ).toEqual([]) +}) + +test('an unrelated addition sharing English with a removed reviewed ID only warns', () => { + const off = { id: 'viewer.common.off', message: 'Off', context: [] } + const added = { id: 'viewer.audio.off', message: 'Off', context: [] } + const reviewed = (translation: string) => ({ + translation, + sourceHash: hashSource('Off'), + review: 'human' as const, + fuzzy: false, + }) + const base: LocaleEntries = Object.fromEntries( + ['zh-CN', 'fr', 'es', 'ja'].map((locale) => [locale, { [off.id]: reviewed('Désactivé') }]), + ) + const current: LocaleEntries = Object.fromEntries( + ['zh-CN', 'fr', 'es', 'ja'].map((locale) => [ + locale, + { [added.id]: { ...reviewed('Désactivé'), review: 'required' as const, fuzzy: true } }, + ]), + ) + expect(reviewRegressions([added], current, base)).toEqual([]) + expect(possibleRenames([added], current, base)).toEqual([ + `possible rename "viewer.common.off" → "viewer.audio.off" (same English): carry over the translation if intended with comment: 'renamed-from: ' and bun run i18n:extract`, + ]) +}) + +test('a declared rename must carry the reviewed translation over', () => { + const renamed = { ...advanced, id: 'editor.buildPanel.advancedTools' } + const base = everyLocale(entry({})) + const declared = (overrides: Partial): LocaleEntries => + Object.fromEntries( + ['zh-CN', 'fr', 'es', 'ja'].map((locale) => [ + locale, + { [renamed.id]: entry({ renamedFrom: advanced.id, ...overrides }) }, + ]), + ) + expect( + reviewRegressions([renamed], declared({ review: 'required', fuzzy: true }), base), + ).toContain( + 'fr: "editor.buildPanel.advancedTools" is declared renamed-from "editor.buildPanel.advanced", whose reviewed translation was not carried over; run bun run i18n:extract', + ) + expect(reviewRegressions([renamed], declared({}), base)).toEqual([]) + expect(possibleRenames([renamed], declared({ review: 'required', fuzzy: true }), base)).toEqual( + [], + ) +}) + +test('an explicit base that cannot be resolved fails the release job', () => { + const result = Bun.spawnSync( + ['bun', 'scripts/i18n.ts', 'release-check', '--base', 'nonexistent-ref'], + { env: { ...process.env, GITHUB_STEP_SUMMARY: '' }, stdout: 'pipe', stderr: 'pipe' }, + ) + expect(result.exitCode).toBe(1) + expect(`${result.stdout}${result.stderr}`).toContain('--base nonexistent-ref is not a commit') +}) + +test('extraction never approves a newly filled translation by itself', () => { + const hash = hashSource('Advanced') + const pasted = { translation: 'Avancé', review: 'missing' as const, fuzzy: false } + expect(annotatedState(pasted, 'Avancé', hash)).toEqual({ + sourceHash: hash, + review: 'required', + fuzzy: true, + }) + expect(annotatedState({ ...pasted, review: 'human' }, 'Avancé', hash)).toEqual({ + sourceHash: hash, + review: 'human', + fuzzy: false, + }) + expect(annotatedState({ ...pasted, review: 'human', sourceHash: hash }, 'Avancé', hash)).toBe( + undefined, + ) +}) + +const LOCALES_DIR = 'packages/editor/src/i18n/locales' + +/** A copy of the real catalogs to run the release job against, edited per case. */ +function catalogFixture(edit: (root: string) => void): { current: string; base: string } { + const root = mkdtempSync(join(tmpdir(), 'pascal-i18n-')) + const base = join(root, 'base') + const current = join(root, 'current') + cpSync(LOCALES_DIR, base, { recursive: true }) + cpSync(LOCALES_DIR, current, { recursive: true }) + edit(current) + return { current, base } +} + +function releaseCheckJob(fixture: { current: string; base: string }) { + const result = Bun.spawnSync( + ['bun', 'scripts/i18n.ts', 'release-check', '--base-dir', fixture.base], + { + env: { ...process.env, I18N_CATALOG_ROOT: fixture.current, GITHUB_STEP_SUMMARY: '' }, + stdout: 'pipe', + stderr: 'pipe', + }, + ) + return { exitCode: result.exitCode, output: `${result.stdout}${result.stderr}` } +} + +function markDraft(root: string, id: string): void { + const path = join(root, 'fr/messages.po') + const po = parsePo(readFileSync(path, 'utf8')) + const item = po.items.find((entry) => entry.msgid === id) + if (!item) throw new Error(`fixture entry ${id} moved`) + item.comments = item.comments.map((comment) => + comment.startsWith('Review: ') ? 'Review: required' : comment, + ) + item.flags.fuzzy = true + writeFileSync(path, stringifyPo(po, { foldLength: 0 })) +} + +test('the release job passes on unchanged catalogs', () => { + expect(releaseCheckJob(catalogFixture(() => {})).exitCode).toBe(0) +}) + +test('the release job fails when a reviewed entry becomes a draft with the same English', () => { + const { exitCode, output } = releaseCheckJob( + catalogFixture((root) => markDraft(root, 'editor.buildPanel.advanced')), + ) + expect(exitCode).toBe(1) + expect(output).toContain( + 'fr: "editor.buildPanel.advanced" is reviewed on the base branch with the same English and must stay reviewed', + ) +}) + +test('the release job fails when a compiled pack ships a draft', () => { + const { exitCode, output } = releaseCheckJob( + catalogFixture((root) => { + const path = join(root, 'fr/messages.ts') + const pack = readFileSync(path, 'utf8') + const draftId = parsePo(readFileSync(join(root, 'fr/messages.po'), 'utf8')).items.find( + (item) => item.flags.fuzzy, + )?.msgid + if (!draftId) throw new Error('fixture has no draft') + writeFileSync( + path, + pack.replace('JSON.parse("{', `JSON.parse("{\\"${draftId}\\":[\\"Brouillon\\"],`), + ) + }), + ) + expect(exitCode).toBe(1) + expect(output).toMatch(/fr: compiled pack ships unreviewed "[^"]+"/) +}) + +/** A throwaway repository with the real catalogs, for the default base lookup. */ +function releaseRepo(steps: (git: (...args: string[]) => void, root: string) => void): string { + const root = mkdtempSync(join(tmpdir(), 'pascal-release-')) + const git = (...args: string[]) => { + const result = Bun.spawnSync( + ['git', '-c', 'user.name=Test', '-c', 'user.email=test@example.com', ...args], + { cwd: root, stdout: 'ignore', stderr: 'pipe' }, + ) + if (result.exitCode !== 0) throw new Error(`git ${args.join(' ')}: ${result.stderr}`) + } + git('init', '-q', '-b', 'main') + steps(git, root) + return root +} + +const addCatalogs = (root: string) => + cpSync(LOCALES_DIR, join(root, LOCALES_DIR), { recursive: true }) + +function defaultReleaseCheck(root: string) { + const result = Bun.spawnSync(['bun', 'scripts/i18n.ts', 'release-check'], { + env: { ...process.env, I18N_ROOT: root, GITHUB_STEP_SUMMARY: '' }, + stdout: 'pipe', + stderr: 'pipe', + }) + return { exitCode: result.exitCode, output: `${result.stdout}${result.stderr}` } +} + +test('the release job compares with the latest editor release tag and fails on a reviewed-to-draft change', () => { + const root = releaseRepo((git, root) => { + addCatalogs(root) + git('add', '.') + git('commit', '-q', '-m', 'release') + git('tag', '@pascal-app/editor@9.9.9') + git('tag', 'v0.0.1') + markDraft(join(root, LOCALES_DIR), 'editor.buildPanel.advanced') + }) + const { exitCode, output } = defaultReleaseCheck(root) + expect(output).toContain('Comparing with @pascal-app/editor@9.9.9') + expect(exitCode).toBe(1) + expect(output).toContain( + 'fr: "editor.buildPanel.advanced" is reviewed on the base branch with the same English and must stay reviewed', + ) +}) + +test('a release tag without catalogs falls back to the commit that introduced them', () => { + const root = releaseRepo((git, root) => { + writeFileSync(join(root, 'README.md'), 'before i18n\n') + git('add', '.') + git('commit', '-q', '-m', 'old release') + git('tag', '@pascal-app/editor@1.0.0') + addCatalogs(root) + git('add', '.') + git('commit', '-q', '-m', 'catalogs') + writeFileSync(join(root, 'README.md'), 'after i18n\n') + git('add', '.') + git('commit', '-q', '-m', 'later work') + markDraft(join(root, LOCALES_DIR), 'editor.buildPanel.advanced') + }) + const { exitCode, output } = defaultReleaseCheck(root) + expect(output).toContain( + 'the commit that introduced the catalogs (@pascal-app/editor@1.0.0 has no catalogs)', + ) + expect(exitCode).toBe(1) + expect(output).toContain('fr: "editor.buildPanel.advanced" is reviewed on the base branch') +}) + +test('with no base that has catalogs the release job fails loudly', () => { + const root = releaseRepo((git, root) => { + addCatalogs(root) + git('add', '.') + git('commit', '-q', '-m', 'catalogs at HEAD only') + }) + const { exitCode, output } = defaultReleaseCheck(root) + expect(exitCode).toBe(1) + expect(output).toContain('release-check: no base with bundled PO catalogs') +}) diff --git a/scripts/i18n-literals.ts b/scripts/i18n-literals.ts new file mode 100644 index 0000000000..96531c5c8a --- /dev/null +++ b/scripts/i18n-literals.ts @@ -0,0 +1,931 @@ +import { readFileSync, writeFileSync } from 'node:fs' +import { dirname, join, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { decodeHTMLStrict } from 'entities' +import ts from 'typescript' +import { baseRevision, readAtRevision } from './i18n-git' + +/** + * User-visible literal scanner shared by the one-time codemod (`convert`) and + * the CI ratchet (`check`). Both see exactly the same candidates, so a file + * the codemod finished is a file the guard keeps finished. + */ + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const BASELINE_PATH = join(ROOT, 'i18n/literal-baseline.json') +const ALLOWLIST_PATH = join(ROOT, 'i18n/literal-allowlist.json') + +export const SCANNED_GLOBS = [ + 'apps/editor/**/*.tsx', + 'packages/editor/src/**/*.tsx', + 'packages/viewer/src/**/*.tsx', + 'packages/nodes/src/**/*.tsx', + 'packages/core/src/**/*.tsx', + 'packages/ui/src/**/*.tsx', + // Plain modules only contribute notification payloads (toast/alert/confirm). + 'apps/editor/**/*.ts', + 'packages/editor/src/**/*.ts', +] + +const SKIPPED_PATH = + /(?:\.test|\.spec|\.stories|\.bench|\.d)\.tsx?$|\/(?:__fixtures__|__tests__|__bench__|test-utils|node_modules|vendor)\// + +/** JSX props whose string value is read by people or assistive technology. */ +const UI_ATTRIBUTES = new Set([ + 'alt', + 'aria-description', + 'aria-label', + 'aria-placeholder', + 'aria-roledescription', + 'aria-valuetext', + 'cancelLabel', + 'caption', + 'confirmLabel', + 'description', + 'emptyLabel', + 'emptyMessage', + 'emptyText', + 'heading', + 'helperText', + 'hint', + 'label', + 'placeholder', + 'subtitle', + 'title', + 'tooltip', +]) + +/** Object keys that carry display copy when built inside a component. */ +const UI_PROPERTIES = new Set([ + 'ariaLabel', + 'cancelLabel', + 'confirmLabel', + 'description', + 'detail', + 'emptyMessage', + 'heading', + 'hint', + 'label', + 'placeholder', + 'subtitle', + 'title', + 'tooltip', +]) + +const NOTIFY_CALLEES = new Set([ + 'alert', + 'confirm', + 'prompt', + 'setError', + 'setErrorMessage', + 'setNotice', + 'setStatusMessage', + 'toast', +]) +const NOTIFY_METHODS = new Set(['success', 'error', 'info', 'warning', 'message', 'loading']) + +export type CandidateKind = + | 'jsx-text' + | 'jsx-sentence' + | 'attribute' + | 'expression' + | 'property' + | 'notify' + +export type Candidate = { + kind: CandidateKind + /** English copy as rendered (JSX whitespace collapsed, placeholders as `{name}`). */ + text: string + line: number + /** Enclosing React component or hook that can own `usePascalI18n()`. */ + component?: string + /** Why the codemod leaves this one for a person. */ + manual?: string +} + +export type Allowlist = { + /** Exact strings that are not translatable copy anywhere (brands, units, symbols). */ + texts: string[] + /** Path prefixes whose literals are not product UI, each with a reason. */ + paths: Record +} + +/** Legacy candidates per file, by text (a multiset: repeated copy appears once per use). */ +export type Baseline = { version: 3; files: Record } + +function readAllowlist(): Allowlist { + return JSON.parse(readFileSync(ALLOWLIST_PATH, 'utf8')) as Allowlist +} + +/** Mirrors React's JSX text whitespace rules (`jsx-runtime` collapses per line). */ +export function normalizeJsxText(raw: string): string { + const lines = raw.split(/\r\n|\n|\r/) + if (lines.length === 1) return raw + const parts: string[] = [] + lines.forEach((line, index) => { + let value = line.replace(/\t/g, ' ') + if (index > 0) value = value.replace(/^ +/, '') + if (index < lines.length - 1) value = value.replace(/ +$/, '') + if (value) parts.push(value) + }) + return parts.join(' ') +} + +/** Leading/trailing ASCII whitespace; a no-break space (` `) is content. */ +export const ASCII_EDGE_WHITESPACE = /^[ \t\r\n]+|[ \t\r\n]+$/g + +/** What one JSX text node renders: React's whitespace rules, then HTML entities. */ +export function renderJsxText(raw: string): string { + return decodeHTMLStrict(normalizeJsxText(raw)) +} + +/** A JSX attribute's string renders with HTML entities decoded; other literals are as written. */ +export function attributeText(node: ts.StringLiteral | ts.NoSubstitutionTemplateLiteral): string { + return ts.isJsxAttribute(node.parent) ? decodeHTMLStrict(node.text) : node.text +} + +function looksTranslatable(text: string, allow: Set): boolean { + const trimmed = text.trim() + if (!/\p{L}{2}/u.test(trimmed)) return false + if (allow.has(trimmed)) return false + // Icon names, snake_case keys, paths and URLs. Hyphenated words ("Read-only", "zoom-in") are copy. + if (/^[a-z0-9-]+(?:[_:./#][a-z0-9-]+)+$/i.test(trimmed)) return false + if (/^(?:https?:|mailto:|\/|#|\.)/.test(trimmed)) return false + if (/^[a-z][a-zA-Z0-9]*$/.test(trimmed) && /[A-Z]/.test(trimmed)) return false + return true +} + +function lineOf(source: ts.SourceFile, node: ts.Node): number { + return source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1 +} + +const IGNORE = /i18n-ignore:\s*\S/ +// A line holding only a comment: a line comment, a block comment, or a JSX {/* comment */}. +const COMMENT_ONLY_LINE = /^\s*(?:\/\/|\/\*|\{\s*\/\*)/ + +/** + * `i18n-ignore: ` exempts a literal on its own line, or on the line + * after a comment-only line. A trailing ignore that exempts one line's literal + * does not reach the next line. + */ +function isIgnored(source: ts.SourceFile, node: ts.Node): boolean { + const text = source.text + const start = node.getStart(source) + const lineStart = text.lastIndexOf('\n', start - 1) + 1 + const lineEnd = text.indexOf('\n', start) === -1 ? text.length : text.indexOf('\n', start) + if (IGNORE.test(text.slice(lineStart, lineEnd))) return true + if (lineStart === 0) return false + const previousLineStart = text.lastIndexOf('\n', lineStart - 2) + 1 + const previousLine = text.slice(previousLineStart, lineStart - 1) + return COMMENT_ONLY_LINE.test(previousLine) && IGNORE.test(previousLine) +} + +function isTranslationCall(node: ts.Node): boolean { + return ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + node.expression.name.text === '_' + ) +} + +function insideTranslationCall(node: ts.Node): boolean { + for (let current = node.parent; current; current = current.parent) { + if (isTranslationCall(current)) return true + } + return false +} + +export function functionName(node: ts.Node): string | undefined { + if (ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node)) { + if (node.name) return node.name.text + } + if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) { + let parent = node.parent + // memo(...) / forwardRef(...) wrappers + while (parent && ts.isCallExpression(parent)) parent = parent.parent + if (parent && ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) { + return parent.name.text + } + } + return undefined +} + +export function isComponentName(name: string | undefined): boolean { + return Boolean(name && (/^[A-Z]/.test(name) || /^use[A-Z]/.test(name))) +} + +const COMPONENT_WRAPPERS = new Set(['memo', 'forwardRef']) + +/** A function passed as an argument, other than to `memo` / `forwardRef`. */ +function isCallbackArgument(node: ts.Node): boolean { + let argument: ts.Node = node + while (argument.parent && ts.isParenthesizedExpression(argument.parent)) + argument = argument.parent + const call = argument.parent + if (!(call && ts.isCallExpression(call) && call.arguments.includes(argument as ts.Expression))) { + return false + } + const callee = call.expression + const name = ts.isIdentifier(callee) + ? callee.text + : ts.isPropertyAccessExpression(callee) + ? callee.name.text + : undefined + return !(name && COMPONENT_WRAPPERS.has(name)) +} + +function isAsync(node: ts.FunctionLikeDeclaration): boolean { + return Boolean( + ts.canHaveModifiers(node) && + ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword), + ) +} + +/** + * The nearest enclosing function named like a component or hook, and why it + * cannot own a hook: an async function, or a named callback such as + * `.map(function Row() …)`, is not rendered by React as a component. + */ +export function componentOwner(node: ts.Node): { + owner?: ts.FunctionLikeDeclaration + problem?: string +} { + for (let current = node.parent; current; current = current.parent) { + if ( + (ts.isFunctionDeclaration(current) || + ts.isArrowFunction(current) || + ts.isFunctionExpression(current)) && + isComponentName(functionName(current)) + ) { + if (isAsync(current)) return { problem: 'inside an async function: hooks cannot run there' } + if (isCallbackArgument(current)) { + return { problem: 'inside a callback function: hooks cannot run there' } + } + return { owner: current } + } + } + return { problem: 'outside a component or hook' } +} + +/** The nearest enclosing component or hook that can own `usePascalI18n()`. */ +export function enclosingComponent(node: ts.Node): ts.FunctionLikeDeclaration | undefined { + return componentOwner(node).owner +} + +function stringValue(node: ts.Expression | undefined): string | undefined { + if (!node) return undefined + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text + return undefined +} + +/** Placeholder name for a simple interpolated expression, or undefined when it needs a person. */ +export function placeholderName(expression: ts.Expression): string | undefined { + if (ts.isIdentifier(expression)) return expression.text + if (ts.isPropertyAccessExpression(expression) && !expression.questionDotToken) { + return expression.name.text + } + if (ts.isNonNullExpression(expression)) return placeholderName(expression.expression) + return undefined +} + +export type SentenceChild = + /** `text` is what the node renders. */ + | { type: 'text'; node: ts.JsxText; text: string } + /** A string literal or `const` string rendered inline, e.g. `{' '}` or `{LABEL}`. */ + | { type: 'static'; node: ts.JsxExpression; text: string } + | { type: 'value'; node: ts.JsxExpression; expression: ts.Expression; name: string } + +/** + * Children of an element that render as one sentence: text, static strings and + * simple interpolations, no nested elements. Static parts are folded into the + * rendered text, so `Share{' '}project` and `Share project` read the same and a + * changed constant changes the sentence. + */ +export function sentenceChildren(element: ts.JsxElement): SentenceChild[] | undefined { + const children = element.children.filter( + (child) => !(ts.isJsxText(child) && child.containsOnlyTriviaWhiteSpaces), + ) + if (children.length < 2) return undefined + const result: SentenceChild[] = [] + let hasText = false + let hasJoin = false + for (const child of children) { + if (ts.isJsxText(child)) { + const text = renderJsxText(child.text) + hasText ||= /\p{L}{2}/u.test(text) + result.push({ type: 'text', node: child, text }) + continue + } + if (ts.isJsxExpression(child) && child.expression && !child.dotDotDotToken) { + const expression = child.expression + const literal = + ts.isStringLiteral(expression) || ts.isNoSubstitutionTemplateLiteral(expression) + ? expression + : ts.isIdentifier(expression) + ? constantValue(expression) + : undefined + if (literal) { + hasText ||= /\p{L}{2}/u.test(literal.text) + hasJoin = true + result.push({ type: 'static', node: child, text: literal.text }) + continue + } + const name = placeholderName(expression) + if (!name) return undefined + hasJoin = true + result.push({ type: 'value', node: child, expression, name }) + continue + } + return undefined + } + return hasText && hasJoin ? result : undefined +} + +export function sentenceMessage(children: SentenceChild[]): string { + return children + .map((child) => (child.type === 'value' ? `{${child.name}}` : child.text)) + .join('') + .replace(ASCII_EDGE_WHITESPACE, '') +} + +function notifyCallee(node: ts.CallExpression): string | undefined { + const callee = node.expression + if (ts.isIdentifier(callee) && NOTIFY_CALLEES.has(callee.text)) return callee.text + if ( + ts.isPropertyAccessExpression(callee) && + ts.isIdentifier(callee.expression) && + callee.expression.text === 'toast' && + NOTIFY_METHODS.has(callee.name.text) + ) { + return `toast.${callee.name.text}` + } + if ( + ts.isPropertyAccessExpression(callee) && + ts.isIdentifier(callee.expression) && + callee.expression.text === 'window' && + NOTIFY_CALLEES.has(callee.name.text) + ) { + return `window.${callee.name.text}` + } + return undefined +} + +export type Visitor = { + jsxText(node: ts.JsxText, text: string): void + sentence(element: ts.JsxElement, children: SentenceChild[], text: string): void + literal(node: ts.StringLiteral | ts.NoSubstitutionTemplateLiteral, kind: CandidateKind): void + template(node: ts.TemplateExpression, kind: CandidateKind): void + /** A `const` holding copy, rendered by name in JSX or a UI prop. */ + constant(node: ts.Identifier, text: string, kind: CandidateKind): void + /** Copy built with `+`, e.g. `'Delete ' + name`. */ + concatenation(node: ts.BinaryExpression, text: string, kind: CandidateKind): void +} + +function bindsName(name: ts.BindingName, wanted: string): boolean { + if (ts.isIdentifier(name)) return name.text === wanted + return name.elements.some( + (element) => !ts.isOmittedExpression(element) && bindsName(element.name, wanted), + ) +} + +type Binding = + | { found: false } + | { found: true; value?: ts.StringLiteral | ts.NoSubstitutionTemplateLiteral } + +/** The declaration of `name` directly owned by one scope node, if any. */ +function bindingIn(scope: ts.Node, name: string): Binding { + if (ts.isFunctionLike(scope)) { + if (scope.parameters.some((parameter) => bindsName(parameter.name, name))) + return { found: true } + return { found: false } + } + if ( + (ts.isForStatement(scope) || ts.isForOfStatement(scope) || ts.isForInStatement(scope)) && + scope.initializer && + ts.isVariableDeclarationList(scope.initializer) && + scope.initializer.declarations.some((declaration) => bindsName(declaration.name, name)) + ) { + return { found: true } + } + if ( + ts.isCatchClause(scope) && + scope.variableDeclaration && + bindsName(scope.variableDeclaration.name, name) + ) { + return { found: true } + } + if ( + !( + ts.isSourceFile(scope) || + ts.isBlock(scope) || + ts.isModuleBlock(scope) || + ts.isCaseClause(scope) + ) + ) { + return { found: false } + } + for (const statement of scope.statements) { + if (ts.isVariableStatement(statement)) { + const isConst = (statement.declarationList.flags & ts.NodeFlags.Const) !== 0 + for (const declaration of statement.declarationList.declarations) { + if (!bindsName(declaration.name, name)) continue + const value = declaration.initializer + return isConst && + ts.isIdentifier(declaration.name) && + value && + (ts.isStringLiteral(value) || ts.isNoSubstitutionTemplateLiteral(value)) + ? { found: true, value } + : { found: true } + } + } + if ( + (ts.isFunctionDeclaration(statement) || + ts.isClassDeclaration(statement) || + ts.isEnumDeclaration(statement)) && + statement.name?.text === name + ) { + return { found: true } + } + if (ts.isImportDeclaration(statement)) { + const clause = statement.importClause + const bindings = clause?.namedBindings + if ( + clause?.name?.text === name || + (bindings && ts.isNamespaceImport(bindings) && bindings.name.text === name) || + (bindings && + ts.isNamedImports(bindings) && + bindings.elements.some((element) => element.name.text === name)) + ) { + return { found: true } + } + } + } + return { found: false } +} + +/** The `const` string an identifier refers to, resolved through the nearest scope that declares it. */ +function constantValue( + identifier: ts.Identifier, +): ts.StringLiteral | ts.NoSubstitutionTemplateLiteral | undefined { + for (let scope = identifier.parent; scope; scope = scope.parent) { + const binding = bindingIn(scope, identifier.text) + if (binding.found) return binding.value + } + return undefined +} + +function isNotifyOptions(node: ts.Node): boolean { + const call = node.parent + return ( + ts.isObjectLiteralExpression(node) && + !!call && + ts.isCallExpression(call) && + call.arguments[1] === node && + notifyCallee(call) !== undefined + ) +} + +/** Walks every user-visible literal in one file. */ +export function visitUiLiterals( + source: ts.SourceFile, + visitor: Visitor, + allow: Set, + /** Plain modules are scanned for notifications only unless the codemod was pointed at them. */ + allKinds = source.fileName.endsWith('.tsx'), +): void { + const notifyOnly = !allKinds + const literalSink = (expression: ts.Expression | undefined, kind: CandidateKind): void => { + if (!expression || (notifyOnly && kind !== 'notify')) return + if (ts.isParenthesizedExpression(expression)) { + literalSink(expression.expression, kind) + return + } + if (ts.isConditionalExpression(expression)) { + literalSink(expression.whenTrue, kind) + literalSink(expression.whenFalse, kind) + return + } + if ( + ts.isBinaryExpression(expression) && + (expression.operatorToken.kind === ts.SyntaxKind.BarBarToken || + expression.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken || + expression.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) + ) { + if (expression.operatorToken.kind !== ts.SyntaxKind.AmpersandAmpersandToken) { + literalSink(expression.left, kind) + } + literalSink(expression.right, kind) + return + } + if ( + ts.isBinaryExpression(expression) && + expression.operatorToken.kind === ts.SyntaxKind.PlusToken + ) { + const parts: ts.Expression[] = [] + const flatten = (part: ts.Expression): void => { + if (ts.isParenthesizedExpression(part)) flatten(part.expression) + else if ( + ts.isBinaryExpression(part) && + part.operatorToken.kind === ts.SyntaxKind.PlusToken + ) { + flatten(part.left) + flatten(part.right) + } else parts.push(part) + } + flatten(expression) + // A `const` string operand is copy like a literal one. + const literalOf = (part: ts.Expression) => + ts.isStringLiteral(part) || ts.isNoSubstitutionTemplateLiteral(part) + ? part + : ts.isIdentifier(part) + ? constantValue(part) + : undefined + const text = parts.map((part) => literalOf(part)?.text ?? '{?}').join('') + const hasCopy = parts.some((part) => /\p{L}{2}/u.test(literalOf(part)?.text ?? '')) + if (hasCopy && looksTranslatable(text, allow) && !isIgnored(source, expression)) { + visitor.concatenation(expression, text, kind) + } + return + } + if (ts.isIdentifier(expression)) { + const value = constantValue(expression) + if ( + value && + looksTranslatable(value.text, allow) && + !isIgnored(source, value) && + !isIgnored(source, expression) + ) { + visitor.constant(expression, value.text, kind) + } + return + } + if (ts.isStringLiteral(expression) || ts.isNoSubstitutionTemplateLiteral(expression)) { + if (looksTranslatable(expression.text, allow) && !isIgnored(source, expression)) { + visitor.literal(expression, kind) + } + return + } + if (ts.isTemplateExpression(expression)) { + const text = [ + expression.head.text, + ...expression.templateSpans.map((span) => span.literal.text), + ].join(' ') + if (looksTranslatable(text, allow) && !isIgnored(source, expression)) { + visitor.template(expression, kind) + } + } + } + + const visit = (node: ts.Node): void => { + if (isTranslationCall(node)) return + + if (ts.isJsxElement(node)) { + const sentence = sentenceChildren(node) + if (sentence) { + const text = sentenceMessage(sentence) + // The whole joined text is what renders, so exemptions apply to it. + if (looksTranslatable(text, allow) && !isIgnored(source, sentence[0]!.node)) { + visitor.sentence(node, sentence, text) + } + ts.forEachChild(node.openingElement, visit) + return + } + } + + if (ts.isJsxText(node) && !node.containsOnlyTriviaWhiteSpaces) { + const text = renderJsxText(node.text) + if (looksTranslatable(text, allow) && !isIgnored(source, node)) visitor.jsxText(node, text) + return + } + + if ( + ts.isJsxExpression(node) && + node.parent && + (ts.isJsxElement(node.parent) || ts.isJsxFragment(node.parent)) + ) { + literalSink(node.expression, 'expression') + } + + if ( + ts.isJsxAttribute(node) && + ts.isIdentifier(node.name) && + UI_ATTRIBUTES.has(node.name.text) + ) { + const initializer = node.initializer + if (initializer && ts.isStringLiteral(initializer)) literalSink(initializer, 'attribute') + else if (initializer && ts.isJsxExpression(initializer)) { + literalSink(initializer.expression, 'attribute') + } + } + + if ( + ts.isPropertyAssignment(node) && + (ts.isIdentifier(node.name) || ts.isStringLiteral(node.name)) && + UI_PROPERTIES.has(node.name.text) && + !insideTranslationCall(node) && + !isNotifyOptions(node.parent) + ) { + literalSink(node.initializer, 'property') + } + + if (ts.isCallExpression(node) && notifyCallee(node)) { + literalSink(node.arguments[0], 'notify') + const options = node.arguments[1] + if (options && ts.isObjectLiteralExpression(options)) { + for (const property of options.properties) { + if ( + ts.isPropertyAssignment(property) && + ts.isIdentifier(property.name) && + property.name.text === 'description' + ) { + literalSink(property.initializer, 'notify') + } + } + } + } + + ts.forEachChild(node, visit) + } + visit(source) +} + +export function parseSource(path: string, text: string): ts.SourceFile { + return ts.createSourceFile( + path, + text, + ts.ScriptTarget.Latest, + true, + path.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ) +} + +export function scanSource( + path: string, + text: string, + allowlist: Allowlist = readAllowlist(), +): Candidate[] { + const source = parseSource(path, text) + const allow = new Set(allowlist.texts) + const candidates: Candidate[] = [] + const add = (node: ts.Node, kind: CandidateKind, value: string, manual?: string) => { + const { owner, problem } = componentOwner(node) + candidates.push({ + kind, + text: value, + line: lineOf(source, node), + component: owner ? functionName(owner) : undefined, + manual: manual ?? problem, + }) + } + visitUiLiterals( + source, + { + jsxText: (node, value) => add(node, 'jsx-text', value.replace(ASCII_EDGE_WHITESPACE, '')), + sentence: (element, _children, value) => add(element, 'jsx-sentence', value), + literal: (node, kind) => add(node, kind, attributeText(node)), + template: (node, kind) => { + const names = node.templateSpans.map((span) => placeholderName(span.expression)) + const value = [ + node.head.text, + ...node.templateSpans.map( + (span, index) => `{${names[index] ?? '?'}}${span.literal.text}`, + ), + ].join('') + add( + node, + kind, + value, + names.includes(undefined) ? 'complex template interpolation' : undefined, + ) + }, + constant: (node, value, kind) => + add(node, kind, value, 'string constant: make it a descriptor or translate at the use'), + concatenation: (node, value, kind) => + add(node, kind, value, 'concatenated copy: write one message with placeholders'), + }, + allow, + ) + return candidates +} + +export function scannedFiles(): string[] { + const allowlist = readAllowlist() + const files = new Set() + for (const pattern of SCANNED_GLOBS) { + for (const path of new Bun.Glob(pattern).scanSync({ cwd: ROOT, onlyFiles: true })) { + if (SKIPPED_PATH.test(`/${path}`)) continue + if (Object.keys(allowlist.paths).some((prefix) => path.startsWith(prefix))) continue + files.add(path) + } + } + return [...files].sort() +} + +export function scanRepository(): Map { + const allowlist = readAllowlist() + const result = new Map() + for (const path of scannedFiles()) { + const candidates = scanSource(path, readFileSync(join(ROOT, path), 'utf8'), allowlist) + if (candidates.length > 0) result.set(path, candidates) + } + return result +} + +export function parseBaseline(text: string | undefined): Baseline | undefined { + if (!text) return undefined + const parsed = JSON.parse(text) as { version?: number; files?: Record } + // Version 3 records rendered text (entities, joins); older baselines are not comparable. + return parsed.version === 3 ? (parsed as Baseline) : undefined +} + +function readBaseline(): Baseline { + return parseBaseline(readFileSync(BASELINE_PATH, 'utf8')) ?? { version: 3, files: {} } +} + +/** The base branch's baseline, when the base has one (see `baseRevision`). */ +function readBaseBaseline(): Baseline | undefined { + const revision = baseRevision() + return revision ? parseBaseline(readAtRevision(revision, BASELINE_PATH)) : undefined +} + +function writeBaseline(scan: Map): void { + const files = Object.fromEntries( + [...scan.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([path, list]) => [path, list.map((candidate) => candidate.text).sort()]), + ) + writeFileSync( + BASELINE_PATH, + `${JSON.stringify({ version: 3, files } satisfies Baseline, null, 2)}\n`, + ) +} + +/** Items of `values` left after removing one occurrence per item of `allowed`. */ +function beyond(values: readonly string[], allowed: readonly string[]): string[] { + const remaining = new Map() + for (const value of allowed) remaining.set(value, (remaining.get(value) ?? 0) + 1) + return values.filter((value) => { + const left = remaining.get(value) ?? 0 + if (left === 0) return true + remaining.set(value, left - 1) + return false + }) +} + +/** + * The ratchet: a file may only keep copy its baseline already lists, so new + * or swapped copy fails, and a file absent from the baseline must have none. + * The baseline itself may only shrink relative to the base branch; exempting + * copy goes through `i18n/literal-allowlist.json` or an `i18n-ignore: `. + */ +export function checkLiterals( + scan = scanRepository(), + baseline = readBaseline(), + base?: Baseline, +): { + errors: string[] + improvable: string[] + total: number +} { + const errors: string[] = [] + const improvable: string[] = [] + let total = 0 + for (const [path, candidates] of scan) { + total += candidates.length + const unknown = new Set( + beyond( + candidates.map((candidate) => candidate.text), + baseline.files[path] ?? [], + ), + ) + if (unknown.size === 0) continue + const listed = candidates + .filter((candidate) => unknown.has(candidate.text)) + .map((candidate) => ` ${path}:${candidate.line} ${JSON.stringify(candidate.text)}`) + errors.push( + `${path}: untranslated user-visible literals not in the baseline. Wrap them with i18n._({ id, message }) or add an "i18n-ignore: " comment.\n${listed.join('\n')}`, + ) + } + for (const [path, allowed] of Object.entries(baseline.files)) { + const left = beyond( + allowed, + (scan.get(path) ?? []).map((candidate) => candidate.text), + ) + if (left.length > 0) improvable.push(`${path}: ${left.length} fewer`) + } + if (base) { + // Growth is measured across the repository, so copy that moves with a + // renamed, split or extracted file passes; each added text must consume an + // equal text removed elsewhere, duplicates counted. + const all = (files: Baseline['files']) => Object.values(files).flat() + const added = beyond(all(baseline.files), all(base.files)) + if (added.length > 0) { + const where = (text: string) => + Object.entries(baseline.files) + .filter(([, texts]) => texts.includes(text)) + .map(([path]) => path) + errors.push( + `i18n/literal-baseline.json adds copy the base branch's baseline does not have; the baseline may only shrink:\n${[ + ...new Set(added), + ] + .map((text) => ` ${JSON.stringify(text)} (${where(text).join(', ')})`) + .join('\n')}`, + ) + } + } + return { errors, improvable, total } +} + +/** Exemptions are reviewed like the baseline: the allowlist may not gain entries relative to the base. */ +export function checkAllowlistGrowth(current: Allowlist, base: Allowlist | undefined): string[] { + if (!base) return [] + const texts = current.texts.filter((text) => !base.texts.includes(text)) + const paths = Object.keys(current.paths).filter((path) => !(path in base.paths)) + return [ + ...texts.map( + (text) => + `i18n/literal-allowlist.json adds the text ${JSON.stringify(text)}; exempt one use with i18n-ignore: instead`, + ), + ...paths.map( + (path) => + `i18n/literal-allowlist.json adds the path ${JSON.stringify(path)}; exempting a path needs a reviewed exception`, + ), + ] +} + +function readBaseAllowlist(): Allowlist | undefined { + const revision = baseRevision() + const text = revision ? readAtRevision(revision, ALLOWLIST_PATH) : undefined + return text ? (JSON.parse(text) as Allowlist) : undefined +} + +export function checkRepositoryLiterals() { + const result = checkLiterals(scanRepository(), readBaseline(), readBaseBaseline()) + result.errors.push(...checkAllowlistGrowth(readAllowlist(), readBaseAllowlist())) + return result +} + +function report(scan: Map): void { + const byArea = new Map() + for (const [path, candidates] of scan) { + const area = path + .split('/') + .slice(0, path.startsWith('packages/editor/src/components/ui/') ? 6 : 4) + .join('/') + byArea.set(area, (byArea.get(area) ?? 0) + candidates.length) + } + for (const [area, count] of [...byArea.entries()].sort((a, b) => b[1] - a[1])) { + console.log(`${String(count).padStart(6)} ${area}`) + } +} + +async function main(): Promise { + const [command, ...args] = process.argv.slice(2) + if (command === 'check') { + const { errors, improvable, total } = checkRepositoryLiterals() + if (improvable.length > 0) { + console.log( + `[i18n literals] ${improvable.length} files are below their baseline; run bun run i18n:literals -- update to lock in the progress`, + ) + } + if (errors.length > 0) { + console.error(errors.map((error) => `[i18n literals] ${error}`).join('\n')) + process.exitCode = 1 + return + } + console.log(`[i18n literals] no new untranslated literals (${total} legacy candidates remain)`) + return + } + if (command === 'update') { + writeBaseline(scanRepository()) + console.log(`Updated ${relative(ROOT, BASELINE_PATH)}`) + return + } + if (command === 'report') { + const scan = scanRepository() + const filter = args.filter((arg) => !arg.startsWith('--')) + if (args.includes('--files')) { + for (const [path, candidates] of scan) { + if (filter.length && !filter.some((prefix) => path.startsWith(prefix))) continue + for (const candidate of candidates) { + console.log( + `${path}:${candidate.line}\t${candidate.kind}\t${candidate.component ?? '-'}\t${JSON.stringify(candidate.text)}${candidate.manual ? `\tMANUAL: ${candidate.manual}` : ''}`, + ) + } + } + return + } + report(scan) + return + } + if (command === 'convert') { + const { convertFiles } = await import('./i18n-codemod') + await convertFiles(args) + return + } + throw new Error( + 'Usage: bun scripts/i18n-literals.ts >', + ) +} + +if (import.meta.main) await main() diff --git a/scripts/i18n-pseudo-audit.ts b/scripts/i18n-pseudo-audit.ts new file mode 100644 index 0000000000..f10dec99fa --- /dev/null +++ b/scripts/i18n-pseudo-audit.ts @@ -0,0 +1,180 @@ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium, type Page } from 'playwright-core' +import { type PseudoSurface, surfaces } from '../i18n/pseudo-surfaces' + +/** + * Pseudo-locale audit. Opens each surface of a running editor with + * `?lang=en-XA`, where every translated letter is accented and the text is + * ~30 % longer and bracketed, then reports any visible text or accessible + * name that still contains plain ASCII words: that copy bypassed the catalog. + * Visible controls without an accessible name are reported too, so narrow + * layouts that hide a button's text still name it. + * Screenshots of each surface go to the output directory for clipping review. + * + * bun run i18n:pseudo-audit -- --base http://localhost:3002 [--out dir] [surface...] + * + * Needs a Chrome/Chromium binary (`PLAYWRIGHT_CHROMIUM_EXECUTABLE`, defaulting + * to the system Google Chrome). It is a contributor and PR tool, not a CI job. + */ + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const ALLOWLIST = JSON.parse(readFileSync(join(ROOT, 'i18n/literal-allowlist.json'), 'utf8')) as { + texts: string[] +} + +export type Finding = { surface: string; where: string; text: string } + +function argument(name: string): string | undefined { + const index = process.argv.indexOf(`--${name}`) + return index === -1 ? undefined : process.argv[index + 1] +} + +/** ASCII words left after removing allowed data (authored names, brands, units). */ +export function untranslatedWords(text: string, allowed: readonly string[]): string[] { + let rest = text + for (const value of [...allowed].sort((a, b) => b.length - a.length)) { + if (!value) continue + // Whole words only: allowing the unit "in" must not hide "Find" or "Pin". + const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + rest = rest.replace(new RegExp(`(? { + const roots = selector ? [...document.querySelectorAll(selector)] : [document.body] + const results: Array<{ where: string; text: string }> = [] + const describe = (element: Element) => { + const parts: string[] = [] + for ( + let current: Element | null = element; + current && parts.length < 3; + current = current.parentElement + ) { + const id = current.id ? `#${current.id}` : '' + const testId = current.getAttribute('data-testid') + parts.unshift( + `${current.tagName.toLowerCase()}${id}${testId ? `[data-testid=${testId}]` : ''}`, + ) + } + return parts.join(' > ') + } + const visible = (element: Element) => + element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }) && + !element.closest('[aria-hidden="true"], script, style, noscript, svg title') + for (const root of roots) { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT) + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + const text = node.textContent?.trim() + const parent = node.parentElement + if (text && parent && visible(parent)) results.push({ where: describe(parent), text }) + } + for (const element of root.querySelectorAll( + '[aria-label], [title], [placeholder], img[alt]', + )) { + if (!visible(element)) continue + for (const name of ['aria-label', 'title', 'placeholder', 'alt']) { + const value = element.getAttribute(name)?.trim() + if (value) results.push({ where: `${describe(element)} @${name}`, text: value }) + } + } + } + const unnamed: string[] = [] + for (const root of roots) { + for (const control of root.querySelectorAll( + 'button, a[href], [role="button"], input, select', + )) { + if (!visible(control)) continue + const labelledBy = control + .getAttribute('aria-labelledby') + ?.split(/\s+/) + .map((id) => document.getElementById(id)?.textContent ?? '') + .join(' ') + const name = [ + control.getAttribute('aria-label'), + labelledBy, + control.getAttribute('title'), + // biome-ignore lint/performance/useDomNodeTextContent: text hidden at this width must not count as a name + (control as HTMLElement).innerText, + // biome-ignore lint/performance/useDomNodeTextContent: text hidden at this width must not count as a name + (control as HTMLInputElement).labels?.[0]?.innerText, + ...[...control.querySelectorAll('img[alt]')].map((image) => image.getAttribute('alt')), + ] + .map((value) => value?.trim()) + .find(Boolean) + if (!name) unnamed.push(describe(control)) + } + } + return { results, unnamed } + }, scope) +} + +export async function auditSurface(page: Page, base: string, surface: PseudoSurface, out: string) { + const findings: Finding[] = [] + const path = typeof surface.path === 'string' ? surface.path : await surface.path(base) + const url = new URL(path, base) + url.searchParams.set('lang', 'en-XA') + await page.setViewportSize(surface.viewport ?? { width: 1440, height: 900 }) + await page.goto(url.toString(), { waitUntil: 'domcontentloaded' }) + await page.waitForLoadState('networkidle', { timeout: 60_000 }).catch(() => undefined) + await surface.steps?.(page) + await page.waitForTimeout(400) + const allowed = [...ALLOWLIST.texts, ...(surface.data ?? [])] + const { results, unnamed } = await collect(page, surface.scope) + for (const where of unnamed) { + findings.push({ surface: surface.name, where, text: 'control has no accessible name' }) + } + for (const entry of results) { + const words = untranslatedWords(entry.text, allowed) + if (words.length > 0) + findings.push({ surface: surface.name, where: entry.where, text: entry.text }) + } + const shot = join(out, `${surface.name}.png`) + if (surface.scope && (await page.locator(surface.scope).count()) === 0) { + findings.push({ surface: surface.name, where: surface.scope, text: 'surface did not open' }) + } + await page.screenshot({ path: shot }) + return { findings, shot } +} + +async function main(): Promise { + const base = argument('base') ?? 'http://localhost:3002' + const out = resolve(argument('out') ?? join(ROOT, '.i18n-audit')) + const wanted = process.argv + .slice(2) + .filter((arg, index, all) => !arg.startsWith('--') && !all[index - 1]?.startsWith('--')) + const selected = surfaces.filter( + (surface) => wanted.length === 0 || wanted.includes(surface.name), + ) + if (selected.length === 0) throw new Error(`No surface matches ${wanted.join(', ')}`) + mkdirSync(out, { recursive: true }) + + const browser = await chromium.launch({ + executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE ?? '/usr/local/bin/google-chrome', + args: ['--use-angle=swiftshader', '--enable-unsafe-swiftshader'], + }) + const findings: Finding[] = [] + try { + const context = await browser.newContext({ deviceScaleFactor: 1 }) + const page = await context.newPage() + for (const surface of selected) { + const result = await auditSurface(page, base, surface, out) + findings.push(...result.findings) + console.log( + `${result.findings.length === 0 ? 'PASS' : 'FAIL'} ${surface.name} (${result.findings.length} untranslated) → ${result.shot}`, + ) + } + } finally { + await browser.close() + } + writeFileSync(join(out, 'findings.json'), `${JSON.stringify(findings, null, 2)}\n`) + for (const finding of findings) { + console.log(` ${finding.surface}: ${JSON.stringify(finding.text)} (${finding.where})`) + } + if (findings.length > 0) process.exitCode = 1 +} + +if (import.meta.main) await main() diff --git a/scripts/i18n.ts b/scripts/i18n.ts index 5878f9ab5a..20b2274ddd 100644 --- a/scripts/i18n.ts +++ b/scripts/i18n.ts @@ -1,12 +1,20 @@ import { createHash } from 'node:crypto' -import { readFileSync, writeFileSync } from 'node:fs' +import { appendFileSync, existsSync, mkdirSync, 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' +import { + baseRevision, + firstCommitWith, + headRevision, + lastReleaseTag, + readAtRevision, + resolveRevision, +} from './i18n-git' +import { checkRepositoryLiterals } from './i18n-literals' export type SourceMessage = { id: string @@ -20,6 +28,8 @@ export type TranslationEntry = { review: 'human' | 'required' | 'missing' fuzzy: boolean reviewNotes?: string + /** Declared with a `renamed-from: ` descriptor comment (`#.` in the PO). */ + renamedFrom?: string } export type TranslationRequest = { @@ -46,23 +56,18 @@ type Glossary = { 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 ROOT = process.env.I18N_ROOT + ? resolve(process.env.I18N_ROOT) + : resolve(dirname(fileURLToPath(import.meta.url)), '..') +const CATALOG_ROOT = process.env.I18N_CATALOG_ROOT + ? resolve(process.env.I18N_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}', -] +/** Generated from English for layout and coverage testing; never shipped as a language. */ +export const PSEUDO_LOCALE = 'en-XA' const SOURCE_HASH_PREFIX = 'Source-Hash: ' const REVIEW_PREFIX = 'Review: ' const ENGLISH_PREFIX = 'English: ' @@ -138,6 +143,13 @@ async function extractSourceMessages(): Promise { return [...messages.values()].sort((left, right) => left.id.localeCompare(right.id)) } +const RENAMED_FROM_PREFIX = 'renamed-from:' + +function renamedFrom(item: PoItem): string | undefined { + const comment = item.extractedComments.find((value) => value.startsWith(RENAMED_FROM_PREFIX)) + return comment?.slice(RENAMED_FROM_PREFIX.length).trim() || undefined +} + function readTranslationState(po: PoFile): Record { return Object.fromEntries( po.items @@ -152,6 +164,7 @@ function readTranslationState(po: PoFile): Record { review: review === 'human' || review === 'required' ? review : 'missing', fuzzy: Boolean(item.flags.fuzzy), reviewNotes: commentValue(item, REVIEW_NOTES_PREFIX), + renamedFrom: renamedFrom(item), } satisfies TranslationEntry, ] }), @@ -462,6 +475,26 @@ function writePo(path: string, po: PoFile): void { writeFileSync(path, stringifyPo(po, { foldLength: 0 })) } +/** + * Review state extraction writes for one target entry, or undefined to keep + * it. A newly filled translation is a draft unless a person already marked it + * `Review: human` without `fuzzy`; extraction never approves copy itself. + */ +export function annotatedState( + prior: TranslationEntry | undefined, + translation: string, + sourceHash: string, +): Pick | undefined { + if (!translation) return { sourceHash: undefined, review: 'missing', fuzzy: false } + if (prior?.sourceHash && prior.sourceHash !== sourceHash) { + return { sourceHash: prior.sourceHash, review: 'required', fuzzy: true } + } + if (prior?.sourceHash) return undefined + return prior?.review === 'human' && !prior.fuzzy + ? { sourceHash, review: 'human', fuzzy: false } + : { sourceHash, review: 'required', fuzzy: true } +} + function annotateCatalogs( previous: Map<(typeof TARGET_LOCALES)[number], Record>, ): void { @@ -477,18 +510,28 @@ function annotateCatalogs( 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) { + const declared = renamedFrom(item) + const origin = declared ? previous.get(locale)?.[declared] : undefined + if ( + !item.msgstr[0] && + origin?.translation && + origin.review === 'human' && + !origin.fuzzy && + origin.sourceHash === sourceHash + ) { + // A declared rename with unchanged English keeps its reviewed translation. + item.msgstr = [origin.translation] replaceComment(item, SOURCE_HASH_PREFIX, sourceHash) replaceComment(item, REVIEW_PREFIX, 'human') delete item.flags.fuzzy + continue + } + const state = annotatedState(prior, item.msgstr[0] ?? '', sourceHash) + if (state) { + replaceComment(item, SOURCE_HASH_PREFIX, state.sourceHash) + replaceComment(item, REVIEW_PREFIX, state.review) + if (state.fuzzy) item.flags.fuzzy = true + else delete item.flags.fuzzy } } writePo(path, po) @@ -524,7 +567,12 @@ function compiledCatalog(locale: string, sources: SourceMessage[]) { function writeCompiledCatalogs(strict = false): void { const sources = readSourceMessages() if (strict) { - const errors = checkCatalogMessages(sources, readJson(GLOSSARY_PATH)) + const errors = checkTranslationEntries( + sources, + readLocaleEntries((path) => readFileSync(path, 'utf8')), + 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) @@ -535,6 +583,147 @@ function writeCompiledCatalogs(strict = false): void { for (const [locale, result] of compiled) { writeFileSync(compiledCatalogPath(locale), result.source) } + writePseudoCatalog(sources) +} + +const PSEUDO_LETTERS: Record = Object.fromEntries( + [...'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'].map((letter, index) => [ + letter, + [...'áƀçðéƒĝĥîĵķļɱñöþǫŕšţûṽŵẋýžÅƁÇÐÉƑĜĤÎĴĶĻṀÑÖÞǪŔŠŢÛṼŴẊÝŽ'][index]!, + ]), +) + +/** + * en-XA: every letter accented and the text padded by about 30 %, wrapped in + * brackets so clipping shows. ICU arguments, plural/select keys, `#`, quoted + * literals and rich-text tags are copied unchanged. + */ +export function pseudoLocalize(message: string): string { + let index = 0 + let letters = 0 + + const text = (end: (character: string) => boolean, inPlural: boolean): string => { + let out = '' + while (index < message.length) { + const character = message[index]! + if (end(character)) return out + if (character === "'") { + const next = message[index + 1] + if (next === "'") { + out += "''" + index += 2 + continue + } + if (next === '{' || next === '}' || next === '#' || next === '<') { + const close = message.indexOf("'", index + 1) + const stop = close === -1 ? message.length : close + 1 + out += message.slice(index, stop) + index = stop + continue + } + } + if (character === '{') { + out += argument() + continue + } + if (character === '<') { + const close = message.indexOf('>', index) + const stop = close === -1 ? message.length : close + 1 + out += message.slice(index, stop) + index = stop + continue + } + if (character === '#' && inPlural) { + out += character + index += 1 + continue + } + const mapped = PSEUDO_LETTERS[character] + if (mapped) letters += 1 + out += mapped ?? character + index += 1 + } + return out + } + + const argument = (): string => { + const start = index + index += 1 + const header = message.slice(index).match(/^\s*([^,}\s]+)\s*(?:,\s*(\w+)\s*)?/) + const type = header?.[2] + if (!(type === 'plural' || type === 'select' || type === 'selectordinal')) { + let depth = 1 + while (index < message.length && depth > 0) { + if (message[index] === '{') depth += 1 + if (message[index] === '}') depth -= 1 + index += 1 + } + return message.slice(start, index) + } + index += header![0].length + let out = message.slice(start, index) + if (message[index] === ',') { + out += ',' + index += 1 + } + while (index < message.length && message[index] !== '}') { + const open = message.indexOf('{', index) + if (open === -1) break + out += message.slice(index, open + 1) + index = open + 1 + out += text((character) => character === '}', type !== 'select') + out += '}' + index += 1 + const spaces = message.slice(index).match(/^\s*/)?.[0] ?? '' + if (message[index + spaces.length] === '}') { + out += spaces + index += spaces.length + } + } + out += '}' + index += 1 + return out + } + + const body = text(() => false, false) + if (letters === 0) return body + return `[${body} ${'~'.repeat(Math.max(1, Math.ceil(letters * 0.3)))}]` +} + +function pseudoCatalog(sources: SourceMessage[]) { + const english = messagesForCompilation('en', sources) + return createCompiledCatalog( + PSEUDO_LOCALE, + Object.fromEntries( + Object.entries(english).map(([id, message]) => [id, pseudoLocalize(message)]), + ), + { namespace: 'ts', outputPrefix: '/*eslint-disable*/', strict: true }, + ) +} + +function writePseudoCatalog(sources: SourceMessage[]): void { + const compiled = pseudoCatalog(sources) + if (compiled.errors.length > 0) { + throw new Error( + compiled.errors + .map(({ id, error }) => `${PSEUDO_LOCALE}: "${id}": ${error.message}`) + .join('\n'), + ) + } + mkdirSync(dirname(compiledCatalogPath(PSEUDO_LOCALE)), { recursive: true }) + writeFileSync(compiledCatalogPath(PSEUDO_LOCALE), compiled.source) +} + +function checkPseudoCatalog(sources: SourceMessage[]): string[] { + const path = compiledCatalogPath(PSEUDO_LOCALE) + const compiled = pseudoCatalog(sources) + const errors = compiled.errors.map( + ({ id, error }) => `${PSEUDO_LOCALE}: failed to compile "${id}": ${error.message}`, + ) + if (!existsSync(path) || compiled.source !== readFileSync(path, 'utf8')) { + errors.push(`${relative(ROOT, path)} is not synchronized; run bun run i18n:compile`) + } + return errors } export function extractCatalogs(): void { @@ -581,64 +770,46 @@ function applyTranslations( } } -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() +export type CatalogCheckOptions = { + /** + * Release sign-off: every bundled entry must be human-reviewed. Ordinary CI + * accepts AI drafts that are `fuzzy` + `Review: required`; they are kept out + * of the compiled packs, so the UI shows English until a person approves. + */ + requireReviewed?: boolean } -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}/`) +export function pendingReview(sources: SourceMessage[]): Record { + return Object.fromEntries( + TARGET_LOCALES.map((locale) => { + const entries = readTranslationState(parsePo(readFileSync(catalogPath(locale), 'utf8'))) + const pending = sources.filter((source) => { + const entry = entries[source.id] + return entry && (entry.fuzzy || entry.review !== 'human') + }).length + return [locale, pending] }), ) +} - 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) +export type LocaleEntries = Record> + +function readLocaleEntries(read: (path: string) => string | undefined): LocaleEntries { + const entries: LocaleEntries = {} + for (const locale of TARGET_LOCALES) { + const text = read(catalogPath(locale)) + if (text !== undefined) entries[locale] = readTranslationState(parsePo(text)) } - return warnings + return entries } -function checkCatalogMessages(sources: SourceMessage[], glossary: Glossary): string[] { +/** Pure completeness/validity/review rules for the bundled target catalogs. */ +export function checkTranslationEntries( + sources: SourceMessage[], + entriesByLocale: LocaleEntries, + glossary: Glossary, + options: CatalogCheckOptions = { requireReviewed: true }, +): string[] { const errors: string[] = [] const sourceById = new Map(sources.map((source) => [source.id, source])) for (const source of sources) { @@ -646,20 +817,28 @@ function checkCatalogMessages(sources: SourceMessage[], glossary: Glossary): str } for (const locale of TARGET_LOCALES) { - const po = parsePo(readFileSync(catalogPath(locale), 'utf8')) - const entries = readTranslationState(po) + const entries = entriesByLocale[locale] ?? {} 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)) { + const awaitingReview = entry.fuzzy || entry.review !== 'human' + const stale = entry.sourceHash !== hashSource(source.message) + if (stale && !(awaitingReview && !options.requireReviewed)) { errors.push(`${locale}: stale "${source.id}"`) } - if (entry.fuzzy || entry.review !== 'human') { + if (awaitingReview && options.requireReviewed) { errors.push(`${locale}: "${source.id}" still requires human review`) + } else if (awaitingReview && !(entry.fuzzy && entry.review === 'required')) { + errors.push( + `${locale}: "${source.id}" is unreviewed but not marked fuzzy + Review: required`, + ) } + // A stale draft translates the previous English; it is never shipped and + // is compared with the current English when it is translated again. + if (stale && awaitingReview) continue for (const error of validateTranslation( source.message, entry.translation, @@ -676,6 +855,263 @@ function checkCatalogMessages(sources: SourceMessage[], glossary: Glossary): str return errors } +/** + * A translation a person approved on the base branch stays approved while its + * English is unchanged: a PR may edit it (still `Review: human`) but never + * send it back to draft, which would silently drop it from the shipped pack. + */ +export function reviewRegressions( + sources: SourceMessage[], + current: LocaleEntries, + base: LocaleEntries, +): string[] { + const errors: string[] = [] + for (const locale of TARGET_LOCALES) { + for (const source of sources) { + const before = base[locale]?.[source.id] + if ( + !before?.translation || + before.fuzzy || + before.review !== 'human' || + before.sourceHash !== hashSource(source.message) + ) { + continue + } + const after = current[locale]?.[source.id] + if (!after?.translation || after.fuzzy || after.review !== 'human') { + errors.push( + `${locale}: "${source.id}" is reviewed on the base branch with the same English and must stay reviewed`, + ) + } + } + // A declared rename must arrive with the reviewed translation; + // `i18n:extract` carries it over when the English is unchanged. + const sourceIds = new Set(sources.map((source) => source.id)) + for (const source of sources) { + const after = current[locale]?.[source.id] + const removedId = after?.renamedFrom + if (!removedId || sourceIds.has(removedId)) continue + const before = base[locale]?.[removedId] + if ( + !before?.translation || + before.fuzzy || + before.review !== 'human' || + before.sourceHash !== hashSource(source.message) + ) { + continue + } + const carried = + after.translation === before.translation && after.review === 'human' && !after.fuzzy + if (!carried) { + errors.push( + `${locale}: "${source.id}" is declared renamed-from "${removedId}", whose reviewed translation was not carried over; run bun run i18n:extract`, + ) + } + } + } + return errors +} + +/** + * Reviewed IDs removed while a new, undeclared ID has the same English. That + * may be a rename or an unrelated addition; a missed carry-over only falls back + * to English, so it is reported, not enforced. + */ +export function possibleRenames( + sources: SourceMessage[], + current: LocaleEntries, + base: LocaleEntries, +): string[] { + const sourceIds = new Set(sources.map((source) => source.id)) + const pairs = new Set() + for (const locale of TARGET_LOCALES) { + const baseEntries = base[locale] ?? {} + for (const [removedId, before] of Object.entries(baseEntries)) { + if (sourceIds.has(removedId) || before.fuzzy || before.review !== 'human') continue + for (const source of sources) { + if (source.id in baseEntries || hashSource(source.message) !== before.sourceHash) continue + const after = current[locale]?.[source.id] + if (after?.renamedFrom || (after?.review === 'human' && !after.fuzzy)) continue + pairs.add(`"${removedId}" → "${source.id}"`) + } + } + } + return [...pairs].map( + (pair) => + `possible rename ${pair} (same English): carry over the translation if intended with comment: 'renamed-from: ' and bun run i18n:extract`, + ) +} + +function isApproved(entry: TranslationEntry | undefined, source: SourceMessage): boolean { + return Boolean( + entry?.translation && + !entry.fuzzy && + entry.review === 'human' && + entry.sourceHash === hashSource(source.message), + ) +} + +/** The compiled module a browser loads for one locale, as message IDs. */ +export function readCompiledPack(locale: string): Record { + const text = readFileSync(compiledCatalogPath(locale), 'utf8') + const literal = text.match(/JSON\.parse\(("(?:[^"\\]|\\.)*")\)/)?.[1] + if (!literal) + throw new Error(`${relative(ROOT, compiledCatalogPath(locale))} has no compiled messages`) + return JSON.parse(JSON.parse(literal) as string) as Record +} + +/** + * Inspects the shipped packs themselves: a draft (fuzzy, `Review: required`, + * unreviewed or stale) must never be compiled in, whatever produced the file. + */ +export function packLeaks( + sources: SourceMessage[], + current: LocaleEntries, + packs: Record>, +): string[] { + const byId = new Map(sources.map((source) => [source.id, source])) + const errors: string[] = [] + for (const locale of TARGET_LOCALES) { + for (const id of Object.keys(packs[locale] ?? {})) { + const source = byId.get(id) + if (!source || !isApproved(current[locale]?.[id], source)) { + errors.push(`${locale}: compiled pack ships unreviewed "${id}"`) + } + } + } + return errors +} + +export function draftCounts( + sources: SourceMessage[], + current: LocaleEntries, +): Record { + return Object.fromEntries( + TARGET_LOCALES.map((locale) => [ + locale, + sources.filter((source) => !isApproved(current[locale]?.[source.id], source)).length, + ]), + ) +} + +function writeJobSummary( + sources: SourceMessage[], + current: LocaleEntries, + base?: string, + warnings: string[] = [], +): void { + const path = process.env.GITHUB_STEP_SUMMARY + if (!path) return + const counts = draftCounts(sources, current) + const rows = TARGET_LOCALES.map((locale) => `| ${locale} | ${counts[locale]} |`) + appendFileSync( + path, + [ + '### Translations awaiting review', + '', + `Drafts ship as English until reviewed. ${sources.length} messages${base ? `; compared with ${base}` : ''}.`, + '', + '| Locale | Drafts |', + '| --- | --- |', + ...rows, + '', + ...(warnings.length > 0 + ? ['**Warnings**', '', ...warnings.map((warning) => `- ${warning}`), ''] + : []), + ].join('\n'), + ) +} + +function currentEntries(): LocaleEntries { + return readLocaleEntries((path) => readFileSync(path, 'utf8')) +} + +function compiledPacks(): Record> { + return Object.fromEntries(TARGET_LOCALES.map((locale) => [locale, readCompiledPack(locale)])) +} + +/** Regression and leak checks shared by `check` (CI) and `release-check` (release). */ +export function releaseCheck( + sources: SourceMessage[], + base: LocaleEntries | undefined, +): { errors: string[]; warnings: string[] } { + const current = currentEntries() + return { + errors: [ + ...(base ? reviewRegressions(sources, current, base) : []), + ...packLeaks(sources, current, compiledPacks()), + ], + warnings: base ? possibleRenames(sources, current, base) : [], + } +} + +/** The package whose release carries the bundled catalogs. */ +const CATALOG_PACKAGE = 'editor' + +function hasCatalogs(revision: string): boolean { + return TARGET_LOCALES.every( + (locale) => readAtRevision(revision, catalogPath(locale)) !== undefined, + ) +} + +/** + * What a release is compared with: the latest `@pascal-app/editor@*` tag + * whose tree has the catalogs; else `origin/main` when it is another commit + * with catalogs; else the commit that introduced the catalogs, whose reviewed + * entries must survive. Never the working tree, and never a base without + * catalogs: with none available this throws. + */ +export function releaseBase(requested?: string): { revision: string; label: string } { + const head = headRevision() + if (requested) { + const revision = resolveRevision(requested) + if (!revision) throw new Error(`release-check: --base ${requested} is not a commit`) + if (!hasCatalogs(revision)) { + throw new Error( + `release-check: --base ${requested} has no bundled PO catalogs to compare with`, + ) + } + return { revision, label: requested } + } + const tag = lastReleaseTag(CATALOG_PACKAGE) + if (tag && hasCatalogs(tag.revision)) return { revision: tag.revision, label: tag.tag } + const reason = tag ? `${tag.tag} has no catalogs` : 'no release tag' + const main = resolveRevision('origin/main') + if (main && main !== head && hasCatalogs(main)) { + return { revision: main, label: `origin/main (${reason})` } + } + const introduced = firstCommitWith(catalogPath('fr')) + if (introduced && introduced !== head && hasCatalogs(introduced)) { + return { + revision: introduced, + label: `${introduced.slice(0, 12)}, the commit that introduced the catalogs (${reason})`, + } + } + throw new Error( + `release-check: no base with bundled PO catalogs (${reason}; origin/main and the catalogs' first commit are unavailable or HEAD); pass --base `, + ) +} + +function checkCatalogMessages( + sources: SourceMessage[], + glossary: Glossary, + options: CatalogCheckOptions = { requireReviewed: true }, +): string[] { + const current = currentEntries() + const base = baseRevision() + const release = releaseCheck( + sources, + base ? readLocaleEntries((path) => readAtRevision(base, path)) : undefined, + ) + reportWarnings(release.warnings) + writeJobSummary(sources, current, base?.slice(0, 12), release.warnings) + return [...checkTranslationEntries(sources, current, glossary, options), ...release.errors] +} + +function reportWarnings(warnings: string[]): void { + for (const warning of warnings) console.warn(`[i18n warning] ${warning}`) +} + function checkEnglishCatalog( extractedSources: SourceMessage[], catalogSources: SourceMessage[], @@ -714,15 +1150,16 @@ function checkCompiledCatalogs(sources: SourceMessage[]): string[] { }) } -export async function checkCatalogs(): Promise { +export async function checkCatalogs(options: CatalogCheckOptions = {}): Promise { const sources = await extractSourceMessages() const catalogSources = readSourceMessages() const glossary = readJson(GLOSSARY_PATH) return [ ...checkLinguiCatalogSync(), ...checkEnglishCatalog(sources, catalogSources), - ...checkCatalogMessages(sources, glossary), + ...checkCatalogMessages(sources, glossary, options), ...checkCompiledCatalogs(catalogSources), + ...checkPseudoCatalog(catalogSources), ] } @@ -740,16 +1177,66 @@ async function main(): Promise { return } - if (command === 'check') { - const errors = await checkCatalogs() - for (const literal of bypassedLiterals()) { - console.warn(`[i18n warning] user-facing literal bypasses Lingui: ${literal}`) + if (command === 'release-check') { + const flag = (name: string) => { + const index = process.argv.indexOf(`--${name}`) + return index === -1 ? undefined : process.argv[index + 1] + } + const baseDir = flag('base-dir') + let resolved: { revision: string; label: string } | undefined + try { + resolved = baseDir ? undefined : releaseBase(flag('base')) + } catch (error) { + console.error(`[i18n] ${error instanceof Error ? error.message : String(error)}`) + process.exitCode = 1 + return + } + const revision = resolved?.revision + if (resolved) console.log(`Comparing with ${resolved.label}`) + const base = baseDir + ? readLocaleEntries((path) => { + const file = join(resolve(baseDir), relative(CATALOG_ROOT, path)) + return existsSync(file) ? readFileSync(file, 'utf8') : undefined + }) + : readLocaleEntries((path) => readAtRevision(revision!, path)) + const sources = readSourceMessages() + const { errors, warnings } = releaseCheck(sources, base) + reportWarnings(warnings) + writeJobSummary(sources, currentEntries(), resolved?.label ?? baseDir, warnings) + if (errors.length > 0) { + console.error(errors.map((error) => `[i18n] ${error}`).join('\n')) + process.exitCode = 1 + return } + console.log( + `No reviewed translation regressed against ${baseDir ?? resolved?.label} and no draft is compiled in`, + ) + return + } + + if (command === 'check') { + const requireReviewed = process.argv.includes('--reviewed') + const errors = await checkCatalogs({ requireReviewed }) + const literals = checkRepositoryLiterals() + errors.push(...literals.errors.map((error) => `literal guard: ${error}`)) 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 + } + const pending = Object.entries(pendingReview(readSourceMessages())) + .filter(([, count]) => count > 0) + .map(([locale, count]) => `${locale} ${count}`) + console.log( + `Checked ${TARGET_LOCALES.length} bundled PO catalogs and ${literals.total} legacy literal candidates` + + (pending.length > 0 + ? `; awaiting human review (shown in English until approved): ${pending.join(', ')}` + : ''), + ) + if (literals.improvable.length > 0) { + console.log( + `[i18n] ${literals.improvable.length} files are below their literal baseline; run bun run i18n:literals -- update`, + ) } return } @@ -781,7 +1268,9 @@ async function main(): Promise { return } - throw new Error('Usage: bun scripts/i18n.ts [locale...]') + throw new Error( + 'Usage: bun scripts/i18n.ts |--base-dir ]|translate> [locale...]', + ) } if (import.meta.main) await main()