@wpelevator/wp-release 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +124 -6
  2. package/dist/changeset-config.d.ts +5 -0
  3. package/dist/changeset-config.js +3 -0
  4. package/dist/cli.d.ts +5 -0
  5. package/dist/cli.js +10 -0
  6. package/dist/commands/i18n-audit.d.ts +2 -0
  7. package/dist/commands/i18n-audit.js +2 -0
  8. package/dist/commands/i18n-compile.d.ts +6 -0
  9. package/dist/commands/i18n-compile.js +76 -0
  10. package/dist/commands/i18n-json.d.ts +2 -0
  11. package/dist/commands/i18n-json.js +2 -0
  12. package/dist/commands/i18n-mo.d.ts +2 -0
  13. package/dist/commands/i18n-mo.js +2 -0
  14. package/dist/commands/i18n-php.d.ts +2 -0
  15. package/dist/commands/i18n-php.js +2 -0
  16. package/dist/commands/i18n-pot.d.ts +2 -0
  17. package/dist/commands/i18n-pot.js +2 -0
  18. package/dist/commands/i18n.d.ts +12 -0
  19. package/dist/commands/i18n.js +174 -0
  20. package/dist/i18n/atomic-output.d.ts +23 -0
  21. package/dist/i18n/atomic-output.js +90 -0
  22. package/dist/i18n/audit.d.ts +7 -0
  23. package/dist/i18n/audit.js +69 -0
  24. package/dist/i18n/catalog.d.ts +54 -0
  25. package/dist/i18n/catalog.js +86 -0
  26. package/dist/i18n/compile.d.ts +27 -0
  27. package/dist/i18n/compile.js +71 -0
  28. package/dist/i18n/extraction.d.ts +45 -0
  29. package/dist/i18n/extraction.js +105 -0
  30. package/dist/i18n/files.d.ts +7 -0
  31. package/dist/i18n/files.js +45 -0
  32. package/dist/i18n/generate.d.ts +49 -0
  33. package/dist/i18n/generate.js +240 -0
  34. package/dist/i18n/javascript.d.ts +7 -0
  35. package/dist/i18n/javascript.js +143 -0
  36. package/dist/i18n/metadata.d.ts +16 -0
  37. package/dist/i18n/metadata.js +117 -0
  38. package/dist/i18n/options.d.ts +52 -0
  39. package/dist/i18n/options.js +156 -0
  40. package/dist/i18n/packages.d.ts +24 -0
  41. package/dist/i18n/packages.js +86 -0
  42. package/dist/i18n/php.d.ts +9 -0
  43. package/dist/i18n/php.js +238 -0
  44. package/dist/i18n/placeholders.d.ts +9 -0
  45. package/dist/i18n/placeholders.js +13 -0
  46. package/dist/i18n/po.d.ts +24 -0
  47. package/dist/i18n/po.js +180 -0
  48. package/dist/i18n/pot.d.ts +16 -0
  49. package/dist/i18n/pot.js +119 -0
  50. package/dist/i18n/translation-json.d.ts +14 -0
  51. package/dist/i18n/translation-json.js +96 -0
  52. package/dist/i18n/translation-php.d.ts +6 -0
  53. package/dist/i18n/translation-php.js +35 -0
  54. package/dist/index.d.ts +22 -0
  55. package/dist/index.js +14 -0
  56. package/package.json +15 -3
package/README.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # WP Release
2
2
 
