From ea82c89aa2e109b9432ade83ef967fefa545b0d2 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Tue, 1 Sep 2026 01:54:16 +0200 Subject: [PATCH 1/7] feat(gottext): adopt the gettext brick at 0.3.0 --- gottext/.gitignore | 3 + gottext/CHANGELOG.md | 82 + gottext/LICENSE | 202 +++ gottext/NOTICE | 11 + gottext/README.md | 26 + gottext/eslint.config.js | 57 + gottext/knip.json | 5 + gottext/package.json | 85 + gottext/pnpm-lock.yaml | 3021 +++++++++++++++++++++++++++++++++ gottext/pnpm-workspace.yaml | 8 + gottext/src/build.ts | 8 + gottext/src/catalog.ts | 57 + gottext/src/compile.ts | 48 + gottext/src/display.ts | 29 + gottext/src/errors.ts | 55 + gottext/src/gates.ts | 151 ++ gottext/src/index.ts | 9 + gottext/src/lockfile.ts | 45 + gottext/src/merge.ts | 332 ++++ gottext/src/platform.ts | 241 +++ gottext/src/pot.ts | 215 +++ gottext/src/start.ts | 50 + gottext/src/sync.ts | 205 +++ gottext/src/testing.ts | 14 + gottext/test/build.test.ts | 403 +++++ gottext/test/catalogs.test.ts | 60 + gottext/test/display.test.ts | 33 + gottext/test/errors.test.ts | 111 ++ gottext/test/lockfile.test.ts | 58 + gottext/test/runtime.test.ts | 100 ++ gottext/test/sync.test.ts | 942 ++++++++++ gottext/test/testing.test.ts | 41 + gottext/tsconfig.build.json | 10 + gottext/tsconfig.json | 21 + gottext/vitest.config.ts | 18 + 35 files changed, 6756 insertions(+) create mode 100644 gottext/.gitignore create mode 100644 gottext/CHANGELOG.md create mode 100644 gottext/LICENSE create mode 100644 gottext/NOTICE create mode 100644 gottext/README.md create mode 100644 gottext/eslint.config.js create mode 100644 gottext/knip.json create mode 100644 gottext/package.json create mode 100644 gottext/pnpm-lock.yaml create mode 100644 gottext/pnpm-workspace.yaml create mode 100644 gottext/src/build.ts create mode 100644 gottext/src/catalog.ts create mode 100644 gottext/src/compile.ts create mode 100644 gottext/src/display.ts create mode 100644 gottext/src/errors.ts create mode 100644 gottext/src/gates.ts create mode 100644 gottext/src/index.ts create mode 100644 gottext/src/lockfile.ts create mode 100644 gottext/src/merge.ts create mode 100644 gottext/src/platform.ts create mode 100644 gottext/src/pot.ts create mode 100644 gottext/src/start.ts create mode 100644 gottext/src/sync.ts create mode 100644 gottext/src/testing.ts create mode 100644 gottext/test/build.test.ts create mode 100644 gottext/test/catalogs.test.ts create mode 100644 gottext/test/display.test.ts create mode 100644 gottext/test/errors.test.ts create mode 100644 gottext/test/lockfile.test.ts create mode 100644 gottext/test/runtime.test.ts create mode 100644 gottext/test/sync.test.ts create mode 100644 gottext/test/testing.test.ts create mode 100644 gottext/tsconfig.build.json create mode 100644 gottext/tsconfig.json create mode 100644 gottext/vitest.config.ts diff --git a/gottext/.gitignore b/gottext/.gitignore new file mode 100644 index 0000000..9ebfc2d --- /dev/null +++ b/gottext/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +coverage/ diff --git a/gottext/CHANGELOG.md b/gottext/CHANGELOG.md new file mode 100644 index 0000000..78a80fb --- /dev/null +++ b/gottext/CHANGELOG.md @@ -0,0 +1,82 @@ +# Changelog + +Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +versioning follows [Semantic Versioning](https://semver.org/). While at 0.x, +minor releases may break. Releases are tagged `vX.Y.Z` and publish from CI. + +## [0.3.0] - 2026-08-25 + +### Added + +- `pushTranslations`, which carries every held catalogue of a supported language + to the platform with its fuzzy flags, trimmed to the template so a stale file + revives nothing, and holding back every answer the platform has settled so a + push never overwrites a reviewer. +- `addLanguage` on the platform seam, and the push adds and fills a supported + language the platform does not list yet. +- Upload pacing, one paced retry when the platform answers its rate refusal. +- A fuzzy aware merge, where a reviewed answer settles a fuzzy one and a fuzzy + export never replaces a settled answer, restored answers keeping their flag. +- `unreviewed`, which names the answers still carrying the fuzzy flag. + +### Changed + +- Clearing a fuzzy flag alone now counts as a meaningful change, so a pull + writes an approval home even when the text did not move. + +## [0.2.0] - 2026-08-24 + +### Added + +- `errorText`, which shows a refused answer in the reader's own language, + filling the template its code names from the values the answer carries and + speaking the server's own words when no template fits. The caller supplies + the templates and the words to fall back on, so both stay translatable. +- A `./testing` entry with `resetLocale`, which takes every text domain and the + display locale back to their sources between tests. +- `globCatalogs`, which turns the lazy chunks a bundler globbed into the loader + a catalogue entry wants, reading the locale from each file's own name. + +### Changed + +- `startLocale` shows the default locale from the moment it is asked, so + `displayLocale` and `formatDate` never answer en-US to a consumer whose + sources are written in another locale. + +## [0.1.1] - 2026-08-21 + +### Added + +- `resolvedVersions` and `pinnedVersions`, which read a pnpm lockfile and the + manifests that pin a package, so a repository can gate itself against + resolving two copies of anything that holds module state. + +### Fixed + +- A message sharing its name with an object prototype member, `constructor` and + `toString` among them, no longer crashes the gates or slips past the orphan + check. +- `serializeCatalog` answers readable JSON for a catalogue carrying no metadata + entry, where it used to write the bare word undefined. + +## [0.1.0] - 2026-08-20 + +### Added + +- `startLocale`, which resolves a locale, loads every catalogue in parallel and + sets each under its own text domain before it returns. A catalogue naming no + domain lands under the default domain the WordPress packages read. +- `displayLocale`, `rememberLocale` and `formatDate`, the seam dates and numbers + follow. `formatDate` takes a date or the text a server stored, and the options + bag any real format needs. +- `pot`, which extracts messages through the four gettext call shapes and + optionally through Go markers and templates. The Go walk reads nothing unless + it is given roots. +- `compileCatalog` and `serializeCatalog`, turning PO sources into the JSON a + locale chunk ships, metadata entry first and deterministic. +- `untranslated`, `orphaned` and `mismatched`, the three gates over a + catalogue's health. `mismatched` refuses a translation whose placeholders + its message does not name, bare placeholders included. +- `syncTranslations` and `poeditorAt`, which carry translations home from a + platform. A translation once set is never removed by a sync, and the summary + says how many answers were kept where the platform holds nothing. diff --git a/gottext/LICENSE b/gottext/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/gottext/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/gottext/NOTICE b/gottext/NOTICE new file mode 100644 index 0000000..772ff45 --- /dev/null +++ b/gottext/NOTICE @@ -0,0 +1,11 @@ +gottext +Copyright 2026 The gottext Authors + +This product includes software developed at the Gopherium project +(https://github.com/gopherium). + +Applications built with gottext resolve @wordpress/i18n themselves. That +package is licensed GPL-2.0-or-later with a dual MPL-2.0 grant on +contributions made after 2021. gottext's own source is Apache-2.0 and +declares it as a peer dependency, so it never redistributes it. A bundle +that combines them is conveyed under the terms of the WordPress package. diff --git a/gottext/README.md b/gottext/README.md new file mode 100644 index 0000000..5092256 --- /dev/null +++ b/gottext/README.md @@ -0,0 +1,26 @@ +# gottext + +The translation brick for Gopherium projects. It carries the gettext +pipeline an application needs to speak more than one language: a +runtime that loads compiled catalogs per text domain, a build layer +that extracts messages into a POT template, compiles PO catalogs to +JSON and gates their health, and a sync layer that exchanges +translations with POEditor without ever removing one. + +## Install + +```sh +pnpm add @gopherium/gottext @wordpress/i18n +``` + +The build entry extracts messages from your sources, which needs one +more package as a development dependency: + +```sh +pnpm add -D gettext-extractor +``` + +## License + +Apache-2.0. See NOTICE for how the GPL licensed runtime peer is +handled. diff --git a/gottext/eslint.config.js b/gottext/eslint.config.js new file mode 100644 index 0000000..9a84048 --- /dev/null +++ b/gottext/eslint.config.js @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: Apache-2.0 + +import jsdoc from 'eslint-plugin-jsdoc' +import sonarjs from 'eslint-plugin-sonarjs' +import tsdoc from 'eslint-plugin-tsdoc' +import tseslint from 'typescript-eslint' + +export default [ + { + ignores: ['dist/**', 'coverage/**'], + }, + { + files: ['**/*.{ts,js}'], + languageOptions: { + parser: tseslint.parser, + }, + rules: { + 'max-len': ['error', { code: 120, tabWidth: 1, ignoreUrls: true }], + }, + }, + { + files: ['src/**/*.ts'], + languageOptions: { + parser: tseslint.parser, + }, + plugins: { sonarjs }, + rules: { + complexity: ['error', 10], + 'sonarjs/cognitive-complexity': ['error', 15], + }, + }, + { + files: ['src/**/*.ts'], + languageOptions: { + parser: tseslint.parser, + }, + settings: { jsdoc: { mode: 'typescript' } }, + plugins: { jsdoc, tsdoc }, + rules: { + 'tsdoc/syntax': 'error', + 'jsdoc/require-jsdoc': [ + 'error', + { + require: { FunctionDeclaration: true, MethodDefinition: true }, + exemptEmptyConstructors: true, + }, + ], + 'jsdoc/require-param': [ + 'error', + { checkDestructured: false, checkDestructuredRoots: false }, + ], + 'jsdoc/require-param-description': 'error', + 'jsdoc/require-returns': 'error', + 'jsdoc/require-returns-description': 'error', + }, + }, +] diff --git a/gottext/knip.json b/gottext/knip.json new file mode 100644 index 0000000..1638f56 --- /dev/null +++ b/gottext/knip.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://unpkg.com/knip@6/schema.json", + "entry": ["src/index.ts", "src/build.ts", "src/sync.ts"], + "project": ["src/**/*.ts"] +} diff --git a/gottext/package.json b/gottext/package.json new file mode 100644 index 0000000..e939d52 --- /dev/null +++ b/gottext/package.json @@ -0,0 +1,85 @@ +{ + "name": "@gopherium/gottext", + "version": "0.3.0", + "description": "The translation brick for Gopherium projects: gettext runtime, extraction and gates, and POEditor sync.", + "license": "Apache-2.0", + "type": "module", + "packageManager": "pnpm@11.10.0", + "repository": { + "type": "git", + "url": "git+https://github.com/gopherium/framework.git", + "directory": "gottext" + }, + "keywords": [ + "i18n", + "gettext", + "translations", + "poeditor", + "gopherium" + ], + "engines": { + "node": ">=22" + }, + "files": [ + "CHANGELOG.md", + "dist", + "NOTICE" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./build": { + "types": "./dist/build.d.ts", + "default": "./dist/build.js" + }, + "./sync": { + "types": "./dist/sync.d.ts", + "default": "./dist/sync.js" + }, + "./testing": { + "types": "./dist/testing.d.ts", + "default": "./dist/testing.js" + }, + "./package.json": "./package.json" + }, + "scripts": { + "build": "rm -rf dist && tsc -p tsconfig.build.json", + "cover": "vitest run --coverage", + "lint": "oxlint && eslint .", + "typecheck": "tsc --noEmit", + "prepublishOnly": "pnpm run build" + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "gettext-parser": "^9.1.1" + }, + "peerDependencies": { + "@wordpress/i18n": ">=6.26.0 <7.0.0", + "gettext-extractor": ">=4.0.6 <5.0.0" + }, + "peerDependenciesMeta": { + "gettext-extractor": { + "optional": true + } + }, + "devDependencies": { + "@types/gettext-parser": "^9.0.0", + "@types/node": "^26.1.2", + "@vitest/coverage-v8": "^4.1.10", + "@wordpress/i18n": "6.26.0", + "eslint": "^10.7.0", + "eslint-plugin-jsdoc": "^63.2.0", + "eslint-plugin-sonarjs": "^4.2.0", + "eslint-plugin-tsdoc": "^0.5.2", + "gettext-extractor": "4.0.6", + "knip": "^6.31.0", + "oxlint": "^1.71.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.65.0", + "vitest": "^4.1.10" + } +} diff --git a/gottext/pnpm-lock.yaml b/gottext/pnpm-lock.yaml new file mode 100644 index 0000000..b08c242 --- /dev/null +++ b/gottext/pnpm-lock.yaml @@ -0,0 +1,3021 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + gettext-parser: + specifier: ^9.1.1 + version: 9.1.1 + devDependencies: + '@types/gettext-parser': + specifier: ^9.0.0 + version: 9.0.0 + '@types/node': + specifier: ^26.1.2 + version: 26.2.0 + '@vitest/coverage-v8': + specifier: ^4.1.10 + version: 4.1.11(vitest@4.1.11) + '@wordpress/i18n': + specifier: 6.26.0 + version: 6.26.0 + eslint: + specifier: ^10.7.0 + version: 10.8.1(jiti@2.7.0) + eslint-plugin-jsdoc: + specifier: ^63.2.0 + version: 63.3.3(eslint@10.8.1(jiti@2.7.0)) + eslint-plugin-sonarjs: + specifier: ^4.2.0 + version: 4.2.0(eslint@10.8.1(jiti@2.7.0)) + eslint-plugin-tsdoc: + specifier: ^0.5.2 + version: 0.5.2(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3) + gettext-extractor: + specifier: 4.0.6 + version: 4.0.6 + knip: + specifier: ^6.31.0 + version: 6.32.2 + oxlint: + specifier: ^1.71.0 + version: 1.79.0 + typescript: + specifier: ~6.0.2 + version: 6.0.3 + typescript-eslint: + specifier: ^8.65.0 + version: 8.67.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3) + vitest: + specifier: ^4.1.10 + version: 4.1.11(@types/node@26.2.0)(@vitest/coverage-v8@4.1.11)(vite@8.2.1(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0)) + +packages: + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@emnapi/core@1.11.2': + resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} + + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@es-joy/jsdoccomment@0.91.0': + resolution: {integrity: sha512-vgqlMGNNhZxwDYbUNIHj3Hskb4R28iqdXx90ufHyt/NeuTQkeqjTDslAs9I0/GCAfbxP5BpH5WsL1R1fht5Lxg==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@es-joy/resolve.exports@1.2.0': + resolution: {integrity: sha512-Q9hjxWI5xBM+qW2enxfe8wDKdFWMfd0Z29k5ZJnuBqD/CasY5Zryj09aCA6owbGATWz+39p5uIdaHXpopOcG8g==} + engines: {node: '>=10'} + + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.7.0': + resolution: {integrity: sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.2': + resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@isaacs/cliui@9.0.0': + resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} + engines: {node: '>=18'} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@microsoft/tsdoc-config@0.18.1': + resolution: {integrity: sha512-9brPoVdfN9k9g0dcWkFeA7IH9bbcttzDJlXvkf8b2OBzd5MueR1V2wkKBL0abn0otvmkHJC6aapBOTJDDeMCZg==} + + '@microsoft/tsdoc@0.16.0': + resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} + + '@napi-rs/wasm-runtime@1.2.3': + resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + + '@oxc-parser/binding-android-arm-eabi@0.143.0': + resolution: {integrity: sha512-n9uozULWflPqBtdmI8lAabLqGKNgLVNN0ZH8HfgCwpKGNtzRzauB76jTiW/3YLkcA7N1zskpi9GdVnZuu1SAvg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxc-parser/binding-android-arm64@0.143.0': + resolution: {integrity: sha512-9BbdjHETk6O3zH/DDid9IgBtF0GlpLabNKN231uraXpRDSfY+iiZxTP5bk1Z63GBownVdhdINFIeddmMz4MzpQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxc-parser/binding-darwin-arm64@0.143.0': + resolution: {integrity: sha512-gh+6ecoHUy4/sUcolBl/1qPXKBbYNxFY0Pk0ujgQvINTMSftJY7o4yb8gOkDJPeZeB8+a+u7xTe6umoP8N5HFA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-parser/binding-darwin-x64@0.143.0': + resolution: {integrity: sha512-qd1hl2d+lXgHv/VQ/M9qm8TrMC5T4RqDBwtOnl+1D0QMjwcz+8AaB4JSg8STgeag0GP6a6L74XEGAsrTSJWNzQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-parser/binding-freebsd-x64@0.143.0': + resolution: {integrity: sha512-M5XXcNa7aOqLPKTR41msfghKu2yQ4xWvCm11/gwU0JzOzHNk5sgW//rVEjJ+LO48+VDAMzXTSzurUVxIDKwozw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-parser/binding-linux-arm-gnueabihf@0.143.0': + resolution: {integrity: sha512-T/GXusuOkPNQhCQCSBbcU/N8j0rAypuDBl1IyFK+lyYT594XsVz80clPC/OtbSSpBGyJxj8uYEfctxVuxVYoww==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm-musleabihf@0.143.0': + resolution: {integrity: sha512-oKu4RcBlXSqo3OC62dp6YTnQaZIurNDpCX3BnAM3+bJxt7s8J2TJKMnC0UYer1qhlRaDCg6wkTaTw+2IlsZ12w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm64-gnu@0.143.0': + resolution: {integrity: sha512-WJBbD186AZmMGaSIhlktC+rPl8L3peCTXAh88Ih9uEvK0en2mPojGyCGYiL6mHtV1RPV3JyfJW5t6n5hh0lXhA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-arm64-musl@0.143.0': + resolution: {integrity: sha512-t1AcYOwEzgceadT4v5e+vaCCb0AncCA3v5AyzfBAz/tMq11qzVccXKzNHtkWdjBsgvTKwRkaUF3QvT4kot8vcQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-ppc64-gnu@0.143.0': + resolution: {integrity: sha512-RsnO/NoD8376LMJq8JS8TwI0ieNaFRTuNe2GVJntQg6gwZNMENZsEbknHdVwjpOmxdGLGodcwaGSbAeRr5Bgjw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-gnu@0.143.0': + resolution: {integrity: sha512-48fSVfR9TZi5CASZFyv0VC6z6BCoeihFsX031mAD/oSH7d9PYsPgIqza7d9mjP7Z2KTEpTFyH6SIu0Ui6R1vdg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-musl@0.143.0': + resolution: {integrity: sha512-T8CpdD+SfE01DnIOD4HpVxu0ZJOfMJ/VhCvikKfaXAxkZ+9veyLM/D2hpi7Y2hFUyPmVQO3FNZHmYzV/WlVR4g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-s390x-gnu@0.143.0': + resolution: {integrity: sha512-QLdeMsCcacenPEFsfxnBUDF1y6opyz5+fmOz9bfD5Y7fiGCMupUCuB3KTPQhNwshIG1P9fPqar9MHxuBDd4bwQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-gnu@0.143.0': + resolution: {integrity: sha512-659ujfqLy6k7cuH3sbzhd8b+ztSq+i6E2E9pG78Q0BmHjAExfGIdgc8cGgMdwAozDXeZFHkJ+LXYJdWsaGdgyw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-musl@0.143.0': + resolution: {integrity: sha512-/Mw/9j4TfZcnKphPrzOE6t4MMknXadcAAuVUlDRTF/ETWB5xOgQvOJV2Mh9We/bWxZdoxaGAdc+hy4GuYwQ2yQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-openharmony-arm64@0.143.0': + resolution: {integrity: sha512-8rIKWR2BFuifbIK/1XB9wTaSdtuJ25dlE7ZQYDnEwj/2xH2vHsxnvIjHT3ZjSVuLLwGGlSslIG/fbOJ8TV8rTw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-parser/binding-win32-arm64-msvc@0.143.0': + resolution: {integrity: sha512-5U9kQYMfRRI6Zq7KDxgbIP0RMnKrfn3gLepRMgJuRkPSUALTiRCk9d/uyhb4lGDjUdzwK7mBkKqhLgzBPCmLpQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-ia32-msvc@0.143.0': + resolution: {integrity: sha512-25P7AaHk4R88Yv2XH4gToDVmh0cOu+bEURQU10CRrmvgabfRArSGAP5osmwUKeSUHj0VS50upbpbRWWW/m7mHA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxc-parser/binding-win32-x64-msvc@0.143.0': + resolution: {integrity: sha512-ORMh3JE1s6V7ySicdRK7vgaDQnn5o+UHg9ct989PlWHbel8O9ARrmWXM6kZjrBMtNucxNayQ8g69G0VfWzhANw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxc-project/types@0.143.0': + resolution: {integrity: sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==} + + '@oxc-project/types@0.146.0': + resolution: {integrity: sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==} + + '@oxc-resolver/binding-android-arm-eabi@11.24.2': + resolution: {integrity: sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==} + cpu: [arm] + os: [android] + + '@oxc-resolver/binding-android-arm64@11.24.2': + resolution: {integrity: sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg==} + cpu: [arm64] + os: [android] + + '@oxc-resolver/binding-darwin-arm64@11.24.2': + resolution: {integrity: sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w==} + cpu: [arm64] + os: [darwin] + + '@oxc-resolver/binding-darwin-x64@11.24.2': + resolution: {integrity: sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q==} + cpu: [x64] + os: [darwin] + + '@oxc-resolver/binding-freebsd-x64@11.24.2': + resolution: {integrity: sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw==} + cpu: [x64] + os: [freebsd] + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': + resolution: {integrity: sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': + resolution: {integrity: sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': + resolution: {integrity: sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-arm64-musl@11.24.2': + resolution: {integrity: sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': + resolution: {integrity: sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': + resolution: {integrity: sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': + resolution: {integrity: sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': + resolution: {integrity: sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-x64-gnu@11.24.2': + resolution: {integrity: sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-x64-musl@11.24.2': + resolution: {integrity: sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-resolver/binding-openharmony-arm64@11.24.2': + resolution: {integrity: sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg==} + cpu: [arm64] + os: [openharmony] + + '@oxc-resolver/binding-wasm32-wasi@11.24.2': + resolution: {integrity: sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': + resolution: {integrity: sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg==} + cpu: [arm64] + os: [win32] + + '@oxc-resolver/binding-win32-x64-msvc@11.24.2': + resolution: {integrity: sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw==} + cpu: [x64] + os: [win32] + + '@oxlint/binding-android-arm-eabi@1.79.0': + resolution: {integrity: sha512-TebFaaMklO/RXzTv7PucaCq9l3X6D1gA+C8H6K4njtjFOV+zWE9MKLpulcJZN9bzytbUbQIY0mZuz12nQ5Kv4Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.79.0': + resolution: {integrity: sha512-KqqnOtAVgNsPPF0YSodkFZA1O80jcKoCZCTu3bgsszxA+MrMP9TLzfXitKjEj1FmrPprKDMdRDMmY3weESO9sg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.79.0': + resolution: {integrity: sha512-BVC2nsMzqQzRDPc5RhixkZ+m1p7iH4bxRRvqkbwDXX0PlQKm1BPy8J8cRjnAFafOq2QzI+BfO3vE8w2GZ3CBag==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.79.0': + resolution: {integrity: sha512-p6Lm+snmhGuLKL1+CpCV8L6ijkE/qJzK2H2jG9+eKJT0n31RbY4FLsdhexekgP3bLpw4Kgde+9DZuDZQ4yIInA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.79.0': + resolution: {integrity: sha512-qDMm0dXZnoHyRqSL4N4xUq82T4sqK5cbKSjvd/dF/YbMUXc2R1wEPf+vmA5S0qUmi0nwXfNbjXBtZaIqzQLIMg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.79.0': + resolution: {integrity: sha512-2od7s0nuKPzqyUZAWk9KkCyGg7eI9dwFPZg+20lB15fKFkVZ0c9ZFxqPfiBAyDTlTkh9stPI0t+JlPCqMbItVA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.79.0': + resolution: {integrity: sha512-ZOQUjkzDnvlhSE3+tWC3YXx94MMl+sYMlwH+u1+YGApGHOJP/YAc8ZBRFOXZ6eOBmxtXAWuS/fBcdZr8qqNO1A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.79.0': + resolution: {integrity: sha512-lu158FR4nGqGeRS3BQvtG85wRgU/Fy4MD5Cxp1hzJXizGiLo6u2742wJSCDKh8cFcZntvX7fcxlq4mMmfryH1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-arm64-musl@1.79.0': + resolution: {integrity: sha512-mbpKQeE2aflTjddaHK7MP8KP/OFbUM++lt5M635ENM8IyIdK0jm2t9pb+2v9mVVIvhF6TqA4l7F79Pll1mi+uw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-ppc64-gnu@1.79.0': + resolution: {integrity: sha512-WpGNua7gaxaHnpSDeog2ji8IDHn/QLPl9LPzwkR/FvVv58vT5BcXjRXnU+wbu3N75cpeha8CdC7ho/U2OIsB4g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-gnu@1.79.0': + resolution: {integrity: sha512-tK1E93A5LVzISg4ngpKJnfTs7EqtIUceGI7MQ4GyDjJiLi8wPCkEyKlj2xkyKWZ1yzkDJyLHTBJ5/iFWRdnJvg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-musl@1.79.0': + resolution: {integrity: sha512-qhQvUIrngXivA2A9pQ+xPCychztn/5qUv7yS3gDwXv3w7Rag+eTeeXWmRyx+t7XsW5x6LuY/8AsTq36UgFIblg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-s390x-gnu@1.79.0': + resolution: {integrity: sha512-sv6AaVgU/eE6u+6WFiQVDcPPwTxP6IJMSB9k701W2r/r6Tx465e8vPvVyRxquNH4Vy6KwRNu90mVbxXJN8+5gg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-gnu@1.79.0': + resolution: {integrity: sha512-iFZL02deziHslb3jEX9KdqlAkYoo4fGyotchKDzdfK1f5mxlIBeiQeHhvK3iFpuEJSB4ma/qeFn9oxPiwnhUPQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-musl@1.79.0': + resolution: {integrity: sha512-3DtZR2raqObnh7wXZoFYFd0Fw7skBvcb3f7A+/lkEiDuh8hrE6vv9b/62Qxao1a9/OeHLw/FcXlXzgsW9wTRFg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxlint/binding-openharmony-arm64@1.79.0': + resolution: {integrity: sha512-Oatt4GuA1WJkqzk2ozx4HrWROOi7opV3AKDw/U8qDIqeTqzsjn5K2x3REJMNjU3/KU/Bkq96Zi3CknaiDTaC/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.79.0': + resolution: {integrity: sha512-NAgZr9Qp8nIA9rpo0JEvwiabTF/2UVqBNnupBG9X4kxXcQoScJUTi+qHhvabb9s/thgj5wQ4XcIaJvb+ZMgoKw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.79.0': + resolution: {integrity: sha512-+KyXjIvcpaXmWW/j9NNY5yWjrIVxaX18VyIheQy3jwc2GSYgpCr7MGI/HxIGQ/shAL5IWEKbhsqoMpAO5Stiog==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.79.0': + resolution: {integrity: sha512-mEelcCMMBS57sIXh2veGMNy+pQwuGtcMxHxGIZWQ5Ba9pJ5jCCUFOZB9E2JhBaxGsURe+WGe0zJp4RVre52gpQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/binding-android-arm-eabi@1.2.5': + resolution: {integrity: sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.5': + resolution: {integrity: sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.5': + resolution: {integrity: sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.5': + resolution: {integrity: sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.5': + resolution: {integrity: sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.5': + resolution: {integrity: sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.5': + resolution: {integrity: sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.5': + resolution: {integrity: sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.5': + resolution: {integrity: sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.5': + resolution: {integrity: sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.5': + resolution: {integrity: sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.5': + resolution: {integrity: sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.5': + resolution: {integrity: sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.5': + resolution: {integrity: sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.5': + resolution: {integrity: sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@sindresorhus/base62@1.0.0': + resolution: {integrity: sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA==} + engines: {node: '>=18'} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tannin/compile@1.1.0': + resolution: {integrity: sha512-n8m9eNDfoNZoxdvWiTfW/hSPhehzLJ3zW7f8E7oT6mCROoMNWCB4TYtv041+2FMAxweiE0j7i1jubQU4MEC/Gg==} + + '@tannin/evaluate@1.2.0': + resolution: {integrity: sha512-3ioXvNowbO/wSrxsDG5DKIMxC81P0QrQTYai8zFNY+umuoHWRPbQ/TuuDEOju9E+jQDXmj6yI5GyejNuh8I+eg==} + + '@tannin/plural-forms@1.1.0': + resolution: {integrity: sha512-xl9R2mDZO/qiHam1AgMnAES6IKIg7OBhcXqy6eDsRCdXuxAFPcjrej9HMjyCLE0DJ/8cHf0i5OQTstuBRhpbHw==} + + '@tannin/postfix@1.1.0': + resolution: {integrity: sha512-oocsqY7g0cR+Gur5jRQLSrX2OtpMLMse1I10JQBm8CdGMrDkh1Mg2gjsiquMHRtBs4Qwu5wgEp5GgIYHk4SNPw==} + + '@tannin/sprintf@1.3.3': + resolution: {integrity: sha512-RwARl+hFwhzy0tg9atWcchLFvoQiOh4rrP7uG2N5E4W80BPCUX0ElcUR9St43fxB9EfjsW2df9Qp+UsTbvQDjA==} + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/gettext-parser@9.0.0': + resolution: {integrity: sha512-LpZ+LeNa/tiFTxouKLkoyXVFF7JChu32YcZfnu+k54NfmlwlIiR017o3r+2yyB1FLg6LDzbF/UyH+AfhqffmXA==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@26.2.0': + resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==} + + '@types/parse5@5.0.3': + resolution: {integrity: sha512-kUNnecmtkunAoQ3CnjmMkzNU/gtxG8guhi+Fk2U/kOpIKjIMKnXGp4IJCgQJrXSgMsWYimYG4TGjz/UzbGEBTw==} + + '@typescript-eslint/eslint-plugin@8.67.0': + resolution: {integrity: sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.67.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.67.0': + resolution: {integrity: sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.56.1': + resolution: {integrity: sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/project-service@8.67.0': + resolution: {integrity: sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.56.1': + resolution: {integrity: sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/scope-manager@8.67.0': + resolution: {integrity: sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.56.1': + resolution: {integrity: sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/tsconfig-utils@8.67.0': + resolution: {integrity: sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.67.0': + resolution: {integrity: sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.56.1': + resolution: {integrity: sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/types@8.67.0': + resolution: {integrity: sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.56.1': + resolution: {integrity: sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/typescript-estree@8.67.0': + resolution: {integrity: sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.56.1': + resolution: {integrity: sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/utils@8.67.0': + resolution: {integrity: sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.56.1': + resolution: {integrity: sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/visitor-keys@8.67.0': + resolution: {integrity: sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitest/coverage-v8@4.1.11': + resolution: {integrity: sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==} + peerDependencies: + '@vitest/browser': 4.1.11 + vitest: 4.1.11 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@4.1.11': + resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} + + '@vitest/mocker@4.1.11': + resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.11': + resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} + + '@vitest/runner@4.1.11': + resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} + + '@vitest/snapshot@4.1.11': + resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} + + '@vitest/spy@4.1.11': + resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} + + '@vitest/utils@4.1.11': + resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} + + '@wordpress/hooks@4.53.0': + resolution: {integrity: sha512-YU30rweJCTu5g7X8bc+w6QyoxJBYLEHH++Wwo/IbbROHUC/4I7e+ZtxnPWls8BTtMP4d28JJ74s+UoKpzd2J7w==} + engines: {node: '>=18.12.0', npm: '>=8.19.2'} + + '@wordpress/i18n@6.26.0': + resolution: {integrity: sha512-v/LzeB7T5YKIfBgmLM0jYG8zOuu81biZ90j8RY9pHIVu+jdRE+DSmt55S3FvtXRo5J2X9FoigFRzYPKXLbitWA==} + engines: {node: '>=18.12.0', npm: '>=8.19.2'} + hasBin: true + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + + are-docs-informative@0.0.2: + resolution: {integrity: sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==} + engines: {node: '>=14'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-v8-to-istanbul@1.0.5: + resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + + builtin-modules@3.3.0: + resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==} + engines: {node: '>=6'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + comment-parser@1.4.7: + resolution: {integrity: sha512-0h+uSNtQGW3D98eQt3jJ8L06Fves8hncB4V/PKdw/Qb8Hnk19VaKuTr55UNRYiSoVa7WwrFls+rh3ux9agmkeQ==} + engines: {node: '>= 12.0.0'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-selector-parser@1.4.1: + resolution: {integrity: sha512-HYPSb7y/Z7BNDCOrakL4raGO2zltZkbeXyAd6Tg9obzix6QhzxCotdBl6VT0Dv4vZfJGVz3WL/xaEI9Ly3ul0g==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + encoding@0.1.13: + resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-plugin-jsdoc@63.3.3: + resolution: {integrity: sha512-xI4IeVRzRFA2DGHrPLIxF3U+oJHU3FE+P9Zb27fVs5dPHgfcpoAs0PyCbznVhK7pwR+9BPUztFeXSgpw/CL4Yg==} + engines: {node: ^22.13.0 || >=24} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 + + eslint-plugin-sonarjs@4.2.0: + resolution: {integrity: sha512-bqADfuNtTL7VK6RU29eoiFTtaaBKIpVPuX3bOl+rBpWSBa0zIBVZlqZNZQjfP6s4iXkAJokv5IsD8OsACkwApg==} + peerDependencies: + eslint: ^8.0.0 || ^9.0.0 || ^10.0.0 + + eslint-plugin-tsdoc@0.5.2: + resolution: {integrity: sha512-BlvqjWZdBJDIPO/YU3zcPCF23CvjYT3gyu63yo6b609NNV3D1b6zceAREy2xnweuBoDpZcLNuPyAUq9cvx6bbQ==} + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.8.1: + resolution: {integrity: sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-uri@3.1.5: + resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} + + fd-package-json@2.0.0: + resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + formatly@0.3.0: + resolution: {integrity: sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==} + engines: {node: '>=18.3.0'} + hasBin: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + functional-red-black-tree@1.0.1: + resolution: {integrity: sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==} + + get-tsconfig@4.14.1: + resolution: {integrity: sha512-Dz/6HxkrxgNehhxLVeyv8sad9UzF2xBVeaKBQNDfJ5XiSXmp2gTR0eO0RWiT2NCKS5aGP9jjkOMggTN90qU50A==} + + gettext-extractor@4.0.6: + resolution: {integrity: sha512-V06SFy/KuWLRL+szfr+rleAnLdzUet/wfLlCo4d2qJ8yF0essGdxX5jBqpCx69TB5U6E3jIC9lZtpvnMkNSCTw==} + engines: {node: '>=20'} + + gettext-parser@1.4.0: + resolution: {integrity: sha512-sedZYLHlHeBop/gZ1jdg59hlUEcpcZJofLq2JFwJT1zTqAU3l2wFv6IsuwFHGqbiT9DWzMUW4/em2+hspnmMMA==} + + gettext-parser@9.1.1: + resolution: {integrity: sha512-ZLeqWPz9OMNrTgMuww0C22kkcNqis+e4059R94t7L7ERlZ2rUNpiDbaAUus+esBC6uBAQWbS9N+R5vJIJg//lw==} + engines: {node: '>=20'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@11.1.0: + resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} + engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + globals@17.11.0: + resolution: {integrity: sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==} + engines: {node: '>=18'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + html-entities@2.6.0: + resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} + engines: {node: '>= 4'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + jackspeak@4.2.3: + resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} + engines: {node: 20 || >=22} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + jju@1.4.0: + resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + + jsdoc-type-pratt-parser@8.0.0: + resolution: {integrity: sha512-uQu/fXVqVaMg6gM8/E5G5+eygVcZ1NV0Z51CvqhNa2bDWxvHMl484ETr6vph4oPyC+KUcbP/w2W2pewfCiR9aQ==} + engines: {node: '>=20.0.0'} + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + jsx-ast-utils-x@0.1.0: + resolution: {integrity: sha512-eQQBjBnsVtGacsG9uJNB8qOr3yA8rga4wAaGG1qRcBzSIvfhERLrWxMAM1hp5fcS6Abo8M4+bUBTekYR0qTPQw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + knip@6.32.2: + resolution: {integrity: sha512-WXTXbmocrw7gqm1A1TQvFN0OgJ7hUSU6E1g6SPRIzzHFogUBhXByc7cYeOFVtJ2uODg7DP4VbESYBYnfbtBYsg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.5.4: + resolution: {integrity: sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + object-deep-merge@2.0.1: + resolution: {integrity: sha512-aKttDKcU3pyZqKcCkDhsMn70WmZFG2JGDQLP9EcLyTSIFQRCPWLAmBZRLJnrVUrhPG1jETEEbfdgbNtJf1LyMg==} + + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + oxc-parser@0.143.0: + resolution: {integrity: sha512-ov0NzaDCOInknS7mP1cwKdJERt3utPW8ldjtdUXQ8Ty0GEFD08wk422vCUN0d7pST6kqtV7dxoI9w1Zi0l/9TA==} + engines: {node: ^20.19.0 || >=22.12.0} + + oxc-resolver@11.24.2: + resolution: {integrity: sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==} + + oxlint@1.79.0: + resolution: {integrity: sha512-hVJ9hq9m2unPS+Of4eJJgCPdIeCC+3DHEUX3tkmrPJr3OK2hz7PhXwgC+ZP71ZcYu8cCDEtQrqLxWNvxBppBVg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=7.0.2001' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + parse-imports-exports@0.2.4: + resolution: {integrity: sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==} + + parse-statements@1.0.11: + resolution: {integrity: sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==} + + parse5@6.0.1: + resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pofile@1.0.11: + resolution: {integrity: sha512-Vy9eH1dRD9wHjYt/QqXcTz+RnX/zg53xK+KljFSX30PvdDMb2z+c6uDUeblUGqqJgz3QFsdlA0IJvHziPmWtQg==} + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + refa@0.12.1: + resolution: {integrity: sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + regexp-ast-analysis@0.7.1: + resolution: {integrity: sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + reserved-identifiers@1.2.0: + resolution: {integrity: sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==} + engines: {node: '>=18'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + rolldown@1.2.5: + resolution: {integrity: sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + scslre@0.3.0: + resolution: {integrity: sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==} + engines: {node: ^14.0.0 || >=16.0.0} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + smol-toml@1.8.0: + resolution: {integrity: sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==} + engines: {node: '>= 18'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + spdx-exceptions@2.5.0: + resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} + + spdx-expression-parse@5.0.0: + resolution: {integrity: sha512-vngmw3Rgn+o2arXNbnZaj5UtOEBuWBfvaI+Wc8GFfykIhA5/vdK9/Sp/XkLv63dykz2rxKDvKEHupF5P0FORcQ==} + + spdx-license-ids@3.0.23: + resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + strip-json-comments@5.0.3: + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} + engines: {node: '>=14.16'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + tannin@1.2.0: + resolution: {integrity: sha512-U7GgX/RcSeUETbV7gYgoz8PD7Ni4y95pgIP/Z6ayI3CfhSujwKEBlGFTCRN+Aqnuyf4AN2yHL+L8x+TCGjb9uA==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + + to-valid-identifier@1.0.0: + resolution: {integrity: sha512-41wJyvKep3yT2tyPqX/4blcfybknGB4D+oETKLs7Q76UiPqRpUJK3hr1nxelyYO0PHKVzJwlu0aCeEAsGI6rpw==} + engines: {node: '>=20'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.67.0: + resolution: {integrity: sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + unbash@4.0.10: + resolution: {integrity: sha512-b7zoBQvpWp0vuN5q2vK2RRBR2SvuruQAs50DApdDveBSn3eSYd84IaHodFqQIMlvY9K2VnyBUEXgwOBuGU9GBg==} + engines: {node: '>=14'} + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + vite@8.2.1: + resolution: {integrity: sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.11: + resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.11 + '@vitest/browser-preview': 4.1.11 + '@vitest/browser-webdriverio': 4.1.11 + '@vitest/coverage-istanbul': 4.1.11 + '@vitest/coverage-v8': 4.1.11 + '@vitest/ui': 4.1.11 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + walk-up-path@4.0.0: + resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} + engines: {node: 20 || >=22} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@1.0.2': {} + + '@emnapi/core@1.11.2': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@es-joy/jsdoccomment@0.91.0': + dependencies: + '@types/estree': 1.0.9 + '@typescript-eslint/types': 8.67.0 + comment-parser: 1.4.7 + esquery: 1.7.0 + jsdoc-type-pratt-parser: 8.0.0 + + '@es-joy/resolve.exports@1.2.0': {} + + '@eslint-community/eslint-utils@4.10.1(eslint@10.8.1(jiti@2.7.0))': + dependencies: + eslint: 10.8.1(jiti@2.7.0) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3 + minimatch: 10.2.6 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.7.0': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.7.2': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@isaacs/cliui@9.0.0': {} + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@microsoft/tsdoc-config@0.18.1': + dependencies: + '@microsoft/tsdoc': 0.16.0 + ajv: 8.18.0 + jju: 1.4.0 + resolve: 1.22.12 + + '@microsoft/tsdoc@0.16.0': {} + + '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@oxc-parser/binding-android-arm-eabi@0.143.0': + optional: true + + '@oxc-parser/binding-android-arm64@0.143.0': + optional: true + + '@oxc-parser/binding-darwin-arm64@0.143.0': + optional: true + + '@oxc-parser/binding-darwin-x64@0.143.0': + optional: true + + '@oxc-parser/binding-freebsd-x64@0.143.0': + optional: true + + '@oxc-parser/binding-linux-arm-gnueabihf@0.143.0': + optional: true + + '@oxc-parser/binding-linux-arm-musleabihf@0.143.0': + optional: true + + '@oxc-parser/binding-linux-arm64-gnu@0.143.0': + optional: true + + '@oxc-parser/binding-linux-arm64-musl@0.143.0': + optional: true + + '@oxc-parser/binding-linux-ppc64-gnu@0.143.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-gnu@0.143.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-musl@0.143.0': + optional: true + + '@oxc-parser/binding-linux-s390x-gnu@0.143.0': + optional: true + + '@oxc-parser/binding-linux-x64-gnu@0.143.0': + optional: true + + '@oxc-parser/binding-linux-x64-musl@0.143.0': + optional: true + + '@oxc-parser/binding-openharmony-arm64@0.143.0': + optional: true + + '@oxc-parser/binding-win32-arm64-msvc@0.143.0': + optional: true + + '@oxc-parser/binding-win32-ia32-msvc@0.143.0': + optional: true + + '@oxc-parser/binding-win32-x64-msvc@0.143.0': + optional: true + + '@oxc-project/types@0.143.0': {} + + '@oxc-project/types@0.146.0': {} + + '@oxc-resolver/binding-android-arm-eabi@11.24.2': + optional: true + + '@oxc-resolver/binding-android-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-darwin-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-darwin-x64@11.24.2': + optional: true + + '@oxc-resolver/binding-freebsd-x64@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-x64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-x64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-openharmony-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-wasm32-wasi@11.24.2': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + optional: true + + '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': + optional: true + + '@oxc-resolver/binding-win32-x64-msvc@11.24.2': + optional: true + + '@oxlint/binding-android-arm-eabi@1.79.0': + optional: true + + '@oxlint/binding-android-arm64@1.79.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.79.0': + optional: true + + '@oxlint/binding-darwin-x64@1.79.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.79.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.79.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.79.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.79.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.79.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.79.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.79.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.79.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.79.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.79.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.79.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.79.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.79.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.79.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.79.0': + optional: true + + '@rolldown/binding-android-arm-eabi@1.2.5': + optional: true + + '@rolldown/binding-android-arm64@1.2.5': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.5': + optional: true + + '@rolldown/binding-darwin-x64@1.2.5': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.5': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.5': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.5': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.5': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.5': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.5': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.5': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.5': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.5': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.5': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.5': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@sindresorhus/base62@1.0.0': {} + + '@standard-schema/spec@1.1.0': {} + + '@tannin/compile@1.1.0': + dependencies: + '@tannin/evaluate': 1.2.0 + '@tannin/postfix': 1.1.0 + + '@tannin/evaluate@1.2.0': {} + + '@tannin/plural-forms@1.1.0': + dependencies: + '@tannin/compile': 1.1.0 + + '@tannin/postfix@1.1.0': {} + + '@tannin/sprintf@1.3.3': {} + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/esrecurse@4.3.1': {} + + '@types/estree@1.0.9': {} + + '@types/gettext-parser@9.0.0': + dependencies: + '@types/node': 26.2.0 + + '@types/json-schema@7.0.15': {} + + '@types/node@26.2.0': + dependencies: + undici-types: 8.3.0 + + '@types/parse5@5.0.3': {} + + '@typescript-eslint/eslint-plugin@8.67.0(@typescript-eslint/parser@8.67.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.67.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.67.0 + '@typescript-eslint/type-utils': 8.67.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/utils': 8.67.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.67.0 + eslint: 10.8.1(jiti@2.7.0) + ignore: 7.0.6 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.67.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.67.0 + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/typescript-estree': 8.67.0(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.67.0 + debug: 4.4.3 + eslint: 10.8.1(jiti@2.7.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.56.1(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@6.0.3) + '@typescript-eslint/types': 8.56.1 + debug: 4.4.3 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.67.0(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@6.0.3) + '@typescript-eslint/types': 8.67.0 + debug: 4.4.3 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.56.1': + dependencies: + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/visitor-keys': 8.56.1 + + '@typescript-eslint/scope-manager@8.67.0': + dependencies: + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/visitor-keys': 8.67.0 + + '@typescript-eslint/tsconfig-utils@8.56.1(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + + '@typescript-eslint/tsconfig-utils@8.67.0(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + + '@typescript-eslint/type-utils@8.67.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/typescript-estree': 8.67.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.67.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3) + debug: 4.4.3 + eslint: 10.8.1(jiti@2.7.0) + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.56.1': {} + + '@typescript-eslint/types@8.67.0': {} + + '@typescript-eslint/typescript-estree@8.56.1(typescript@6.0.3)': + dependencies: + '@typescript-eslint/project-service': 8.56.1(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@6.0.3) + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/visitor-keys': 8.56.1 + debug: 4.4.3 + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/typescript-estree@8.67.0(typescript@6.0.3)': + dependencies: + '@typescript-eslint/project-service': 8.67.0(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@6.0.3) + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/visitor-keys': 8.67.0 + debug: 4.4.3 + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.56.1(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.1(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.56.1 + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/typescript-estree': 8.56.1(typescript@6.0.3) + eslint: 10.8.1(jiti@2.7.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.67.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.1(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.67.0 + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/typescript-estree': 8.67.0(typescript@6.0.3) + eslint: 10.8.1(jiti@2.7.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.56.1': + dependencies: + '@typescript-eslint/types': 8.56.1 + eslint-visitor-keys: 5.0.1 + + '@typescript-eslint/visitor-keys@8.67.0': + dependencies: + '@typescript-eslint/types': 8.67.0 + eslint-visitor-keys: 5.0.1 + + '@vitest/coverage-v8@4.1.11(vitest@4.1.11)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.11 + ast-v8-to-istanbul: 1.0.5 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.4 + obug: 2.1.4 + std-env: 4.2.0 + tinyrainbow: 3.1.1 + vitest: 4.1.11(@types/node@26.2.0)(@vitest/coverage-v8@4.1.11)(vite@8.2.1(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0)) + + '@vitest/expect@4.1.11': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.11(vite@8.2.1(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.11 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.2.1(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0) + + '@vitest/pretty-format@4.1.11': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.11': + dependencies: + '@vitest/utils': 4.1.11 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + '@vitest/utils': 4.1.11 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.11': {} + + '@vitest/utils@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + + '@wordpress/hooks@4.53.0': {} + + '@wordpress/i18n@6.26.0': + dependencies: + '@tannin/sprintf': 1.3.3 + '@wordpress/hooks': 4.53.0 + gettext-parser: 1.4.0 + tannin: 1.2.0 + + acorn-jsx@5.3.2(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + + acorn@8.18.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.5 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + are-docs-informative@0.0.2: {} + + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@1.0.5: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + + balanced-match@4.0.4: {} + + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + + builtin-modules@3.3.0: {} + + bytes@3.1.2: {} + + chai@6.2.2: {} + + comment-parser@1.4.7: {} + + content-type@1.0.5: {} + + convert-source-map@2.0.0: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-selector-parser@1.4.1: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + detect-libc@2.1.2: {} + + encoding@0.1.13: + dependencies: + iconv-lite: 0.6.3 + + es-errors@1.3.0: {} + + es-module-lexer@2.3.2: {} + + escape-string-regexp@4.0.0: {} + + eslint-plugin-jsdoc@63.3.3(eslint@10.8.1(jiti@2.7.0)): + dependencies: + '@es-joy/jsdoccomment': 0.91.0 + '@es-joy/resolve.exports': 1.2.0 + are-docs-informative: 0.0.2 + comment-parser: 1.4.7 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint: 10.8.1(jiti@2.7.0) + espree: 11.2.0 + esquery: 1.7.0 + html-entities: 2.6.0 + object-deep-merge: 2.0.1 + parse-imports-exports: 0.2.4 + semver: 7.8.5 + spdx-expression-parse: 5.0.0 + to-valid-identifier: 1.0.0 + transitivePeerDependencies: + - supports-color + + eslint-plugin-sonarjs@4.2.0(eslint@10.8.1(jiti@2.7.0)): + dependencies: + '@eslint-community/regexpp': 4.12.2 + builtin-modules: 3.3.0 + bytes: 3.1.2 + eslint: 10.8.1(jiti@2.7.0) + functional-red-black-tree: 1.0.1 + globals: 17.11.0 + jsx-ast-utils-x: 0.1.0 + lodash.merge: 4.6.2 + minimatch: 10.2.6 + scslre: 0.3.0 + semver: 7.8.5 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + yaml: 2.9.0 + + eslint-plugin-tsdoc@0.5.2(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3): + dependencies: + '@microsoft/tsdoc': 0.16.0 + '@microsoft/tsdoc-config': 0.18.1 + '@typescript-eslint/utils': 8.56.1(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3) + transitivePeerDependencies: + - eslint + - supports-color + - typescript + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.8.1(jiti@2.7.0): + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.1(jiti@2.7.0)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.7.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.2 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.6 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.7.0 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + eslint-visitor-keys: 5.0.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + expect-type@1.4.0: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-uri@3.1.5: {} + + fd-package-json@2.0.0: + dependencies: + walk-up-path: 4.0.0 + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.4 + keyv: 4.5.4 + + flatted@3.4.4: {} + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + formatly@0.3.0: + dependencies: + fd-package-json: 2.0.0 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + functional-red-black-tree@1.0.1: {} + + get-tsconfig@4.14.1: + dependencies: + resolve-pkg-maps: 1.0.0 + + gettext-extractor@4.0.6: + dependencies: + '@types/parse5': 5.0.3 + css-selector-parser: 1.4.1 + glob: 11.1.0 + parse5: 6.0.1 + pofile: 1.0.11 + typescript: 5.9.3 + + gettext-parser@1.4.0: + dependencies: + encoding: 0.1.13 + safe-buffer: 5.2.1 + + gettext-parser@9.1.1: + dependencies: + content-type: 1.0.5 + encoding: 0.1.13 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@11.1.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 4.2.3 + minimatch: 10.2.6 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 2.0.2 + + globals@17.11.0: {} + + has-flag@4.0.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + html-entities@2.6.0: {} + + html-escaper@2.0.2: {} + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + ignore@5.3.2: {} + + ignore@7.0.6: {} + + imurmurhash@0.1.4: {} + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jackspeak@4.2.3: + dependencies: + '@isaacs/cliui': 9.0.0 + + jiti@2.7.0: {} + + jju@1.4.0: {} + + js-tokens@10.0.0: {} + + jsdoc-type-pratt-parser@8.0.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + jsx-ast-utils-x@0.1.0: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + knip@6.32.2: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + formatly: 0.3.0 + get-tsconfig: 4.14.1 + jiti: 2.7.0 + oxc-parser: 0.143.0 + oxc-resolver: 11.24.2 + picomatch: 4.0.5 + smol-toml: 1.8.0 + strip-json-comments: 5.0.3 + tinyglobby: 0.2.17 + unbash: 4.0.10 + yaml: 2.9.0 + zod: 4.4.3 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + lru-cache@11.5.2: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.5.4: + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + + minipass@7.1.3: {} + + ms@2.1.3: {} + + nanoid@3.3.18: {} + + natural-compare@1.4.0: {} + + object-deep-merge@2.0.1: {} + + obug@2.1.4: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + oxc-parser@0.143.0: + dependencies: + '@oxc-project/types': 0.143.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.143.0 + '@oxc-parser/binding-android-arm64': 0.143.0 + '@oxc-parser/binding-darwin-arm64': 0.143.0 + '@oxc-parser/binding-darwin-x64': 0.143.0 + '@oxc-parser/binding-freebsd-x64': 0.143.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.143.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.143.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.143.0 + '@oxc-parser/binding-linux-arm64-musl': 0.143.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.143.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.143.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.143.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.143.0 + '@oxc-parser/binding-linux-x64-gnu': 0.143.0 + '@oxc-parser/binding-linux-x64-musl': 0.143.0 + '@oxc-parser/binding-openharmony-arm64': 0.143.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.143.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.143.0 + '@oxc-parser/binding-win32-x64-msvc': 0.143.0 + + oxc-resolver@11.24.2: + optionalDependencies: + '@oxc-resolver/binding-android-arm-eabi': 11.24.2 + '@oxc-resolver/binding-android-arm64': 11.24.2 + '@oxc-resolver/binding-darwin-arm64': 11.24.2 + '@oxc-resolver/binding-darwin-x64': 11.24.2 + '@oxc-resolver/binding-freebsd-x64': 11.24.2 + '@oxc-resolver/binding-linux-arm-gnueabihf': 11.24.2 + '@oxc-resolver/binding-linux-arm-musleabihf': 11.24.2 + '@oxc-resolver/binding-linux-arm64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-arm64-musl': 11.24.2 + '@oxc-resolver/binding-linux-ppc64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-riscv64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-riscv64-musl': 11.24.2 + '@oxc-resolver/binding-linux-s390x-gnu': 11.24.2 + '@oxc-resolver/binding-linux-x64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-x64-musl': 11.24.2 + '@oxc-resolver/binding-openharmony-arm64': 11.24.2 + '@oxc-resolver/binding-wasm32-wasi': 11.24.2 + '@oxc-resolver/binding-win32-arm64-msvc': 11.24.2 + '@oxc-resolver/binding-win32-x64-msvc': 11.24.2 + + oxlint@1.79.0: + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.79.0 + '@oxlint/binding-android-arm64': 1.79.0 + '@oxlint/binding-darwin-arm64': 1.79.0 + '@oxlint/binding-darwin-x64': 1.79.0 + '@oxlint/binding-freebsd-x64': 1.79.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.79.0 + '@oxlint/binding-linux-arm-musleabihf': 1.79.0 + '@oxlint/binding-linux-arm64-gnu': 1.79.0 + '@oxlint/binding-linux-arm64-musl': 1.79.0 + '@oxlint/binding-linux-ppc64-gnu': 1.79.0 + '@oxlint/binding-linux-riscv64-gnu': 1.79.0 + '@oxlint/binding-linux-riscv64-musl': 1.79.0 + '@oxlint/binding-linux-s390x-gnu': 1.79.0 + '@oxlint/binding-linux-x64-gnu': 1.79.0 + '@oxlint/binding-linux-x64-musl': 1.79.0 + '@oxlint/binding-openharmony-arm64': 1.79.0 + '@oxlint/binding-win32-arm64-msvc': 1.79.0 + '@oxlint/binding-win32-ia32-msvc': 1.79.0 + '@oxlint/binding-win32-x64-msvc': 1.79.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + package-json-from-dist@1.0.1: {} + + parse-imports-exports@0.2.4: + dependencies: + parse-statements: 1.0.11 + + parse-statements@1.0.11: {} + + parse5@6.0.1: {} + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.2 + minipass: 7.1.3 + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + pofile@1.0.11: {} + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + punycode@2.3.1: {} + + refa@0.12.1: + dependencies: + '@eslint-community/regexpp': 4.12.2 + + regexp-ast-analysis@0.7.1: + dependencies: + '@eslint-community/regexpp': 4.12.2 + refa: 0.12.1 + + require-from-string@2.0.2: {} + + reserved-identifiers@1.2.0: {} + + resolve-pkg-maps@1.0.0: {} + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + rolldown@1.2.5: + dependencies: + '@oxc-project/types': 0.146.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.5 + '@rolldown/binding-android-arm64': 1.2.5 + '@rolldown/binding-darwin-arm64': 1.2.5 + '@rolldown/binding-darwin-x64': 1.2.5 + '@rolldown/binding-freebsd-x64': 1.2.5 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.5 + '@rolldown/binding-linux-arm64-gnu': 1.2.5 + '@rolldown/binding-linux-arm64-musl': 1.2.5 + '@rolldown/binding-linux-ppc64-gnu': 1.2.5 + '@rolldown/binding-linux-s390x-gnu': 1.2.5 + '@rolldown/binding-linux-x64-gnu': 1.2.5 + '@rolldown/binding-linux-x64-musl': 1.2.5 + '@rolldown/binding-openharmony-arm64': 1.2.5 + '@rolldown/binding-win32-arm64-msvc': 1.2.5 + '@rolldown/binding-win32-x64-msvc': 1.2.5 + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + scslre@0.3.0: + dependencies: + '@eslint-community/regexpp': 4.12.2 + refa: 0.12.1 + regexp-ast-analysis: 0.7.1 + + semver@7.8.5: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + signal-exit@4.1.0: {} + + smol-toml@1.8.0: {} + + source-map-js@1.2.1: {} + + spdx-exceptions@2.5.0: {} + + spdx-expression-parse@5.0.0: + dependencies: + spdx-exceptions: 2.5.0 + spdx-license-ids: 3.0.23 + + spdx-license-ids@3.0.23: {} + + stackback@0.0.2: {} + + std-env@4.2.0: {} + + strip-json-comments@5.0.3: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + tannin@1.2.0: + dependencies: + '@tannin/plural-forms': 1.1.0 + + tinybench@2.9.0: {} + + tinyexec@1.3.0: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinyrainbow@3.1.1: {} + + to-valid-identifier@1.0.0: + dependencies: + '@sindresorhus/base62': 1.0.0 + reserved-identifiers: 1.2.0 + + ts-api-utils@2.5.0(typescript@6.0.3): + dependencies: + typescript: 6.0.3 + + tslib@2.8.1: + optional: true + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.67.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.67.0(@typescript-eslint/parser@8.67.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.67.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.67.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.67.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3) + eslint: 10.8.1(jiti@2.7.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + typescript@6.0.3: {} + + unbash@4.0.10: {} + + undici-types@8.3.0: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + vite@8.2.1(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.26 + rolldown: 1.2.5 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.2.0 + fsevents: 2.3.3 + jiti: 2.7.0 + yaml: 2.9.0 + + vitest@4.1.11(@types/node@26.2.0)(@vitest/coverage-v8@4.1.11)(vite@8.2.1(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@8.2.1(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.2.1(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.2.0 + '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) + transitivePeerDependencies: + - msw + + walk-up-path@4.0.0: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + yaml@2.9.0: {} + + yocto-queue@0.1.0: {} + + zod@4.4.3: {} diff --git a/gottext/pnpm-workspace.yaml b/gottext/pnpm-workspace.yaml new file mode 100644 index 0000000..eacc753 --- /dev/null +++ b/gottext/pnpm-workspace.yaml @@ -0,0 +1,8 @@ +peerDependencyRules: + allowedVersions: + typescript: '6' + +minimumReleaseAgeExclude: + - '@wordpress/i18n@6.26.0' + - 'gettext-extractor@4.0.6' + - 'gettext-parser@9.1.1' diff --git a/gottext/src/build.ts b/gottext/src/build.ts new file mode 100644 index 0000000..adf69c0 --- /dev/null +++ b/gottext/src/build.ts @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: Apache-2.0 + +export { compileCatalog, serializeCatalog } from './compile.js' +export { mismatched, orphaned, unreviewed, untranslated } from './gates.js' +export { pinnedVersions, resolvedVersions } from './lockfile.js' +export { goMessages, goString, messages, pot } from './pot.js' +export type { Found, PotOptions } from './pot.js' +export type { Catalog } from './catalog.js' diff --git a/gottext/src/catalog.ts b/gottext/src/catalog.ts new file mode 100644 index 0000000..b66dbe8 --- /dev/null +++ b/gottext/src/catalog.ts @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** Catalog is one compiled gettext catalogue as setLocaleData consumes it. */ +export type Catalog = Record> + +/** METADATA is the entry carrying a catalogue's headers rather than a message. */ +export const METADATA = '' + +/** Chunks are the lazy catalogue modules a bundler produced, keyed by path. */ +export type Chunks = Record Promise<{ default: Catalog }>> + +/** + * Returns the file name a path ends in, without its extension. + * @param path - The path a bundler keyed a chunk under. + * @returns The stem naming the locale. + */ +function stemOf(path: string): string { + const name = path.slice(path.lastIndexOf('/') + 1) + const dot = name.lastIndexOf('.') + return dot === -1 ? name : name.slice(0, dot) +} + +/** + * Returns the loader answering the catalogue a locale names among the chunks. + * @param chunks - The lazy catalogue modules a bundler produced. + * @returns A loader answering one locale's catalogue, or nothing when none ships. + */ +export function globCatalogs(chunks: Chunks): (locale: string) => Promise { + const byLocale = new Map Promise<{ default: Catalog }>>() + for (const [path, load] of Object.entries(chunks)) { + byLocale.set(stemOf(path), load) + } + return async (locale: string): Promise => { + const load = byLocale.get(locale) + return load === undefined ? undefined : (await load()).default + } +} + +/** + * Returns the entry a table holds under a key, ignoring anything it inherits. + * @param table - The table to read, or nothing when the catalogue lacks it. + * @param key - The key to look under. + * @returns The entry, or nothing when the table holds none of its own. + */ +export function held(table: Record | undefined, key: string): T | undefined { + return table !== undefined && Object.hasOwn(table, key) ? table[key] : undefined +} + +/** + * Returns the key a message waits under, its context and message joined. + * @param context - The context telling two senses of one word apart, if any. + * @param msgid - The source message. + * @returns The lookup key. + */ +export function keyOf(context: string, msgid: string): string { + return context === '' ? msgid : `${context}${msgid}` +} diff --git a/gottext/src/compile.ts b/gottext/src/compile.ts new file mode 100644 index 0000000..c986dc7 --- /dev/null +++ b/gottext/src/compile.ts @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { po } from 'gettext-parser' + +import { METADATA, keyOf, type Catalog } from './catalog.js' + +/** + * Returns the catalogue a PO source compiles to, ready for setLocaleData. + * @param source - The catalogue as PO text. + * @returns The compiled catalogue. + */ +export function compileCatalog(source: string): Catalog { + const parsed = po.parse(source) + const entries: [string, string[]][] = [] + for (const [context, held] of Object.entries(parsed.translations)) { + for (const [msgid, entry] of Object.entries(held)) { + if (msgid !== METADATA) { + entries.push([keyOf(context, msgid), entry.msgstr]) + } + } + } + entries.sort(([left], [right]) => Number(left > right) - Number(left < right)) + const compiled = new Map>() + compiled.set(METADATA, { + lang: parsed.headers.Language ?? '', + 'plural-forms': parsed.headers['Plural-Forms'] ?? '', + }) + for (const [key, msgstr] of entries) { + compiled.set(key, msgstr) + } + return Object.fromEntries(compiled) as Catalog +} + +/** + * Returns the catalogue as the bytes a locale chunk ships, metadata entry first. + * @param catalog - The compiled catalogue. + * @returns The catalogue as JSON text. + */ +export function serializeCatalog(catalog: Catalog): string { + const keys = Object.keys(catalog).filter((held) => held !== METADATA) + keys.sort((left, right) => Number(left > right) - Number(left < right)) + const metadata = catalog[METADATA] ?? {} + const lines = [`${JSON.stringify(METADATA)}:${JSON.stringify(metadata)}`] + for (const key of keys) { + lines.push(`${JSON.stringify(key)}:${JSON.stringify(catalog[key])}`) + } + return `{${lines.join(',')}}\n` +} diff --git a/gottext/src/display.ts b/gottext/src/display.ts new file mode 100644 index 0000000..fd92680 --- /dev/null +++ b/gottext/src/display.ts @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: Apache-2.0 + +let settled = 'en-US' + +/** + * Stores the locale the interface stands in. + * @param locale - The locale the interface settled on. + */ +export function rememberLocale(locale: string): void { + settled = locale +} + +/** + * Returns the locale the interface stands in. + * @returns The settled locale. + */ +export function displayLocale(): string { + return settled +} + +/** + * Returns a moment as a date in the locale the interface stands in. + * @param at - The moment, as a date or as the text a server stored. + * @param options - How much of the date to show. + * @returns The date to show, empty when there is no moment. + */ +export function formatDate(at: Date | string, options?: Intl.DateTimeFormatOptions): string { + return at === '' ? '' : new Date(at).toLocaleDateString(settled, options) +} diff --git a/gottext/src/errors.ts b/gottext/src/errors.ts new file mode 100644 index 0000000..aec42a2 --- /dev/null +++ b/gottext/src/errors.ts @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { sprintf } from '@wordpress/i18n' + +import { held } from './catalog.js' + +/** RefusedAnswer is what a server answered when it turned a request away. */ +export interface RefusedAnswer { + /** message is the server's own prose, empty when it said nothing readable. */ + message: string + /** code is the stable name of the condition, absent when the server named none. */ + code?: string + /** meta carries the named values the condition's message speaks about. */ + meta?: Record +} + +/** PLACEHOLDERS matches the named places a template asks the answer to fill in, as sprintf reads them. */ +const PLACEHOLDERS = + /%\(([$_a-zA-Z][$_a-zA-Z0-9]*)\)[ +0#-]*\d*(?:\.(?:\d+|\*))?(?:ll|[lhqL])?[cduxXefgsp]/g + +/** + * Reports whether the answer carries every value the template names. + * @param template - The message to fill in. + * @param meta - The data the answer carries. + * @returns True when nothing the template asks for is missing. + */ +function filled(template: string, meta: Record): boolean { + for (const match of template.matchAll(PLACEHOLDERS)) { + if (held(meta, match[1]) === undefined) { + return false + } + } + return true +} + +/** + * Returns the message a reader is shown for a refused request, in their own language. + * @param refused - What the server answered. + * @param templates - The translated message each code stands for. + * @param fallback - The translated words to show when the answer says nothing readable. + * @returns The message to show. + */ +export function errorText( + refused: RefusedAnswer, + templates: Record, + fallback: string, +): string { + const spoken = refused.message === '' ? fallback : refused.message + const template = refused.code === undefined ? undefined : held(templates, refused.code) + const meta = refused.meta ?? {} + if (template === undefined || !filled(template, meta)) { + return spoken + } + return sprintf(template, meta as never) +} diff --git a/gottext/src/gates.ts b/gottext/src/gates.ts new file mode 100644 index 0000000..fea7493 --- /dev/null +++ b/gottext/src/gates.ts @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { po } from 'gettext-parser' + +import { METADATA, held, keyOf } from './catalog.js' +import { fuzzyOf } from './merge.js' + +/** + * Returns every key a catalogue carries a filled translation for. + * @param source - The catalogue as PO text. + * @returns The keys answered. + */ +function answered(source: string): Set { + const held = new Set() + for (const [context, entries] of Object.entries(po.parse(source).translations)) { + for (const [msgid, entry] of Object.entries(entries)) { + if (msgid !== METADATA && entry.msgstr.length > 0 && entry.msgstr.every((form) => form !== '')) { + held.add(keyOf(context, msgid)) + } + } + } + return held +} + +/** + * Returns every message of a catalogue that still waits for a translation. + * @param source - The catalogue as PO text. + * @param template - The template naming every message that must be answered. + * @returns The keys still waiting, in the order the template holds them. + */ +export function untranslated(source: string, template: string = source): string[] { + const held = answered(source) + const waiting: string[] = [] + for (const [context, entries] of Object.entries(po.parse(template).translations)) { + for (const msgid of Object.keys(entries)) { + if (msgid !== METADATA && !held.has(keyOf(context, msgid))) { + waiting.push(keyOf(context, msgid)) + } + } + } + return waiting +} + +/** + * Returns every message a catalogue carries that the template does not name. + * @param source - The catalogue as PO text. + * @param template - The template naming every message the catalogue may carry. + * @returns The keys carried without a place in the template. + */ +export function orphaned(source: string, template: string): string[] { + const named = po.parse(template).translations + const carried: string[] = [] + for (const [context, entries] of Object.entries(po.parse(source).translations)) { + for (const msgid of Object.keys(entries)) { + if (msgid !== METADATA && held(named[context], msgid) === undefined) { + carried.push(keyOf(context, msgid)) + } + } + } + return carried +} + +/** + * Returns every message whose answer still carries the fuzzy flag. + * @param source - The catalogue as PO text. + * @returns The keys answered but not yet reviewed. + */ +export function unreviewed(source: string): string[] { + const waiting: string[] = [] + for (const [context, entries] of Object.entries(po.parse(source).translations)) { + for (const [msgid, entry] of Object.entries(entries)) { + if (msgid !== METADATA && fuzzyOf(entry) && entry.msgstr.some((form) => form !== '')) { + waiting.push(keyOf(context, msgid)) + } + } + } + return waiting +} + +/** NAMED is a placeholder naming what goes into it. */ +const NAMED = /%\(([A-Za-z_][A-Za-z0-9_]*)\)[bcdieEfgGosuxX]/g + +/** BARE is a placeholder naming nothing. */ +const BARE = /%(?:\d+\$)?[bcdieEfgGosuxX]/g + +/** + * Returns the placeholders a message names, each once. + * @param message - The message to read. + * @returns The names, in a settled order. + */ +function placeholders(message: string): string[] { + return [...new Set([...message.matchAll(NAMED)].map((found) => found[1]))].sort() +} + +/** + * Returns the placeholders a message carries that name nothing, in a settled order. + * @param message - The message to read. + * @returns The bare placeholders, sorted. + */ +function bare(message: string): string[] { + return [...message.replace(NAMED, '').matchAll(BARE)].map((found) => found[0]).sort() +} + +/** + * Returns whether two placeholder lists hold the same members. + * @param left - The list on the left. + * @param right - The list on the right. + * @returns Whether both hold the same members. + */ +function alike(left: string[], right: string[]): boolean { + return left.length === right.length && left.every((held, at) => held === right[at]) +} + +/** + * Returns whether a translated form answers its message with the placeholders that message names. + * @param form - The translated form. + * @param message - The message the form answers. + * @returns Whether the form matches. + */ +function answersPlaceholders(form: string, message: string): boolean { + if (form === '') { + return true + } + return alike(placeholders(form), placeholders(message)) && alike(bare(form), bare(message)) +} + +/** + * Returns every message whose translation names placeholders its message does not. + * @param source - The catalogue as PO text. + * @param template - The template naming every message and the placeholders it carries. + * @returns The keys whose translation would not render. + */ +export function mismatched(source: string, template: string): string[] { + const carried = po.parse(source).translations + const broken: string[] = [] + for (const [context, entries] of Object.entries(po.parse(template).translations)) { + for (const [msgid, entry] of Object.entries(entries)) { + const answer = held(carried[context], msgid) + if (msgid === METADATA || answer === undefined) { + continue + } + const plural = entry.msgid_plural ?? msgid + const matches = answer.msgstr.every((form, at) => + answersPlaceholders(form, at === 0 ? msgid : plural)) + if (!matches) { + broken.push(keyOf(context, msgid)) + } + } + } + return broken +} diff --git a/gottext/src/index.ts b/gottext/src/index.ts new file mode 100644 index 0000000..1c5e519 --- /dev/null +++ b/gottext/src/index.ts @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: Apache-2.0 + +export { displayLocale, formatDate, rememberLocale } from './display.js' +export { startLocale } from './start.js' +export type { CatalogEntry, LocaleOptions } from './start.js' +export { errorText } from './errors.js' +export type { RefusedAnswer } from './errors.js' +export { globCatalogs } from './catalog.js' +export type { Catalog, Chunks } from './catalog.js' diff --git a/gottext/src/lockfile.ts b/gottext/src/lockfile.ts new file mode 100644 index 0000000..5634224 --- /dev/null +++ b/gottext/src/lockfile.ts @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +/** + * Returns every version of a package the lockfile resolves. + * @param lockfile - The pnpm lockfile as text. + * @param name - The package to look for. + * @returns The resolved versions, sorted. + */ +export function resolvedVersions(lockfile: string, name: string): string[] { + const start = lockfile.indexOf('\npackages:\n') + const end = lockfile.indexOf('\nsnapshots:\n') + const packages = lockfile.slice(start, end === -1 ? undefined : end) + const found = new Set() + for (const line of packages.split('\n')) { + const match = /^ {2}'?(.+)@([^@']+)'?:$/.exec(line) + if (match && match[1] === name) { + found.add(match[2]) + } + } + return [...found].sort() +} + +/** + * Returns the version each package pins a dependency at. + * @param root - The repository root the packages sit under. + * @param packages - The directories holding the manifests to read. + * @param name - The package to look for. + * @returns The pinned versions, one per manifest that declares it. + */ +export function pinnedVersions(root: string, packages: string[], name: string): string[] { + const pinned: string[] = [] + for (const held of packages) { + const manifest = JSON.parse(readFileSync(join(root, held, 'package.json'), 'utf8')) as { + dependencies?: Record + } + const version = manifest.dependencies?.[name] + if (version !== undefined) { + pinned.push(version) + } + } + return pinned +} diff --git a/gottext/src/merge.ts b/gottext/src/merge.ts new file mode 100644 index 0000000..6354e6c --- /dev/null +++ b/gottext/src/merge.ts @@ -0,0 +1,332 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { po } from 'gettext-parser' +import type { GetTextTranslation, GetTextTranslations } from 'gettext-parser' + +import { METADATA, held as ownEntry } from './catalog.js' + +/** COMPILED is how every catalogue this brick writes is laid out. */ +const COMPILED = { foldLength: 0, eol: '\n' } as const + +/** + * Returns the language a platform code names, in the casing a catalogue file uses. + * @param code - The code as the platform writes it. + * @returns The language, with its region upper cased. + */ +export function localeOf(code: string): string { + const [language, region] = code.split('-') + return region === undefined ? language : `${language}-${region.toUpperCase()}` +} + +/** + * Returns the code the platform names a language by, bare when its region repeats it. + * @param locale - The language as a catalogue file names it. + * @returns The code as the platform writes it. + */ +export function platformCodeOf(locale: string): string { + const [language, region] = locale.split('-') + if (region === undefined || region.toLowerCase() === language) { + return language + } + return `${language}-${region.toLowerCase()}` +} + +/** + * Returns the language the site knows a platform code as, if it knows one. + * @param code - The language as the platform names it. + * @param supported - The languages the site answers in. + * @returns The site's own name for it, or nothing when it answers in no such language. + */ +export function localeFor(code: string, supported: string[]): string | undefined { + if (supported.includes(code)) { + return code + } + if (code.includes('-')) { + return undefined + } + const doubled = localeOf(`${code}-${code}`) + return supported.includes(doubled) ? doubled : undefined +} + +/** + * Returns how many messages a catalogue answers. + * @param source - The catalogue as PO text. + * @returns The count of messages carrying a translation. + */ +export function translated(source: string): number { + let held = 0 + for (const entries of Object.values(po.parse(source).translations)) { + for (const [msgid, entry] of Object.entries(entries)) { + if (msgid !== METADATA && entry.msgstr.some((form) => form !== '')) { + held += 1 + } + } + } + return held +} + +/** + * Returns how many translated forms a catalogue carries. + * @param source - The catalogue as PO text. + * @returns The count of answered forms across every message. + */ +export function answeredForms(source: string): number { + let held = 0 + for (const entries of Object.values(po.parse(source).translations)) { + for (const [msgid, entry] of Object.entries(entries)) { + if (msgid !== METADATA) { + held += entry.msgstr.filter((form) => form !== '').length + } + } + } + return held +} + +/** + * Returns an incoming catalogue under the plural rule the committed one declares. + * @param current - The catalogue as committed. + * @param incoming - The catalogue the platform exported. + * @returns The incoming catalogue, carrying the committed plural rule and no form beyond it. + */ +export function withPluralRuleOf(current: string, incoming: string): string { + const rule = po.parse(current).headers['Plural-Forms'] + if (rule === undefined) { + return incoming + } + const held = po.parse(incoming) + held.headers['Plural-Forms'] = rule + const counted = /nplurals\s*=\s*(\d+)/.exec(rule) + const forms = counted === null ? 0 : Number(counted[1]) + if (forms < 1) { + throw new Error('the committed catalogue declares a plural rule naming no usable count') + } + for (const entries of Object.values(held.translations)) { + for (const entry of Object.values(entries)) { + if (entry.msgstr.length > forms) { + entry.msgstr = entry.msgstr.slice(0, forms) + } + } + } + return po.compile(held, COMPILED).toString() +} + +/** + * Returns an incoming catalogue without any message the template does not name. + * @param incoming - The catalogue the platform exported. + * @param template - The template naming every message the catalogue may carry. + * @returns The incoming catalogue, trimmed to the template. + */ +export function namedByTemplate(incoming: string, template: string): string { + const named = po.parse(template).translations + const held = po.parse(incoming) + for (const [context, entries] of Object.entries(held.translations)) { + for (const msgid of Object.keys(entries)) { + if (msgid !== METADATA && ownEntry(named[context], msgid) === undefined) { + delete held.translations[context][msgid] + } + } + } + return po.compile(held, COMPILED).toString() +} + +/** + * Reports whether an entry carries the fuzzy flag. + * @param entry - The entry as parsed. + * @returns Whether the entry is fuzzy. + */ +export function fuzzyOf(entry: GetTextTranslation): boolean { + return /(^|,)\s*fuzzy\s*(,|$)/.test(entry.comments?.flag ?? '') +} + +/** + * Writes whether an entry is fuzzy, leaving its other flags standing. + * @param entry - The entry to mark. + * @param fuzzy - Whether the entry is fuzzy. + */ +function flaggedFuzzy(entry: GetTextTranslation, fuzzy: boolean): void { + const others = (entry.comments?.flag ?? '') + .split(',') + .map((flag) => flag.trim()) + .filter((flag) => flag !== '' && flag !== 'fuzzy') + const flags = fuzzy ? [...others, 'fuzzy'] : others + if (flags.length > 0) { + entry.comments = { ...entry.comments, flag: flags.join(', ') } + return + } + if (entry.comments !== undefined) { + delete entry.comments.flag + } +} + +/** + * Returns the forms a merge keeps and which sides answered them. + * @param preferred - The forms that win wherever they are answered. + * @param fallback - The forms filling what the preferred side holds empty. + * @returns The forms and whether each side supplied any of them. + */ +function mergedForms( + preferred: string[], + fallback: string[], +): { forms: string[], tookPreferred: boolean, tookFallback: boolean } { + const forms: string[] = [] + let tookPreferred = false + let tookFallback = false + for (let at = 0; at < Math.max(preferred.length, fallback.length); at += 1) { + const wanted = preferred[at] ?? '' + if (wanted !== '') { + forms.push(wanted) + tookPreferred = true + continue + } + forms.push(fallback[at] ?? '') + tookFallback = tookFallback || (fallback[at] ?? '') !== '' + } + return { forms, tookPreferred, tookFallback } +} + +/** + * Writes one committed answer into a catalogue that arrived without every settled form of it. + * @param held - The catalogue the platform exported, as parsed. + * @param context - The context the answer sits under. + * @param msgid - The message the answer belongs to. + * @param entry - The committed answer. + */ +function restoring( + held: GetTextTranslations, + context: string, + msgid: string, + entry: GetTextTranslation, +): void { + const arrived = ownEntry(held.translations[context], msgid) + if (arrived === undefined) { + held.translations[context] ??= {} + held.translations[context][msgid] = entry + return + } + arrived.msgid_plural ??= entry.msgid_plural + const settled = settledForms(entry, arrived) + arrived.msgstr = settled.forms + flaggedFuzzy(arrived, settled.fuzzy) +} + +/** + * Returns the merged forms of one answered message and whether they stay fuzzy. + * @param entry - The committed answer. + * @param arrived - The answer the platform exported. + * @returns The forms to keep and the fuzzy state they carry. + */ +function settledForms( + entry: GetTextTranslation, + arrived: GetTextTranslation, +): { forms: string[], fuzzy: boolean } { + const ourFuzzy = fuzzyOf(entry) + const theirFuzzy = fuzzyOf(arrived) + const oursFirst = theirFuzzy && !ourFuzzy + const preferred = oursFirst ? entry.msgstr : arrived.msgstr + const fallback = oursFirst ? arrived.msgstr : entry.msgstr + const merged = mergedForms(preferred, fallback) + const preferredFuzzy = oursFirst ? ourFuzzy : theirFuzzy + const fallbackFuzzy = oursFirst ? theirFuzzy : ourFuzzy + return { + forms: merged.forms, + fuzzy: (merged.tookPreferred && preferredFuzzy) || (merged.tookFallback && fallbackFuzzy), + } +} + +/** + * Returns an incoming catalogue restoring every committed answer it lost. + * @param current - The catalogue as committed. + * @param incoming - The catalogue the platform exported. + * @param template - The template naming every message the catalogue may carry. + * @returns The incoming catalogue, keeping each committed form the export holds empty or lacks. + */ +export function keepingAnswers(current: string, incoming: string, template: string): string { + const named = po.parse(template).translations + const ours = po.parse(current).translations + const held = po.parse(incoming) + for (const [context, entries] of Object.entries(ours)) { + for (const [msgid, entry] of Object.entries(entries)) { + const wanted = msgid !== METADATA && ownEntry(named[context], msgid) !== undefined + if (wanted && entry.msgstr.some((form) => form !== '')) { + restoring(held, context, msgid, entry) + } + } + } + return po.compile(held, COMPILED).toString() +} + +/** + * Returns a catalogue without the messages an export already answers and nobody may overwrite. + * @param source - The catalogue as committed. + * @param exported - The catalogue the platform exported. + * @returns The catalogue, holding only what the platform has not settled. + */ +export function withoutSettled(source: string, exported: string): string { + const theirs = po.parse(exported).translations + const held = po.parse(source) + for (const [context, entries] of Object.entries(held.translations)) { + for (const [msgid, entry] of Object.entries(entries)) { + const arrived = ownEntry(theirs[context], msgid) + if (msgid === METADATA || arrived === undefined || fuzzyOf(arrived)) { + continue + } + if (arrived.msgstr.length > 0 && arrived.msgstr.every((form) => form !== '')) { + delete held.translations[context][msgid] + continue + } + entry.msgstr = entry.msgstr.map((form, at) => arrived.msgstr[at] || form) + } + } + return po.compile(held, COMPILED).toString() +} + +/** Answers is what one language's export carries, keyed by context and message. */ +type Answers = Record> + +/** + * Returns the translations a catalogue holds, without the headers an export restamps. + * @param source - The catalogue as PO text. + * @returns The translations with their fuzzy state, keyed by context and message. + */ +function answersOf(source: string): Answers { + const held: Answers = {} + for (const [context, entries] of Object.entries(po.parse(source).translations).sort()) { + held[context] = {} + for (const [msgid, entry] of Object.entries(entries).sort()) { + if (msgid !== METADATA) { + held[context][msgid] = { msgstr: entry.msgstr, fuzzy: fuzzyOf(entry) } + } + } + } + return held +} + +/** + * Reports whether an incoming catalogue says anything the current one does not. + * @param current - The catalogue as committed, or nothing when none is committed yet. + * @param incoming - The catalogue the platform exported. + * @returns True when a translation differs, false when only the export stamp moved. + */ +export function meaningfulChange(current: string | undefined, incoming: string): boolean { + if (current === undefined) { + return true + } + return JSON.stringify(answersOf(current)) !== JSON.stringify(answersOf(incoming)) +} + +/** + * Returns how many messages a template names. + * @param source - The template as POT text. + * @returns The count of named messages. + */ +export function namedMessages(source: string): number { + let held = 0 + for (const entries of Object.values(po.parse(source).translations)) { + for (const msgid of Object.keys(entries)) { + if (msgid !== METADATA) { + held += 1 + } + } + } + return held +} diff --git a/gottext/src/platform.ts b/gottext/src/platform.ts new file mode 100644 index 0000000..3b1c170 --- /dev/null +++ b/gottext/src/platform.ts @@ -0,0 +1,241 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { localeOf, namedMessages } from './merge.js' + +/** REQUEST_TIMEOUT is how long any one call to the platform may take. */ +const REQUEST_TIMEOUT = 30_000 + +/** API is where the translation platform answers. */ +const API = 'https://api.poeditor.com/v2' + +/** Answer is what the platform answers a request with. */ +interface Answer { + response: { status: string, code?: string, message?: string } + result?: { languages?: { code: string }[], url?: string, terms?: { deleted?: number } } +} + +/** RATE_LIMITED is the refusal code the platform answers hurried uploads with. */ +const RATE_LIMITED = '4048' + +/** Poeditor is what a repository reads a translation platform through. */ +export interface Poeditor { + languages: () => Promise + exportPo: (locale: string) => Promise + uploadTerms: (source: string) => Promise + uploadTranslations: (locale: string, source: string) => Promise + addLanguage: (locale: string) => Promise +} + +/** Retiring is what a repository retires a platform's absent terms through. */ +export interface Retiring { + retireTerms: (source: string) => Promise +} + +/** PlatformOptions is what reaching one translation platform project needs. */ +export interface PlatformOptions { + /** token is the credential the platform answers to. */ + token: string + /** project is the project the translations live in. */ + project: string + /** domain is the text domain, which names the template the upload carries. */ + domain: string + /** fetched is how a request is sent, the runtime's own by default. */ + fetched?: typeof fetch + /** paced is how many milliseconds separate uploads and precede a rate retry. */ + paced?: number + /** paused is how a wait is spent, the runtime's own timer by default. */ + paused?: (ms: number) => Promise +} + +/** + * Returns the platform's answer, refusing a response that never arrived whole. + * @param response - The answer as it arrived. + * @returns The answer, parsed. + */ +async function answerOf(response: Response): Promise { + if (!response.ok) { + throw new Error(`the translation platform answered ${response.status}`) + } + return (await response.json()) as Answer +} + +/** + * Returns the result an answer carries, refusing anything that is not a success. + * @param answered - The answer, parsed. + * @returns The result the platform answered with. + */ +function resultChecked(answered: Answer): NonNullable { + if (answered.response.status !== 'success') { + throw new Error( + `the translation platform refused: ${answered.response.message ?? 'no reason given'}`, + ) + } + return answered.result ?? {} +} + +/** + * Returns the result a platform answer carries, refusing anything that is not a success. + * @param response - The answer as it arrived. + * @returns The result the platform answered with. + */ +async function resultOf(response: Response): Promise> { + return resultChecked(await answerOf(response)) +} + +/** + * Returns the reader and retirer of one translation platform project. + * @param options - The credential, the project and the domain to reach it under. + * @returns The reader, carrying the retirement its own interface names. + */ +export function poeditorAt(options: PlatformOptions): Poeditor & Retiring { + const fetched = options.fetched ?? fetch + const paced = options.paced ?? 20_000 + const paused = options.paused + ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))) + let uploaded = false + /** + * Returns the values every call carries. + * @returns The credential and the project. + */ + function credentials(): URLSearchParams { + return new URLSearchParams({ api_token: options.token, id: options.project }) + } + /** + * Returns the platform's answer to one call. + * @param path - The call to make. + * @param form - The values the call carries. + * @returns The result the platform answered with. + */ + async function ask( + path: string, + form: URLSearchParams, + ): Promise> { + return resultOf(await fetched(`${API}/${path}`, { + method: 'POST', + body: form, + signal: AbortSignal.timeout(REQUEST_TIMEOUT), + })) + } + /** + * Returns an upload form carrying the credentials and the named file. + * @param updating - What the upload changes on the platform. + * @param filename - The name the file travels under. + * @param source - The file's text. + * @returns The form, ready for the extras one upload adds. + */ + function formFor(updating: string, filename: string, source: string): FormData { + const form = new FormData() + form.set('api_token', options.token) + form.set('id', options.project) + form.set('updating', updating) + form.set('file', new Blob([source]), filename) + return form + } + /** + * Sends one upload form, pacing it behind the last and retrying one rate refusal. + * @param form - The upload to send. + * @returns The result the platform answered with. + */ + async function uploadForm(form: FormData): Promise> { + if (uploaded) { + await paused(paced) + } + uploaded = true + const send = () => fetched(`${API}/projects/upload`, { + method: 'POST', + body: form, + signal: AbortSignal.timeout(REQUEST_TIMEOUT), + }) + const first = await answerOf(await send()) + if (first.response.code !== RATE_LIMITED) { + return resultChecked(first) + } + await paused(paced) + return resultOf(await send()) + } + /** + * Sends the template to the platform, saying whether absent terms retire. + * @param source - The catalogue template as POT text. + * @param retiring - Whether terms the template does not name are deleted. + * @returns The result the platform answered with. + */ + async function sendTemplate( + source: string, + retiring: boolean, + ): Promise> { + const form = formFor('terms', `${options.domain}.pot`, source) + if (retiring) { + form.set('sync_terms', '1') + } + return uploadForm(form) + } + return { + /** + * Returns every language the project carries. + * @returns The languages, named as a catalogue file names them. + */ + languages: async () => { + const held = await ask('languages/list', credentials()) + return (held.languages ?? []).map((named) => localeOf(named.code)) + }, + /** + * Adds to the platform every term the template names, leaving translations alone. + * @param source - The catalogue template as POT text. + */ + uploadTerms: async (source: string) => { + await sendTemplate(source, false) + }, + /** + * Tells the platform a language exists, so a catalogue can follow. + * @param locale - The language to add. + */ + addLanguage: async (locale: string) => { + const form = credentials() + form.set('language', locale.toLowerCase()) + await ask('languages/add', form) + }, + /** + * Sends one language's terms and translations together, fuzzy flags preserved. + * @param locale - The language, named as the platform names it. + * @param source - The catalogue as PO text. + */ + uploadTranslations: async (locale: string, source: string) => { + const form = formFor('terms_translations', `${options.domain}.po`, source) + form.set('overwrite', '1') + form.set('language', locale.toLowerCase()) + await uploadForm(form) + }, + /** + * Deletes from the platform every term the template does not name. + * @param source - The catalogue template as POT text. + * @returns How many terms the platform deleted. + */ + retireTerms: async (source: string) => { + if (namedMessages(source) === 0) { + throw new Error('the template names no messages, so retiring would empty the project') + } + return (await sendTemplate(source, true)).terms?.deleted ?? 0 + }, + /** + * Returns one language's catalogue as the platform exports it. + * @param locale - The language to export. + * @returns The catalogue as PO text. + */ + exportPo: async (locale: string) => { + const form = credentials() + form.set('language', locale.toLowerCase()) + form.set('type', 'po') + const held = await ask('projects/export', form) + if (held.url === undefined) { + throw new Error(`the translation platform named no export for ${locale}`) + } + const downloaded = await fetched(held.url, { + signal: AbortSignal.timeout(REQUEST_TIMEOUT), + }) + if (!downloaded.ok) { + throw new Error(`the export for ${locale} answered ${downloaded.status}`) + } + return downloaded.text() + }, + } +} diff --git a/gottext/src/pot.ts b/gottext/src/pot.ts new file mode 100644 index 0000000..d51c2e9 --- /dev/null +++ b/gottext/src/pot.ts @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { GettextExtractor, JsExtractors } from 'gettext-extractor' +import { po, type GetTextTranslation } from 'gettext-parser' +import { readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' + +/** IGNORED is the set of paths that can never name a source, whatever a consumer ships. */ +const IGNORED = ['**/node_modules/**', '**/*.d.ts', '**/*.test.ts', '**/*.test.tsx'] + +/** NO_COMMENTS is the comment positions the extractor leaves out of the template. */ +const NO_COMMENTS = { otherLineLeading: false, sameLineLeading: false, sameLineTrailing: false } + +/** SKIPPED_DIRS is the directories a source walk skips whole. */ +const SKIPPED_DIRS = new Set(['node_modules', 'dist', 'coverage']) + +/** TEMPLATE_CALL is the shape a Go template asks the translator with. */ +const TEMPLATE_CALL = /\bT\.Get "((?:[^"\\]|\\.)*)"/g + +/** MARKER_CALL is the shape Go source marks a message with. */ +const MARKER_CALL = /\bMsgid\("((?:[^"\\]|\\.)*)"\)/g + +/** Found is a message one of the four gettext calls declared. */ +export interface Found { + /** text is the message as the sources write it. */ + text: string + /** textPlural is the plural form, absent unless the call named one. */ + textPlural: string | null + /** context is what tells two senses of one word apart, absent unless the call named one. */ + context: string | null +} + +/** PotOptions is what building a template needs. */ +export interface PotOptions { + /** domain is the text domain the template carries. */ + domain: string + /** root is the path the source globs resolve against. */ + root: string + /** sources are the globs holding every message, read from the root. */ + sources: string[] + /** ignored are the paths inside those globs that ship no message, read beside the baseline. */ + ignored?: string[] + /** goRoots are the directories a Go walk reads, none by default. */ + goRoots?: string[] +} + +/** + * Returns every file under a directory, skipping installed and built trees. + * @param dir - The directory to walk. + * @returns The files found, as absolute paths. + */ +function sourceFiles(dir: string): string[] { + const held: string[] = [] + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (!SKIPPED_DIRS.has(entry.name)) { + held.push(...sourceFiles(join(dir, entry.name))) + } + continue + } + held.push(join(dir, entry.name)) + } + return held +} + +/** + * Returns the message a captured Go quoted string holds. + * @param raw - The captured text between the quotes. + * @param file - The file the capture came from. + * @returns The message with its escapes resolved. + */ +export function goString(raw: string, file: string): string { + try { + return JSON.parse(`"${raw}"`) as string + } catch { + throw new Error(`the string ${raw} in ${file} holds an escape this extractor cannot read`) + } +} + +/** + * Adds the messages one file declares to the given set. + * @param file - The file to read. + * @param held - The set the messages land in. + */ +function collect(file: string, held: Set): void { + if (file.endsWith('.html')) { + for (const found of readFileSync(file, 'utf8').matchAll(TEMPLATE_CALL)) { + held.add(goString(found[1], file)) + } + return + } + if (!file.endsWith('.go') || file.endsWith('_test.go')) { + return + } + for (const found of readFileSync(file, 'utf8').matchAll(MARKER_CALL)) { + held.add(goString(found[1], file)) + } +} + +/** + * Returns every message the Go side declares, through its markers and its templates. + * @param root - The path the walk starts from. + * @param dirs - The directories to walk. + * @returns The messages found. + */ +export function goMessages(root: string, dirs: string[]): string[] { + const held = new Set() + for (const dir of dirs) { + for (const file of sourceFiles(join(root, dir))) { + collect(file, held) + } + } + return [...held] +} + +/** + * Returns the key an entry is ordered by. + * @param entry - The entry to key. + * @returns The context and message joined. + */ +function keyOf(entry: GetTextTranslation): string { + return `${entry.msgctxt ?? ''} ${entry.msgid}` +} + +/** + * Returns the ordering placing entries by context then message, by code unit. + * @param left - The entry on the left. + * @param right - The entry on the right. + * @returns A negative number when the left entry sorts first. + */ +function byKey(left: GetTextTranslation, right: GetTextTranslation): number { + return Number(keyOf(left) > keyOf(right)) - Number(keyOf(left) < keyOf(right)) +} + +/** + * Returns every translatable message the given sources declare. + * @param options - Where to read the sources from. + * @returns The messages the extractor found. + */ +export function messages(options: PotOptions): Found[] { + const extractor = new GettextExtractor() + const parser = extractor.createJsParser([ + JsExtractors.callExpression(['__', 'i18n.__'], { + arguments: { text: 0 }, + comments: NO_COMMENTS, + }), + JsExtractors.callExpression(['_x', 'i18n._x'], { + arguments: { text: 0, context: 1 }, + comments: NO_COMMENTS, + }), + JsExtractors.callExpression(['_n', 'i18n._n'], { + arguments: { text: 0, textPlural: 1 }, + comments: NO_COMMENTS, + }), + JsExtractors.callExpression(['_nx', 'i18n._nx'], { + arguments: { text: 0, textPlural: 1, context: 3 }, + comments: NO_COMMENTS, + }), + ]) + for (const pattern of options.sources) { + parser.parseFilesGlob(pattern, { + cwd: options.root, + ignore: [...IGNORED, ...(options.ignored ?? [])], + absolute: true, + }) + } + return extractor.getMessages() as Found[] +} + +/** + * Returns the template entry one found message becomes. + * @param message - The message the extractor found. + * @returns The entry the template carries for it. + */ +function entryOf(message: Found): GetTextTranslation { + return { + msgctxt: message.context ?? undefined, + msgid: message.text, + msgid_plural: message.textPlural ?? undefined, + msgstr: message.textPlural ? ['', ''] : [''], + } +} + +/** + * Returns the catalogue template built from the given sources. + * @param options - The domain to carry and where to read the sources from. + * @returns The template as UTF-8 bytes. + */ +export function pot(options: PotOptions): Buffer { + const translations: Record> = {} + for (const text of goMessages(options.root, options.goRoots ?? [])) { + translations[''] ??= {} + translations[''][text] = { msgid: text, msgstr: [''] } + } + for (const message of messages(options)) { + const context = message.context ?? '' + translations[context] ??= {} + translations[context][message.text] = entryOf(message) + } + return po.compile( + { + charset: 'UTF-8', + headers: { + 'Project-Id-Version': options.domain, + 'MIME-Version': '1.0', + 'Content-Type': 'text/plain; charset=UTF-8', + 'Content-Transfer-Encoding': '8bit', + 'Plural-Forms': 'nplurals=2; plural=(n != 1);', + 'X-Domain': options.domain, + }, + translations, + }, + { sort: byKey, foldLength: 0, eol: '\n' }, + ) +} diff --git a/gottext/src/start.ts b/gottext/src/start.ts new file mode 100644 index 0000000..93ffd46 --- /dev/null +++ b/gottext/src/start.ts @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { setLocaleData } from '@wordpress/i18n' + +import type { Catalog } from './catalog.js' +import { rememberLocale } from './display.js' + +/** CatalogEntry names one text domain beside how its catalogue loads. */ +export interface CatalogEntry { + /** domain is the text domain the catalogue answers, the default domain when absent. */ + domain?: string + /** load answers the catalogue for a locale, or nothing when none ships for it. */ + load: (locale: string) => Promise +} + +/** LocaleOptions carries what a start needs beyond its domains. */ +export interface LocaleOptions { + /** defaultLocale is the sources' own locale, shown while the resolver runs and loading no catalogue of its own. */ + defaultLocale?: string +} + +/** + * Resolves the locale, loads every catalogue in parallel, and sets each under its domain. + * @param resolve - Answers the locale the interface should stand in. + * @param entries - The text domains to load. + * @param options - What the start needs beyond its domains. + * @returns The settled locale. + */ +export async function startLocale( + resolve: () => Promise, + entries: CatalogEntry[], + options: LocaleOptions = {}, +): Promise { + if (options.defaultLocale !== undefined) { + rememberLocale(options.defaultLocale) + } + const locale = await resolve() + rememberLocale(locale) + if (locale === options.defaultLocale) { + return locale + } + const loaded = await Promise.all(entries.map((entry) => entry.load(locale))) + entries.forEach((entry, at) => { + const catalog = loaded[at] + if (catalog !== undefined) { + setLocaleData(catalog, entry.domain) + } + }) + return locale +} diff --git a/gottext/src/sync.ts b/gottext/src/sync.ts new file mode 100644 index 0000000..47cbe52 --- /dev/null +++ b/gottext/src/sync.ts @@ -0,0 +1,205 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { + answeredForms, + keepingAnswers, + localeFor, + meaningfulChange, + namedByTemplate, + platformCodeOf, + translated, + withPluralRuleOf, + withoutSettled, +} from './merge.js' +import type { Poeditor } from './platform.js' + +export { + answeredForms, + keepingAnswers, + localeFor, + localeOf, + meaningfulChange, + namedByTemplate, + namedMessages, + translated, + withPluralRuleOf, +} from './merge.js' +export { poeditorAt } from './platform.js' +export type { PlatformOptions, Poeditor, Retiring } from './platform.js' + +/** Catalogues is where the catalogues a sync reads and writes live. */ +export interface Catalogues { + read: (locale: string) => string | undefined + write: (locale: string, source: string) => void +} + +/** Synced is what one sync did, in words fit for a log. */ +export interface Synced { + moved: string[] + skipped: string[] + kept: string[] +} + +/** Pushed is what one push did, in words fit for a log. */ +export interface Pushed { + pushed: string[] + skipped: string[] + added: string[] +} + +/** Matched is one language the platform lists that the site answers in. */ +interface Matched { + named: string + locale: string +} + +/** + * Returns the platform's languages the site answers in, and the words for those it does not. + * @param platform - The translation platform to read. + * @param supported - The languages the site answers in. + * @returns The matched languages and the skip lines for the rest. + */ +async function matchedLanguages( + platform: Poeditor, + supported: string[], +): Promise<{ matched: Matched[], skipped: string[] }> { + const matched: Matched[] = [] + const skipped: string[] = [] + for (const named of await platform.languages()) { + const locale = localeFor(named, supported) + if (locale === undefined) { + skipped.push(`${named}, which the site does not answer in`) + continue + } + matched.push({ named, locale }) + } + return { matched, skipped } +} + +/** + * Carries every supported catalogue the platform does not list yet, adding its language first. + * @param platform - The translation platform to write. + * @param absent - The supported languages the platform does not list, holding a catalogue. + * @param held - Where the catalogues live. + * @param template - The catalogue template naming every message the site shows. + * @param skipped - Where the languages passed over are recorded. + * @returns The languages that were added and pushed. + */ +async function pushingAbsent( + platform: Poeditor, + absent: string[], + held: Catalogues, + template: string, + skipped: string[], +): Promise { + const added: string[] = [] + for (const locale of absent) { + const current = held.read(locale) + if (current === undefined) { + skipped.push(`${locale}, which the repository holds no catalogue for`) + continue + } + const named = platformCodeOf(locale) + await platform.addLanguage(named) + await platform.uploadTranslations(named, namedByTemplate(current, template)) + added.push(locale) + } + return added +} + +/** + * Carries every repository catalogue to the platform for the languages it lists. + * @param platform - The translation platform to write. + * @param supported - The languages the site answers in. + * @param held - Where the catalogues live. + * @param template - The catalogue template naming every message the site shows. + * @returns The languages that were pushed and the ones passed over. + */ +export async function pushTranslations( + platform: Poeditor, + supported: string[], + held: Catalogues, + template: string, +): Promise { + const pushed: string[] = [] + await platform.uploadTerms(template) + const { matched, skipped } = await matchedLanguages(platform, supported) + for (const { named, locale } of matched) { + const current = held.read(locale) + if (current === undefined) { + skipped.push(`${named}, which the repository holds no catalogue for`) + continue + } + const unsettled = withoutSettled( + namedByTemplate(current, template), + await platform.exportPo(named), + ) + if (translated(unsettled) === 0) { + skipped.push(`${named}, which the platform has settled every answer of`) + continue + } + await platform.uploadTranslations(named, unsettled) + pushed.push(locale) + } + const listed = matched.map((held) => held.locale) + const absent = supported.filter((locale) => !listed.includes(locale)) + const added = await pushingAbsent(platform, absent, held, template, skipped) + return { pushed: [...pushed, ...added], skipped, added } +} + +/** + * Returns the catalogue a sync writes and how many committed forms it restored. + * @param current - The catalogue as committed, or nothing when none is committed yet. + * @param exported - The catalogue the platform exported, trimmed to the template. + * @param template - The catalogue template naming every message the site shows. + * @returns The catalogue to write and the count of forms the export had lost. + */ +function merged( + current: string | undefined, + exported: string, + template: string, +): { incoming: string, restored: number } { + if (current === undefined) { + return { incoming: exported, restored: 0 } + } + const clamped = withPluralRuleOf(current, exported) + const incoming = keepingAnswers(current, clamped, template) + return { incoming, restored: answeredForms(incoming) - answeredForms(clamped) } +} + +/** + * Carries every translation the platform holds for a language the site answers in. + * @param platform - The translation platform to read. + * @param supported - The languages the site answers in. + * @param held - Where the catalogues live. + * @param template - The catalogue template naming every message the site shows. + * @returns The languages that moved, the ones passed over, and the answers kept. + */ +export async function syncTranslations( + platform: Poeditor, + supported: string[], + held: Catalogues, + template: string, +): Promise { + const moved: string[] = [] + const kept: string[] = [] + await platform.uploadTerms(template) + const { matched, skipped } = await matchedLanguages(platform, supported) + for (const { named, locale } of matched) { + const exported = namedByTemplate(await platform.exportPo(named), template) + if (translated(exported) === 0) { + skipped.push(`${named}, which nobody has translated yet`) + continue + } + const current = held.read(locale) + const { incoming, restored } = merged(current, exported, template) + if (restored > 0) { + kept.push(`${locale}, keeping ${restored} answered where the platform holds nothing`) + } + if (meaningfulChange(current, incoming)) { + held.write(locale, incoming) + moved.push(locale) + } + } + return { moved, skipped, kept } +} diff --git a/gottext/src/testing.ts b/gottext/src/testing.ts new file mode 100644 index 0000000..307c8da --- /dev/null +++ b/gottext/src/testing.ts @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { resetLocaleData } from '@wordpress/i18n' + +import { rememberLocale } from './display.js' + +/** + * Takes every text domain and the display locale back to their sources between tests. + * @param defaultLocale - The locale to settle back on, en-US absent a choice. + */ +export function resetLocale(defaultLocale = 'en-US'): void { + resetLocaleData({}) + rememberLocale(defaultLocale) +} diff --git a/gottext/test/build.test.ts b/gottext/test/build.test.ts new file mode 100644 index 0000000..69cf33e --- /dev/null +++ b/gottext/test/build.test.ts @@ -0,0 +1,403 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { expect, test } from 'vitest' + +import { + compileCatalog, + goMessages, + goString, + mismatched, + orphaned, + pot, + serializeCatalog, + unreviewed, + untranslated, +} from '../src/build.js' + +/** + * Returns a root holding the given files, each written under it. + * @param files - The files to write, keyed by path under the root. + * @returns The root the files sit under. + */ +function rootWith(files: Record): string { + const root = mkdtempSync(join(tmpdir(), 'gottext-')) + for (const [path, body] of Object.entries(files)) { + const target = join(root, path) + mkdirSync(dirname(target), { recursive: true }) + writeFileSync(target, body) + } + return root +} + +const SOURCES = ['src/**/*.ts'] + +test('names every message the sources declare', () => { + const root = rootWith({ 'src/a.ts': "__('Older posts', 'probe')\n" }) + + const held = pot({ domain: 'probe', root, sources: SOURCES }).toString() + + expect(held).toContain('msgid "Older posts"') +}) + +test('names the context a message stands in', () => { + const root = rootWith({ 'src/a.ts': "_x('Draft', 'status', 'probe')\n" }) + + const held = pot({ domain: 'probe', root, sources: SOURCES }).toString() + + expect(held).toContain('msgctxt "status"') + expect(held).toContain('msgid "Draft"') +}) + +test('names both forms of a plural message', () => { + const root = rootWith({ 'src/a.ts': "_n('%(count)d post', '%(count)d posts', 2, 'probe')\n" }) + + const held = pot({ domain: 'probe', root, sources: SOURCES }).toString() + + expect(held).toContain('msgid_plural "%(count)d posts"') + expect(held).toContain('msgstr[1] ""') +}) + +test('carries the domain it was given in its header', () => { + const root = rootWith({ 'src/a.ts': "__('Older posts', 'probe')\n" }) + + const held = pot({ domain: 'probe', root, sources: SOURCES }).toString() + + expect(held).toContain('X-Domain: probe') + expect(held).toContain('Project-Id-Version: probe') +}) + +test('names its messages in a settled order however the sources are read', () => { + const root = rootWith({ + 'src/b.ts': "__('Zebra', 'probe')\n__('Apple', 'probe')\n", + }) + + const held = pot({ domain: 'probe', root, sources: SOURCES }).toString() + + expect(held.indexOf('msgid "Apple"')).toBeLessThan(held.indexOf('msgid "Zebra"')) +}) + +test('keeps the safety baseline whatever exclusions it is handed', () => { + const root = rootWith({ + 'src/a.ts': "__('Real message', 'probe')\n", + 'src/a.d.ts': "__('Declared message', 'probe')\n", + 'src/a.test.ts': "__('Suite message', 'probe')\n", + 'src/node_modules/thing/v.ts': "__('Vendored message', 'probe')\n", + 'src/keep/x.ts': "__('Kept message', 'probe')\n", + 'src/skip/x.ts': "__('Skipped message', 'probe')\n", + }) + + const held = pot({ domain: 'probe', root, sources: SOURCES, ignored: ['**/skip/**'] }).toString() + + expect(held).toContain('Real message') + expect(held).toContain('Kept message') + expect(held).not.toContain('Skipped message') + expect(held).not.toContain('Declared message') + expect(held).not.toContain('Suite message') + expect(held).not.toContain('Vendored message') +}) + +test('walks no Go roots unless it is given some', () => { + const root = rootWith({ 'src/a.ts': "__('Older posts', 'probe')\n" }) + + expect(() => pot({ domain: 'probe', root, sources: SOURCES })).not.toThrow() +}) + +test('names the messages a Go marker declares', () => { + const root = rootWith({ + 'src/a.ts': "__('Older posts', 'probe')\n", + 'internal/thing.go': 'package thing\n\nvar held = i18n.Msgid("Saved")\n', + }) + + const held = pot({ domain: 'probe', root, sources: SOURCES, goRoots: ['internal'] }).toString() + + expect(held).toContain('msgid "Saved"') +}) + +test('names the messages a Go template asks for', () => { + const root = rootWith({ + 'src/a.ts': "__('Older posts', 'probe')\n", + 'internal/page.html': '

