From 0ca388dbe59fe11755e7fb9090f15642a264cfc3 Mon Sep 17 00:00:00 2001 From: nyatinte Date: Sun, 15 Mar 2026 23:48:45 +0900 Subject: [PATCH 1/4] feat: add Paraglide.js i18n with English/Japanese support - Add Paraglide.js v2 compiler-based i18n (en/ja) - Introduce src/i18n.ts as single entry point for locale init and message exports - Replace all hardcoded UI strings with m.xxx() message calls - Decouple WorkspaceNotFoundError from its message (localized at catch site) - Guard against LANG=C/POSIX crashing Intl.Locale with try/catch fallback - Add Japanese E2E tests and unit tests for non-BCP47 locales - Add paraglide:compile prerequisite to build/check/fix/typecheck scripts Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + messages/en.json | 10 ++ messages/ja.json | 10 ++ package.json | 14 +- pnpm-lock.yaml | 193 ++++++++++++++++++++++++ project.inlang/settings.json | 10 ++ src/cli.ts | 13 +- src/i18n.test.ts | 41 +++++ src/i18n.ts | 22 +++ src/index.ts | 15 +- src/runner.ts | 3 +- src/ui.ts | 7 +- src/workspace.test.ts | 2 +- src/workspace.ts | 6 +- test/__snapshots__/prw.e2e.test.ts.snap | 21 +++ test/prw.e2e.test.ts | 44 ++++++ tsconfig.json | 6 +- 17 files changed, 394 insertions(+), 24 deletions(-) create mode 100644 messages/en.json create mode 100644 messages/ja.json create mode 100644 project.inlang/settings.json create mode 100644 src/i18n.test.ts create mode 100644 src/i18n.ts diff --git a/.gitignore b/.gitignore index 6b5200c..d74e61f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +generated/ man/*.1 coverage/ *.tsbuildinfo diff --git a/messages/en.json b/messages/en.json new file mode 100644 index 0000000..52b9949 --- /dev/null +++ b/messages/en.json @@ -0,0 +1,10 @@ +{ + "select_package": "Select package", + "select_script": "Select script", + "cancelled": "Cancelled.", + "no_packages_match": "No packages match: {query}", + "multiple_packages_match": "Multiple packages match: {query}. Be more specific.", + "no_scripts_in_package": "No scripts in {name}", + "workspace_root_required": "Run prw inside a pnpm workspace.", + "pnpm_run_failed": "Failed to run pnpm: {message}" +} diff --git a/messages/ja.json b/messages/ja.json new file mode 100644 index 0000000..9752b18 --- /dev/null +++ b/messages/ja.json @@ -0,0 +1,10 @@ +{ + "select_package": "パッケージを選択", + "select_script": "スクリプトを選択", + "cancelled": "キャンセルしました。", + "no_packages_match": "一致するパッケージがありません: {query}", + "multiple_packages_match": "複数のパッケージが一致します: {query}。もっと絞り込んでください。", + "no_scripts_in_package": "{name} にスクリプトがありません", + "workspace_root_required": "pnpm ワークスペース内で prw を実行してください。", + "pnpm_run_failed": "pnpm の実行に失敗しました: {message}" +} diff --git a/package.json b/package.json index d65310d..3f69486 100644 --- a/package.json +++ b/package.json @@ -16,18 +16,22 @@ "bugs": { "url": "https://github.com/nyatinte/prw/issues" }, + "imports": { + "#paraglide/*": "./generated/paraglide/*" + }, "keywords": [ "pnpm", "workspace", "cli" ], "scripts": { - "build": "tsdown", + "build": "pnpm paraglide:compile && tsdown", "build:man": "marked-man man/prw.1.md -o man/prw.1", "changeset": "changeset", - "check": "ultracite check", - "fix": "ultracite fix", + "check": "pnpm paraglide:compile && ultracite check", + "fix": "pnpm paraglide:compile && ultracite fix", "knip": "knip", + "paraglide:compile": "paraglide-js compile --project ./project.inlang --outdir ./generated/paraglide --silent", "prepare": "lefthook install", "prepublishOnly": "pnpm build && pnpm build:man", "release": "changeset publish", @@ -35,7 +39,7 @@ "test:run": "pnpm build && vitest run", "test:coverage": "pnpm build && vitest run --coverage", "test:update": "pnpm build && vitest -u", - "typecheck": "tsgo --noEmit -p tsconfig.json", + "typecheck": "pnpm paraglide:compile && tsgo --noEmit -p tsconfig.json", "version-packages": "changeset version" }, "man": [ @@ -57,6 +61,7 @@ "dependencies": { "@clack/prompts": "1.1.0", "citty": "0.2.1", + "os-locale": "8.0.0", "picocolors": "1.1.1", "tinyglobby": "0.2.15", "yaml": "2.8.2" @@ -64,6 +69,7 @@ "devDependencies": { "@biomejs/biome": "2.4.6", "@changesets/cli": "2.30.0", + "@inlang/paraglide-js": "2.15.0", "@types/node": "24.12.0", "@typescript/native-preview": "7.0.0-dev.20260314.1", "@vitest/coverage-v8": "4.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e6fbcc9..e412877 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: citty: specifier: 0.2.1 version: 0.2.1 + os-locale: + specifier: 8.0.0 + version: 8.0.0 picocolors: specifier: 1.1.1 version: 1.1.1 @@ -30,6 +33,9 @@ importers: '@changesets/cli': specifier: 2.30.0 version: 2.30.0(@types/node@24.12.0) + '@inlang/paraglide-js': + specifier: 2.15.0 + version: 2.15.0 '@types/node': specifier: 24.12.0 version: 24.12.0 @@ -564,6 +570,17 @@ packages: peerDependencies: hono: ^4 + '@inlang/paraglide-js@2.15.0': + resolution: {integrity: sha512-2ZOa9nssVn4tjkKskqb88KP5A7cTIjo8AiM9xnPvH+vBhRIRenO+ftAbVOHhHcHjcFxy2QFcOfBAH/Cw1LIsUg==} + hasBin: true + + '@inlang/recommend-sherlock@0.2.1': + resolution: {integrity: sha512-ckv8HvHy/iTqaVAEKrr+gnl+p3XFNwe5D2+6w6wJk2ORV2XkcRkKOJ/XsTUJbPSiyi4PI+p+T3bqbmNx/rDUlg==} + + '@inlang/sdk@2.8.0': + resolution: {integrity: sha512-w1jysvUDTMgCaONklIgOJAp9dUDl0UhLbsdqfWEwY/GIqoc9IwpuHsrP3pzC+h3DfOpkMMDnDkTpPv8kIZ98iA==} + engines: {node: '>=18.0.0'} + '@inquirer/external-editor@1.0.3': resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} engines: {node: '>=18'} @@ -576,6 +593,9 @@ packages: '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -586,6 +606,13 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@lix-js/sdk@0.4.7': + resolution: {integrity: sha512-pRbW+joG12L0ULfMiWYosIW0plmW4AsUdiPCp+Z8rAsElJ+wJ6in58zhD3UwUcd4BNcpldEGjg6PdA7e0RgsDQ==} + engines: {node: '>=18'} + + '@lix-js/server-protocol-schema@0.1.1': + resolution: {integrity: sha512-jBeALB6prAbtr5q4vTuxnRZZv1M2rKe8iNqRQhFJ4Tv7150unEa0vKyz0hs8Gl3fUGsWaNJBh3J8++fpbrpRBQ==} + '@manypkg/find-root@1.1.0': resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} @@ -957,6 +984,13 @@ packages: cpu: [x64] os: [win32] + '@sinclair/typebox@0.31.28': + resolution: {integrity: sha512-/s55Jujywdw/Jpan+vsy6JZs1z2ZTGxTmbZTPiuSL2wz9mfzA2gN1zzaqmvfi4pq+uOt7Du85fkiwv5ymW84aQ==} + + '@sqlite.org/sqlite-wasm@3.48.0-build4': + resolution: {integrity: sha512-hI6twvUkzOmyGZhQMza1gpfqErZxXRw6JEsiVjUbo7tFanVD+8Oil0Ih3l2nGzHdxPI41zFmfUQG7GHqhciKZQ==} + hasBin: true + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -1120,6 +1154,11 @@ packages: '@vitest/utils@4.1.0': resolution: {integrity: sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==} + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -1142,6 +1181,9 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + array-timsort@1.0.3: + resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==} + array-union@2.1.0: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} @@ -1190,10 +1232,22 @@ packages: citty@0.2.1: resolution: {integrity: sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==} + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + commander@14.0.3: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} + comment-json@4.6.2: + resolution: {integrity: sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w==} + engines: {node: '>= 6'} + + consola@3.4.0: + resolution: {integrity: sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA==} + engines: {node: ^14.18.0 || >=16.10.0} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -1204,6 +1258,14 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + dedent@1.5.1: + resolution: {integrity: sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} @@ -1443,6 +1505,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + js-sha256@0.11.1: + resolution: {integrity: sha512-o6WSo/LUvY2uC4j7mO50a2ms7E/EAdbP0swigLV+nzHKTTaYnaLIWJ02VdXrsJX0vGedDESQnLsOekr94ryfjg==} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -1462,6 +1527,11 @@ packages: engines: {node: '>=6'} hasBin: true + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + jsonc-parser@3.3.1: resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} @@ -1481,6 +1551,10 @@ packages: '@types/node': '>=18' typescript: '>=5.0.4 <7' + kysely@0.27.6: + resolution: {integrity: sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ==} + engines: {node: '>=14.0.0'} + lefthook-darwin-arm64@2.1.4: resolution: {integrity: sha512-BUAAE9+rUrjr39a+wH/1zHmGrDdwUQ2Yq/z6BQbM/yUb9qtXBRcQ5eOXxApqWW177VhGBpX31aqIlfAZ5Q7wzw==} cpu: [arm64] @@ -1621,6 +1695,10 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + os-locale@8.0.0: + resolution: {integrity: sha512-0hdyXRn0wSKMTHCo2CkPJdTKzIhg1Wb7DxJtkeD8M8rRXSwP2ZFRu4UiWBnkjPdRbL5X+AnUVXBE2SZOmkm3dg==} + engines: {node: '>=20'} + outdent@0.5.0: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} @@ -1844,6 +1922,11 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + sqlite-wasm-kysely@0.3.0: + resolution: {integrity: sha512-TzjBNv7KwRw6E3pdKdlRyZiTmUIE0UttT/Sl56MVwVARl/u5gp978KepazCJZewFUnlWHz9i3NQd4kOtP/Afdg==} + peerDependencies: + kysely: '*' + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -1981,6 +2064,10 @@ packages: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} + unplugin@2.3.11: + resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} + engines: {node: '>=18.12.0'} + unrun@0.2.32: resolution: {integrity: sha512-opd3z6791rf281JdByf0RdRQrpcc7WyzqittqIXodM/5meNWdTwrVxeyzbaCp4/Rgls/um14oUaif1gomO8YGg==} engines: {node: '>=20.19.0'} @@ -1991,6 +2078,17 @@ packages: synckit: optional: true + urlpattern-polyfill@10.1.0: + resolution: {integrity: sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==} + + uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + hasBin: true + + uuid@13.0.0: + resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==} + hasBin: true + validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} @@ -2073,6 +2171,9 @@ packages: resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} engines: {node: 20 || >=22} + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + which@1.3.1: resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} hasBin: true @@ -2508,6 +2609,32 @@ snapshots: dependencies: hono: 4.12.8 + '@inlang/paraglide-js@2.15.0': + dependencies: + '@inlang/recommend-sherlock': 0.2.1 + '@inlang/sdk': 2.8.0 + commander: 11.1.0 + consola: 3.4.0 + json5: 2.2.3 + unplugin: 2.3.11 + urlpattern-polyfill: 10.1.0 + transitivePeerDependencies: + - babel-plugin-macros + + '@inlang/recommend-sherlock@0.2.1': + dependencies: + comment-json: 4.6.2 + + '@inlang/sdk@2.8.0': + dependencies: + '@lix-js/sdk': 0.4.7 + '@sinclair/typebox': 0.31.28 + kysely: 0.27.6 + sqlite-wasm-kysely: 0.3.0(kysely@0.27.6) + uuid: 13.0.0 + transitivePeerDependencies: + - babel-plugin-macros + '@inquirer/external-editor@1.0.3(@types/node@24.12.0)': dependencies: chardet: 2.1.1 @@ -2520,6 +2647,11 @@ snapshots: '@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.5.5': {} @@ -2529,6 +2661,20 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@lix-js/sdk@0.4.7': + dependencies: + '@lix-js/server-protocol-schema': 0.1.1 + dedent: 1.5.1 + human-id: 4.1.3 + js-sha256: 0.11.1 + kysely: 0.27.6 + sqlite-wasm-kysely: 0.3.0(kysely@0.27.6) + uuid: 10.0.0 + transitivePeerDependencies: + - babel-plugin-macros + + '@lix-js/server-protocol-schema@0.1.1': {} + '@manypkg/find-root@1.1.0': dependencies: '@babel/runtime': 7.28.6 @@ -2756,6 +2902,10 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.59.0': optional: true + '@sinclair/typebox@0.31.28': {} + + '@sqlite.org/sqlite-wasm@3.48.0-build4': {} + '@standard-schema/spec@1.1.0': {} '@takumi-rs/core-darwin-arm64@0.65.0': @@ -2907,6 +3057,8 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + acorn@8.16.0: {} + ansi-colors@4.1.3: {} ansi-regex@5.0.1: {} @@ -2921,6 +3073,8 @@ snapshots: argparse@2.0.1: {} + array-timsort@1.0.3: {} + array-union@2.1.0: {} assertion-error@2.0.1: {} @@ -2961,8 +3115,17 @@ snapshots: citty@0.2.1: {} + commander@11.1.0: {} + commander@14.0.3: {} + comment-json@4.6.2: + dependencies: + array-timsort: 1.0.3 + esprima: 4.0.1 + + consola@3.4.0: {} + convert-source-map@2.0.0: {} cross-spawn@5.1.0: @@ -2977,6 +3140,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + dedent@1.5.1: {} + deepmerge@4.3.1: {} defu@6.1.4: {} @@ -3230,6 +3395,8 @@ snapshots: jiti@2.6.1: {} + js-sha256@0.11.1: {} + js-tokens@10.0.0: {} js-tokens@4.0.0: {} @@ -3245,6 +3412,8 @@ snapshots: jsesc@3.1.0: {} + json5@2.2.3: {} + jsonc-parser@3.3.1: {} jsonfile@4.0.0: @@ -3274,6 +3443,8 @@ snapshots: yaml: 2.8.2 zod: 4.3.6 + kysely@0.27.6: {} + lefthook-darwin-arm64@2.1.4: optional: true @@ -3398,6 +3569,8 @@ snapshots: obug@2.1.1: {} + os-locale@8.0.0: {} + outdent@0.5.0: {} oxc-resolver@11.19.1: @@ -3646,6 +3819,11 @@ snapshots: sprintf-js@1.0.3: {} + sqlite-wasm-kysely@0.3.0(kysely@0.27.6): + dependencies: + '@sqlite.org/sqlite-wasm': 3.48.0-build4 + kysely: 0.27.6 + stackback@0.0.2: {} std-env@4.0.0: {} @@ -3769,10 +3947,23 @@ snapshots: universalify@0.1.2: {} + unplugin@2.3.11: + dependencies: + '@jridgewell/remapping': 2.3.5 + acorn: 8.16.0 + picomatch: 4.0.3 + webpack-virtual-modules: 0.6.2 + unrun@0.2.32: dependencies: rolldown: 1.0.0-rc.9 + urlpattern-polyfill@10.1.0: {} + + uuid@10.0.0: {} + + uuid@13.0.0: {} + validate-npm-package-license@3.0.4: dependencies: spdx-correct: 3.2.0 @@ -3822,6 +4013,8 @@ snapshots: walk-up-path@4.0.0: {} + webpack-virtual-modules@0.6.2: {} + which@1.3.1: dependencies: isexe: 2.0.0 diff --git a/project.inlang/settings.json b/project.inlang/settings.json new file mode 100644 index 0000000..e83618b --- /dev/null +++ b/project.inlang/settings.json @@ -0,0 +1,10 @@ +{ + "baseLocale": "en", + "locales": ["en", "ja"], + "modules": [ + "https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4.4.0/dist/index.js" + ], + "plugin.inlang.messageFormat": { + "pathPattern": "./messages/{locale}.json" + } +} diff --git a/src/cli.ts b/src/cli.ts index 18f1a59..a072073 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,19 +1,20 @@ import { isCancel, log, S_STEP_SUBMIT } from "@clack/prompts"; import color from "picocolors"; import type { HistoryEntry } from "./history.js"; -import { SELECT_PACKAGE_MESSAGE, selectPackage, selectScript } from "./ui.js"; +import { m } from "./i18n.js"; +import { selectPackage, selectScript } from "./ui.js"; import type { Package } from "./workspace.js"; import { getScripts, matchPackages } from "./workspace.js"; function logSelectedPackage(pkg: Package): void { - log.message([SELECT_PACKAGE_MESSAGE, color.dim(pkg.name)], { + log.message([m.select_package(), color.dim(pkg.name)], { symbol: color.green(S_STEP_SUBMIT), }); } function exitOnCancel(selected: T | symbol): T { if (isCancel(selected)) { - console.log("Cancelled."); + console.log(m.cancelled()); process.exit(0); } return selected as T; @@ -34,13 +35,13 @@ export async function selectPackageByArgs( const matches = matchPackages(packages, query); if (matches.length === 0) { - console.error(`No packages match: ${query}`); + console.error(m.no_packages_match({ query })); process.exit(1); } if (initialScript) { if (matches.length !== 1) { - console.error(`Multiple packages match: ${query}. Be more specific.`); + console.error(m.multiple_packages_match({ query })); process.exit(1); } const [matchedPackage] = matches as [Package]; @@ -70,7 +71,7 @@ export async function resolveScript( const scripts = getScripts(root, pkg); if (scripts.length === 0) { - console.error(`No scripts in ${pkg.name}`); + console.error(m.no_scripts_in_package({ name: pkg.name })); process.exit(1); } diff --git a/src/i18n.test.ts b/src/i18n.test.ts new file mode 100644 index 0000000..903c5f2 --- /dev/null +++ b/src/i18n.test.ts @@ -0,0 +1,41 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("os-locale", () => ({ + default: vi.fn(() => "en-US"), +})); + +import osLocale from "os-locale"; +import { getLocale } from "#paraglide/runtime.js"; +import { initializeLocale } from "./i18n.js"; + +describe(initializeLocale, () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each([ + ["ja-JP", "ja"], + ["ja", "ja"], + ["en-US", "en"], + ["en", "en"], + ])("resolves %s to %s", (input, expected) => { + vi.mocked(osLocale).mockReturnValue(input); + initializeLocale(); + expect(getLocale()).toBe(expected); + }); + + it("falls back to baseLocale for unsupported locale", () => { + vi.mocked(osLocale).mockReturnValue("zh-CN"); + initializeLocale(); + expect(getLocale()).toBe("en"); + }); + + it.each([ + "C", + "POSIX", + ])("falls back to baseLocale for non-BCP47 locale %s", (input) => { + vi.mocked(osLocale).mockReturnValue(input); + initializeLocale(); + expect(getLocale()).toBe("en"); + }); +}); diff --git a/src/i18n.ts b/src/i18n.ts new file mode 100644 index 0000000..e26f510 --- /dev/null +++ b/src/i18n.ts @@ -0,0 +1,22 @@ +import osLocale from "os-locale"; + +// biome-ignore lint/performance/noBarrelFile: re-export paraglide messages as single entry point +export { m } from "#paraglide/messages.js"; + +import { baseLocale, locales, overwriteGetLocale } from "#paraglide/runtime.js"; + +type Locale = (typeof locales)[number]; + +export function initializeLocale(): void { + const tag = osLocale(); + let language: string; + try { + language = new Intl.Locale(tag).language; + } catch { + language = baseLocale; + } + const locale = locales.includes(language as Locale) + ? (language as Locale) + : baseLocale; + overwriteGetLocale(() => locale); +} diff --git a/src/index.ts b/src/index.ts index 98483ab..1f4ca13 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,8 +2,13 @@ import { defineCommand, runMain } from "citty"; import pkg from "../package.json" with { type: "json" }; import { resolveScript, selectPackageByArgs } from "./cli.js"; import { loadHistory, saveHistory } from "./history.js"; +import { initializeLocale, m } from "./i18n.js"; import { runScript } from "./runner.js"; -import { findWorkspaceRoot, getPackages } from "./workspace.js"; +import { + findWorkspaceRoot, + getPackages, + WorkspaceNotFoundError, +} from "./workspace.js"; const command = defineCommand({ meta: { @@ -39,6 +44,8 @@ const command = defineCommand({ return; } + initializeLocale(); + try { const root = findWorkspaceRoot(process.cwd()); const packagesPromise = getPackages(root); @@ -61,7 +68,11 @@ const command = defineCommand({ runScript(root, pkg, script); } catch (error) { - console.error(error instanceof Error ? error.message : String(error)); + if (error instanceof WorkspaceNotFoundError) { + console.error(m.workspace_root_required()); + } else { + console.error(error instanceof Error ? error.message : String(error)); + } process.exit(1); } }, diff --git a/src/runner.ts b/src/runner.ts index 09913f4..9e8cf30 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -1,4 +1,5 @@ import { spawnSync } from "node:child_process"; +import { m } from "./i18n.js"; import type { Package } from "./workspace.js"; import { isRootPackage } from "./workspace.js"; @@ -12,7 +13,7 @@ export function runScript(root: string, pkg: Package, script: string): void { }); if (result.error) { - console.error(`Failed to run pnpm: ${result.error.message}`); + console.error(m.pnpm_run_failed({ message: result.error.message })); process.exit(1); } diff --git a/src/ui.ts b/src/ui.ts index 9ec0eca..7678246 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -1,11 +1,10 @@ import { autocomplete, isCancel } from "@clack/prompts"; import type { HistoryEntry } from "./history.js"; +import { m } from "./i18n.js"; import { sortPackages, sortScripts } from "./sort.js"; import type { Package, Script } from "./workspace.js"; import { isRootPackage } from "./workspace.js"; -export const SELECT_PACKAGE_MESSAGE = "Select package"; - export async function selectPackage( packages: Package[], history: HistoryEntry[] @@ -13,7 +12,7 @@ export async function selectPackage( const sorted = sortPackages(packages, history); const selected = await autocomplete({ - message: SELECT_PACKAGE_MESSAGE, + message: m.select_package(), options: sorted.map((pkg) => ({ value: pkg.name, label: pkg.name, @@ -40,7 +39,7 @@ export async function selectScript( const sorted = sortScripts(scripts, pkg.name, history); const selected = await autocomplete({ - message: "Select script", + message: m.select_script(), options: sorted.map((script) => ({ value: script.name, label: script.name, diff --git a/src/workspace.test.ts b/src/workspace.test.ts index 5e52749..62ef425 100644 --- a/src/workspace.test.ts +++ b/src/workspace.test.ts @@ -57,7 +57,7 @@ describe("workspace", () => { await using fixture = await createFixture(); expect(() => findWorkspaceRoot(fixture.path)).toThrow( - new WorkspaceNotFoundError("Run prw inside a pnpm workspace.") + WorkspaceNotFoundError ); }); }); diff --git a/src/workspace.ts b/src/workspace.ts index 6268d8e..0df8f8c 100644 --- a/src/workspace.ts +++ b/src/workspace.ts @@ -8,8 +8,8 @@ const WORKSPACE_CONFIG_FILE = "pnpm-workspace.yaml"; const TRAILING_PATH_SEPARATOR_PATTERN = /[\\/]+$/; export class WorkspaceNotFoundError extends Error { - constructor(message: string) { - super(message); + constructor() { + super(); this.name = "WorkspaceNotFoundError"; } } @@ -42,7 +42,7 @@ export function findWorkspaceRoot(cwd: string): string { current = parent; } - throw new WorkspaceNotFoundError("Run prw inside a pnpm workspace."); + throw new WorkspaceNotFoundError(); } export async function getPackages(root: string): Promise { diff --git a/test/__snapshots__/prw.e2e.test.ts.snap b/test/__snapshots__/prw.e2e.test.ts.snap index c46f8f0..e8b17ee 100644 --- a/test/__snapshots__/prw.e2e.test.ts.snap +++ b/test/__snapshots__/prw.e2e.test.ts.snap @@ -21,6 +21,27 @@ exports[`prw e2e > reports an error when launched completely outside a workspace Run prw inside a pnpm workspace." `; +exports[`prw e2e > shows Japanese package picker when LANG is ja_JP 1`] = ` +" +│ +◆ パッケージを選択 +│ +│ Search: _ +│ ● (root) +│ ○ @large/admin +│ ○ @large/api +│ ○ @large/config +│ ○ @large/dashboard +│ ○ @large/logger +│ ○ @large/mobile +│ ○ @large/types +│ ○ @large/ui +│ ○ @large/utils +│ ○ @large/web +│ ↑/↓ to select • Enter: confirm • Type: to search +└" +`; + exports[`prw e2e > shows help when "--help" is passed 1`] = ` " Interactive pnpm workspace package & script runner (prw v0.0.0-test) diff --git a/test/prw.e2e.test.ts b/test/prw.e2e.test.ts index 146d642..1655cae 100644 --- a/test/prw.e2e.test.ts +++ b/test/prw.e2e.test.ts @@ -45,10 +45,12 @@ function launchPrwSession({ cwd, homeDir, args = [], + locale = "en_US.UTF-8", }: { cwd: string; homeDir: string; args?: string[]; + locale?: string; }): Promise { return launchTerminal({ command: "node", @@ -59,6 +61,9 @@ function launchPrwSession({ env: { HOME: homeDir, PATH: process.env.PATH, + LC_ALL: locale, + LC_MESSAGES: locale, + LANG: locale, }, }); } @@ -459,4 +464,43 @@ describe.sequential("prw e2e", () => { await closeSessionSafely(session); } }); + + it("shows Japanese package picker when LANG is ja_JP", async () => { + await using fixture = await createFixture(); + const session = await launchPrwSession({ + cwd: resolve(repoRoot, "example/large"), + homeDir: fixture.path, + locale: "ja_JP.UTF-8", + }); + + try { + await session.waitForText("パッケージを選択"); + + expect(await readTerminal(session)).toMatchSnapshot(); + } finally { + await closeSessionSafely(session); + } + }); + + it("shows Japanese error when launched outside a workspace with LANG=ja_JP", async () => { + await using fixture = await createFixture(); + await using homeFixture = await createFixture(); + const session = await launchPrwSession({ + cwd: fixture.path, + homeDir: homeFixture.path, + locale: "ja_JP.UTF-8", + }); + + try { + await session.waitForText( + "pnpm ワークスペース内で prw を実行してください。" + ); + + expect(await readTerminal(session)).toContain( + "pnpm ワークスペース内で prw を実行してください。" + ); + } finally { + await closeSessionSafely(session); + } + }); }); diff --git a/tsconfig.json b/tsconfig.json index c5e9c48..951dba1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ "moduleResolution": "NodeNext", "lib": ["ES2024"], "types": ["node"], + "allowJs": true, "strict": true, "verbatimModuleSyntax": true, "noUncheckedIndexedAccess": true, @@ -12,9 +13,8 @@ "skipLibCheck": true, "resolveJsonModule": true, "forceConsistentCasingInFileNames": true, - "outDir": "./dist", - "rootDir": "./src" + "outDir": "./dist" }, - "include": ["src"], + "include": ["src", "generated"], "exclude": ["node_modules", "dist", "**/*.test.ts"] } From df2790cc037372b39d5ecc298592e7f453003348 Mon Sep 17 00:00:00 2001 From: nyatinte Date: Sun, 15 Mar 2026 23:59:13 +0900 Subject: [PATCH 2/4] fix: add #paraglide/* path mapping to knip.json for CI Co-Authored-By: Claude Opus 4.6 --- knip.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/knip.json b/knip.json index 1783f9a..dd0d4cc 100644 --- a/knip.json +++ b/knip.json @@ -1,4 +1,7 @@ { "ignoreDependencies": ["@biomejs/biome"], - "ignore": ["example/**"] + "ignore": ["example/**"], + "paths": { + "#paraglide/*": ["./generated/paraglide/*"] + } } From 7e18b5b3026ac0b6387eb80673bb2e8eeacafdf4 Mon Sep 17 00:00:00 2001 From: nyatinte Date: Sun, 15 Mar 2026 23:59:23 +0900 Subject: [PATCH 3/4] refactor: use locales.find() instead of includes() with type assertion Co-Authored-By: Claude Opus 4.6 --- src/i18n.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/i18n.ts b/src/i18n.ts index e26f510..5a85760 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -5,8 +5,6 @@ export { m } from "#paraglide/messages.js"; import { baseLocale, locales, overwriteGetLocale } from "#paraglide/runtime.js"; -type Locale = (typeof locales)[number]; - export function initializeLocale(): void { const tag = osLocale(); let language: string; @@ -15,8 +13,6 @@ export function initializeLocale(): void { } catch { language = baseLocale; } - const locale = locales.includes(language as Locale) - ? (language as Locale) - : baseLocale; + const locale = locales.find((l) => l === language) ?? baseLocale; overwriteGetLocale(() => locale); } From 14c3015727d17298b2153e4c55b7a40524a58e33 Mon Sep 17 00:00:00 2001 From: nyatinte Date: Sun, 15 Mar 2026 23:59:37 +0900 Subject: [PATCH 4/4] chore: add changeset for i18n feature Co-Authored-By: Claude Opus 4.6 --- .changeset/add-i18n-support.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/add-i18n-support.md diff --git a/.changeset/add-i18n-support.md b/.changeset/add-i18n-support.md new file mode 100644 index 0000000..6572256 --- /dev/null +++ b/.changeset/add-i18n-support.md @@ -0,0 +1,5 @@ +--- +"@nyatinte/prw": minor +--- + +Add Paraglide.js-based i18n with English and Japanese support