diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9284b34..de8b10f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,5 +16,5 @@ jobs: # bundling regressions that make the published bin unrunnable. - run: node dist/cli.js --help - run: bun dist/cli.js --help - - run: bun test + - run: bun run test - run: bunx tsc --noEmit diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f994e91..e4c4b17 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -21,7 +21,7 @@ jobs: - uses: oven-sh/setup-bun@v2 - run: bun install --frozen-lockfile - - run: bun test + - run: bun run test - run: bun run build - uses: actions/setup-node@v4 diff --git a/.gitignore b/.gitignore index 348463a..63b5bea 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ logs *.tsbuildinfo .idea .DS_Store +tests/.tmp-fixtures diff --git a/bun.lock b/bun.lock index 043673f..a1f6d42 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "solid-translate", "dependencies": { + "@babel/parser": "^7.26.0", "glob": "^11.0.0", "zod": "^3.23.0", }, @@ -12,10 +13,12 @@ "@ai-sdk/anthropic": "^1.2.12", "@ai-sdk/google": "^1.2.22", "@ai-sdk/openai": "^1.3.24", + "@babel/core": "^7.26.0", "@happy-dom/global-registrator": "^16.0.0", "@semantic-release/git": "^10.0.1", "@types/bun": "latest", "ai": "^4.0.0", + "babel-preset-solid": "^1.9.12", "semantic-release": "25", "solid-js": "^1.8.0", "tsup": "^8.0.0", @@ -56,9 +59,41 @@ "@ai-sdk/ui-utils": ["@ai-sdk/ui-utils@1.2.11", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w=="], - "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], + + "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], + + "@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], + + "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], + + "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A=="], + + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], + + "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], "@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="], @@ -120,6 +155,8 @@ "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], @@ -258,8 +295,14 @@ "array-ify": ["array-ify@1.0.0", "", {}, "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng=="], + "babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.7", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-/O6JWUmjv03OI9lL2ry9bUjpD5S3PclM55RRJEyCdcFZ5W2SEA/59d+l2hNsk3gI6kiWRdRPdOtqZmsQzFN1pQ=="], + + "babel-preset-solid": ["babel-preset-solid@1.9.12", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.6" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.12" }, "optionalPeers": ["solid-js"] }, "sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg=="], + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.43", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ=="], + "before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], "bottleneck": ["bottleneck@2.19.5", "", {}, "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw=="], @@ -268,6 +311,8 @@ "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + "browserslist": ["browserslist@4.28.6", "", { "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001803", "electron-to-chromium": "^1.5.389", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw=="], + "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], "bundle-require": ["bundle-require@5.1.0", "", { "dependencies": { "load-tsconfig": "^0.2.3" }, "peerDependencies": { "esbuild": ">=0.18" } }, "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA=="], @@ -276,6 +321,8 @@ "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + "caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="], + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "char-regex": ["char-regex@1.0.2", "", {}, "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw=="], @@ -314,6 +361,8 @@ "convert-hrtime": ["convert-hrtime@5.0.0", "", {}, "sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], "cosmiconfig": ["cosmiconfig@9.0.1", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ=="], @@ -338,10 +387,14 @@ "duplexer2": ["duplexer2@0.1.4", "", { "dependencies": { "readable-stream": "^2.0.2" } }, "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA=="], + "electron-to-chromium": ["electron-to-chromium@1.5.393", "", {}, "sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg=="], + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "emojilib": ["emojilib@2.4.0", "", {}, "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw=="], + "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "env-ci": ["env-ci@11.2.0", "", { "dependencies": { "execa": "^8.0.0", "java-properties": "^1.0.2" } }, "sha512-D5kWfzkmaOQDioPmiviWAVtKmpPT4/iJmMVQxWxMPJTFyTkdc5JQUfc5iXEeWxcOdsYTKSAiA/Age4NUOqKsRA=="], "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], @@ -384,6 +437,8 @@ "function-timeout": ["function-timeout@1.0.2", "", {}, "sha512-939eZS4gJ3htTHAldmyyuzlrD58P03fHG49v2JfFXbV6OhvZKRC9j2yAtdHw/zrp2zXHuv05zMIy40F0ge7spA=="], + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], "get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="], @@ -408,6 +463,8 @@ "hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="], + "html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="], + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], @@ -460,6 +517,8 @@ "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + "json-parse-better-errors": ["json-parse-better-errors@1.0.2", "", {}, "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw=="], "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], @@ -468,6 +527,8 @@ "json-with-bigint": ["json-with-bigint@3.5.8", "", {}, "sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw=="], + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "jsondiffpatch": ["jsondiffpatch@0.6.0", "", { "dependencies": { "@types/diff-match-patch": "^1.0.36", "chalk": "^5.3.0", "diff-match-patch": "^1.0.5" }, "bin": { "jsondiffpatch": "bin/jsondiffpatch.js" } }, "sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ=="], "jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], @@ -496,7 +557,7 @@ "lodash.uniqby": ["lodash.uniqby@4.7.0", "", {}, "sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww=="], - "lru-cache": ["lru-cache@11.3.5", "", {}, "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw=="], + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], @@ -536,6 +597,8 @@ "node-emoji": ["node-emoji@2.2.0", "", { "dependencies": { "@sindresorhus/is": "^4.6.0", "char-regex": "^1.0.2", "emojilib": "^2.4.0", "skin-tone": "^2.0.0" } }, "sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw=="], + "node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="], + "normalize-package-data": ["normalize-package-data@8.0.0", "", { "dependencies": { "hosted-git-info": "^9.0.0", "semver": "^7.3.5", "validate-npm-package-license": "^3.0.4" } }, "sha512-RWk+PI433eESQ7ounYxIp67CYuVsS1uYSonX3kA6ps/3LWfjVQa/ptEg6Y3T6uAMq1mWpX9PQ+qx+QaHpsc7gQ=="], "normalize-url": ["normalize-url@9.0.0", "", {}, "sha512-z9nC87iaZXXySbWWtTHfCFJyFvKaUAW6lODhikG7ILSbVgmwuFjUqkgnheHvAUcGedO29e2QGBRXMUD64aurqQ=="], @@ -576,7 +639,7 @@ "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], - "parse5": ["parse5@5.1.1", "", {}, "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug=="], + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], "parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@6.0.1", "", { "dependencies": { "parse5": "^6.0.1" } }, "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA=="], @@ -638,7 +701,7 @@ "semantic-release": ["semantic-release@25.0.3", "", { "dependencies": { "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/error": "^4.0.0", "@semantic-release/github": "^12.0.0", "@semantic-release/npm": "^13.1.1", "@semantic-release/release-notes-generator": "^14.1.0", "aggregate-error": "^5.0.0", "cosmiconfig": "^9.0.0", "debug": "^4.0.0", "env-ci": "^11.0.0", "execa": "^9.0.0", "figures": "^6.0.0", "find-versions": "^6.0.0", "get-stream": "^6.0.0", "git-log-parser": "^1.2.0", "hook-std": "^4.0.0", "hosted-git-info": "^9.0.0", "import-from-esm": "^2.0.0", "lodash-es": "^4.17.21", "marked": "^15.0.0", "marked-terminal": "^7.3.0", "micromatch": "^4.0.2", "p-each-series": "^3.0.0", "p-reduce": "^3.0.0", "read-package-up": "^12.0.0", "resolve-from": "^5.0.0", "semver": "^7.3.2", "signale": "^1.2.1", "yargs": "^18.0.0" }, "bin": { "semantic-release": "bin/semantic-release.js" } }, "sha512-WRgl5GcypwramYX4HV+eQGzUbD7UUbljVmS+5G1uMwX/wLgYuJAxGeerXJDMO2xshng4+FXqCgyB5QfClV6WjA=="], - "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "semver-regex": ["semver-regex@4.0.5", "", {}, "sha512-hunMQrEy1T6Jr2uEVjrAIqjwWcQTgOAcIM52C8MY1EZSD3DDNft04XzvYKPqjED65bNVVko0YI38nYeEHCX3yw=="], @@ -752,6 +815,8 @@ "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + "url-join": ["url-join@5.0.0", "", {}, "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA=="], "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], @@ -778,6 +843,8 @@ "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="], "yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="], @@ -802,16 +869,24 @@ "@semantic-release/npm/execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="], + "@semantic-release/npm/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@semantic-release/release-notes-generator/get-stream": ["get-stream@7.0.1", "", {}, "sha512-3M8C1EOFN6r8AMUhwUAACIoXZJEOufDU5+0gFFN5uNs6XYOralD2Pqkl7m046va6x77FwposWXbAhPPIOus7mQ=="], "@semantic-release/release-notes-generator/read-package-up": ["read-package-up@11.0.0", "", { "dependencies": { "find-up-simple": "^1.0.0", "read-pkg": "^9.0.0", "type-fest": "^4.6.0" } }, "sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ=="], + "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="], + "cli-highlight/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "cli-highlight/parse5": ["parse5@5.1.1", "", {}, "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug=="], + "cli-highlight/yargs": ["yargs@16.2.0", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw=="], "cli-table3/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "conventional-changelog-writer/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "crypto-random-string/type-fest": ["type-fest@1.4.0", "", {}, "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA=="], "env-ci/execa": ["execa@8.0.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^8.0.1", "human-signals": "^5.0.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^4.1.0", "strip-final-newline": "^3.0.0" } }, "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg=="], @@ -822,12 +897,16 @@ "handlebars/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "hosted-git-info/lru-cache": ["lru-cache@11.3.5", "", {}, "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw=="], + "import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], "load-json-file/parse-json": ["parse-json@4.0.0", "", { "dependencies": { "error-ex": "^1.3.1", "json-parse-better-errors": "^1.0.1" } }, "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw=="], "make-asynchronous/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + "normalize-package-data/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "npm/@gar/promise-retry": ["@gar/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="], "npm/@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], @@ -1110,8 +1189,12 @@ "npm/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + "parse-json/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + "parse5-htmlparser2-tree-adapter/parse5": ["parse5@6.0.1", "", {}, "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="], + "path-scurry/lru-cache": ["lru-cache@11.3.5", "", {}, "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw=="], + "read-pkg/parse-json": ["parse-json@8.3.0", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "index-to-position": "^1.1.0", "type-fest": "^4.39.1" } }, "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ=="], "semantic-release/@semantic-release/error": ["@semantic-release/error@4.0.0", "", {}, "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ=="], @@ -1122,6 +1205,8 @@ "semantic-release/p-reduce": ["p-reduce@3.0.0", "", {}, "sha512-xsrIUgI0Kn6iyDYm9StOpOeK29XM1aboGji26+QEortiFST1hGZaUQOLhtEbqHErPpGW/aSz6allwK2qcptp0Q=="], + "semantic-release/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "signale/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], "signale/figures": ["figures@2.0.0", "", { "dependencies": { "escape-string-regexp": "^1.0.5" } }, "sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA=="], @@ -1190,6 +1275,10 @@ "npm/minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + "parse-json/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "read-pkg/parse-json/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + "read-pkg/parse-json/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], "semantic-release/aggregate-error/clean-stack": ["clean-stack@5.3.0", "", { "dependencies": { "escape-string-regexp": "5.0.0" } }, "sha512-9ngPTOhYGQqNVSfeJkYXHmF7AGWp4/nN5D/QqNQs3Dvxd1Kk/WpjHfNujKHYUQ/5CoGyOyFNoWSPk5afzP0QVg=="], @@ -1290,6 +1379,8 @@ "npm/minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "read-pkg/parse-json/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "semantic-release/execa/npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], "semantic-release/execa/npm-run-path/unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], @@ -1298,6 +1389,10 @@ "@semantic-release/release-notes-generator/read-package-up/read-pkg/normalize-package-data/hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="], + "@semantic-release/release-notes-generator/read-package-up/read-pkg/normalize-package-data/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "@semantic-release/release-notes-generator/read-package-up/read-pkg/parse-json/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + "cli-highlight/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], "cli-highlight/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -1308,6 +1403,8 @@ "@semantic-release/release-notes-generator/read-package-up/read-pkg/normalize-package-data/hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "@semantic-release/release-notes-generator/read-package-up/read-pkg/parse-json/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "cli-highlight/yargs/cliui/wrap-ansi/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "cli-highlight/yargs/cliui/wrap-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], diff --git a/package.json b/package.json index 88db39f..46e8832 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "scripts": { "build": "tsup", "dev": "tsup --watch", - "test": "bun test", + "test": "bun test --conditions=browser", "prepublishOnly": "bun run build" }, "keywords": [ @@ -80,6 +80,7 @@ } }, "dependencies": { + "@babel/parser": "^7.26.0", "glob": "^11.0.0", "zod": "^3.23.0" }, @@ -87,10 +88,12 @@ "@ai-sdk/anthropic": "^1.2.12", "@ai-sdk/google": "^1.2.22", "@ai-sdk/openai": "^1.3.24", + "@babel/core": "^7.26.0", "@happy-dom/global-registrator": "^16.0.0", + "@semantic-release/git": "^10.0.1", "@types/bun": "latest", "ai": "^4.0.0", - "@semantic-release/git": "^10.0.1", + "babel-preset-solid": "^1.9.12", "semantic-release": "25", "solid-js": "^1.8.0", "tsup": "^8.0.0", diff --git a/src/cli.ts b/src/cli.ts index 66cd324..4dd77c6 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,7 +11,10 @@ import { import { resolve, join, dirname, relative, basename } from "node:path"; import { translateBatch, translateMarkdown } from "./translate.js"; import { hashContent } from "./hash.js"; -import { extractStringsFromSource } from "./extract.js"; +import { + extractStringsFromSource, + type ExtractWarning, +} from "./extract.js"; import { syncLocaleFiles, formatSyncFailures } from "./lock.js"; import type { CLIConfig, LockFile } from "./types.js"; @@ -188,6 +191,7 @@ async function runExtract() { const { glob } = await import("glob"); const strings: Record = {}; + const warnings: ExtractWarning[] = []; let total = 0; for (const pattern of patterns) { @@ -198,6 +202,7 @@ async function runExtract() { const extracted = extractStringsFromSource( code, relative(root, file), + warnings, ); for (const entry of extracted) { strings[entry.key] = entry.source; @@ -209,6 +214,10 @@ async function runExtract() { } } + for (const w of warnings) { + console.warn(`warning: ${w.file}:${w.line} — ${w.message}`); + } + // Merge with existing source file const sourceFilePath = join(localesDir, `${sourceLocale}.json`); let existing: Record = {}; diff --git a/src/components.tsx b/src/components.tsx index f707b62..6caf11a 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -144,11 +144,15 @@ export interface PluralProps { /** * Renders the appropriate plural form based on CLDR plural rules for the current locale. * + * String forms are translated through the translation dictionary (the source + * string is the key — matching extraction) and support an `{n}` placeholder + * interpolated with the count. Non-string forms render as-is, untranslated. + * * ```tsx * * ``` */ @@ -169,7 +173,14 @@ export function Plural(props: PluralProps): JSX.Element { other: props.other, }; - return forms[category] ?? props.other; + const form = forms[category] ?? props.other; + + // Translate string forms through the dictionary, keyed by source string + if (ctx && typeof form === "string") { + return ctx.t(form, { n: props.n }); + } + + return form; }) as unknown as JSX.Element; } diff --git a/src/extract.ts b/src/extract.ts index b56c146..03134ab 100644 --- a/src/extract.ts +++ b/src/extract.ts @@ -1,3 +1,5 @@ +import { parse } from "@babel/parser"; + /** Extracted translatable string from source code */ export interface ExtractedString { key: string; @@ -8,68 +10,566 @@ export interface ExtractedString { context?: string; } +/** A source construct that could not be extracted statically */ +export interface ExtractWarning { + file: string; + line: number; + message: string; +} + +// Minimal structural type for Babel AST nodes — avoids a hard dependency on +// @babel/types (we only walk, never construct). +interface Node { + type: string; + [key: string]: any; +} + /** - * Extract translatable strings from source code by finding: + * Extract translatable strings from source code by parsing it with + * @babel/parser and walking the AST. Finds: * - `text` — source text is used as the key * - `fallback` — explicit key * - `text` — with AI context * - `text` — both * - `text ... more` — builds template with {0} placeholders + * - `` — each string-literal form * - `msg("text")` — shared string marker + * + * The produced keys have EXACT parity with the keys the runtime `` + * component builds after Solid's JSX compiler (babel-preset-solid / + * dom-expressions) has processed the source: + * + * 1. JSX text is whitespace-collapsed exactly like dom-expressions' + * `trimWhitespace`: `\r` removed; if the text contains a newline, every + * line after the first has leading whitespace stripped and + * whitespace-only lines are dropped, lines are joined with a single + * space; finally all whitespace runs collapse to one space. HTML + * entities are decoded after collapsing. + * 2. Static literal expressions (`{"text"}`, `{5}`, `{`text`}`, + * `{"a" + "b"}`) are inlined by the compiler and merge into the + * surrounding text at runtime, so they merge into the key here too. + * 3. Element children (``, ``, ``, any component) + * and dynamic expressions the compiler wraps in a memo (calls, member + * access, tagged templates) become ordered `{0}`, `{1}`, ... + * placeholders. + * 4. `{null}`, `{undefined}` and boolean literals render nothing and are + * skipped. + * 5. The final key is the template with leading/trailing whitespace + * trimmed (the runtime trims edges before lookup and restores them + * around the translation). + * + * Expressions whose runtime type cannot be known statically (bare + * identifiers, identifier-only conditionals, interpolated template + * literals, ...) are inlined raw by the compiler — a string value would + * merge into the runtime key, an element would become a slot — so the + * whole `` is skipped with a warning. Wrap such values in `` to + * make them extractable. + * + * Pass a `warnings` array to collect unextractable shapes (dynamic + * `msg()` arguments, spread props on ``, non-literal `` + * forms, ...). */ export function extractStringsFromSource( code: string, filePath: string, + warnings?: ExtractWarning[], ): ExtractedString[] { const results: ExtractedString[] = []; const seen = new Set(); - // Match children - const tComponentRegex = /]*)?>([^]*?)<\/T>/g; - let match: RegExpExecArray | null; - - while ((match = tComponentRegex.exec(code)) !== null) { - const attrs = match[1] || ""; - const rawChildren = match[2]!; - const line = code.substring(0, match.index).split("\n").length; - - // Parse id attribute - const idMatch = attrs.match(/id=["']([^"']+)["']/); - // Parse context attribute - const contextMatch = attrs.match(/context=["']([^"']+)["']/); - - // Build source text: replace , , , with {n} placeholders - // Single-pass replacement to preserve document order - let slotIndex = 0; - const source = rawChildren - .replace( - /<(?:Var|Num|Currency|DateTime)(?:\s[^>]*)?>([^]*?)<\/(?:Var|Num|Currency|DateTime)>/g, - () => `{${slotIndex++}}`, - ) - .trim(); - - const key = idMatch ? idMatch[1]! : source; - if (!key || seen.has(key)) continue; - seen.add(key); - - results.push({ - key, - source, - file: filePath, + const warn = (line: number, message: string) => { + warnings?.push({ file: filePath, line, message }); + }; + + let ast: Node; + try { + ast = parse(code, { + sourceType: "module", + plugins: ["typescript", "jsx"], + errorRecovery: true, + }) as unknown as Node; + } catch (err: any) { + warn(err?.loc?.line ?? 1, `Failed to parse file: ${err?.message ?? err}`); + return results; + } + + const push = (entry: ExtractedString) => { + if (!entry.key || seen.has(entry.key)) return; + seen.add(entry.key); + results.push(entry); + }; + + const visit = (node: Node) => { + if (node.type === "JSXElement") { + const name = jsxName(node); + if (name === "T") { + const entry = processT(node, filePath, warn); + if (entry) push(entry); + } else if (name === "Plural") { + for (const entry of processPlural(node, filePath, warn)) { + push(entry); + } + } + } else if ( + node.type === "CallExpression" && + node.callee?.type === "Identifier" && + node.callee.name === "msg" + ) { + const entry = processMsg(node, filePath, warn); + if (entry) push(entry); + } + walkChildren(node, visit); + }; + + visit(ast); + return results; +} + +// --------------------------------------------------------------------------- +// AST walking +// --------------------------------------------------------------------------- + +function walkChildren(node: Node, visit: (node: Node) => void) { + for (const key of Object.keys(node)) { + if (key === "loc" || key === "leadingComments" || key === "trailingComments" || key === "innerComments") { + continue; + } + const value = node[key]; + if (Array.isArray(value)) { + for (const item of value) { + if (item && typeof item === "object" && typeof item.type === "string") { + visit(item); + } + } + } else if (value && typeof value === "object" && typeof value.type === "string") { + visit(value); + } + } +} + +function jsxName(node: Node): string | undefined { + const name = node.openingElement?.name; + return name?.type === "JSXIdentifier" ? name.name : undefined; +} + +// --------------------------------------------------------------------------- +// extraction +// --------------------------------------------------------------------------- + +function processT( + node: Node, + filePath: string, + warn: (line: number, message: string) => void, +): ExtractedString | null { + const line = node.loc?.start?.line ?? 1; + + let id: string | undefined; + let context: string | undefined; + + for (const attr of node.openingElement.attributes as Node[]) { + if (attr.type === "JSXSpreadAttribute") { + warn( + attr.loc?.start?.line ?? line, + " has spread props; id/context cannot be determined statically", + ); + continue; + } + if (attr.type !== "JSXAttribute" || attr.name?.type !== "JSXIdentifier") { + continue; + } + const attrName = attr.name.name; + if (attrName !== "id" && attrName !== "context") continue; + + const value = staticAttrString(attr.value); + if (value === null) { + warn( + attr.loc?.start?.line ?? line, + ` is not a static string; skipped`, + ); + if (attrName === "id") return null; + continue; + } + if (attrName === "id") id = value; + else context = value; + } + + const built = buildTemplate(node.children as Node[], filePath, warn); + if (built === null) return null; + + const { body } = splitEdgeWhitespace(built); + const key = id ?? body; + + // Nothing translatable: empty, or only placeholders/whitespace + if (!id && (body === "" || /^(?:\{\d+\}|\s)*$/.test(body))) return null; + + return { key, source: body, file: filePath, line, context }; +} + +/** + * Build the runtime template string for a list of JSX children. + * Returns null (after warning) if any child's runtime shape is unknowable. + */ +function buildTemplate( + children: Node[], + filePath: string, + warn: (line: number, message: string) => void, + state = { template: "", slots: 0 }, +): string | null { + for (const child of children) { + const line = child.loc?.start?.line ?? 1; + + if (child.type === "JSXText") { + const collapsed = collapseJsxText(child.extra?.raw ?? child.value ?? ""); + if (collapsed !== "") state.template += decodeEntities(collapsed); + continue; + } + + if (child.type === "JSXElement") { + state.template += `{${state.slots++}}`; + continue; + } + + if (child.type === "JSXFragment") { + if (buildTemplate(child.children as Node[], filePath, warn, state) === null) { + return null; + } + continue; + } + + if (child.type !== "JSXExpressionContainer") continue; + + const expr = unwrapTs(child.expression as Node); + + if (expr.type === "JSXEmptyExpression") continue; + + // {null} / {undefined} / {true} / {false} render nothing + if ( + expr.type === "NullLiteral" || + expr.type === "BooleanLiteral" || + (expr.type === "Identifier" && expr.name === "undefined") + ) { + continue; + } + + // Static literals are inlined by the compiler and merge into text + const folded = foldStatic(expr); + if (folded !== null) { + state.template += String(folded); + continue; + } + + if (expr.type === "JSXElement") { + state.template += `{${state.slots++}}`; + continue; + } + + if (expr.type === "JSXFragment") { + if (buildTemplate(expr.children as Node[], filePath, warn, state) === null) { + return null; + } + continue; + } + + // Inlined function values are slots at runtime (typeof kid === "function") + if ( + expr.type === "ArrowFunctionExpression" || + expr.type === "FunctionExpression" + ) { + state.template += `{${state.slots++}}`; + continue; + } + + // Expressions the compiler wraps in a memo become function slots + if (isCompiledDynamic(expr)) { + state.template += `{${state.slots++}}`; + continue; + } + + // Anything else (bare identifiers, identifier-only conditionals, ...) + // is inlined raw by the compiler: a string value would merge into the + // runtime key, an element would become a slot. Unknowable — skip. + warn( line, - context: contextMatch ? contextMatch[1] : undefined, - }); + " contains an expression whose runtime value cannot be determined statically; " + + "wrap it in (or use a function call) to make this string extractable", + ); + return null; } - // Match msg("text") and msg('text') calls - const msgRegex = /\bmsg\(\s*["']([^"']+)["']\s*(?:,\s*\{[^}]*\})?\s*\)/g; - while ((match = msgRegex.exec(code)) !== null) { - const source = match[1]!; - if (seen.has(source)) continue; - seen.add(source); - const line = code.substring(0, match.index).split("\n").length; - results.push({ key: source, source, file: filePath, line }); + return state.template; +} + +// --------------------------------------------------------------------------- +// extraction +// --------------------------------------------------------------------------- + +const PLURAL_FORMS = ["zero", "one", "two", "few", "many", "other"]; + +function processPlural( + node: Node, + filePath: string, + warn: (line: number, message: string) => void, +): ExtractedString[] { + const entries: ExtractedString[] = []; + + for (const attr of node.openingElement.attributes as Node[]) { + if (attr.type !== "JSXAttribute" || attr.name?.type !== "JSXIdentifier") { + continue; + } + const attrName = attr.name.name; + if (!PLURAL_FORMS.includes(attrName)) continue; + + const line = attr.loc?.start?.line ?? node.loc?.start?.line ?? 1; + const value = staticAttrString(attr.value); + if (value === null) { + warn( + line, + ` is not a static string and will not be translated; ` + + `use a string form with an {n} placeholder to make it translatable`, + ); + continue; + } + if (value === "") continue; + entries.push({ key: value, source: value, file: filePath, line }); } - return results; + return entries; +} + +// --------------------------------------------------------------------------- +// msg() extraction +// --------------------------------------------------------------------------- + +function processMsg( + node: Node, + filePath: string, + warn: (line: number, message: string) => void, +): ExtractedString | null { + const line = node.loc?.start?.line ?? 1; + const arg = node.arguments?.[0] ? unwrapTs(node.arguments[0] as Node) : undefined; + + if (!arg) { + warn(line, "msg() called without arguments; skipped"); + return null; + } + + const folded = foldStatic(arg); + if (typeof folded !== "string") { + warn( + line, + "msg() argument is not a static string (dynamic values cannot be extracted); skipped", + ); + return null; + } + if (folded === "") return null; + + return { key: folded, source: folded, file: filePath, line }; +} + +// --------------------------------------------------------------------------- +// Attribute helpers +// --------------------------------------------------------------------------- + +/** + * Resolve a JSX attribute value to a static string. + * Handles `attr="text"`, `attr='text'`, `attr={"text"}`, `attr={`text`}`. + * Returns null if the value is not statically a string. + */ +function staticAttrString(value: Node | null | undefined): string | null { + if (!value) return null; + if (value.type === "StringLiteral") return value.value; + if (value.type === "JSXExpressionContainer") { + const folded = foldStatic(unwrapTs(value.expression as Node)); + return typeof folded === "string" ? folded : null; + } + return null; +} + +// --------------------------------------------------------------------------- +// Static expression folding +// --------------------------------------------------------------------------- + +/** Unwrap TypeScript-only wrapper expressions (stripped at compile time) */ +function unwrapTs(node: Node): Node { + let current = node; + while ( + current.type === "TSAsExpression" || + current.type === "TSSatisfiesExpression" || + current.type === "TSNonNullExpression" || + current.type === "TSTypeAssertion" || + current.type === "ParenthesizedExpression" + ) { + current = current.expression as Node; + } + return current; +} + +/** + * Fold a static literal expression to its runtime value. + * Returns null when the expression is not statically foldable. + */ +function foldStatic(node: Node): string | number | null { + const expr = unwrapTs(node); + + if (expr.type === "StringLiteral") return expr.value; + if (expr.type === "NumericLiteral") return expr.value; + + if (expr.type === "TemplateLiteral" && expr.expressions.length === 0) { + return expr.quasis[0]?.value?.cooked ?? null; + } + + if (expr.type === "UnaryExpression" && expr.prefix) { + const inner = foldStatic(expr.argument as Node); + if (typeof inner === "number") { + if (expr.operator === "-") return -inner; + if (expr.operator === "+") return inner; + } + return null; + } + + if (expr.type === "BinaryExpression" && expr.operator === "+") { + const left = foldStatic(expr.left as Node); + const right = foldStatic(expr.right as Node); + if (left === null || right === null) return null; + // JS semantics: string concat if either side is a string + return (left as any) + (right as any); + } + + return null; +} + +/** + * Mirror of dom-expressions' `isDynamic` for component children: an + * expression containing a call, member access, tagged template, or spread + * (outside nested functions) is wrapped in a memo by the compiler and + * arrives at the runtime as a function — i.e. a slot. + */ +function isCompiledDynamic(node: Node): boolean { + if ( + node.type === "CallExpression" || + node.type === "OptionalCallExpression" || + node.type === "TaggedTemplateExpression" || + node.type === "MemberExpression" || + node.type === "OptionalMemberExpression" || + node.type === "SpreadElement" || + node.type === "NewExpression" || + (node.type === "BinaryExpression" && node.operator === "in") + ) { + return true; + } + + // Function bodies are lazy — dynamic content inside them does not make + // the expression itself dynamic (matches dom-expressions traversal). + if ( + node.type === "ArrowFunctionExpression" || + node.type === "FunctionExpression" + ) { + return false; + } + + let found = false; + walkChildren(node, function check(child: Node) { + if (found) return; + if ( + child.type === "ArrowFunctionExpression" || + child.type === "FunctionExpression" + ) { + return; + } + if ( + child.type === "CallExpression" || + child.type === "OptionalCallExpression" || + child.type === "TaggedTemplateExpression" || + child.type === "MemberExpression" || + child.type === "OptionalMemberExpression" || + child.type === "SpreadElement" || + child.type === "NewExpression" || + (child.type === "BinaryExpression" && child.operator === "in") + ) { + found = true; + return; + } + walkChildren(child, check); + }); + return found; +} + +// --------------------------------------------------------------------------- +// JSX text handling +// --------------------------------------------------------------------------- + +/** + * Exact replication of dom-expressions' `trimWhitespace`, which the Solid + * JSX compiler applies to every JSX text node at build time. + */ +function collapseJsxText(raw: string): string { + let text = raw.replace(/\r/g, ""); + if (/\n/.test(text)) { + text = text + .split("\n") + .map((t, i) => (i ? t.replace(/^\s*/, "") : t)) + .filter((t) => !/^\s*$/.test(t)) + .join(" "); + } + return text.replace(/\s+/g, " "); +} + +/** Split off leading/trailing whitespace (runtime `` does the same). */ +function splitEdgeWhitespace(text: string): { + lead: string; + body: string; + trail: string; +} { + const lead = /^\s*/.exec(text)![0]; + const rest = text.slice(lead.length); + const trail = /\s*$/.exec(rest)![0]; + return { lead, body: rest.slice(0, rest.length - trail.length), trail }; +} + +const NAMED_ENTITIES: Record = { + amp: "&", + lt: "<", + gt: ">", + quot: '"', + apos: "'", + nbsp: "\u00a0", + copy: "©", + reg: "®", + trade: "™", + hellip: "…", + mdash: "—", + ndash: "–", + lsquo: "‘", + rsquo: "’", + ldquo: "“", + rdquo: "”", + laquo: "«", + raquo: "»", + times: "×", + divide: "÷", + middot: "·", + bull: "•", + deg: "°", + plusmn: "±", + euro: "€", + pound: "£", + yen: "¥", + cent: "¢", + sect: "§", + para: "¶", + dagger: "†", + shy: "\u00ad", +}; + +/** Decode numeric and common named HTML entities (compiler decodes JSX text) */ +function decodeEntities(text: string): string { + if (!text.includes("&")) return text; + return text.replace( + /&(?:#x([0-9a-fA-F]+)|#(\d+)|([a-zA-Z][a-zA-Z0-9]*));/g, + (match, hex, dec, name) => { + if (hex) return String.fromCodePoint(parseInt(hex, 16)); + if (dec) return String.fromCodePoint(parseInt(dec, 10)); + return NAMED_ENTITIES[name] ?? match; + }, + ); } diff --git a/src/index.ts b/src/index.ts index fa4c91a..cf30091 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,6 @@ import { useContext, createSignal, createMemo, - children as resolveChildren, type JSX, } from "solid-js"; import { @@ -277,39 +276,20 @@ export interface TProps { */ export function T(props: TProps): JSX.Element { const ctx = useContext(TranslationContext); - const resolved = resolveChildren(() => props.children); return createMemo(() => { - const kids = resolved.toArray(); + // IMPORTANT: children are read raw, WITHOUT resolveChildren(). The Solid + // compiler passes static text as plain strings and wraps every dynamic + // part (expressions, , , elements) in a function or object — + // that boundary is exactly what separates translatable text from {n} + // slots. Resolving children first would collapse dynamic strings into + // text and destroy the key. + const kids = flattenChildren(props.children); // No context — just render children if (!ctx) return kids.length === 1 ? kids[0] : kids; - // Simple case: single text child - if (kids.length === 1 && typeof kids[0] === "string") { - const key = props.id || (kids[0] as string); - return ctx.t(key, props.params); - } - - // Explicit id with non-text children — translate via id - if (props.id) { - const translated = ctx.t(props.id, props.params); - - // If translation is just text (no slot placeholders), return it - if (!/{(\d+)}/.test(translated)) return translated; - - // Collect non-text children (Var, Num, etc.) as ordered slots - const slots: JSX.Element[] = []; - for (const kid of kids) { - if (typeof kid !== "string" && typeof kid !== "number") { - slots.push(kid as JSX.Element); - } - } - - return interpolateSlots(translated, slots); - } - - // Mixed children without explicit id — build a template key + // Build the template key + ordered slots from the raw children const slots: JSX.Element[] = []; let template = ""; for (const kid of kids) { @@ -317,14 +297,27 @@ export function T(props: TProps): JSX.Element { template += kid; } else if (typeof kid === "number") { template += String(kid); + } else if (kid == null || typeof kid === "boolean") { + // {null} / {undefined} / booleans render nothing } else { template += `{${slots.length}}`; slots.push(kid as JSX.Element); } } - const translated = ctx.t(template, props.params); - if (slots.length === 0) return translated; + // Leading/trailing whitespace is layout, not copy — keep it out of the + // key, restore it around the translation. + const lead = /^\s*/.exec(template)![0]; + const rest = template.slice(lead.length); + const trail = /\s*$/.exec(rest)![0]; + const body = rest.slice(0, rest.length - trail.length); + + const key = props.id || body; + const translated = lead + ctx.t(key, props.params) + trail; + + // If translation has no slot placeholders, return it as plain text + if (slots.length === 0 || !/{(\d+)}/.test(translated)) return translated; + return interpolateSlots(translated, slots); }) as unknown as JSX.Element; } @@ -333,6 +326,16 @@ export function T(props: TProps): JSX.Element { // Helpers // --------------------------------------------------------------------------- +/** Flatten (possibly nested) children arrays WITHOUT resolving functions */ +function flattenChildren(child: unknown, out: unknown[] = []): unknown[] { + if (Array.isArray(child)) { + for (const c of child) flattenChildren(c, out); + } else { + out.push(child); + } + return out; +} + /** Split a translated string by `{0}`, `{1}`, etc. and interleave with slots */ function interpolateSlots( text: string, diff --git a/src/vite.ts b/src/vite.ts index 6edd2de..fbffb49 100644 --- a/src/vite.ts +++ b/src/vite.ts @@ -9,7 +9,10 @@ import { import { resolve, join, relative, basename } from "node:path"; import { hashContent } from "./hash.js"; import { translateBatch } from "./translate.js"; -import { extractStringsFromSource } from "./extract.js"; +import { + extractStringsFromSource, + type ExtractWarning, +} from "./extract.js"; import { syncLocaleFiles, formatSyncFailures } from "./lock.js"; import type { SolidTranslatePluginConfig } from "./types.js"; @@ -281,6 +284,7 @@ async function autoExtractStrings( ): Promise<{ strings: Record; contexts: Record }> { const strings: Record = {}; const contexts: Record = {}; + const warnings: ExtractWarning[] = []; // Dynamically import glob for file matching const { glob } = await import("glob"); @@ -293,6 +297,7 @@ async function autoExtractStrings( const extracted = extractStringsFromSource( code, relative(root, file), + warnings, ); for (const entry of extracted) { strings[entry.key] = entry.source; @@ -306,5 +311,9 @@ async function autoExtractStrings( } } + for (const w of warnings) { + console.warn(`[solid-translate] ${w.file}:${w.line} — ${w.message}`); + } + return { strings, contexts }; } diff --git a/tests/extract-enhanced.test.ts b/tests/extract-enhanced.test.ts index 0da7d6d..13490c8 100644 --- a/tests/extract-enhanced.test.ts +++ b/tests/extract-enhanced.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from "bun:test"; -import { extractStringsFromSource } from "../src/extract"; +import { extractStringsFromSource, type ExtractWarning } from "../src/extract"; describe("extractStringsFromSource — enhanced features", () => { test("extracts context prop", () => { @@ -107,4 +107,143 @@ describe("extractStringsFromSource — enhanced features", () => { expect(keys).toContain("Welcome to our app!"); expect(keys).toContain("item_count"); }); + + // ------------------------------------------------------------------------- + // AST-only behaviors + // ------------------------------------------------------------------------- + + test("id/context work with expression-container string values", () => { + const code = `Text`; + const result = extractStringsFromSource(code, "test.tsx"); + expect(result).toHaveLength(1); + expect(result[0]!.key).toBe("expr.key"); + expect(result[0]!.context).toBe("hint"); + }); + + test("static string/number literal expressions merge into the text", () => { + const code = `Hi {"there"} friend, you have {5} items`; + const result = extractStringsFromSource(code, "test.tsx"); + expect(result).toHaveLength(1); + expect(result[0]!.key).toBe("Hi there friend, you have 5 items"); + }); + + test("static string concatenation folds into the text", () => { + const code = `{"a" + "b"} c`; + const result = extractStringsFromSource(code, "test.tsx"); + expect(result).toHaveLength(1); + expect(result[0]!.key).toBe("ab c"); + }); + + test("null/undefined/boolean expression children are skipped", () => { + const code = `a{null}{undefined}{true}{false}b`; + const result = extractStringsFromSource(code, "test.tsx"); + expect(result).toHaveLength(1); + expect(result[0]!.key).toBe("ab"); + }); + + test("comment-only expression containers are skipped", () => { + const code = `a{/* note */}b`; + const result = extractStringsFromSource(code, "test.tsx"); + expect(result[0]!.key).toBe("ab"); + }); + + test("HTML entities are decoded like the compiler", () => { + const code = `Don't & won't stop`; + const result = extractStringsFromSource(code, "test.tsx"); + expect(result[0]!.key).toBe("Don't & won't stop"); + }); + + test("multiline text around a Var matches compiler collapsing", () => { + // dom-expressions strips the leading whitespace of continuation lines, + // so there is NO space between the {0} slot and "more text". + const code = ` + Hello {name()} + more text + `; + const result = extractStringsFromSource(code, "test.tsx"); + expect(result[0]!.key).toBe("Hello {0}more text"); + }); + + test("member expressions become placeholders", () => { + const code = `Hi {user.name} welcome`; + const result = extractStringsFromSource(code, "test.tsx"); + expect(result[0]!.key).toBe("Hi {0} welcome"); + }); + + test("fragments are processed inline", () => { + const code = `x<>yzw`; + const result = extractStringsFromSource(code, "test.tsx"); + expect(result[0]!.key).toBe("xy{0}w"); + }); + + test("pure-placeholder without id is skipped", () => { + const code = `{greeting()}`; + const result = extractStringsFromSource(code, "test.tsx"); + expect(result).toHaveLength(0); + }); + + // ------------------------------------------------------------------------- + // Warnings + // ------------------------------------------------------------------------- + + test("warns and skips with a bare-identifier child", () => { + // The compiler inlines bare identifiers raw — a string value would merge + // into the runtime key, so the key cannot be known statically. + const warnings: ExtractWarning[] = []; + const code = `const el = Hello {x} end;`; + const result = extractStringsFromSource(code, "test.tsx", warnings); + expect(result).toHaveLength(0); + expect(warnings).toHaveLength(1); + expect(warnings[0]!.file).toBe("test.tsx"); + expect(warnings[0]!.line).toBe(1); + expect(warnings[0]!.message).toContain("Var"); + }); + + test("warns and skips with an interpolated template child", () => { + const warnings: ExtractWarning[] = []; + const code = "const el = {`hi ${x}`} end;"; + const result = extractStringsFromSource(code, "test.tsx", warnings); + expect(result).toHaveLength(0); + expect(warnings).toHaveLength(1); + }); + + test("warns on dynamic msg() arguments", () => { + const warnings: ExtractWarning[] = []; + const code = [ + `const a = msg(someVar);`, + "const b = msg(`Hello ${name}`);", + `const c = msg("a" + dynamic);`, + ].join("\n"); + const result = extractStringsFromSource(code, "test.ts", warnings); + expect(result).toHaveLength(0); + expect(warnings).toHaveLength(3); + expect(warnings.map((w) => w.line)).toEqual([1, 2, 3]); + }); + + test("warns on spread props on but still extracts children", () => { + const warnings: ExtractWarning[] = []; + const code = `Spread text`; + const result = extractStringsFromSource(code, "test.tsx", warnings); + expect(result).toHaveLength(1); + expect(result[0]!.key).toBe("Spread text"); + expect(warnings).toHaveLength(1); + expect(warnings[0]!.message).toContain("spread"); + }); + + test("warns and skips with a dynamic id", () => { + const warnings: ExtractWarning[] = []; + const code = `Text`; + const result = extractStringsFromSource(code, "test.tsx", warnings); + expect(result).toHaveLength(0); + expect(warnings).toHaveLength(1); + }); + + test("warns on non-literal forms, extracts literal ones", () => { + const warnings: ExtractWarning[] = []; + const code = ``; + const result = extractStringsFromSource(code, "test.tsx", warnings); + expect(result.map((r) => r.key)).toEqual(["1 item"]); + expect(warnings).toHaveLength(1); + expect(warnings[0]!.message).toContain("Plural"); + }); }); diff --git a/tests/extract.test.ts b/tests/extract.test.ts index 7bf99b4..af3e903 100644 --- a/tests/extract.test.ts +++ b/tests/extract.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from "bun:test"; -import { extractStringsFromSource } from "../src/extract"; +import { extractStringsFromSource, type ExtractWarning } from "../src/extract"; describe("extractStringsFromSource", () => { test("extracts plain text", () => { @@ -21,11 +21,13 @@ describe("extractStringsFromSource", () => { test("extracts multiple strings", () => { const code = ` -
- Welcome - Goodbye - Home -
+ const page = ( +
+ Welcome + Goodbye + Home +
+ ); `; const result = extractStringsFromSource(code, "page.tsx"); expect(result).toHaveLength(3); @@ -33,7 +35,7 @@ describe("extractStringsFromSource", () => { }); test("deduplicates identical strings", () => { - const code = `Hello Hello`; + const code = `const x =
Hello Hello
;`; const result = extractStringsFromSource(code, "test.tsx"); expect(result).toHaveLength(1); }); @@ -45,7 +47,7 @@ describe("extractStringsFromSource", () => { }); test("tracks line numbers", () => { - const code = `line 1\nline 2\nHello\nline 4`; + const code = `const a = 1;\nconst b = 2;\nconst c = Hello;\nconst d = 4;`; const result = extractStringsFromSource(code, "test.tsx"); expect(result[0]!.line).toBe(3); }); @@ -56,7 +58,7 @@ describe("extractStringsFromSource", () => { }); test("ignores non-T components", () => { - const code = `
Hello
World`; + const code = `const x =
HelloWorld
;`; const result = extractStringsFromSource(code, "test.tsx"); expect(result).toHaveLength(0); }); @@ -66,4 +68,111 @@ describe("extractStringsFromSource", () => { const result = extractStringsFromSource(code, "test.tsx"); expect(result[0]!.key).toBe("greeting"); }); + + // ------------------------------------------------------------------------- + // Defects of the old regex extractor + // ------------------------------------------------------------------------- + + test("(a) extracts text with apostrophes", () => { + const code = `Don't save`; + const result = extractStringsFromSource(code, "test.tsx"); + expect(result).toHaveLength(1); + expect(result[0]!.key).toBe("Don't save"); + }); + + test("(a) extracts msg() with apostrophes, escapes, and mixed quotes", () => { + const code = ` + const a = msg("Don't save"); + const b = msg('He said "hi"'); + const c = msg("Line1\\nLine2"); + const d = msg('It\\'s fine'); + `; + const result = extractStringsFromSource(code, "test.ts"); + expect(result.map((r) => r.key)).toEqual([ + "Don't save", + 'He said "hi"', + "Line1\nLine2", + "It's fine", + ]); + }); + + test("(b) extracts msg() with no-substitution template literals", () => { + const code = "const a = msg(`Save changes`);"; + const result = extractStringsFromSource(code, "test.ts"); + expect(result).toHaveLength(1); + expect(result[0]!.key).toBe("Save changes"); + }); + + test("(c) dynamic expression children become ordered placeholders", () => { + const code = `Hello {name()}`; + const result = extractStringsFromSource(code, "test.tsx"); + expect(result).toHaveLength(1); + expect(result[0]!.key).toBe("Hello {0}"); + }); + + test("(d) element children become placeholders, not raw HTML", () => { + const code = `Click
here to continue
`; + const result = extractStringsFromSource(code, "test.tsx"); + expect(result).toHaveLength(1); + expect(result[0]!.key).toBe("Click {0} to continue"); + }); + + test("(e) multiline text collapses like the Solid JSX compiler", () => { + const code = ` + Hello world, + this is multiline + `; + const result = extractStringsFromSource(code, "test.tsx"); + expect(result).toHaveLength(1); + expect(result[0]!.key).toBe("Hello world, this is multiline"); + }); + + test("(f) attributes containing '>' do not corrupt extraction", () => { + const code = ` b }}>Compare`; + const result = extractStringsFromSource(code, "test.tsx"); + expect(result).toHaveLength(1); + expect(result[0]!.key).toBe("Compare"); + }); + + test("(g) self-closing is extracted", () => { + const code = ``; + const result = extractStringsFromSource(code, "test.tsx"); + expect(result).toHaveLength(1); + expect(result[0]!.key).toBe("standalone.key"); + expect(result[0]!.source).toBe(""); + }); + + test("(h) extracts string-literal forms", () => { + const code = ``; + const result = extractStringsFromSource(code, "test.tsx"); + expect(result.map((r) => r.key)).toEqual([ + "No items", + "1 item", + "{n} items", + ]); + }); + + test("(j) ignores and msg() inside comments and string literals", () => { + const code = ` + // Not this one + /* msg("nor this") */ + const s = "also not this"; + const m = 'msg("still no")'; + const real = msg("Real string"); + `; + const result = extractStringsFromSource(code, "test.ts"); + expect(result).toHaveLength(1); + expect(result[0]!.key).toBe("Real string"); + }); + + test("collects a warning on unparseable files", () => { + const warnings: ExtractWarning[] = []; + const result = extractStringsFromSource( + `const = = <<>> not js at all ~~~`, + "broken.ts", + warnings, + ); + expect(result).toHaveLength(0); + expect(warnings.length).toBeGreaterThan(0); + }); }); diff --git a/tests/runtime-parity.test.ts b/tests/runtime-parity.test.ts new file mode 100644 index 0000000..91b0e09 --- /dev/null +++ b/tests/runtime-parity.test.ts @@ -0,0 +1,379 @@ +/** + * Runtime key-parity integration tests. + * + * Each fixture is compiled with the REAL Solid JSX compiler + * (babel-preset-solid / dom-expressions), rendered into happy-dom with a + * translation dictionary keyed by whatever `extractStringsFromSource` + * produced for the very same source — then we assert the translated text + * actually appears. This proves extraction keys and runtime lookup keys + * are identical, end to end. + * + * NOTE: requires the browser export condition (`bun test + * --conditions=browser`, i.e. `bun run test`) so solid-js resolves to its + * client build instead of the server build. + */ +import { describe, test, expect, afterAll } from "bun:test"; +import { mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { extractStringsFromSource } from "../src/extract"; + +// @ts-expect-error — no type declarations shipped +import { transformAsync } from "@babel/core"; +// @ts-expect-error — no type declarations shipped +import presetSolid from "babel-preset-solid"; + +const FIXTURE_DIR = join(import.meta.dir, ".tmp-fixtures"); +mkdirSync(FIXTURE_DIR, { recursive: true }); +afterAll(() => { + rmSync(FIXTURE_DIR, { recursive: true, force: true }); +}); + +let fixtureId = 0; + +/** + * Compile a JSX snippet with babel-preset-solid, render it inside a + * TranslationProvider, and return the container plus the extracted keys. + */ +async function renderFixture( + snippet: string, + options: { + translations: Record>; + locale?: string; + props?: Record; + }, +): Promise<{ + container: HTMLElement; + keys: string[]; + dispose: () => void; +}> { + const source = ` +import { T, Var, Num, Currency, DateTime, Plural, TranslationProvider } from "../../src/index.ts"; + +export default function Fixture(props) { + return ( + +
${snippet}
+
+ ); +} +`; + + const keys = extractStringsFromSource(source, "fixture.tsx").map( + (e) => e.key, + ); + + const compiled = await transformAsync(source, { + presets: [[presetSolid, { generate: "dom", hydratable: false }]], + filename: "fixture.tsx", + }); + + const path = join(FIXTURE_DIR, `fixture-${fixtureId++}.mjs`); + writeFileSync(path, compiled!.code!); + const mod = await import(path); + + const { render } = await import("solid-js/web"); + const container = document.createElement("div"); + document.body.appendChild(container); + + const dispose = render( + () => + mod.default({ + __locale: options.locale ?? "es", + __translations: options.translations, + ...options.props, + }), + container, + ); + + return { + container, + keys, + dispose: () => { + dispose(); + container.remove(); + }, + }; +} + +const out = (c: HTMLElement) => c.querySelector("#out")!.textContent; + +describe("extraction ↔ runtime key parity", () => { + test("(a) text with apostrophes", async () => { + const { container, keys, dispose } = await renderFixture( + `Don't save`, + { translations: { es: { "Don't save": "No guardes" } } }, + ); + expect(keys).toEqual(["Don't save"]); + expect(out(container)).toBe("No guardes"); + dispose(); + }); + + test("(b) msg() key matches its runtime return value", async () => { + // msg() is a runtime no-op: the string itself is the runtime key that + // t() looks up. Extraction of the template literal must yield the same. + const source = "const label = msg(`Save changes`);"; + const keys = extractStringsFromSource(source, "x.ts").map((e) => e.key); + const { msg } = await import("../src/msg"); + expect(keys).toEqual([msg(`Save changes`)]); + }); + + test("(c) dynamic expression children build {0} keys", async () => { + const { container, keys, dispose } = await renderFixture( + `Hello {props.name()}`, + { + translations: { es: { "Hello {0}": "Hola {0}!" } }, + props: { name: () => "Alice" }, + }, + ); + expect(keys).toEqual(["Hello {0}"]); + expect(out(container)).toBe("Hola Alice!"); + dispose(); + }); + + test("(d) element children become slots and stay rendered", async () => { + const { container, keys, dispose } = await renderFixture( + `Click here now`, + { translations: { es: { "Click {0} now": "Cliquez {0} maintenant" } } }, + ); + expect(keys).toEqual(["Click {0} now"]); + expect(out(container)).toBe("Cliquez here maintenant"); + const anchor = container.querySelector("a")!; + expect(anchor.getAttribute("href")).toBe("/docs"); + expect(anchor.textContent).toBe("here"); + dispose(); + }); + + test("(e) multiline text collapses identically on both sides", async () => { + const { container, keys, dispose } = await renderFixture( + ` + Hello world, + this is multiline + `, + { + translations: { + es: { "Hello world, this is multiline": "Hola mundo multilinea" }, + }, + }, + ); + expect(keys).toEqual(["Hello world, this is multiline"]); + expect(out(container)).toBe("Hola mundo multilinea"); + dispose(); + }); + + test("(e) multiline text around a slot (no space before continuation)", async () => { + const { container, keys, dispose } = await renderFixture( + ` + Hello {props.name()} + more text + `, + { + translations: { es: { "Hello {0}more text": "Hola {0} y mas" } }, + props: { name: () => "Bob" }, + }, + ); + expect(keys).toEqual(["Hello {0}more text"]); + expect(out(container)).toBe("Hola Bob y mas"); + dispose(); + }); + + test("(f) attributes containing '>' still translate", async () => { + const { container, keys, dispose } = await renderFixture( + ` 1 ? "y" : "n" }}>Compare`, + { translations: { es: { Compare: "Comparar" } } }, + ); + expect(keys).toEqual(["Compare"]); + expect(out(container)).toBe("Comparar"); + dispose(); + }); + + test("(g) self-closing translates via its id", async () => { + const { container, keys, dispose } = await renderFixture( + ``, + { translations: { es: { "standalone.key": "Autonomo" } } }, + ); + expect(keys).toEqual(["standalone.key"]); + expect(out(container)).toBe("Autonomo"); + dispose(); + }); + + test("Var/Currency slots interpolate and reorder", async () => { + const { container, keys, dispose } = await renderFixture( + `You owe {props.amount()} to {props.name()}`, + { + translations: { + es: { "You owe {0} to {1}": "Le debes {0} a {1}" }, + }, + props: { amount: () => 29.99, name: () => "Alice" }, + }, + ); + expect(keys).toEqual(["You owe {0} to {1}"]); + // Currency renders with the active locale's (es) formatting + expect(out(container)).toBe( + `Le debes ${new Intl.NumberFormat("es", { style: "currency", currency: "USD" }).format(29.99)} a Alice`, + ); + dispose(); + }); + + test("static literal expressions merge into the key on both sides", async () => { + const { container, keys, dispose } = await renderFixture( + `Hi {"there"} friend, you have {5} items`, + { + translations: { + es: { + "Hi there friend, you have 5 items": "Hola amigo, tienes 5 cosas", + }, + }, + }, + ); + expect(keys).toEqual(["Hi there friend, you have 5 items"]); + expect(out(container)).toBe("Hola amigo, tienes 5 cosas"); + dispose(); + }); + + test("HTML entities decode identically on both sides", async () => { + const { container, keys, dispose } = await renderFixture( + `Don't & stop`, + { translations: { es: { "Don't & stop": "No pares" } } }, + ); + expect(keys).toEqual(["Don't & stop"]); + expect(out(container)).toBe("No pares"); + dispose(); + }); + + test("edge whitespace stays out of the key but is preserved in render", async () => { + const { container, keys, dispose } = await renderFixture( + `before padded after`, + { translations: { es: { padded: "relleno" } } }, + ); + expect(keys).toEqual(["padded"]); + expect(out(container)).toBe("before relleno after"); + dispose(); + }); + + test("id + params translate with interpolation", async () => { + const { container, keys, dispose } = await renderFixture( + `Hello {"{{name}}"}`, + { translations: { es: { greeting: "Hola {{name}}" } } }, + ); + expect(keys).toEqual(["greeting"]); + expect(out(container)).toBe("Hola Ana"); + dispose(); + }); + + test("missing translation falls back to source text with slots", async () => { + const { container, keys, dispose } = await renderFixture( + `Hello {props.name()}`, + { translations: { es: {} }, props: { name: () => "Zed" } }, + ); + expect(keys).toEqual(["Hello {0}"]); + expect(out(container)).toBe("Hello Zed"); + dispose(); + }); + + test("(h) forms translate through the dictionary", async () => { + const translations = { + es: { + "No items": "Sin articulos", + "1 item": "1 articulo", + "{n} items": "{n} articulos", + }, + }; + const snippet = (n: number) => + ``; + + const one = await renderFixture(snippet(1), { translations }); + expect(one.keys).toEqual(["No items", "1 item", "{n} items"]); + expect(out(one.container)).toBe("1 articulo"); + one.dispose(); + + const five = await renderFixture(snippet(5), { translations }); + expect(out(five.container)).toBe("5 articulos"); + five.dispose(); + + const zero = await renderFixture(snippet(0), { translations }); + // CLDR: 0 selects "other" in en/es — "zero" is only used by locales + // whose plural rules have a zero category + expect(out(zero.container)).toBe("0 articulos"); + zero.dispose(); + }); + + test(" without a provider renders source content unchanged", async () => { + const source = ` +import { T, Var } from "../../src/index.ts"; +export default function Fixture(props) { + return
Hello {props.name()}!
; +} +`; + const compiled = await transformAsync(source, { + presets: [[presetSolid, { generate: "dom", hydratable: false }]], + filename: "fixture.tsx", + }); + const path = join(FIXTURE_DIR, `fixture-${fixtureId++}.mjs`); + writeFileSync(path, compiled!.code!); + const mod = await import(path); + const { render } = await import("solid-js/web"); + const container = document.createElement("div"); + document.body.appendChild(container); + const dispose = render( + () => mod.default({ name: () => "Ada" }), + container, + ); + expect(out(container)).toBe("Hello Ada!"); + dispose(); + container.remove(); + }); + + test("locale switching re-renders translated content", async () => { + const source = ` +import { T, TranslationProvider, useLocale } from "../../src/index.ts"; + +function Inner() { + const { setLocale } = useLocale(); + return ( + <> +
Good morning
+ + + ); +} + +export default function Fixture(props) { + return ( + + + + ); +} +`; + const keys = extractStringsFromSource(source, "fixture.tsx").map( + (e) => e.key, + ); + expect(keys).toContain("Good morning"); + + const compiled = await transformAsync(source, { + presets: [[presetSolid, { generate: "dom", hydratable: false }]], + filename: "fixture.tsx", + }); + const path = join(FIXTURE_DIR, `fixture-${fixtureId++}.mjs`); + writeFileSync(path, compiled!.code!); + const mod = await import(path); + const { render } = await import("solid-js/web"); + const container = document.createElement("div"); + document.body.appendChild(container); + const dispose = render( + () => + mod.default({ + __translations: { + es: { "Good morning": "Buenos dias" }, + fr: { "Good morning": "Bonjour" }, + }, + }), + container, + ); + expect(out(container)).toBe("Buenos dias"); + (container.querySelector("#switch") as HTMLButtonElement).click(); + expect(out(container)).toBe("Bonjour"); + dispose(); + container.remove(); + }); +}); diff --git a/tsup.config.ts b/tsup.config.ts index 5845f68..8a4a384 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -23,6 +23,7 @@ export default defineConfig([ sourcemap: true, platform: "node", external: ["vite", "ai", "glob"], + noExternal: ["@babel/parser"], }, // CLI entry (node) — npx solid-translate { @@ -32,6 +33,7 @@ export default defineConfig([ sourcemap: false, platform: "node", external: ["ai", "glob", "@ai-sdk/*"], + noExternal: ["@babel/parser"], banner: { js: "#!/usr/bin/env node", },