{{T.Get "Read more"}}

\n', + }) + + const held = pot({ domain: 'probe', root, sources: SOURCES, goRoots: ['internal'] }).toString() + + expect(held).toContain('msgid "Read more"') +}) + +test('passes over a Go test file', () => { + const root = rootWith({ + 'src/a.ts': "__('Older posts', 'probe')\n", + 'internal/thing_test.go': 'package thing\n\nvar held = i18n.Msgid("Only in a test")\n', + }) + + const held = pot({ domain: 'probe', root, sources: SOURCES, goRoots: ['internal'] }).toString() + + expect(held).not.toContain('Only in a test') +}) + +test('reads a Go root through the directories nested under it', () => { + const root = rootWith({ + 'src/a.ts': "__('Older posts', 'probe')\n", + 'internal/site/templates/page.html': '

{{T.Get "Read more"}}

\n', + }) + + const held = pot({ domain: 'probe', root, sources: SOURCES, goRoots: ['internal'] }).toString() + + expect(held).toContain('msgid "Read more"') +}) + +test('walks past an installed tree inside a Go root', () => { + const root = rootWith({ + 'src/a.ts': "__('Older posts', 'probe')\n", + 'internal/node_modules/thing/x.go': 'package thing\n\nvar held = Msgid("Vendored")\n', + }) + + const held = pot({ domain: 'probe', root, sources: SOURCES, goRoots: ['internal'] }).toString() + + expect(held).not.toContain('Vendored') +}) + +test('reads the escapes a Go string carries', () => { + expect(goString('a \\"quoted\\" word', 'thing.go')).toBe('a "quoted" word') +}) + +test('refuses a Go string holding an escape it cannot read', () => { + expect(() => goString('a \\q word', 'thing.go')).toThrow(/escape/) +}) + +test('answers the messages a Go root declares', () => { + const root = rootWith({ 'internal/thing.go': 'package thing\n\nvar held = Msgid("Saved")\n' }) + + expect(goMessages(root, ['internal'])).toEqual(['Saved']) +}) + +const CATALOGUE = `msgid "" +msgstr "" +"Language: es-ES\\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\\n" + +msgid "Older posts" +msgstr "Entradas anteriores" +` + +test('compiles a catalogue with its metadata entry first', () => { + const held = compileCatalog(CATALOGUE) + + expect(Object.keys(held)[0]).toBe('') + expect(held['']).toEqual({ lang: 'es-ES', 'plural-forms': 'nplurals=2; plural=(n != 1);' }) + expect(held['Older posts']).toEqual(['Entradas anteriores']) +}) + +test('joins a context to its message in the compiled key', () => { + const source = `msgid "" +msgstr "" + +msgctxt "status" +msgid "Draft" +msgstr "Borrador" +` + + expect(Object.keys(compileCatalog(source))).toContain('statusDraft') +}) + +const MANY = `msgid "" +msgstr "" +"Language: es-ES\\n" + +msgid "Zebra" +msgstr "Cebra" + +msgid "Apple" +msgstr "Manzana" + +msgid "Mango" +msgstr "Mango" +` + +test('compiles its messages in a settled order', () => { + expect(Object.keys(compileCatalog(MANY))).toEqual(['', 'Apple', 'Mango', 'Zebra']) +}) + +test('serializes its messages in a settled order', () => { + const held = serializeCatalog(compileCatalog(MANY)) + + expect(held.indexOf('"Apple"')).toBeLessThan(held.indexOf('"Mango"')) + expect(held.indexOf('"Mango"')).toBeLessThan(held.indexOf('"Zebra"')) +}) + +test('serializes a catalogue with its metadata entry first and a trailing newline', () => { + const held = serializeCatalog(compileCatalog(CATALOGUE)) + + expect(held.startsWith('{"":')).toBe(true) + expect(held.endsWith('}\n')).toBe(true) +}) + +const TEMPLATE = `msgid "" +msgstr "" + +msgid "Older posts" +msgstr "" + +msgid "Newer posts" +msgstr "" +` + +test('names every message still waiting for a translation', () => { + expect(untranslated(CATALOGUE, TEMPLATE)).toEqual(['Newer posts']) +}) + +test('names every message the template no longer carries', () => { + const stale = `${CATALOGUE} +msgid "Retired" +msgstr "Retirado" +` + + expect(orphaned(stale, TEMPLATE)).toEqual(['Retired']) +}) + +test('names a translation whose placeholders its message does not name', () => { + const naming = 'msgid "%(count)d post"\nmsgstr ""\n' + const broken = 'msgid "%(count)d post"\nmsgstr "%(total)d entrada"\n' + + expect(mismatched(broken, naming)).toEqual(['%(count)d post']) +}) + +test('passes a translation naming the placeholders its message names', () => { + const naming = 'msgid "%(count)d post"\nmsgstr ""\n' + const answered = 'msgid "%(count)d post"\nmsgstr "%(count)d entrada"\n' + + expect(mismatched(answered, naming)).toEqual([]) +}) + +test('names a translation carrying a placeholder that names nothing', () => { + const naming = 'msgid "%(count)d post"\nmsgstr ""\n' + const bare = 'msgid "%(count)d post"\nmsgstr "%d entrada"\n' + + expect(mismatched(bare, naming)).toEqual(['%(count)d post']) +}) + +test('passes a translation keeping the bare placeholder its message names', () => { + const naming = 'msgid "Disable %s"\nmsgstr ""\n' + const answered = 'msgid "Disable %s"\nmsgstr "Desactivar a %s"\n' + + expect(mismatched(answered, naming)).toEqual([]) +}) + +test('names a translation dropping the bare placeholder its message names', () => { + const naming = 'msgid "Disable %s"\nmsgstr ""\n' + const dropped = 'msgid "Disable %s"\nmsgstr "Desactivar"\n' + + expect(mismatched(dropped, naming)).toEqual(['Disable %s']) +}) + +test('passes a translation reordering the positional placeholders its message names', () => { + const naming = 'msgid "%1$s of %2$s"\nmsgstr ""\n' + const answered = 'msgid "%1$s of %2$s"\nmsgstr "%2$s de %1$s"\n' + + expect(mismatched(answered, naming)).toEqual([]) +}) + +test('names a message that shares its name with a prototype member', () => { + const naming = 'msgid "Older posts"\nmsgstr ""\n' + const stale = 'msgid ""\nmsgstr ""\n\nmsgid "constructor"\nmsgstr "constructor"\n' + + expect(orphaned(stale, naming)).toEqual(['constructor']) +}) + +test('reads a template naming a prototype member the catalogue does not answer', () => { + const naming = 'msgid ""\nmsgstr ""\n\nmsgid "toString"\nmsgstr ""\n' + const held = 'msgid ""\nmsgstr ""\n' + + expect(() => mismatched(held, naming)).not.toThrow() + expect(mismatched(held, naming)).toEqual([]) +}) + +test('serializes a catalogue carrying no metadata entry as readable JSON', () => { + const held = serializeCatalog({ Saved: ['Guardado'] }) + + expect(() => JSON.parse(held)).not.toThrow() +}) + +test('names a translation answering a named placeholder with a bare one', () => { + const naming = 'msgid "%(field)s declared."\nmsgstr ""\n' + const swapped = 'msgid "%(field)s declared."\nmsgstr "%s declarado."\n' + + expect(mismatched(swapped, naming)).toEqual(['%(field)s declared.']) +}) + +test('passes a message no translation answers yet', () => { + const naming = 'msgid "%(count)d post"\nmsgstr ""\n' + const waiting = 'msgid "%(count)d post"\nmsgstr ""\n' + + expect(mismatched(waiting, naming)).toEqual([]) +}) + +test('reads each plural form against the form it answers', () => { + const naming = `msgid "%(count)d post" +msgid_plural "%(count)d posts" +msgstr[0] "" +msgstr[1] "" +` + const broken = `msgid "%(count)d post" +msgid_plural "%(count)d posts" +msgstr[0] "%(count)d entrada" +msgstr[1] "%(total)d entradas" +` + + expect(mismatched(broken, naming)).toEqual(['%(count)d post']) +}) + +test('passes over the metadata entry rather than reading it as a message', () => { + const naming = 'msgid ""\nmsgstr ""\n\nmsgid "Older posts"\nmsgstr ""\n' + + expect(mismatched(CATALOGUE, naming)).toEqual([]) +}) + +/** REVIEWING is a catalogue holding one settled answer and two fuzzy ones. */ +const REVIEWING = `msgid "" +msgstr "" + +msgid "Older posts" +msgstr "Entradas anteriores" + +#, fuzzy +msgid "Newer posts" +msgstr "Entradas nuevas" + +#, fuzzy +msgctxt "status" +msgid "Draft" +msgstr "Borrador" +` + +test('keeps a fuzzy answer in the compiled catalogue', () => { + const held = compileCatalog(REVIEWING) + + expect(held['Newer posts']).toEqual(['Entradas nuevas']) +}) + +test('counts a fuzzy answer as answered rather than waiting', () => { + const naming = 'msgid "Newer posts"\nmsgstr ""\n' + + expect(untranslated(REVIEWING, naming)).toEqual([]) +}) + +test('names every answer still waiting for review', () => { + expect(unreviewed(REVIEWING)).toEqual(['Newer posts', 'statusDraft']) +}) + +test('does not read an empty fuzzy entry as waiting for review', () => { + const empty = 'msgid "Older posts"\nmsgstr ""\n\n#, fuzzy\nmsgid "Newer posts"\nmsgstr ""\n' + + expect(unreviewed(empty)).toEqual([]) +}) + +test('sees a fuzzy answer through the mismatched gate', () => { + const naming = 'msgid "%(count)d post"\nmsgstr ""\n' + const fuzzyBroken = '#, fuzzy\nmsgid "%(count)d post"\nmsgstr "%(total)d entrada"\n' + + expect(mismatched(fuzzyBroken, naming)).toEqual(['%(count)d post']) +}) diff --git a/gottext/test/catalogs.test.ts b/gottext/test/catalogs.test.ts new file mode 100644 index 0000000..52b92cc --- /dev/null +++ b/gottext/test/catalogs.test.ts @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { expect, test, vi } from 'vitest' + +import { globCatalogs } from '../src/index.js' +import type { Catalog } from '../src/index.js' + +const SPANISH: Catalog = { + '': { lang: 'es-ES', 'plural-forms': 'nplurals=2; plural=(n != 1);' }, + 'Older posts': ['Entradas anteriores'], +} + +const FRENCH: Catalog = { + '': { lang: 'fr-FR', 'plural-forms': 'nplurals=2; plural=(n > 1);' }, + 'Older posts': ['Articles precedents'], +} + +test('answers the catalogue whose file the locale names', async () => { + const load = globCatalogs({ + '../languages/es-ES.json': async () => ({ default: SPANISH }), + '../languages/fr-FR.json': async () => ({ default: FRENCH }), + }) + + expect(await load('es-ES')).toBe(SPANISH) + expect(await load('fr-FR')).toBe(FRENCH) +}) + +test('answers nothing for a locale shipping no catalogue', async () => { + const load = globCatalogs({ '../languages/es-ES.json': async () => ({ default: SPANISH }) }) + + expect(await load('de-DE')).toBeUndefined() +}) + +test('reads the file stem however deep the path runs', async () => { + const load = globCatalogs({ + '/srv/app/frontend/src/languages/editor/es-ES.json': async () => ({ default: SPANISH }), + }) + + expect(await load('es-ES')).toBe(SPANISH) +}) + +test('reads a whole path ending in no extension', async () => { + const load = globCatalogs({ '../languages/es-ES': async () => ({ default: SPANISH }) }) + + expect(await load('es-ES')).toBe(SPANISH) +}) + +test('leaves every other catalogue unread', async () => { + const spanish = vi.fn(async () => ({ default: SPANISH })) + const french = vi.fn(async () => ({ default: FRENCH })) + const load = globCatalogs({ + '../languages/es-ES.json': spanish, + '../languages/fr-FR.json': french, + }) + + await load('es-ES') + + expect(spanish).toHaveBeenCalledOnce() + expect(french).not.toHaveBeenCalled() +}) diff --git a/gottext/test/display.test.ts b/gottext/test/display.test.ts new file mode 100644 index 0000000..aaacdd5 --- /dev/null +++ b/gottext/test/display.test.ts @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { expect, test } from 'vitest' + +import { formatDate, rememberLocale } from '../src/index.js' + +const AT = '2026-07-30T10:00:00Z' + +test('shows nothing for an absent timestamp', () => { + rememberLocale('en-US') + + expect(formatDate('')).toBe('') +}) + +test('shows a stored timestamp in the locale the interface settled on', () => { + rememberLocale('en-US') + const american = formatDate(AT) + rememberLocale('de-DE') + + expect(formatDate(AT)).not.toBe(american) +}) + +test('shows a date it is handed as it shows the same instant given as text', () => { + rememberLocale('en-US') + + expect(formatDate(new Date(AT))).toBe(formatDate(AT)) +}) + +test('follows the options it is handed', () => { + rememberLocale('en-US') + + expect(formatDate(AT, { year: 'numeric' })).toBe('2026') +}) diff --git a/gottext/test/errors.test.ts b/gottext/test/errors.test.ts new file mode 100644 index 0000000..f345a15 --- /dev/null +++ b/gottext/test/errors.test.ts @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { expect, test } from 'vitest' + +import { errorText } from '../src/index.js' + +const TEMPLATES = { + first_out_of_range: 'Ask for between %(min)d and %(max)d at a time.', + name_taken: 'That name is already taken.', + locale_unknown: 'AlphOne does not speak %(wanted)s yet.', +} + +const FALLBACK = 'Something went wrong. Try again.' + +test('fills the template its code names with the data the answer carries', () => { + const shown = errorText( + { message: 'graph: first must be between 1 and 200', code: 'first_out_of_range', meta: { min: 1, max: 200 } }, + TEMPLATES, + FALLBACK, + ) + + expect(shown).toBe('Ask for between 1 and 200 at a time.') +}) + +test('shows a template naming no values as it stands', () => { + const shown = errorText({ message: 'fields: name taken', code: 'name_taken' }, TEMPLATES, FALLBACK) + + expect(shown).toBe('That name is already taken.') +}) + +test('speaks the answer when no template holds its code', () => { + const shown = errorText( + { message: 'the server said something new', code: 'a_code_from_the_future' }, + TEMPLATES, + FALLBACK, + ) + + expect(shown).toBe('the server said something new') +}) + +test('speaks the answer when it names no code at all', () => { + const shown = errorText({ message: 'the server answered 500' }, TEMPLATES, FALLBACK) + + expect(shown).toBe('the server answered 500') +}) + +test('speaks the answer when the template asks for a value the answer lacks', () => { + const shown = errorText( + { message: 'graph: first must be between 1 and 200', code: 'first_out_of_range', meta: { min: 1 } }, + TEMPLATES, + FALLBACK, + ) + + expect(shown).toBe('graph: first must be between 1 and 200') +}) + +test('speaks the answer when the template asks for values and none arrived', () => { + const shown = errorText({ message: 'locale refused', code: 'locale_unknown' }, TEMPLATES, FALLBACK) + + expect(shown).toBe('locale refused') +}) + +test('speaks the answer when a template asking for a decimal lacks its value', () => { + const shown = errorText( + { message: 'the balance is short', code: 'short', meta: { held: 9 } }, + { short: 'You hold %(held).2f of the %(needed).2f asked for.' }, + FALLBACK, + ) + + expect(shown).toBe('the balance is short') +}) + +test('fills a template asking for widths and decimals', () => { + const shown = errorText( + { message: 'raw', code: 'short', meta: { held: 9, needed: 12.5 } }, + { short: 'You hold %(held)05.2f of the %(needed).1f asked for.' }, + FALLBACK, + ) + + expect(shown).toBe('You hold 9.00 of the 12.5 asked for.') +}) + +test('speaks the answer when its code names something every object inherits', () => { + for (const code of ['constructor', 'toString', 'hasOwnProperty']) { + const shown = errorText({ message: 'the server said this', code }, TEMPLATES, FALLBACK) + + expect(shown).toBe('the server said this') + } +}) + +test('speaks the answer when a template names a value every object inherits', () => { + const shown = errorText( + { message: 'the server said this', code: 'inherited', meta: {} }, + { inherited: 'Value is %(constructor)s.' }, + FALLBACK, + ) + + expect(shown).toBe('the server said this') +}) + +test('falls back to the words the caller supplied when the answer says nothing', () => { + const shown = errorText({ message: '' }, TEMPLATES, FALLBACK) + + expect(shown).toBe(FALLBACK) +}) + +test('prefers a filled template over an answer that says nothing', () => { + const shown = errorText({ message: '', code: 'name_taken' }, TEMPLATES, FALLBACK) + + expect(shown).toBe('That name is already taken.') +}) diff --git a/gottext/test/lockfile.test.ts b/gottext/test/lockfile.test.ts new file mode 100644 index 0000000..c4704fe --- /dev/null +++ b/gottext/test/lockfile.test.ts @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, test } from 'vitest' + +import { pinnedVersions, resolvedVersions } from '../src/build.js' + +const LOCKFILE = ` +packages: + + '@wordpress/i18n@6.26.0': + resolution: {integrity: sha512-a} + + left-pad@1.3.0: + resolution: {integrity: sha512-b} + +snapshots: + + '@wordpress/i18n@9.9.9': {} +` + +test('names every version a lockfile resolves for a package', () => { + expect(resolvedVersions(LOCKFILE, '@wordpress/i18n')).toEqual(['6.26.0']) +}) + +test('reads a package the lockfile names without quotes', () => { + expect(resolvedVersions(LOCKFILE, 'left-pad')).toEqual(['1.3.0']) +}) + +test('names nothing for a package the lockfile does not resolve', () => { + expect(resolvedVersions(LOCKFILE, 'not-a-package')).toEqual([]) +}) + +test('reads a lockfile whose packages run to the end', () => { + const held = "\npackages:\n left-pad@1.3.0:\n resolution: {integrity: sha512-x}\n" + + expect(resolvedVersions(held, 'left-pad')).toEqual(['1.3.0']) +}) + +test('names the version each package pins', () => { + const root = mkdtempSync(join(tmpdir(), 'gottext-lock-')) + mkdirSync(join(root, 'app'), { recursive: true }) + mkdirSync(join(root, 'kit'), { recursive: true }) + writeFileSync(join(root, 'app', 'package.json'), '{"dependencies":{"probe":"1.0.0"}}') + writeFileSync(join(root, 'kit', 'package.json'), '{"dependencies":{"probe":"1.0.0"}}') + + expect(pinnedVersions(root, ['app', 'kit'], 'probe')).toEqual(['1.0.0', '1.0.0']) +}) + +test('passes over a package that declares no such dependency', () => { + const root = mkdtempSync(join(tmpdir(), 'gottext-lock-')) + mkdirSync(join(root, 'app'), { recursive: true }) + writeFileSync(join(root, 'app', 'package.json'), '{"dependencies":{"other":"2.0.0"}}') + + expect(pinnedVersions(root, ['app'], 'probe')).toEqual([]) +}) diff --git a/gottext/test/runtime.test.ts b/gottext/test/runtime.test.ts new file mode 100644 index 0000000..34b6231 --- /dev/null +++ b/gottext/test/runtime.test.ts @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { __, getLocaleData } from '@wordpress/i18n' +import { expect, test } from 'vitest' + +import { displayLocale, rememberLocale, startLocale } from '../src/index.js' +import type { Catalog } from '../src/index.js' + +const CATALOG: Catalog = { + '': { lang: 'es-ES', 'plural-forms': 'nplurals=2; plural=(n != 1);' }, + 'Older posts': ['Entradas anteriores'], +} + +test('answers the locale its resolver settles on', async () => { + const settled = await startLocale(async () => 'es-ES', []) + + expect(settled).toBe('es-ES') +}) + +test('remembers the settled locale for display', async () => { + await startLocale(async () => 'es-ES', []) + + expect(displayLocale()).toBe('es-ES') +}) + +test('holds the default locale from the moment the start is asked', async () => { + rememberLocale('de-DE') + let release: (locale: string) => void = () => {} + const pending = startLocale( + () => new Promise((resolve) => { release = resolve }), + [], + { defaultLocale: 'es-ES' }, + ) + + expect(displayLocale()).toBe('es-ES') + + release('es-ES') + await pending +}) + +test('sets a loaded catalogue under its domain before returning', async () => { + await startLocale(async () => 'es-ES', [ + { domain: 'gottext-probe', load: async () => CATALOG }, + ]) + + expect(getLocaleData('gottext-probe')['']).toBeDefined() + expect(__('Older posts', 'gottext-probe')).toBe('Entradas anteriores') +}) + +test('leaves a domain untouched when its loader answers nothing', async () => { + await startLocale(async () => 'es-ES', [ + { domain: 'gottext-empty', load: async () => undefined }, + ]) + + expect(__('Older posts', 'gottext-empty')).toBe('Older posts') +}) + +test('loads nothing for the locale the sources are written in', async () => { + const asked: string[] = [] + + await startLocale( + async () => 'en-US', + [ + { + domain: 'gottext-default', + load: async (locale: string) => { + asked.push(locale) + return CATALOG + }, + }, + ], + { defaultLocale: 'en-US' }, + ) + + expect(asked).toEqual([]) + expect(__('Older posts', 'gottext-default')).toBe('Older posts') +}) + +test('remembers the locale the sources are written in even though it loads nothing', async () => { + const settled = await startLocale(async () => 'en-US', [], { defaultLocale: 'en-US' }) + + expect(settled).toBe('en-US') + expect(displayLocale()).toBe('en-US') +}) + +test('sets a catalogue naming no domain under the default domain', async () => { + await startLocale(async () => 'es-ES', [{ load: async () => CATALOG }]) + + expect(__('Older posts')).toBe('Entradas anteriores') +}) + +test('sets every catalogue it is handed', async () => { + await startLocale(async () => 'es-ES', [ + { domain: 'gottext-one', load: async () => CATALOG }, + { domain: 'gottext-two', load: async () => CATALOG }, + ]) + + expect(__('Older posts', 'gottext-one')).toBe('Entradas anteriores') + expect(__('Older posts', 'gottext-two')).toBe('Entradas anteriores') +}) diff --git a/gottext/test/sync.test.ts b/gottext/test/sync.test.ts new file mode 100644 index 0000000..e673873 --- /dev/null +++ b/gottext/test/sync.test.ts @@ -0,0 +1,942 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { expect, test, vi } from 'vitest' + +import { + answeredForms, + keepingAnswers, + localeFor, + localeOf, + meaningfulChange, + namedByTemplate, + poeditorAt, + pushTranslations, + syncTranslations, + translated, + withPluralRuleOf, +} from '../src/sync.js' +import type { Catalogues, Poeditor } from '../src/sync.js' + +const HEADER = `msgid "" +msgstr "" +"Language: es-ES\\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\\n" +` + +const TEMPLATE = 'msgid "Older posts"\nmsgstr ""\n' + +/** + * Returns a catalogue carrying one translation. + * @param msgstr - The translation the catalogue holds. + * @returns The catalogue as PO text. + */ +function catalogue(msgstr: string): string { + return `${HEADER}\nmsgid "Older posts"\nmsgstr "${msgstr}"\n` +} + +/** + * Returns a platform answering with the given languages and exports. + * @param languages - The languages the platform lists. + * @param exports - The catalogue each language exports, keyed by platform name. + * @returns The platform. + */ +function platformOf(languages: string[], exports: Record): Poeditor { + return { + languages: async () => languages, + exportPo: async (named: string) => exports[named] ?? '', + uploadTerms: async () => {}, + uploadTranslations: async () => {}, + addLanguage: async () => {}, + } +} + +/** + * Returns a platform recording every catalogue upload and language it receives. + * @param languages - The languages the platform lists. + * @returns The platform, the uploads as name and source pairs, and what else arrived. + */ +function receivingPlatform(languages: string[]): { + platform: Poeditor + uploads: [string, string][] + termsSent: string[] + languagesAdded: string[] +} { + const uploads: [string, string][] = [] + const termsSent: string[] = [] + const languagesAdded: string[] = [] + return { + platform: { + languages: async () => languages, + exportPo: async () => '', + uploadTerms: async (source: string) => { + termsSent.push(source) + }, + uploadTranslations: async (named: string, source: string) => { + uploads.push([named, source]) + }, + addLanguage: async (named: string) => { + languagesAdded.push(named) + }, + }, + uploads, + termsSent, + languagesAdded, + } +} + +/** + * Returns a catalogue store over the given committed sources. + * @param committed - The catalogues already committed, keyed by locale. + * @returns The store and the writes it received. + */ +function storeOf(committed: Record = {}): { + held: Catalogues + written: Record +} { + const written: Record = {} + return { + held: { + read: (locale: string) => committed[locale], + write: (locale: string, source: string) => { + written[locale] = source + }, + }, + written, + } +} + +test('reads a language the platform names in its own casing', () => { + expect(localeOf('es-es')).toBe('es-ES') + expect(localeOf('es')).toBe('es') +}) + +test('matches a language named exactly as the site names it', () => { + expect(localeFor('es-ES', ['en-US', 'es-ES'])).toBe('es-ES') +}) + +test('matches a bare language to its doubled region', () => { + expect(localeFor('es', ['en-US', 'es-ES'])).toBe('es-ES') + expect(localeFor('es', ['es-ES', 'es-MX'])).toBe('es-ES') +}) + +test('refuses a bare language whose doubled region the site does not answer in', () => { + expect(localeFor('pt', ['en-US', 'pt-BR'])).toBeUndefined() +}) + +test('refuses a region the site does not answer in', () => { + expect(localeFor('es-MX', ['en-US', 'es-ES'])).toBeUndefined() +}) + +test('counts the messages and the forms a catalogue answers', () => { + const held = `${HEADER} +msgid "%(count)d post" +msgid_plural "%(count)d posts" +msgstr[0] "%(count)d entrada" +msgstr[1] "" + +msgid "Older posts" +msgstr "Entradas anteriores" +` + + expect(translated(held)).toBe(2) + expect(answeredForms(held)).toBe(2) +}) + +test('keeps the plural rule the committed catalogue declares', () => { + const ours = `msgid ""\nmsgstr ""\n"Plural-Forms: nplurals=2; plural=(n != 1);\\n"\n` + const theirs = `msgid "" +msgstr "" +"Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n==2 ? 1 : 2);\\n" + +msgid "%(count)d post" +msgid_plural "%(count)d posts" +msgstr[0] "%(count)d entrada" +msgstr[1] "%(count)d entradas" +msgstr[2] "" +` + + const held = withPluralRuleOf(ours, theirs) + + expect(held).toContain('nplurals=2') + expect(held).not.toMatch(/msgstr\[2\]/) +}) + +test('refuses a committed rule naming no usable count', () => { + const ours = 'msgid ""\nmsgstr ""\n"Plural-Forms: plural=(n != 1);\\n"\n' + const theirs = 'msgid ""\nmsgstr ""\n\nmsgid "Older posts"\nmsgstr "Entradas anteriores"\n' + + expect(() => withPluralRuleOf(ours, theirs)).toThrow(/count/) + expect(() => withPluralRuleOf(ours.replace('plural=', 'nplurals=0; plural='), theirs)) + .toThrow(/count/) +}) + +test('takes the platform plural rule when the catalogue declares none', () => { + const ours = 'msgid ""\nmsgstr ""\n"Language: es-ES\\n"\n' + const theirs = 'msgid ""\nmsgstr ""\n"Plural-Forms: nplurals=2; plural=(n != 1);\\n"\n' + + expect(withPluralRuleOf(ours, theirs)).toBe(theirs) +}) + +test('drops a message the template does not name', () => { + const theirs = `${HEADER}\nmsgid "Older posts"\nmsgstr "Entradas anteriores"\n\nmsgid "Retired"\nmsgstr "Retirado"\n` + + expect(namedByTemplate(theirs, TEMPLATE)).not.toContain('Retirado') +}) + +test('keeps a committed answer the export holds empty', () => { + const ours = 'msgid ""\nmsgstr ""\n\nmsgid "Older posts"\nmsgstr "Entradas anteriores"\n' + const theirs = 'msgid ""\nmsgstr ""\n\nmsgid "Older posts"\nmsgstr ""\n' + + expect(keepingAnswers(ours, theirs, TEMPLATE)).toContain('Entradas anteriores') +}) + +test('keeps a plural form the export holds empty', () => { + const naming = 'msgid "%(count)d post"\nmsgid_plural "%(count)d posts"\nmsgstr[0] ""\nmsgstr[1] ""\n' + const ours = `msgid "" +msgstr "" + +msgid "%(count)d post" +msgid_plural "%(count)d posts" +msgstr[0] "%(count)d entrada" +msgstr[1] "%(count)d entradas" +` + const theirs = `msgid "" +msgstr "" + +msgid "%(count)d post" +msgid_plural "%(count)d posts" +msgstr[0] "%(count)d entrada" +msgstr[1] "" +` + + expect(keepingAnswers(ours, theirs, naming)).toContain('%(count)d entradas') +}) + +test('keeps every committed form when the export flattens the entry', () => { + const naming = 'msgid "%(count)d post"\nmsgid_plural "%(count)d posts"\nmsgstr[0] ""\nmsgstr[1] ""\n' + const ours = `msgid "" +msgstr "" +"Plural-Forms: nplurals=2; plural=(n != 1);\\n" + +msgid "%(count)d post" +msgid_plural "%(count)d posts" +msgstr[0] "una entrada" +msgstr[1] "varias entradas" +` + const theirs = `msgid "" +msgstr "" +"Plural-Forms: nplurals=2; plural=(n != 1);\\n" + +msgid "%(count)d post" +msgstr "una entrada" +` + + const held = keepingAnswers(ours, theirs, naming) + + expect(held).toContain('varias entradas') + expect(held).toContain('msgid_plural') +}) + +test('keeps committed forms the export stops short of', () => { + const naming = 'msgid "%(count)d post"\nmsgid_plural "%(count)d posts"\nmsgstr[0] ""\nmsgstr[1] ""\n' + const ours = `msgid "" +msgstr "" + +msgid "%(count)d post" +msgid_plural "%(count)d posts" +msgstr[0] "una entrada" +msgstr[1] "varias entradas" +` + const theirs = `msgid "" +msgstr "" + +msgid "%(count)d post" +msgid_plural "%(count)d posts" +msgstr[0] "la entrada" +` + + const held = keepingAnswers(ours, theirs, naming) + + expect(held).toContain('la entrada') + expect(held).toContain('varias entradas') +}) + +test('leaves a form empty that neither catalogue answers', () => { + const naming = 'msgid "%(count)d post"\nmsgid_plural "%(count)d posts"\nmsgstr[0] ""\nmsgstr[1] ""\n' + const ours = `msgid "" +msgstr "" + +msgid "%(count)d post" +msgid_plural "%(count)d posts" +msgstr[0] "una entrada" +` + const theirs = `msgid "" +msgstr "" + +msgid "%(count)d post" +msgid_plural "%(count)d posts" +msgstr[0] "la entrada" +msgstr[1] "" +` + + const held = keepingAnswers(ours, theirs, naming) + + expect(held).toContain('la entrada') + expect(held).toMatch(/msgstr\[1\] ""/) +}) + +test('takes an incoming answer over the committed one', () => { + const ours = 'msgid ""\nmsgstr ""\n\nmsgid "Older posts"\nmsgstr "Entradas anteriores"\n' + const theirs = 'msgid ""\nmsgstr ""\n\nmsgid "Older posts"\nmsgstr "Entradas antiguas"\n' + + const held = keepingAnswers(ours, theirs, TEMPLATE) + + expect(held).toContain('Entradas antiguas') + expect(held).not.toContain('Entradas anteriores') +}) + +test('restores an answer the export omits entirely', () => { + const naming = 'msgid "Older posts"\nmsgstr ""\n\nmsgid "Newer posts"\nmsgstr ""\n' + const ours = 'msgid ""\nmsgstr ""\n\nmsgid "Older posts"\nmsgstr "Entradas anteriores"\n' + const theirs = 'msgid ""\nmsgstr ""\n\nmsgid "Newer posts"\nmsgstr "Entradas nuevas"\n' + + const held = keepingAnswers(ours, theirs, naming) + + expect(held).toContain('Entradas anteriores') + expect(held).toContain('Entradas nuevas') +}) + +test('keeps an answer whose message shares its name with a prototype member', () => { + const naming = 'msgid "constructor"\nmsgstr ""\n' + const ours = 'msgid ""\nmsgstr ""\n\nmsgid "constructor"\nmsgstr "constructor"\n' + const theirs = 'msgid ""\nmsgstr ""\n' + + expect(() => keepingAnswers(ours, theirs, naming)).not.toThrow() + expect(keepingAnswers(ours, theirs, naming)).toContain('msgid "constructor"') +}) + +test('restores an answer under a context the export does not know', () => { + const naming = 'msgctxt "posts"\nmsgid "Older"\nmsgstr ""\n' + const ours = 'msgid ""\nmsgstr ""\n\nmsgctxt "posts"\nmsgid "Older"\nmsgstr "Antiguas"\n' + const theirs = 'msgid ""\nmsgstr ""\n' + + expect(keepingAnswers(ours, theirs, naming)).toContain('Antiguas') +}) + +test('does not restore an answer the template no longer names', () => { + const ours = 'msgid ""\nmsgstr ""\n\nmsgid "Retired"\nmsgstr "Retirado"\n' + const theirs = 'msgid ""\nmsgstr ""\n\nmsgid "Older posts"\nmsgstr "Entradas anteriores"\n' + + expect(keepingAnswers(ours, theirs, TEMPLATE)).not.toContain('Retirado') +}) + +test('reports a change only when a translation moved', () => { + const same = catalogue('Entradas anteriores') + + expect(meaningfulChange(same, same)).toBe(false) + expect(meaningfulChange(same, catalogue('Entradas antiguas'))).toBe(true) + expect(meaningfulChange(undefined, same)).toBe(true) +}) + +test('asks the platform under its own name and writes under the site locale', async () => { + const platform = platformOf(['es'], { es: catalogue('Entradas anteriores') }) + const { held, written } = storeOf() + + const done = await syncTranslations(platform, ['en-US', 'es-ES'], held, TEMPLATE) + + expect(Object.keys(written)).toEqual(['es-ES']) + expect(done.moved).toEqual(['es-ES']) +}) + +test('skips a language the site does not answer in', async () => { + const platform = platformOf(['fr'], { fr: catalogue('Articles plus anciens') }) + const { held, written } = storeOf() + + const done = await syncTranslations(platform, ['en-US', 'es-ES'], held, TEMPLATE) + + expect(written).toEqual({}) + expect(done.skipped[0]).toContain('fr') +}) + +test('skips a language nobody has translated yet', async () => { + const bare = `${HEADER}\nmsgid "Older posts"\nmsgstr ""\n` + const platform = platformOf(['es'], { es: bare }) + const { held, written } = storeOf() + + const done = await syncTranslations(platform, ['en-US', 'es-ES'], held, TEMPLATE) + + expect(written).toEqual({}) + expect(done.skipped[0]).toContain('nobody has translated') +}) + +test('writes nothing when the platform says what the catalogue already says', async () => { + const same = catalogue('Entradas anteriores') + const platform = platformOf(['es'], { es: same }) + const { held, written } = storeOf({ 'es-ES': same }) + + const done = await syncTranslations(platform, ['en-US', 'es-ES'], held, TEMPLATE) + + expect(written).toEqual({}) + expect(done.moved).toEqual([]) +}) + +test('never lowers the answers a catalogue holds, and says how many it kept', async () => { + const naming = 'msgid "Older posts"\nmsgstr ""\n\nmsgid "Newer posts"\nmsgstr ""\n' + const theirs = `${HEADER}\nmsgid "Older posts"\nmsgstr ""\n\nmsgid "Newer posts"\nmsgstr "Entradas nuevas"\n` + const platform = platformOf(['es'], { es: theirs }) + const { held, written } = storeOf({ 'es-ES': catalogue('Entradas anteriores') }) + + const done = await syncTranslations(platform, ['en-US', 'es-ES'], held, naming) + + expect(written['es-ES']).toContain('Entradas nuevas') + expect(written['es-ES']).toContain('Entradas anteriores') + expect(done.kept[0]).toContain('1') +}) + +test('sends the template before asking what the platform holds', async () => { + const order: string[] = [] + const platform: Poeditor = { + languages: async () => { + order.push('languages') + return [] + }, + exportPo: async () => '', + uploadTerms: async () => { + order.push('upload') + }, + uploadTranslations: async () => {}, + addLanguage: async () => {}, + } + const { held } = storeOf() + + await syncTranslations(platform, ['en-US'], held, TEMPLATE) + + expect(order).toEqual(['upload', 'languages']) +}) + +/** + * Returns a fetch answering the platform's success envelope. + * @param result - The result the platform answers with. + * @returns The fetch stub. + */ +function answering(result: unknown): typeof fetch { + return vi.fn(async () => + new Response(JSON.stringify({ response: { status: 'success' }, result }), { status: 200 }), + ) as unknown as typeof fetch +} + +test('names the upload after the domain it carries', async () => { + const sent: FormData[] = [] + const fetched = vi.fn(async (_url: string, init?: RequestInit) => { + sent.push(init?.body as FormData) + return new Response(JSON.stringify({ response: { status: 'success' }, result: {} })) + }) as unknown as typeof fetch + + await poeditorAt({ token: 't', project: 'p', domain: 'probe', fetched }).uploadTerms(TEMPLATE) + + expect((sent[0].get('file') as File).name).toBe('probe.pot') + expect(sent[0].get('updating')).toBe('terms') + expect(sent[0].get('sync_terms')).toBeNull() +}) + +test('lists the languages a project carries', async () => { + const platform = poeditorAt({ + token: 't', + project: 'p', + domain: 'probe', + fetched: answering({ languages: [{ code: 'es' }, { code: 'fr-ca' }] }), + }) + + expect(await platform.languages()).toEqual(['es', 'fr-CA']) +}) + +test('refuses a platform answer that reports a failure', async () => { + const fetched = vi.fn(async () => + new Response(JSON.stringify({ response: { status: 'fail', message: 'no' } })), + ) as unknown as typeof fetch + + await expect(poeditorAt({ token: 't', project: 'p', domain: 'probe', fetched }).languages()) + .rejects.toThrow(/refused/) +}) + +test('reads a platform answer carrying no result at all', async () => { + const fetched = vi.fn(async () => + new Response(JSON.stringify({ response: { status: 'success' } })), + ) as unknown as typeof fetch + + const held = await poeditorAt({ token: 't', project: 'p', domain: 'probe', fetched }).languages() + + expect(held).toEqual([]) +}) + +test('names no reason when the platform refused without one', async () => { + const fetched = vi.fn(async () => + new Response(JSON.stringify({ response: { status: 'fail' } })), + ) as unknown as typeof fetch + + await expect(poeditorAt({ token: 't', project: 'p', domain: 'probe', fetched }).languages()) + .rejects.toThrow(/no reason given/) +}) + +test('sends through the runtime fetch unless it is handed one', async () => { + vi.stubGlobal('fetch', vi.fn(async () => + new Response(JSON.stringify({ response: { status: 'success' }, result: { languages: [] } })), + )) + + const held = await poeditorAt({ token: 't', project: 'p', domain: 'probe' }).languages() + + expect(held).toEqual([]) + vi.unstubAllGlobals() +}) + +test('refuses a platform answer that did not arrive', async () => { + const fetched = vi.fn(async () => new Response('', { status: 500 })) as unknown as typeof fetch + + await expect(poeditorAt({ token: 't', project: 'p', domain: 'probe', fetched }).languages()) + .rejects.toThrow(/500/) +}) + +test('refuses to retire with a template naming no messages', async () => { + const platform = poeditorAt({ + token: 't', + project: 'p', + domain: 'probe', + fetched: answering({}), + }) + + await expect(platform.retireTerms('msgid ""\nmsgstr ""\n')).rejects.toThrow(/no messages/) +}) + +test('reports how many terms a retirement deleted', async () => { + const platform = poeditorAt({ + token: 't', + project: 'p', + domain: 'probe', + fetched: answering({ terms: { deleted: 3 } }), + }) + + expect(await platform.retireTerms(TEMPLATE)).toBe(3) +}) + +test('reports no deletion when the platform names none', async () => { + const platform = poeditorAt({ + token: 't', + project: 'p', + domain: 'probe', + fetched: answering({}), + }) + + expect(await platform.retireTerms(TEMPLATE)).toBe(0) +}) + +test('downloads the export the platform prepared', async () => { + const fetched = vi.fn(async (url: string) => + url.includes('export') + ? new Response(JSON.stringify({ + response: { status: 'success' }, + result: { url: 'https://example.com/es.po' }, + })) + : new Response(catalogue('Entradas anteriores')), + ) as unknown as typeof fetch + + const held = await poeditorAt({ token: 't', project: 'p', domain: 'probe', fetched }) + .exportPo('es-ES') + + expect(held).toContain('Entradas anteriores') +}) + +test('refuses an export the platform named no address for', async () => { + const platform = poeditorAt({ + token: 't', + project: 'p', + domain: 'probe', + fetched: answering({}), + }) + + await expect(platform.exportPo('es-ES')).rejects.toThrow(/no export/) +}) + +test('refuses an export that could not be downloaded', async () => { + const fetched = vi.fn(async (url: string) => + url.includes('export') + ? new Response(JSON.stringify({ + response: { status: 'success' }, + result: { url: 'https://example.com/es.po' }, + })) + : new Response('', { status: 404 }), + ) as unknown as typeof fetch + + await expect(poeditorAt({ token: 't', project: 'p', domain: 'probe', fetched }).exportPo('es-ES')) + .rejects.toThrow(/404/) +}) + +/** + * Returns a catalogue carrying one fuzzy translation. + * @param msgstr - The machine answer the catalogue holds. + * @returns The catalogue as PO text. + */ +function fuzzyCatalogue(msgstr: string): string { + return `${HEADER}\n#, fuzzy\nmsgid "Older posts"\nmsgstr "${msgstr}"\n` +} + +test('a reviewed answer replaces the fuzzy one it settles', () => { + const held = keepingAnswers(fuzzyCatalogue('Entradas anteriores'), catalogue('Entradas previas'), TEMPLATE) + + expect(held).toContain('msgstr "Entradas previas"') + expect(held).not.toContain('#, fuzzy') +}) + +test('a fuzzy export never replaces a settled answer', () => { + const held = keepingAnswers(catalogue('Entradas'), fuzzyCatalogue('Entradas raras'), TEMPLATE) + + expect(held).toContain('msgstr "Entradas"') + expect(held).not.toContain('Entradas raras') + expect(held).not.toContain('#, fuzzy') +}) + +test('a restored answer keeps its fuzzy flag', () => { + const held = keepingAnswers(fuzzyCatalogue('Entradas anteriores'), catalogue(''), TEMPLATE) + + expect(held).toContain('msgstr "Entradas anteriores"') + expect(held).toContain('#, fuzzy') +}) + +test('two fuzzy answers take the platform text and stay fuzzy', () => { + const held = keepingAnswers( + fuzzyCatalogue('Entradas anteriores'), + fuzzyCatalogue('Entradas previas'), + TEMPLATE, + ) + + expect(held).toContain('msgstr "Entradas previas"') + expect(held).toContain('#, fuzzy') +}) + +test('clearing a fuzzy flag alone is a meaningful change', () => { + expect(meaningfulChange(fuzzyCatalogue('Entradas'), catalogue('Entradas'))).toBe(true) +}) + +test('pushes each held catalogue under the platform its language is named by', async () => { + const fuzzy = `${HEADER}\n#, fuzzy\nmsgid "Older posts"\nmsgstr "Entradas anteriores"\n` + const { platform, uploads, termsSent } = receivingPlatform(['es']) + const { held } = storeOf({ 'es-ES': fuzzy }) + + const done = await pushTranslations(platform, ['es-ES'], held, TEMPLATE) + + expect(termsSent).toEqual([TEMPLATE]) + expect(uploads).toHaveLength(1) + expect(uploads[0][0]).toBe('es') + expect(uploads[0][1]).toContain('msgstr "Entradas anteriores"') + expect(uploads[0][1]).toContain('#, fuzzy') + expect(done.pushed).toEqual(['es-ES']) +}) + +test('pushes only the messages the template names', async () => { + const stale = `${HEADER}\nmsgid "Older posts"\nmsgstr "Entradas"\n\nmsgid "Retired"\nmsgstr "Retirada"\n` + const { platform, uploads } = receivingPlatform(['es']) + const { held } = storeOf({ 'es-ES': stale }) + + await pushTranslations(platform, ['es-ES'], held, TEMPLATE) + + expect(uploads[0][1]).toContain('msgstr "Entradas"') + expect(uploads[0][1]).not.toContain('Retired') +}) + +test('passes over a pushed language the site does not answer in', async () => { + const { platform, uploads } = receivingPlatform(['de']) + const { held } = storeOf({}) + + const done = await pushTranslations(platform, ['es-ES'], held, TEMPLATE) + + expect(uploads).toHaveLength(0) + expect(done.pushed).toEqual([]) + expect(done.skipped).toEqual([ + 'de, which the site does not answer in', + 'es-ES, which the repository holds no catalogue for', + ]) +}) + +test('passes over a pushed language the repository holds no catalogue for', async () => { + const { platform, uploads } = receivingPlatform(['es']) + const { held } = storeOf({}) + + const done = await pushTranslations(platform, ['es-ES'], held, TEMPLATE) + + expect(uploads).toHaveLength(0) + expect(done.skipped).toEqual(['es, which the repository holds no catalogue for']) +}) + +/** + * Returns a platform recording uploads and exporting the given catalogue per language. + * @param languages - The languages the platform lists. + * @param exports - The catalogue each language exports, keyed by platform name. + * @returns The platform and the uploads it received. + */ +function exportingPlatform(languages: string[], exports: Record): { + platform: Poeditor + uploads: [string, string][] +} { + const uploads: [string, string][] = [] + return { + platform: { + languages: async () => languages, + exportPo: async (named: string) => exports[named] ?? '', + uploadTerms: async () => {}, + uploadTranslations: async (named: string, source: string) => { + uploads.push([named, source]) + }, + addLanguage: async () => {}, + }, + uploads, + } +} + +test('never pushes over an answer the platform has settled', async () => { + const { platform, uploads } = exportingPlatform(['es'], { es: catalogue('Entradas revisadas') }) + const { held } = storeOf({ 'es-ES': fuzzyCatalogue('Entradas de la maquina') }) + + const done = await pushTranslations(platform, ['es-ES'], held, TEMPLATE) + + expect(uploads).toHaveLength(0) + expect(done.pushed).toEqual([]) + expect(done.skipped).toEqual(['es, which the platform has settled every answer of']) +}) + +test('pushes over an answer the platform still holds fuzzy', async () => { + const { platform, uploads } = exportingPlatform(['es'], { es: fuzzyCatalogue('Entradas viejas') }) + const { held } = storeOf({ 'es-ES': fuzzyCatalogue('Entradas nuevas') }) + + await pushTranslations(platform, ['es-ES'], held, TEMPLATE) + + expect(uploads).toHaveLength(1) + expect(uploads[0][1]).toContain('Entradas nuevas') +}) + +test('pushes a settled answer of its own over a fuzzy one the platform holds', async () => { + const { platform, uploads } = exportingPlatform(['es'], { es: fuzzyCatalogue('Entradas raras') }) + const { held } = storeOf({ 'es-ES': catalogue('Entradas corregidas') }) + + await pushTranslations(platform, ['es-ES'], held, TEMPLATE) + + expect(uploads[0][1]).toContain('Entradas corregidas') + expect(uploads[0][1]).not.toContain('#, fuzzy') +}) + +test('pushes a message the platform holds no answer for', async () => { + const { platform, uploads } = exportingPlatform(['es'], { es: catalogue('') }) + const { held } = storeOf({ 'es-ES': fuzzyCatalogue('Entradas anteriores') }) + + await pushTranslations(platform, ['es-ES'], held, TEMPLATE) + + expect(uploads[0][1]).toContain('Entradas anteriores') +}) + +/** PLURAL_TEMPLATE names one message carrying two forms. */ +const PLURAL_TEMPLATE = `msgid "%(count)d post" +msgid_plural "%(count)d posts" +msgstr[0] "" +msgstr[1] "" +` + +/** + * Returns a catalogue answering the plural message with the given forms. + * @param one - The singular form. + * @param many - The plural form. + * @param fuzzy - Whether the entry needs review. + * @returns The catalogue as PO text. + */ +function pluralCatalogue(one: string, many: string, fuzzy = false): string { + return `${HEADER} +${fuzzy ? '#, fuzzy\n' : ''}msgid "%(count)d post" +msgid_plural "%(count)d posts" +msgstr[0] "${one}" +msgstr[1] "${many}" +` +} + +test('pushes the form the platform left empty and keeps the one it answered', async () => { + const { platform, uploads } = exportingPlatform(['es'], { + es: pluralCatalogue('%(count)d entrada revisada', ''), + }) + const { held } = storeOf({ + 'es-ES': pluralCatalogue('%(count)d entrada maquina', '%(count)d entradas maquina', true), + }) + + await pushTranslations(platform, ['es-ES'], held, PLURAL_TEMPLATE) + + expect(uploads).toHaveLength(1) + expect(uploads[0][1]).toContain('%(count)d entrada revisada') + expect(uploads[0][1]).toContain('%(count)d entradas maquina') + expect(uploads[0][1]).not.toContain('entrada maquina') +}) + +test('never pushes over a plural the platform answered in every form', async () => { + const { platform, uploads } = exportingPlatform(['es'], { + es: pluralCatalogue('%(count)d entrada', '%(count)d entradas'), + }) + const { held } = storeOf({ + 'es-ES': pluralCatalogue('%(count)d vieja', '%(count)d viejas', true), + }) + + const done = await pushTranslations(platform, ['es-ES'], held, PLURAL_TEMPLATE) + + expect(uploads).toHaveLength(0) + expect(done.skipped).toEqual(['es, which the platform has settled every answer of']) +}) + +test('says which supported language it holds no catalogue to add', async () => { + const { platform, languagesAdded } = receivingPlatform([]) + const { held } = storeOf({}) + + const done = await pushTranslations(platform, ['fr-FR'], held, TEMPLATE) + + expect(languagesAdded).toEqual([]) + expect(done.skipped).toEqual(['fr-FR, which the repository holds no catalogue for']) +}) + +test('adds a language the platform lacks and pushes its full catalogue', async () => { + const { platform, uploads, languagesAdded } = receivingPlatform([]) + const { held } = storeOf({ 'es-ES': fuzzyCatalogue('Entradas anteriores') }) + + const done = await pushTranslations(platform, ['es-ES'], held, TEMPLATE) + + expect(languagesAdded).toEqual(['es']) + expect(uploads).toHaveLength(1) + expect(uploads[0][0]).toBe('es') + expect(uploads[0][1]).toContain('#, fuzzy') + expect(done.added).toEqual(['es-ES']) + expect(done.pushed).toEqual(['es-ES']) +}) + +test('adds a dialect under its full code', async () => { + const { platform, uploads, languagesAdded } = receivingPlatform([]) + const { held } = storeOf({ 'pt-BR': `${HEADER}\nmsgid "Older posts"\nmsgstr "Posts antigos"\n` }) + + await pushTranslations(platform, ['pt-BR'], held, TEMPLATE) + + expect(languagesAdded).toEqual(['pt-br']) + expect(uploads[0][0]).toBe('pt-br') +}) + +test('adds nothing for a language the platform already lists', async () => { + const { platform, uploads, languagesAdded } = receivingPlatform(['es']) + const { held } = storeOf({ 'es-ES': catalogue('Entradas') }) + + const done = await pushTranslations(platform, ['es-ES'], held, TEMPLATE) + + expect(languagesAdded).toEqual([]) + expect(uploads).toHaveLength(1) + expect(done.added).toEqual([]) +}) + +test('adds nothing for a supported language the repository holds no catalogue for', async () => { + const { platform, uploads, languagesAdded } = receivingPlatform([]) + const { held } = storeOf({}) + + const done = await pushTranslations(platform, ['fr-FR'], held, TEMPLATE) + + expect(languagesAdded).toEqual([]) + expect(uploads).toHaveLength(0) + expect(done.added).toEqual([]) + expect(done.pushed).toEqual([]) +}) + +test('tells the platform a new language exists', async () => { + const sent: URLSearchParams[] = [] + const fetched = vi.fn(async (url: string, init?: RequestInit) => { + sent.push(init?.body as URLSearchParams) + expect(url).toContain('languages/add') + return new Response(JSON.stringify({ response: { status: 'success' }, result: {} })) + }) as unknown as typeof fetch + + await poeditorAt({ token: 't', project: 'p', domain: 'probe', fetched }).addLanguage('pt-BR') + + expect(sent[0].get('language')).toBe('pt-br') + expect(sent[0].get('api_token')).toBe('t') +}) + +test('uploads a language catalogue with its translations and terms together', async () => { + const sent: FormData[] = [] + const fetched = vi.fn(async (_url: string, init?: RequestInit) => { + sent.push(init?.body as FormData) + return new Response(JSON.stringify({ response: { status: 'success' }, result: {} })) + }) as unknown as typeof fetch + + await poeditorAt({ token: 't', project: 'p', domain: 'probe', fetched }) + .uploadTranslations('fr-CA', catalogue('Anciens billets')) + + expect(sent[0].get('updating')).toBe('terms_translations') + expect(sent[0].get('overwrite')).toBe('1') + expect(sent[0].get('language')).toBe('fr-ca') + expect((sent[0].get('file') as File).name).toBe('probe.po') + expect(await (sent[0].get('file') as File).text()).toContain('Anciens billets') +}) + +test('retries one upload the platform rate limited', async () => { + const waits: number[] = [] + let asked = 0 + const fetched = vi.fn(async () => { + asked += 1 + return asked === 1 + ? new Response(JSON.stringify({ response: { status: 'fail', code: '4048', message: 'slow down' } })) + : new Response(JSON.stringify({ response: { status: 'success' }, result: {} })) + }) as unknown as typeof fetch + const platform = poeditorAt({ + token: 't', project: 'p', domain: 'probe', fetched, + paced: 123, + paused: async (ms: number) => { + waits.push(ms) + }, + }) + + await platform.uploadTranslations('es', catalogue('Entradas')) + + expect(asked).toBe(2) + expect(waits).toEqual([123]) +}) + +test('surfaces a rate refusal that outlives the retry', async () => { + const fetched = vi.fn(async () => + new Response(JSON.stringify({ response: { status: 'fail', code: '4048', message: 'slow down' } })), + ) as unknown as typeof fetch + const platform = poeditorAt({ + token: 't', project: 'p', domain: 'probe', fetched, + paced: 1, + paused: async () => {}, + }) + + await expect(platform.uploadTranslations('es', catalogue('Entradas'))).rejects.toThrow(/slow down/) + expect(fetched).toHaveBeenCalledTimes(2) +}) + +test('spaces consecutive uploads apart', async () => { + const waits: number[] = [] + const fetched = vi.fn(async () => + new Response(JSON.stringify({ response: { status: 'success' }, result: {} })), + ) as unknown as typeof fetch + const platform = poeditorAt({ + token: 't', project: 'p', domain: 'probe', fetched, + paced: 456, + paused: async (ms: number) => { + waits.push(ms) + }, + }) + + await platform.uploadTerms(TEMPLATE) + await platform.uploadTranslations('es', catalogue('Entradas')) + await platform.uploadTranslations('fr', catalogue('Anciens')) + + expect(waits).toEqual([456, 456]) +}) + +test('waits between uploads through the runtime clock unless handed a pause', async () => { + const fetched = vi.fn(async () => + new Response(JSON.stringify({ response: { status: 'success' }, result: {} })), + ) as unknown as typeof fetch + const platform = poeditorAt({ token: 't', project: 'p', domain: 'probe', fetched, paced: 1 }) + + await platform.uploadTranslations('es', catalogue('Entradas')) + await platform.uploadTranslations('fr', catalogue('Anciens')) + + expect(fetched).toHaveBeenCalledTimes(2) +}) diff --git a/gottext/test/testing.test.ts b/gottext/test/testing.test.ts new file mode 100644 index 0000000..604e05c --- /dev/null +++ b/gottext/test/testing.test.ts @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { __, setLocaleData } from '@wordpress/i18n' +import { expect, test } from 'vitest' + +import { displayLocale, rememberLocale } from '../src/index.js' +import type { Catalog } from '../src/index.js' +import { resetLocale } from '../src/testing.js' + +const CATALOG: Catalog = { + '': { lang: 'es-ES', 'plural-forms': 'nplurals=2; plural=(n != 1);' }, + 'Older posts': ['Entradas anteriores'], +} + +test('takes every loaded domain back to its sources', () => { + setLocaleData(CATALOG, 'gottext-reset-one') + setLocaleData(CATALOG, 'gottext-reset-two') + setLocaleData(CATALOG) + + resetLocale() + + expect(__('Older posts', 'gottext-reset-one')).toBe('Older posts') + expect(__('Older posts', 'gottext-reset-two')).toBe('Older posts') + expect(__('Older posts')).toBe('Older posts') +}) + +test('settles back on the locale it is handed', () => { + rememberLocale('es-ES') + + resetLocale('fr-FR') + + expect(displayLocale()).toBe('fr-FR') +}) + +test('settles back on en-US when handed no locale', () => { + rememberLocale('es-ES') + + resetLocale() + + expect(displayLocale()).toBe('en-US') +}) diff --git a/gottext/tsconfig.build.json b/gottext/tsconfig.build.json new file mode 100644 index 0000000..42f379a --- /dev/null +++ b/gottext/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/gottext/tsconfig.json b/gottext/tsconfig.json new file mode 100644 index 0000000..0d58a62 --- /dev/null +++ b/gottext/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "es2023", + "lib": ["ES2023"], + "module": "esnext", + "types": ["node"], + "skipLibCheck": true, + "strict": true, + + "moduleResolution": "bundler", + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src", "test", "vitest.config.ts"] +} diff --git a/gottext/vitest.config.ts b/gottext/vitest.config.ts new file mode 100644 index 0000000..2f5b2b8 --- /dev/null +++ b/gottext/vitest.config.ts @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + coverage: { + include: ['src/**'], + reporter: ['text', 'lcov'], + thresholds: { + statements: 100, + branches: 100, + functions: 100, + lines: 100, + }, + }, + }, +}) From 88c828b0d28c019b4f8e1ab37338fe317c02a793 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Tue, 1 Sep 2026 01:54:17 +0200 Subject: [PATCH 2/7] build(ci): run and publish the node brick --- .github/workflows/ci.yml | 21 ++++++++++++++++ .github/workflows/publish-gottext.yml | 36 +++++++++++++++++++++++++++ README.md | 11 +++++--- 3 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/publish-gottext.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aaee778..8008ac5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,27 @@ jobs: - run: go test -race -covermode=atomic -coverprofile=cover.out ./... - run: go vet ./... + gottext: + runs-on: ubuntu-latest + defaults: + run: + working-directory: gottext + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: gottext/pnpm-lock.yaml + - run: pnpm install --frozen-lockfile + - run: pnpm run typecheck + - run: pnpm run cover + - run: pnpm run lint + - run: pnpm exec knip + - run: pnpm run build + - run: node -e "Promise.all(['@gopherium/gottext', '@gopherium/gottext/build', '@gopherium/gottext/sync', '@gopherium/gottext/testing'].map((entry) => import(entry))).then(() => console.log('every entry loads'))" + lint: runs-on: ubuntu-latest strategy: diff --git a/.github/workflows/publish-gottext.yml b/.github/workflows/publish-gottext.yml new file mode 100644 index 0000000..616c147 --- /dev/null +++ b/.github/workflows/publish-gottext.yml @@ -0,0 +1,36 @@ +name: publish-gottext + +on: + push: + tags: + - "gottext/v*" + +permissions: + contents: read + id-token: write + +jobs: + publish: + runs-on: ubuntu-latest + defaults: + run: + working-directory: gottext + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 22 + - name: Update npm for trusted publishing + run: npm install -g npm@^11.5.1 + - name: Verify the tag matches the package version + run: | + tag_version="${GITHUB_REF_NAME#gottext/v}" + pkg_version="$(node -p "require('./package.json').version")" + if [ "$tag_version" != "$pkg_version" ]; then + echo "tag $tag_version does not match package.json version $pkg_version" >&2 + exit 1 + fi + - run: pnpm install --frozen-lockfile + - run: pnpm run cover + - run: npm publish diff --git a/README.md b/README.md index 0d0679c..25af040 100644 --- a/README.md +++ b/README.md @@ -11,15 +11,18 @@ need and ignore the rest. ## Modules +- [`gottext`](gottext/) reads, writes and syncs gettext catalogs for + TypeScript applications, published to npm as `@gopherium/gottext`. - [`mailkit`](mailkit/) renders mail from template files and sends it over SMTP. ## Design -One repository, one Go module per brick, no shared code between them. -Every module carries its own go.mod, its own CHANGELOG and its own -lint configuration, and is released independently under a path -prefixed tag such as `mailkit/v0.1.0`. Modules depend on published +One repository, one self contained brick per directory, no shared code +between them. A Go brick carries its own go.mod and a TypeScript brick +its own package.json, each with its own CHANGELOG and lint +configuration, released independently under a path prefixed tag such +as `mailkit/v0.1.0` or `gottext/v0.4.0`. Bricks depend on published versions only, never on sibling source, so what you pin is what you get. From 24271f96184b668a3bea54a485c44ebf8f342d1e Mon Sep 17 00:00:00 2001 From: SirLouen Date: Tue, 1 Sep 2026 02:07:54 +0200 Subject: [PATCH 3/7] fix(ci): read the pnpm version from the brick manifest --- .github/workflows/ci.yml | 2 ++ .github/workflows/publish-gottext.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8008ac5..badf99d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,8 @@ jobs: steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + package_json_file: gottext/package.json - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: 22 diff --git a/.github/workflows/publish-gottext.yml b/.github/workflows/publish-gottext.yml index 616c147..1960bb2 100644 --- a/.github/workflows/publish-gottext.yml +++ b/.github/workflows/publish-gottext.yml @@ -18,6 +18,8 @@ jobs: steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + package_json_file: gottext/package.json - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: 22 From fc36c4df79783052c7c479debf1e41dae46be4c4 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Tue, 1 Sep 2026 02:37:37 +0200 Subject: [PATCH 4/7] build(ci): stop persisting the checkout token --- .github/workflows/ci.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index badf99d..624fe7d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,8 @@ jobs: working-directory: ${{ matrix.module }} steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: ${{ matrix.module }}/go.mod @@ -34,6 +36,8 @@ jobs: working-directory: gottext steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 with: package_json_file: gottext/package.json @@ -58,6 +62,8 @@ jobs: module: ["mailkit"] steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: ${{ matrix.module }}/go.mod @@ -78,6 +84,8 @@ jobs: working-directory: ${{ matrix.module }} steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: ${{ matrix.module }}/go.mod From 299a00f47195c9f00934425ae88c4d086708af88 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Tue, 1 Sep 2026 02:37:37 +0200 Subject: [PATCH 5/7] build(ci): publish from a verified artifact --- .github/workflows/publish-gottext.yml | 32 ++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/.github/workflows/publish-gottext.yml b/.github/workflows/publish-gottext.yml index 1960bb2..a48b94e 100644 --- a/.github/workflows/publish-gottext.yml +++ b/.github/workflows/publish-gottext.yml @@ -7,24 +7,23 @@ on: permissions: contents: read - id-token: write jobs: - publish: + pack: runs-on: ubuntu-latest defaults: run: working-directory: gottext steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 with: package_json_file: gottext/package.json - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: 22 - - name: Update npm for trusted publishing - run: npm install -g npm@^11.5.1 - name: Verify the tag matches the package version run: | tag_version="${GITHUB_REF_NAME#gottext/v}" @@ -35,4 +34,27 @@ jobs: fi - run: pnpm install --frozen-lockfile - run: pnpm run cover - - run: npm publish + - run: pnpm run build + - run: npm pack + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: package + path: gottext/*.tgz + retention-days: 1 + + publish: + needs: pack + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: ">=22.14.0 <23" + - name: Update npm for trusted publishing + run: npm install -g npm@11.19.1 --ignore-scripts + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: package + - run: npm publish ./*.tgz From 4307196fc6edd63ecf12ba6b800ebab911d144f1 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Tue, 1 Sep 2026 02:37:37 +0200 Subject: [PATCH 6/7] docs(gottext): name the prefixed release tag --- gottext/CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/gottext/CHANGELOG.md b/gottext/CHANGELOG.md index 78a80fb..29c495d 100644 --- a/gottext/CHANGELOG.md +++ b/gottext/CHANGELOG.md @@ -2,7 +2,8 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versioning follows [Semantic Versioning](https://semver.org/). While at 0.x, -minor releases may break. Releases are tagged `vX.Y.Z` and publish from CI. +minor releases may break. Releases are tagged `gottext/vX.Y.Z` and publish +from CI. ## [0.3.0] - 2026-08-25 From 8335f356d2d0973a9a33d4bd6e722f460fc45356 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Tue, 1 Sep 2026 02:37:38 +0200 Subject: [PATCH 7/7] docs: hyphenate the compound modifiers --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 25af040..161f61e 100644 --- a/README.md +++ b/README.md @@ -18,10 +18,10 @@ need and ignore the rest. ## Design -One repository, one self contained brick per directory, no shared code +One repository, one self-contained brick per directory, no shared code between them. A Go brick carries its own go.mod and a TypeScript brick its own package.json, each with its own CHANGELOG and lint -configuration, released independently under a path prefixed tag such +configuration, released independently under a path-prefixed tag such as `mailkit/v0.1.0` or `gottext/v0.4.0`. Bricks depend on published versions only, never on sibling source, so what you pin is what you get.