3
- Package, checksum, sign, verify and version-check WordPress plugins and themes, deploy plugins to the WordPress.org plugin SVN repository, and deploy plugins, themes and packages to Git repositories. Usable as a CLI, as a library, and through the [`wp-release-zip`](https://github.com/wpelevator/wp-release-zip), [`wp-release-checksums`](https://github.com/wpelevator/wp-release-checksums), [`wp-release-sign`](https://github.com/wpelevator/wp-release-sign) and [`wp-release-deploy-svn`](https://github.com/wpelevator/wp-release-deploy-svn) GitHub Actions.
3
+ Package, checksum, sign, verify and version-check WordPress plugins and themes, extract and compile their translations, deploy plugins to the WordPress.org plugin SVN repository, and deploy plugins, themes and packages to Git repositories. Usable as a CLI, as a library, and through the [`wp-release-zip`](https://github.com/wpelevator/wp-release-zip), [`wp-release-checksums`](https://github.com/wpelevator/wp-release-checksums), [`wp-release-sign`](https://github.com/wpelevator/wp-release-sign) and [`wp-release-deploy-svn`](https://github.com/wpelevator/wp-release-deploy-svn) GitHub Actions.
4
4
 
5
5
  Themes are released by uploading a ZIP on WordPress.org, so `deploy-svn` is for plugins only; use `zip` to build the theme upload.
6
6
 
7
- Option names follow the existing community tools (10up WordPress.org actions, `wp dist-archive`, `wp-scripts plugin-zip`, `grunt-wp-deploy`) so that migrating needs few or no renames. See [Comparison with existing tools](#comparison-with-existing-tools) for the differences.
7
+ Packaging and deploy options follow the existing community tools (10up WordPress.org actions, `wp dist-archive`, `wp-scripts plugin-zip`, `grunt-wp-deploy`) so that migrating needs few or no renames. The internationalization commands favor simpler, CI-friendly options over matching `wp i18n`. See [Comparison with existing tools](#comparison-with-existing-tools) for the differences.
8
8
 
9
9
  ## Install
10
10
 
@@ -35,12 +35,17 @@ npx wp-release --help
35
35
  | `wp-release deploy-git [<source-dir>] --git-url <url> [options]` | Deploy the files of a plugin, theme or package to a Git repository as a release commit. |
36
36
  | `wp-release deploy-git --release-ref <ref> [--package <name>] [--list]` | Deploy the configured packages of a release tag or branch to their Git targets. |
37
37
  | `wp-release deploy-svn --release-ref <ref> [--package <name>] [--list]` | Deploy the configured plugin of a release tag to WordPress.org. |
38
+ | `wp-release i18n-pot [<source-dir>] [options]` | Extract translatable strings into a reproducible POT file. |
39
+ | `wp-release i18n-audit [<source-dir>] [options]` | Report translation string problems without writing files. |
40
+ | `wp-release i18n-mo <file.po> [options]` | Compile a translated PO file into MO. |
41
+ | `wp-release i18n-json <file.po> --domain <domain> [options]` | Compile the JavaScript strings of a translated PO file into WordPress script JSON files. |
42
+ | `wp-release i18n-php <file.po> [options]` | Compile a translated PO file into a WordPress `.l10n.php` file. |
38
43
  | `wp-release changeset-add [options]` | Record the release impact of a pull request. |
39
44
  | `wp-release changeset-status [options]` | List pending releases and check changed packages for changesets. |
40
45
  | `wp-release changeset-version [options]` | Apply version bumps and changelog entries. |
41
46
  | `wp-release changeset-tag [options]` | Create missing package release tags. |
42
47
 
43
- Run `npx wp-release <command> --help` for the options of each command. Directory-based commands accept a first `<source-dir>` argument or `--source-dir`, defaulting to the current directory. `sign` takes file arguments, `keygen` generates keys, and changeset commands operate on the configured repository. Every command prints its result as JSON with `--json`.
48
+ Run `npx wp-release <command> --help` for the options of each command. Directory-based commands accept a first `<source-dir>` argument or `--source-dir`, defaulting to the current directory. `sign` and the `i18n-mo`, `i18n-json` and `i18n-php` compilers take file arguments, `keygen` generates keys, and changeset commands operate on the configured repository. Every command prints its result as JSON with `--json`.
44
49
 
45
50
  In GitHub Actions, `--github-output` writes the complete result as JSON to `steps.<id>.outputs.result` and exposes each top-level string, number or boolean as a plain output. Use `steps.zip.outputs.zipPath` for a ZIP path, for example. Arrays and objects remain in `result`: tag metadata is read with `fromJSON( steps.tag.outputs.result ).tags[0].tag`, even when only one package is selected. There is no plain `steps.tag.outputs.tag` output.
46
51
 
@@ -795,6 +800,83 @@ A deploy releases the files of the working tree, so build the packages first. On
795
800
 
796
801
  Other monorepo tools split the same way with less of the release in the config: [splitsh-lite](https://github.com/splitsh/lite), [Symplify MonorepoBuilder](https://github.com/symplify/monorepo-builder) and [`danharrin/monorepo-split-github-action`](https://github.com/danharrin/monorepo-split-github-action) push the source history of each package directory, with the packages and repositories listed in the workflow or a matrix, while `deploy-git` releases the built files on top of that history, so `vendor/` and `build/` reach Composer and WordPress installs. Like the publishers of [GoReleaser](https://goreleaser.com/), targets are lists with templates, and pre-releases skip the targets where they would replace the stable version.
797
802
 
803
+ ### WordPress internationalization
804
+
805
+ `i18n-pot` extracts plugin/theme strings and writes a reproducible UTF-8 POT. `i18n-audit` reads the same source and returns findings without writing files. Both run with Node alone: no PHP, WordPress installation, database, WP-CLI or network requests. Extraction is independent of `files`, `.distignore` and Git `export-ignore` distribution rules.
806
+
807
+ ```sh
808
+ wp-release i18n-pot .
809
+ wp-release i18n-pot --package plugins/agent-pilot
810
+ wp-release i18n-pot --all
811
+ wp-release i18n-pot --package plugins/agent-pilot --check
812
+ wp-release i18n-audit --package plugins/agent-pilot --json
813
+ wp-release i18n-audit . --strict --suppress conflicting-comments
814
+ ```
815
+
816
+ Config-free source directories must have plugin/theme headers. The actual main file is discovered using the package metadata reader, including plugins whose main filename differs from their slug. The selected domain defaults to `Text Domain`, then the directory name. Calls with omitted domains belong to WordPress's `default` domain; block metadata without a domain inherits the package domain. `--all-domains` explicitly combines domains. A block's explicit `textdomain` always controls its messages.
817
+
818
+ `--package` selects one configured package and permits extraction-specific overrides; `--all` requires configuration and selects enabled WordPress packages only. Source directories, package selectors and `--all` are mutually exclusive. Config-free directory mode reads only its explicit options and metadata; it does not import repository config implicitly. A disabled package fails when explicitly selected. Configuration uses `i18n` at the root and on package entries, including an exact `glob` for an individual package, following the existing group model. Package entries remain non-overlapping; there is no additional package manifest configuration source.
819
+
820
+ ```js
821
+ export default defineConfig( {
822
+ root: 'packages',
823
+ i18n: {
824
+ headers: { 'Report-Msgid-Bugs-To': 'https://example.org/issues' },
825
+ fileComment: `Copyright (C) Example Company
826
+ This file is distributed under the same license as the Example plugin.`,
827
+ },
828
+ packages: [ {
829
+ glob: 'plugins/*',
830
+ i18n: {
831
+ include: [ '**/*.{php,js,jsx,ts,tsx,mjs,cjs}', '**/block.json', '**/theme.json' ],
832
+ exclude: [ '**/node_modules/**', '**/vendor/**', '**/tests/**', '**/build/**' ],
833
+ destination: 'languages',
834
+ strict: false,
835
+ },
836
+ } ],
837
+ } );
838
+ ```
839
+
840
+ Precedence is defaults, root configuration, package group/individual entry, then explicit command options. Scalars override, lists replace inherited lists, and gettext headers merge by key, case-insensitively, preserving intentionally empty values. A header set to `false` is removed, like `'Project-Id-Version': false` to keep the plugin version out of the POT so that it changes only when strings change; a later layer can set it again. `Content-Type` can't be removed, since it declares the UTF-8 charset. Options include `enabled`, `domain`, `allDomains`, `include`, `exclude`, `destination`, `headers`, `fileComment`, `references`, `strict`, `legacyGlobals` and audit `suppress`. `fileComment` adds static text above the POT header; each line is emitted as a gettext comment, and no copyright year is generated. The CLI accepts the same setting with `--file-comment`. Repeat `--include` or `--exclude` for multiple globs; these use case-sensitive [minimatch](https://github.com/isaacs/minimatch) semantics with `/` paths, `**`, brace expansion and dotfile matching, without gitignore negation or comma splitting. Default excludes cover dependency directories, dot directories, tests, minified files and generated translations. Source/build exclusions are a repository choice; this repository excludes `build`, `tools` and isolated vendors in its config.
841
+
842
+ The default output is `<Domain Path>/<text-domain>.pot`, or `languages/<text-domain>.pot`. A leading `/` in `Domain Path` is package-relative, and configured destinations cannot escape the package. `--destination` changes the package-relative directory. An explicit `--output <file.pot>` resolves against the command's working directory and requires one package. All multi-package destinations are validated before writing; each completed package replaces its file atomically, independently of other packages. `--check` compares bytes without writing and fails if the POT is absent or stale. Source references are preserved by default; `--no-references` omits them. `POT-Creation-Date` and `PO-Revision-Date` default to empty values; no current date or year is inserted. Name/version headers come from metadata, and `--header NAME=VALUE` overrides any default, including with an empty value, and `--header NAME` removes the header. The POT lists the plugin or theme header strings first, then the strings of PHP, JavaScript, `block.json` and other metadata files in source order, so related strings stay together for translators. Each entry has its extracted comments, references and flags before the strings, in the usual gettext layout. The default headers come in a fixed order, and custom headers follow them. No generator header or generated copyright comment is added; set `fileComment` for a static one.
843
+
844
+ #### Supported extraction syntax
845
+
846
+ PHP extraction uses `php-parser` in PHP 8.3 mode, tested with attributes, enums, match expressions, promoted properties and readonly classes. Supported WordPress functions are `__`, `_e`, `_x`, `_ex`, `_n`, `_nx`, `_n_noop`, `_nx_noop`, `esc_attr__`, `esc_attr_e`, `esc_attr_x`, `esc_html__`, `esc_html_e` and `esc_html_x`. Global and explicitly global calls, namespace fallback and explicit function imports/aliases are supported. Known file-local declarations and unrelated namespaced imports are excluded; methods and static methods are never translation calls. Named arguments resolve by PHP parameter name. Literal strings, static concatenation and non-interpolated heredoc/nowdoc preserve decoded escapes, Unicode and newlines. Counts may be dynamic; IDs, contexts and selected domains must be static.
847
+
848
+ JavaScript extraction uses Babel's parser and lexical scope traversal, without transforms or project Babel config. JS, JSX, TS, TSX, MJS, CJS, MTS and CTS are supported. Named imports/aliases and namespace imports from `@wordpress/i18n`, unshadowed `wp.i18n` calls and unbound canonical legacy `__`, `_x`, `_n` and `_nx` calls extract strings. `--no-legacy-globals` requires imported or `wp.i18n` bindings. Locally shadowed/reassigned imports are excluded. Static literals, templates without substitutions, concatenation and TypeScript assertions are supported. Destructured CommonJS imports, transpiled `(0, i18n.__)` forms and arbitrary minified bundles are outside the supported syntax; scan source and use explicit source/build mapping for compiled JSON.
849
+
850
+ A `translators:` comment attaches to the first translation call whose containing expression or nearest statement immediately follows it. Whitespace, parsed comments and ternary branch punctuation may intervene, but unrelated statements or argument expressions may not. Comments before outer wrappers reach nested translation calls; a comment is consumed once, so unrelated sibling calls do not inherit it. Multiline comment content is preserved. WordPress plugin/theme header labels serialize as extracted comments. Bundled metadata tables follow [WordPress 6.8 block fields](https://github.com/WordPress/wordpress-develop/blob/6.8/src/wp-includes/block-i18n.json) and [theme fields](https://github.com/WordPress/wordpress-develop/blob/6.8/src/wp-includes/theme-i18n.json), including styles, variations, presets, template names and nested block settings. Their upstream data license is GPL-2.0-or-later; tables are maintained intentionally with fixtures and never fetched during generation.
851
+
852
+ Invalid syntax, unreadable selected files, selected source symlinks, empty/reserved message IDs, conflicting plural definitions and output errors fail generation. Unsupported dynamic arguments warn and are fatal with `--strict`. Audit adds `placeholder-comment`, `unordered-placeholders`, `plural-placeholders`, `empty-message` and `conflicting-comments` findings; suppress these explicitly by code where justified. Placeholders support `%%`, positional indexes, numeric width/precision, padding/flags and PHP/JS sprintf conversion types. Audit checks extraction and message quality, not every untranslated user-facing string. Audit fails on errors; strict audit also fails on warnings. Failed checks return exit code `2`; unexpected I/O/option-loading errors follow the existing CLI error handling. `--json`, GitHub annotations, summaries and step outputs use the existing CLI reporting facilities.
853
+
854
+ #### Compile translated catalogs
855
+
856
+ Runtime formats require a translated PO, not an untranslated POT. PO updating, fuzzy matching and automatic translation are not provided. Compilation validates `Language` and `Plural-Forms`, excludes fuzzy, obsolete and untranslated entries, preserves contexts and plural forms, and never changes the input PO. An incompletely translated plural entry is excluded as a unit. Gettext parsing handles source encoding; runtime outputs use UTF-8. Each output is replaced atomically; JSON outputs are independent writes, and obsolete output files are not deleted automatically.
857
+
858
+ ```sh
859
+ wp-release i18n-mo languages/my-plugin-lv.po
860
+ wp-release i18n-php languages/my-plugin-lv.po
861
+ wp-release i18n-json languages/my-plugin-lv.po --domain my-plugin --map scripts.json --output languages
862
+ wp-release i18n-mo languages/my-plugin-lv.po --check
863
+ ```
864
+
865
+ MO and `.l10n.php` default beside the PO, with `.mo` and `.l10n.php` extensions; `--output` selects an explicit file relative to cwd. WordPress PHP catalogs require WordPress 6.5 or later. JSON requires an explicit domain; `--output` is a directory, defaulting to the PO directory. `--check` compares proposed bytes without writing and exits `2` on absent/stale outputs. Compiler commands are primitives and do not select configured packages.
866
+
867
+ A script map is a JSON object from normalized package-relative source references to arrays of normalized runtime script paths. Many source files can feed one bundle, and one source can feed several scripts. An explicit map selects only listed references; an empty target array excludes that source. Without a map, JS/MJS/CJS references use identity paths, while JSX/TypeScript references require mapping. PHP-only references do not enter JSON catalogs.
868
+
869
+ ```json
870
+ {
871
+ "src/blocks/editor.tsx": [ "build/editor.js", "build/shared.js" ],
872
+ "src/components/panel.js": [ "build/editor.js" ]
873
+ }
874
+ ```
875
+
876
+ JSON filenames are `<domain>-<Language>-<md5(runtime-path)>.json`; the WordPress `default` domain uses `<Language>-<md5(runtime-path)>.json`. WordPress's `.min.js` normalization applies consistently to grouping, hashing and the JSON `source`. Jed keys preserve context through `\u0004`, plural arrays contain all translated forms, and revision dates come from the PO metadata. `.l10n.php` writes safely escaped data arrays with NUL-separated plural translations; catalog text is never treated as executable PHP.
877
+
878
+ The package can be installed and used in any WordPress plugin or theme project; repository package selection is optional, and directory mode works without a wp-release config.
879
+
798
880
  ### Exit codes
799
881
 
800
882
  | Code | Meaning |
@@ -946,9 +1028,24 @@ const { valid, changed, missing, extra } = await verifyChecksums( {
946
1028
 
947
1029
  `loadFileRules()` reads the ignore file and `--exclude` patterns into a gitignore matcher, optionally adds Git `export-ignore` paths, and `selectFiles()` lists the files it doesn't ignore. `compareChecksums()` compares two hash maps without reading any files, and `parseChecksums()` validates manifest JSON. `extractZip()` extracts a ZIP after the same checks as `deploy-svn --from-zip`.
948
1030
 
1031
+ Translations:
1032
+ ```js
1033
+ import { extractCatalog, auditCatalog, serializePot, generatePackagePot } from '@wpelevator/wp-release';
1034
+
1035
+ const { catalog, diagnostics, files } = await extractCatalog( {
1036
+ sourceDir: '/project/plugin',
1037
+ options: { domain: 'my-plugin' },
1038
+ } );
1039
+ const findings = auditCatalog( catalog );
1040
+ const pot = serializePot( catalog );
1041
+ const result = await generatePackagePot( { sourceDir: '/project/plugin', check: true } );
1042
+ ```
1043
+
1044
+ Parsers return messages and structured diagnostics with stable codes, severity and source-relative locations. Catalog messages retain IDs, contexts, plurals, translations, translator comments, flags and structured references. `aggregateCatalog()` selects/merges a domain, unions references/comments and diagnoses plural conflicts. `resolveI18nPackages()` is the separate config adapter; extraction and serialization primitives know nothing about the repository. Library functions do not log, parse process arguments or set exit codes. Extraction results report unique files consulted, including the main plugin/theme file used for headers and header strings even when source globs exclude it. `parseTranslatedPo()`, `compileMo()`, `compileScriptTranslations()`, `compilePhpTranslations()` and `compileTranslations()` expose the same typed operations to library callers.
1045
+
949
1046
  ## Development
950
1047
 
951
- The source is TypeScript in `src/`, with tests next to the files they test (`*.test.ts`). The published package contains only the compiled `dist/` with JavaScript and `.d.ts` files.
1048
+ The source is TypeScript in `src/`, with tests next to the files they test (`*.test.ts`). The published package contains only the compiled `dist/` with JavaScript and `.d.ts` files; tests and translation fixtures stay in the repository.
952
1049
 
953
1050
  ```bash
954
1051
  npm run build --workspace @wpelevator/wp-release
@@ -960,6 +1057,8 @@ npm run lint --workspace @wpelevator/wp-release
960
1057
  - `test` runs the `.ts` tests directly with `node --test`, using Node.js type stripping, so it needs Node.js 22.18 or later but no build. The SVN integration tests in `svn-deploy.integration.test.ts` create repositories with `svnadmin` and deploy to their `file://` URLs; they're skipped when `svn` or `svnadmin` isn't installed.
961
1058
  - `lint` runs ESLint and a `tsc` type check.
962
1059
 
1060
+ The i18n tests validate gettext round trips, script catalogs and generated PHP data without launching PHP, WP-CLI or WordPress, covering contexts, plural forms, comments, flags, Unicode, escaping and PHP-looking input as data.
1061
+
963
1062
  The `wpelevator-source` export condition points at `src/index.ts`. The wp-release tests and monorepo CLI scripts use it to work with the source instead of a `dist/` build. Action builds use the declared dependency's default export, which resolves to `dist/` for the workspace copy and the published package. Build wp-release before building the actions.
964
1063
 
965
1064
  `execute( 'deploy-svn', args, { cwd, logger, svnRunner } )` runs a single-plugin deployment or a configured plan or deployment. The optional `svnRunner` replaces the external command runner in both modes; without it, the command uses its normal runner.
@@ -968,8 +1067,8 @@ The `wpelevator-source` export condition points at `src/index.ts`. The wp-releas
968
1067
 
969
1068
  wp-release has two layers, so that it fits any repository layout:
970
1069
 
971
- - **Primitives** work on the directory, file or values they're given and know nothing about the repository: `build <dir>`, `zip <dir>`, `checksums`, `sign`, `verify`, `version-check`, `deploy-git <dir>` and `deploy-svn <dir>`.
972
- - **The config layer** reads `wp-release.config.js` for everything about the repository: where packages are (`root` and the package globs), how their versions and tags are named (`version`, `tagFormat`), which files they ship (`files`), where they deploy (`deploy`) and the git `remote`. The changeset commands (with a single-project fallback when no config exists), `deploy-git` and `deploy-svn --release-ref`, and `build --package` and `zip --package` belong to it, and the global `--config` and `--tag-format` options select or adjust the config. `zip` and `checksums` also read caller configuration for integrity settings even without `--package`; verification reads it only when explicitly selected. Only `--release-ref` reads which package and version a ref releases. A primitive with a config mode, like `deploy-git --release-ref` beside `deploy-git <dir>` or `zip --package` beside `zip <dir>`, only takes the package directory, its files and its targets from the config there, and otherwise works the same.
1070
+ - **Primitives** work on the directory, file or values they're given and know nothing about the repository: `build <dir>`, `zip <dir>`, `checksums`, `sign`, `verify`, `version-check`, `deploy-git <dir>`, `deploy-svn <dir>`, `i18n-pot <dir>`, `i18n-audit <dir>` and the `i18n-mo`, `i18n-json` and `i18n-php` compilers.
1071
+ - **The config layer** reads `wp-release.config.js` for everything about the repository: where packages are (`root` and the package globs), how their versions and tags are named (`version`, `tagFormat`), which files they ship (`files`), how their translations are extracted (`i18n`), where they deploy (`deploy`) and the git `remote`. The changeset commands (with a single-project fallback when no config exists), `deploy-git` and `deploy-svn --release-ref`, `build --package` and `zip --package`, and `i18n-pot` and `i18n-audit` with `--package` or `--all` belong to it, and the global `--config` and `--tag-format` options select or adjust the config. `zip` and `checksums` also read caller configuration for integrity settings even without `--package`; verification reads it only when explicitly selected. Only `--release-ref` reads which package and version a ref releases. A primitive with a config mode, like `deploy-git --release-ref` beside `deploy-git <dir>` or `zip --package` beside `zip <dir>`, only takes the package directory, its files and its targets from the config there, and otherwise works the same.
973
1072
 
974
1073
  Configured repositories use the layout and naming conventions their config sets. Config-free changeset releases use the root-only detection and defaults described above.
975
1074
 
@@ -1004,6 +1103,23 @@ The changeset release modules, from the entry point down:
1004
1103
  | `file-journal.ts` | `FileJournal`, which restores every file a failed `changeset-version` run changed. |
1005
1104
  | `versions.ts` | Version checks and SemVer precedence. |
1006
1105
 
1106
+ The internationalization modules in `src/i18n/`, from the entry points down:
1107
+
1108
+ | Module | Responsibility |
1109
+ |---|---|
1110
+ | `packages.ts` | `resolveI18nPackages()`, the config adapter that selects a directory, one package or all packages and resolves their options and POT paths. |
1111
+ | `options.ts` | Validating and merging the `i18n` options of the config layers and the CLI. |
1112
+ | `generate.ts` | `extractCatalog()` and `generatePackagePot()`, which read the selected files of a package and write or check its POT. |
1113
+ | `compile.ts` | `compileTranslations()`, which compiles a translated PO into MO, script JSON or `.l10n.php` and writes or checks the outputs. |
1114
+ | `files.ts` | Selecting source files with the include and exclude globs, independently of the distribution file rules. |
1115
+ | `php.ts`, `javascript.ts` and `metadata.ts` | Parsers for PHP, JavaScript and TypeScript, and `block.json`, `theme.json` and plugin and theme headers. |
1116
+ | `extraction.ts` | The translation functions and their arguments, shared by the parsers. |
1117
+ | `catalog.ts` | Catalog types, merging messages of a domain and the diagnostics. |
1118
+ | `audit.ts` and `placeholders.ts` | Audit findings and the sprintf placeholders that they check. |
1119
+ | `pot.ts` | Serializing a catalog as a POT. |
1120
+ | `po.ts`, `translation-json.ts` and `translation-php.ts` | Parsing translated PO files and compiling MO, script JSON and `.l10n.php`. |
1121
+ | `atomic-output.ts` | Validating output paths and replacing output files atomically. |
1122
+
1007
1123
  ## Comparison with existing tools
1008
1124
 
1009
1125
  | Tool | What it does | How `wp-release` differs |
@@ -1012,5 +1128,7 @@ The changeset release modules, from the entry point down:
1012
1128
  | [`grunt-wp-deploy`](https://github.com/stephenharris/grunt-wp-deploy) | Grunt task with `deploy_trunk` and `deploy_tag` booleans, interactive password prompts and a `Stable tag` check. | No Grunt dependency and never interactive. `--readme-only` is a separate flag, where `grunt-wp-deploy` can only update trunk with the full build. Assets are independent of trunk writes. |
1013
1129
  | [codeinwp theme deploy](https://github.com/codeinwp/action-wordpress-theme-deploy) | Fork of the 10up plugin action that commits themes to the themes SVN repository. | Doesn't deploy themes. Theme releases are ZIP uploads on WordPress.org; `zip` builds the upload with the theme folder as the root. |
1014
1130
  | [`wp dist-archive`](https://github.com/wp-cli/dist-archive-command) | WP-CLI command that zips a project using `.distignore`, with `--plugin-dirname` and `--filename-format`. | Same `.distignore` file and `{name}.{version}` style filename format, but needs only Node.js instead of PHP and WP-CLI. `.distignore` is matched with full gitignore syntax, including `!` negation, and `--distignore` / `--exclude` let you point at another ignore file or add patterns on the command line. Builds are reproducible, and `--checksums` writes a manifest next to the ZIP. |
1131
+ | [`wp i18n make-pot`](https://developer.wordpress.org/cli/commands/i18n/make-pot/) | WP-CLI command that extracts PHP, JavaScript, `block.json` and `theme.json` strings into a POT, with `--domain`, `--include`/`--exclude`, `--headers`, `--file-comment`, `--merge`/`--subtract` and audit warnings. | `i18n-pot` and `i18n-audit` need only Node.js, write byte-for-byte reproducible POTs without dates, and `--check` fails CI on a stale POT. Headers are set one at a time with a repeatable `--header NAME=VALUE` instead of a JSON object, and `--include`/`--exclude` take one glob each, so patterns never need escaping or comma splitting. `--no-references` and `--all-domains` say what they do. Monorepos keep these settings once in `wp-release.config.js` and select packages with `--package` or `--all` instead of running the command for every directory. The audit is a separate read-only command with stable finding codes for `--suppress`, instead of warnings mixed into generation. There's no `--merge`, `--subtract` or `--skip-*`; `--include` and `--exclude` cover the same needs. |
1132
+ | [`wp i18n make-mo`, `make-json` and `make-php`](https://developer.wordpress.org/cli/commands/i18n/) | WP-CLI commands that compile PO files into MO, script JSON and `.l10n.php` files. `make-json` maps sources with `--use-map` and removes JavaScript strings from the PO unless `--no-purge` is passed. | `i18n-mo`, `i18n-json` and `i18n-php` compile one translated PO at a time, never change it, and leave out fuzzy, obsolete and untranslated entries. `i18n-json` takes a many-to-many `--map` from source files to built scripts, and JSX and TypeScript sources need one, so catalogs are never written for files WordPress doesn't load. `--check` compares the outputs without writing. There's no `update-po`, so update PO files from the POT with gettext's `msgmerge` or a translation platform. |
1015
1133
  | [`wp plugin verify-checksums`](https://developer.wordpress.org/cli/commands/plugin/verify-checksums/) | WP-CLI command that checks installed plugins against the WordPress.org checksums API. | Reads the same manifest format, but from a file, so it also works for plugins and themes that aren't hosted on WordPress.org and for build directories in CI. `checksums` writes the manifest that WordPress.org generates for its own plugins. |
1016
1134
  | [`wp-scripts plugin-zip`](https://github.com/WordPress/gutenberg/tree/trunk/packages/scripts#plugin-zip) | Zips files from `package.json` `files` or a fixed allowlist, named after the npm package, with `--root-folder` / `--no-root-folder`. | Reuses the root folder flags, selects files with `.distignore` by default and can optionally honor Git `export-ignore` attributes, puts the version from the plugin headers in the filename and streams the archive instead of building it in memory. |
@@ -1,3 +1,4 @@
1
+ import type { I18nOptions } from './i18n/options.ts';
1
2
  import type { IntegrityConfig } from './integrity.ts';
2
3
  import type { Changeset, ChangesetBump, ChangesetPackage } from './changesets.ts';
3
4
  export type PackageVersionSource = {
@@ -59,6 +60,8 @@ export interface PackageVersionConfig {
59
60
  export type PackageIdSource = 'package.json#name' | 'composer.json#name' | 'path';
60
61
  /** One directory pattern that describes packages sharing release behavior. */
61
62
  export interface ChangesetPackageGroup {
63
+ /** Extraction settings, independent of release file rules. */
64
+ i18n?: I18nOptions;
62
65
  /** Package directories to discover, relative to the configured package root. */
63
66
  glob: string;
64
67
  /** Package paths excluded from this group, relative to root. */
@@ -218,6 +221,8 @@ export interface DependentsConfig {
218
221
  }
219
222
  /** JavaScript configuration for changeset-aware releases. */
220
223
  export interface ChangesetConfig {
224
+ /** Extraction defaults for configured WordPress packages. */
225
+ i18n?: I18nOptions;
221
226
  /** Default checksum format, placement and manifest signing for ZIPs. */
222
227
  integrity?: IntegrityConfig;
223
228
  /** Branch receiving ordinary changes. */
@@ -6,6 +6,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
6
6
  }
7
7
  return path;
8
8
  };
9
+ import { validateI18nOptions } from "./i18n/options.js";
9
10
  import { access } from 'node:fs/promises';
10
11
  import path from 'node:path';
11
12
  import { pathToFileURL } from 'node:url';
@@ -171,6 +172,7 @@ function validateChangesetConfig(config, configFile) {
171
172
  throw new ValidationError(`wp-release config ${configFile} must default-export a configuration object.`);
172
173
  }
173
174
  const candidate = config;
175
+ validateI18nOptions(candidate.i18n, `${configFile}.i18n`);
174
176
  if (!Array.isArray(candidate.packages)) {
175
177
  throw new ValidationError(`wp-release config ${configFile} must define a packages array.`);
176
178
  }
@@ -267,6 +269,7 @@ function validateChangesetConfig(config, configFile) {
267
269
  !Array.isArray(group.version.to)))) {
268
270
  throw new ValidationError(`wp-release config ${configFile} has an invalid packages[${index}] entry.`);
269
271
  }
272
+ validateI18nOptions(group.i18n, `${configFile}.packages[${index}].i18n`);
270
273
  if (undefined !== group.idFrom &&
271
274
  !['path', 'package.json#name', 'composer.json#name'].includes(group.idFrom)) {
272
275
  throw new ValidationError(`wp-release config ${configFile} packages[${index}].idFrom is not supported.`);
package/dist/cli.d.ts CHANGED
@@ -59,6 +59,11 @@ export declare function formatGitHubOutputs(result: unknown): string;
59
59
  * Results of the commands that can be run with execute(), by command name.
60
60
  */
61
61
  export interface CommandResults {
62
+ 'i18n-mo': import('./i18n/compile.ts').CompileTranslationsResult;
63
+ 'i18n-json': import('./i18n/compile.ts').CompileTranslationsResult;
64
+ 'i18n-php': import('./i18n/compile.ts').CompileTranslationsResult;
65
+ 'i18n-pot': import('./commands/i18n.ts').I18nCommandResult;
66
+ 'i18n-audit': import('./commands/i18n.ts').I18nCommandResult;
62
67
  build: import('./commands/build.ts').BuildResult;
63
68
  'deploy-svn': import('./commands/deploy-svn.ts').DeploySvnResult;
64
69
  zip: ZipResult;
package/dist/cli.js CHANGED
@@ -1,6 +1,11 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { appendFileSync } from 'node:fs';
3
3
  import { parseArgs } from 'node:util';
4
+ import i18nMo from "./commands/i18n-mo.js";
5
+ import i18nJson from "./commands/i18n-json.js";
6
+ import i18nPhp from "./commands/i18n-php.js";
7
+ import i18nPot from "./commands/i18n-pot.js";
8
+ import i18nAudit from "./commands/i18n-audit.js";
4
9
  import zip from "./commands/zip.js";
5
10
  import build from "./commands/build.js";
6
11
  import checksums from "./commands/checksums.js";
@@ -23,6 +28,11 @@ export const EXIT_SUCCESS = 0;
23
28
  export const EXIT_FAILURE = 1;
24
29
  export const EXIT_INVALID = 2;
25
30
  export const commands = [
31
+ i18nMo,
32
+ i18nJson,
33
+ i18nPhp,
34
+ i18nPot,
35
+ i18nAudit,
26
36
  build,
27
37
  zip,
28
38
  checksums,
@@ -0,0 +1,2 @@
1
+ declare const _default: import("./command.ts").Command;
2
+ export default _default;
@@ -0,0 +1,2 @@
1
+ import { createI18nCommand } from "./i18n.js";
2
+ export default createI18nCommand('audit');
@@ -0,0 +1,6 @@
1
+ import type { Command } from './command.ts';
2
+ /**
3
+ * Flat compiler adapters: translated PO input, independent of package/source discovery.
4
+ * @param format
5
+ */
6
+ export declare function createI18nCompileCommand(format: 'mo' | 'json' | 'php'): Command;
@@ -0,0 +1,76 @@
1
+ import { resolve } from 'node:path';
2
+ import { compileTranslations } from "../i18n/compile.js";
3
+ import { ValidationError } from "../errors.js";
4
+ import { readJson } from "../json-files.js";
5
+ import { getBoolean, getString } from "./command.js";
6
+ /**
7
+ * Flat compiler adapters: translated PO input, independent of package/source discovery.
8
+ * @param format
9
+ */
10
+ export function createI18nCompileCommand(format) {
11
+ return {
12
+ name: `i18n-${format}`,
13
+ usage: `i18n-${format} <file.po> [options]`,
14
+ summary: `Compile translated PO into ${'php' === format ? 'WordPress .l10n.php' : format.toUpperCase()} files.`,
15
+ help: `Options:
16
+ --output <path> Output file (${format === 'json' ? 'directory for JSON' : 'defaults beside the PO'})
17
+ --check Compare generated bytes without writing
18
+ ${'json' === format
19
+ ? ' --domain <domain> Required text domain\n --map <file.json> Source paths to runtime script path arrays (many-to-many)'
20
+ : ''}
21
+
22
+ Requires Language and Plural-Forms headers. Fuzzy, obsolete and untranslated
23
+ entries are excluded. Input PO files are never changed. No PHP/WP-CLI runtime
24
+ is needed; compilation validates data before independent atomic output writes.`,
25
+ options: {
26
+ output: { type: 'string' },
27
+ check: { type: 'boolean' },
28
+ ...('json' === format
29
+ ? {
30
+ domain: { type: 'string' },
31
+ map: { type: 'string' },
32
+ }
33
+ : {}),
34
+ },
35
+ async run({ values, positionals, cwd, logger }) {
36
+ if (1 !== positionals.length || !positionals[0]?.trim()) {
37
+ throw new ValidationError(`i18n-${format} requires exactly one input PO file.`);
38
+ }
39
+ const output = getString(values, 'output');
40
+ const map = getString(values, 'map');
41
+ if ('' === output?.trim() || '' === map?.trim()) {
42
+ throw new ValidationError('--output and --map paths must not be empty.');
43
+ }
44
+ let scriptMap;
45
+ if (map) {
46
+ const parsed = await readJson(resolve(cwd, map));
47
+ if (!parsed ||
48
+ 'object' !== typeof parsed ||
49
+ Array.isArray(parsed)) {
50
+ throw new ValidationError('The script map must be an object of source paths to runtime path arrays.');
51
+ }
52
+ scriptMap = parsed;
53
+ }
54
+ const result = await compileTranslations({
55
+ sourcePath: resolve(cwd, positionals[0]),
56
+ format,
57
+ ...(output ? { outputPath: resolve(cwd, output) } : {}),
58
+ domain: getString(values, 'domain'),
59
+ scriptMap,
60
+ check: getBoolean(values, 'check'),
61
+ });
62
+ const stale = result.outputs.filter((outputFile) => !outputFile.current);
63
+ for (const outputFile of result.outputs) {
64
+ logger.info(outputFile.outputPath);
65
+ }
66
+ for (const outputFile of stale) {
67
+ logger.error('Translation output is missing or stale.', {
68
+ file: outputFile.outputPath,
69
+ title: 'stale-translation',
70
+ });
71
+ }
72
+ logger.summary(() => `### wp-release i18n-${format}\n\n${result.messages} translated messages for ${result.locale}; ${result.outputs.length} outputs.\n`);
73
+ return { exitCode: stale.length ? 2 : 0, result };
74
+ },
75
+ };
76
+ }
@@ -0,0 +1,2 @@
1
+ declare const _default: import("./command.ts").Command;
2
+ export default _default;
@@ -0,0 +1,2 @@
1
+ import { createI18nCompileCommand } from "./i18n-compile.js";
2
+ export default createI18nCompileCommand('json');
@@ -0,0 +1,2 @@
1
+ declare const _default: import("./command.ts").Command;
2
+ export default _default;
@@ -0,0 +1,2 @@
1
+ import { createI18nCompileCommand } from "./i18n-compile.js";
2
+ export default createI18nCompileCommand('mo');
@@ -0,0 +1,2 @@
1
+ declare const _default: import("./command.ts").Command;
2
+ export default _default;
@@ -0,0 +1,2 @@
1
+ import { createI18nCompileCommand } from "./i18n-compile.js";
2
+ export default createI18nCompileCommand('php');
@@ -0,0 +1,2 @@
1
+ declare const _default: import("./command.ts").Command;
2
+ export default _default;
@@ -0,0 +1,2 @@
1
+ import { createI18nCommand } from "./i18n.js";
2
+ export default createI18nCommand('pot');
@@ -0,0 +1,12 @@
1
+ import type { PackagePotResult } from '../i18n/generate.ts';
2
+ import type { Command } from './command.ts';
3
+ export interface I18nCommandResult {
4
+ packages: Array<PackagePotResult & {
5
+ package?: string;
6
+ }>;
7
+ }
8
+ /**
9
+ * Small command adapters share selection/reporting; domain logic stays in the library.
10
+ * @param operation
11
+ */
12
+ export declare function createI18nCommand(operation: 'pot' | 'audit'): Command;
@@ -0,0 +1,174 @@
1
+ import { resolve } from 'node:path';
2
+ import { hasFatalDiagnostics } from "../i18n/catalog.js";
3
+ import { extractCatalog, generatePackagePot } from "../i18n/generate.js";
4
+ import { auditCatalog } from "../i18n/audit.js";
5
+ import { resolveI18nPackages } from "../i18n/packages.js";
6
+ import { ValidationError } from "../errors.js";
7
+ import { getBoolean, getString, getStrings } from "./command.js";
8
+ function cliOptions(values) {
9
+ const headers = {};
10
+ for (const header of getStrings(values, 'header')) {
11
+ const index = header.indexOf('=');
12
+ if (0 === index || !header) {
13
+ throw new ValidationError('--header requires NAME=VALUE, with an empty value allowed, or NAME to remove the header.');
14
+ }
15
+ if (-1 === index) {
16
+ headers[header] = false;
17
+ }
18
+ else {
19
+ headers[header.slice(0, index)] = header.slice(index + 1);
20
+ }
21
+ }
22
+ const domain = getString(values, 'domain');
23
+ const destination = getString(values, 'destination');
24
+ const fileComment = getString(values, 'file-comment');
25
+ return {
26
+ ...(undefined !== domain ? { domain } : {}),
27
+ ...(undefined !== destination ? { destination } : {}),
28
+ ...(undefined !== fileComment ? { fileComment } : {}),
29
+ ...(values.include
30
+ ? { include: getStrings(values, 'include') }
31
+ : {}),
32
+ ...(values.exclude
33
+ ? { exclude: getStrings(values, 'exclude') }
34
+ : {}),
35
+ ...(values.suppress
36
+ ? { suppress: getStrings(values, 'suppress') }
37
+ : {}),
38
+ ...(getBoolean(values, 'strict') ? { strict: true } : {}),
39
+ ...(getBoolean(values, 'all-domains') ? { allDomains: true } : {}),
40
+ ...(getBoolean(values, 'no-references')
41
+ ? { references: false }
42
+ : {}),
43
+ ...(getBoolean(values, 'no-legacy-globals')
44
+ ? { legacyGlobals: false }
45
+ : {}),
46
+ ...(Object.keys(headers).length ? { headers } : {}),
47
+ };
48
+ }
49
+ function report(context, diagnostics, sourceDir) {
50
+ for (const diagnostic of diagnostics) {
51
+ context.logger[diagnostic.severity](diagnostic.message, {
52
+ file: resolve(sourceDir, diagnostic.file),
53
+ line: diagnostic.line,
54
+ title: diagnostic.code,
55
+ });
56
+ }
57
+ }
58
+ /**
59
+ * Small command adapters share selection/reporting; domain logic stays in the library.
60
+ * @param operation
61
+ */
62
+ export function createI18nCommand(operation) {
63
+ return {
64
+ name: `i18n-${operation}`,
65
+ usage: `i18n-${operation} [<source-dir>] [options]`,
66
+ summary: 'pot' === operation
67
+ ? 'Extract WordPress source strings into a reproducible POT.'
68
+ : 'Audit WordPress source strings without writing files.',
69
+ help: `Options:
70
+ --package <name> Configured package (extraction overrides allowed)
71
+ --all All enabled configured WordPress packages
72
+ --source-dir <dir> Plugin/theme directory, or positional argument (default .)
73
+ --domain <domain> Select one domain (default Text Domain, then slug)
74
+ --all-domains Explicitly combine domains
75
+ --include <glob> Repeatable source globs, replacing inherited includes
76
+ --exclude <glob> Repeatable exclusion globs, replacing inherited excludes
77
+ --destination <dir> Package-relative POT directory
78
+ --header <NAME=VALUE> Repeatable gettext header override; NAME alone removes it
79
+ ${'pot' === operation
80
+ ? ' --file-comment <text> Static comment prepended to the POT\n'
81
+ : ''}
82
+ --no-references Omit source references
83
+ --no-legacy-globals JS requires imports or wp.i18n calls
84
+ --strict Warnings fail with exit code 2
85
+ ${'pot' === operation
86
+ ? ' --output <file.pot> Explicit output relative to cwd; one package only\n --check Compare bytes without writing'
87
+ : ' --suppress <code> Repeatable audit finding codes to suppress'}
88
+
89
+ No PHP, WordPress, WP-CLI or network needed. Distribution file rules do not apply.
90
+ Each package writes atomically; a multi-package run is not a transaction.`,
91
+ options: {
92
+ package: { type: 'string' },
93
+ all: { type: 'boolean' },
94
+ 'source-dir': { type: 'string' },
95
+ domain: { type: 'string' },
96
+ 'all-domains': { type: 'boolean' },
97
+ include: { type: 'string', multiple: true },
98
+ exclude: { type: 'string', multiple: true },
99
+ destination: { type: 'string' },
100
+ header: { type: 'string', multiple: true },
101
+ 'no-references': { type: 'boolean' },
102
+ 'no-legacy-globals': { type: 'boolean' },
103
+ strict: { type: 'boolean' },
104
+ ...('pot' === operation
105
+ ? {
106
+ 'file-comment': { type: 'string' },
107
+ output: { type: 'string' },
108
+ check: { type: 'boolean' },
109
+ }
110
+ : { suppress: { type: 'string', multiple: true } }),
111
+ },
112
+ async run(context) {
113
+ const { values, positionals, cwd, configFile, logger } = context;
114
+ if (positionals.length > 1 ||
115
+ (positionals.length && undefined !== values['source-dir'])) {
116
+ throw new ValidationError('Pass only one source directory.');
117
+ }
118
+ const source = getString(values, 'source-dir');
119
+ if (undefined !== source && !source.trim()) {
120
+ throw new ValidationError('--source-dir must not be empty.');
121
+ }
122
+ const output = getString(values, 'output');
123
+ if (undefined !== output && !output.trim()) {
124
+ throw new ValidationError('--output must not be empty.');
125
+ }
126
+ const selected = await resolveI18nPackages({
127
+ cwd,
128
+ configFile,
129
+ package: getString(values, 'package'),
130
+ all: getBoolean(values, 'all'),
131
+ sourceDir: getString(values, 'source-dir') ?? positionals[0],
132
+ options: cliOptions(values),
133
+ validateOutputs: 'pot' === operation,
134
+ ...(output ? { outputPath: resolve(cwd, output) } : {}),
135
+ });
136
+ const result = { packages: [] };
137
+ let failed = false;
138
+ for (const pkg of selected) {
139
+ let generated;
140
+ if ('pot' === operation) {
141
+ generated = await generatePackagePot({
142
+ ...pkg,
143
+ check: getBoolean(values, 'check'),
144
+ });
145
+ }
146
+ else {
147
+ const extracted = await extractCatalog(pkg);
148
+ generated = {
149
+ outputPath: pkg.outputPath,
150
+ messages: extracted.catalog.messages.length,
151
+ files: extracted.files,
152
+ diagnostics: [
153
+ ...extracted.diagnostics,
154
+ ...auditCatalog(extracted.catalog, pkg.options.suppress),
155
+ ],
156
+ written: false,
157
+ current: false,
158
+ };
159
+ }
160
+ result.packages.push({
161
+ ...generated,
162
+ ...(pkg.package ? { package: pkg.package } : {}),
163
+ });
164
+ failed ||= hasFatalDiagnostics(generated.diagnostics, pkg.options.strict);
165
+ report(context, generated.diagnostics, pkg.sourceDir);
166
+ logger.info(`${pkg.package ?? pkg.sourceDir}: ${generated.messages} messages, ${generated.files} files.`);
167
+ }
168
+ logger.summary(() => `### wp-release i18n-${operation}\n\n${result.packages
169
+ .map((pkg) => `- ${pkg.package ?? pkg.outputPath}: ${pkg.messages} messages, ${pkg.diagnostics.length} diagnostics.`)
170
+ .join('\n')}\n`);
171
+ return { exitCode: failed ? 2 : 0, result };
172
+ },
173
+ };
174
+ }