@wpelevator/wp-release 0.15.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 (98) hide show
  1. package/README.md +366 -39
  2. package/dist/changeset-config.d.ts +8 -0
  3. package/dist/changeset-config.js +3 -0
  4. package/dist/changeset-release.d.ts +9 -3
  5. package/dist/changeset-release.js +23 -14
  6. package/dist/cli.d.ts +6 -0
  7. package/dist/cli.js +12 -0
  8. package/dist/commands/artifact-signing.d.ts +20 -0
  9. package/dist/commands/artifact-signing.js +42 -0
  10. package/dist/commands/build.d.ts +32 -0
  11. package/dist/commands/build.js +91 -0
  12. package/dist/commands/changeset-add.d.ts +55 -6
  13. package/dist/commands/changeset-add.js +12 -3
  14. package/dist/commands/changeset-args.js +2 -2
  15. package/dist/commands/changeset-project-args.d.ts +63 -0
  16. package/dist/commands/changeset-project-args.js +114 -0
  17. package/dist/commands/changeset-status.d.ts +50 -1
  18. package/dist/commands/changeset-status.js +4 -2
  19. package/dist/commands/changeset-tag.d.ts +55 -6
  20. package/dist/commands/changeset-tag.js +7 -4
  21. package/dist/commands/changeset-version.d.ts +55 -6
  22. package/dist/commands/changeset-version.js +4 -2
  23. package/dist/commands/checksums.d.ts +4 -1
  24. package/dist/commands/checksums.js +27 -6
  25. package/dist/commands/i18n-audit.d.ts +2 -0
  26. package/dist/commands/i18n-audit.js +2 -0
  27. package/dist/commands/i18n-compile.d.ts +6 -0
  28. package/dist/commands/i18n-compile.js +76 -0
  29. package/dist/commands/i18n-json.d.ts +2 -0
  30. package/dist/commands/i18n-json.js +2 -0
  31. package/dist/commands/i18n-mo.d.ts +2 -0
  32. package/dist/commands/i18n-mo.js +2 -0
  33. package/dist/commands/i18n-php.d.ts +2 -0
  34. package/dist/commands/i18n-php.js +2 -0
  35. package/dist/commands/i18n-pot.d.ts +2 -0
  36. package/dist/commands/i18n-pot.js +2 -0
  37. package/dist/commands/i18n.d.ts +12 -0
  38. package/dist/commands/i18n.js +174 -0
  39. package/dist/commands/package-source.d.ts +43 -0
  40. package/dist/commands/package-source.js +101 -0
  41. package/dist/commands/verify.d.ts +7 -1
  42. package/dist/commands/verify.js +53 -17
  43. package/dist/commands/zip.d.ts +20 -8
  44. package/dist/commands/zip.js +109 -124
  45. package/dist/i18n/atomic-output.d.ts +23 -0
  46. package/dist/i18n/atomic-output.js +90 -0
  47. package/dist/i18n/audit.d.ts +7 -0
  48. package/dist/i18n/audit.js +69 -0
  49. package/dist/i18n/catalog.d.ts +54 -0
  50. package/dist/i18n/catalog.js +86 -0
  51. package/dist/i18n/compile.d.ts +27 -0
  52. package/dist/i18n/compile.js +71 -0
  53. package/dist/i18n/extraction.d.ts +45 -0
  54. package/dist/i18n/extraction.js +105 -0
  55. package/dist/i18n/files.d.ts +7 -0
  56. package/dist/i18n/files.js +45 -0
  57. package/dist/i18n/generate.d.ts +49 -0
  58. package/dist/i18n/generate.js +240 -0
  59. package/dist/i18n/javascript.d.ts +7 -0
  60. package/dist/i18n/javascript.js +143 -0
  61. package/dist/i18n/metadata.d.ts +16 -0
  62. package/dist/i18n/metadata.js +117 -0
  63. package/dist/i18n/options.d.ts +52 -0
  64. package/dist/i18n/options.js +156 -0
  65. package/dist/i18n/packages.d.ts +24 -0
  66. package/dist/i18n/packages.js +86 -0
  67. package/dist/i18n/php.d.ts +9 -0
  68. package/dist/i18n/php.js +238 -0
  69. package/dist/i18n/placeholders.d.ts +9 -0
  70. package/dist/i18n/placeholders.js +13 -0
  71. package/dist/i18n/po.d.ts +24 -0
  72. package/dist/i18n/po.js +180 -0
  73. package/dist/i18n/pot.d.ts +16 -0
  74. package/dist/i18n/pot.js +119 -0
  75. package/dist/i18n/translation-json.d.ts +14 -0
  76. package/dist/i18n/translation-json.js +96 -0
  77. package/dist/i18n/translation-php.d.ts +6 -0
  78. package/dist/i18n/translation-php.js +35 -0
  79. package/dist/index.d.ts +28 -0
  80. package/dist/index.js +16 -0
  81. package/dist/integrity.d.ts +33 -0
  82. package/dist/integrity.js +115 -0
  83. package/dist/output-files.d.ts +7 -0
  84. package/dist/output-files.js +25 -0
  85. package/dist/package-build.d.ts +33 -0
  86. package/dist/package-build.js +112 -0
  87. package/dist/package-files.d.ts +25 -0
  88. package/dist/package-files.js +24 -0
  89. package/dist/package-zip.d.ts +14 -2
  90. package/dist/package-zip.js +129 -38
  91. package/dist/release-tags.js +6 -0
  92. package/dist/single-project.d.ts +32 -0
  93. package/dist/single-project.js +144 -0
  94. package/dist/summary.d.ts +9 -0
  95. package/dist/summary.js +25 -0
  96. package/dist/zip.d.ts +3 -0
  97. package/dist/zip.js +16 -6
  98. package/package.json +20 -3
@@ -1,108 +1,15 @@
1
- import { basename, resolve } from 'node:path';
1
+ import { checksumSigningArgs, getArtifactSigning } from "./artifact-signing.js";
2
2
  import { ValidationError } from "../errors.js";
3
- import { resolveFileSelection } from "../file-selection.js";
3
+ import { basename, dirname, resolve, sep } from 'node:path';
4
+ import { loadChangesetConfig } from "../changeset-config.js";
5
+ import { resolveManifestCodec, checkChecksumsFile } from "../integrity.js";
6
+ import { getPackageArg, getPackageSource, getDirectorySource, } from "./package-source.js";
4
7
  import { FILENAME_FORMAT, formatFilename } from "../filename.js";
5
- import { getPackageFiles } from "../package-deploy.js";
6
- import { readPackageMeta } from "../package-meta.js";
7
8
  import { getChecksumsPath, zipPackage } from "../package-zip.js";
8
- import { SIGNING_KEY_ENV, checkSigningInput, signFiles } from "../signing.js";
9
+ import { SIGNING_KEY_ENV, signFiles, getSignaturePath } from "../signing.js";
9
10
  import { getSignSummary, getZipSummary } from "../summary.js";
10
- import { checkDirectory, fileSelectionArgs, getFileSelectionInput, getSourceDirArg, parseSignatureFormats, resolveOutput, sourceDirArgs, } from "./args.js";
11
+ import { fileSelectionArgs, parseSignatureFormats, resolveOutput, sourceDirArgs, } from "./args.js";
11
12
  import { getBoolean, getString, getStrings } from "./command.js";
12
- /** Options of a source directory and its files, which --package takes from the config. */
13
- const SOURCE_FLAGS = [
14
- 'source-dir',
15
- 'distignore',
16
- 'no-distignore',
17
- 'exclude',
18
- 'exclude-gitattributes',
19
- ];
20
- /**
21
- * Get the configured package to zip, failing for the options that set the
22
- * source directory and the files instead.
23
- *
24
- * @param values Parsed CLI values.
25
- * @param positionals Positional arguments.
26
- * @return The package identifier or path, or undefined without --package.
27
- * @throws {ValidationError} For an empty --package, or --package with a source directory or file selection option.
28
- */
29
- function getPackageArg(values, positionals) {
30
- const name = getString(values, 'package')?.trim();
31
- const conflicts = SOURCE_FLAGS.filter((flag) => undefined !== getString(values, flag) ||
32
- getBoolean(values, flag) ||
33
- getStrings(values, flag).length);
34
- if ('' === name) {
35
- throw new ValidationError('--package <name> must not be empty.');
36
- }
37
- if (name && (positionals.length || conflicts.length)) {
38
- throw new ValidationError(`--package takes the directory and files of the package from the config, so it can't be combined with ${positionals.length
39
- ? 'a source directory'
40
- : conflicts.map((flag) => `--${flag}`).join(', ')}.`);
41
- }
42
- return name;
43
- }
44
- /**
45
- * Describe the files of a directory with the slug and the version from the
46
- * options, or from the plugin or theme headers.
47
- *
48
- * @param values Parsed CLI values.
49
- * @param sourceDir Directory to zip.
50
- * @param selection File selection.
51
- * @param slug Default slug.
52
- * @return Source directory, file selection, slug, version and type.
53
- */
54
- async function describeSource(values, sourceDir, selection, slug) {
55
- const name = getString(values, 'slug') ?? slug;
56
- const meta = await readPackageMeta({ dir: sourceDir, slug: name });
57
- return {
58
- sourceDir,
59
- selection,
60
- slug: name,
61
- version: getString(values, 'version') ?? meta?.version ?? null,
62
- ...(meta ? { type: meta.type } : {}),
63
- };
64
- }
65
- /**
66
- * Get the files of a source directory from the CLI options.
67
- *
68
- * @param values Parsed CLI values.
69
- * @param positionals Positional arguments.
70
- * @param cwd Working directory.
71
- * @return Source directory, file selection, slug and version.
72
- */
73
- async function getDirectorySource(values, positionals, cwd) {
74
- const sourceDir = resolve(cwd, getSourceDirArg(values, positionals));
75
- await checkDirectory(sourceDir);
76
- return describeSource(values, sourceDir, resolveFileSelection({
77
- cwd,
78
- sourceDir,
79
- ...getFileSelectionInput(values),
80
- }), basename(cwd));
81
- }
82
- /**
83
- * Get the directory of a configured package with the files config of its
84
- * package group, which its deploys use too.
85
- *
86
- * @param values Parsed CLI values.
87
- * @param cwd Repository root with the wp-release config.
88
- * @param name Package identifier or path under the configured root.
89
- * @param configFile Config file, instead of the conventional one.
90
- * @return Source directory, file selection, slug, version and package.
91
- * @throws {ValidationError} Without a config or for an unknown package.
92
- */
93
- async function getPackageSource(values, cwd, name, configFile) {
94
- const pkg = await getPackageFiles({
95
- cwd,
96
- package: name,
97
- ...(configFile ? { configFile } : {}),
98
- });
99
- const source = await describeSource(values, pkg.directory, resolveFileSelection({
100
- cwd,
101
- sourceDir: pkg.directory,
102
- ...pkg.files,
103
- }), basename(pkg.directory));
104
- return { ...source, package: pkg.name };
105
- }
106
13
  export default {
107
14
  name: 'zip',
108
15
  usage: 'zip [<source-dir>] [options] | zip --package <name> [options]',
@@ -135,18 +42,24 @@ Options:
135
42
  --checksums Also write the checksums manifest of the archived
136
43
  files next to the ZIP, with .checksums.json
137
44
  instead of .zip
138
- --sign Also sign the ZIP and the checksums manifest with
139
- the ${SIGNING_KEY_ENV} key, like
140
- "wp-release sign", writing a WordPress .sig next
141
- to each
45
+ --checksums-file <file> Relative manifest filename; implies checksums.
46
+ Beside the ZIP, or inside its package root with
47
+ --embed-checksums. No absolute paths or dot segments.
48
+ --checksums-format <format> wordpress-json (default) or sha256sum
49
+ --checksums-algorithms <names> md5,sha256 (default), comma-separated or repeated
50
+ --embed-checksums Embed manifest instead of writing it beside ZIP
51
+ --no-embed-checksums Override embedding configured in integrity
52
+ --sign-checksums Sign the manifest only, before embedding it
53
+ --no-sign-checksums Override configured manifest signing
54
+ --sign Sign only the ZIP with the ${SIGNING_KEY_ENV} key
142
55
  --sign-format <names> Signature formats of --sign, comma-separated or
143
56
  repeated, like wordpress,minisign,ssh, as the
144
57
  --format of "wp-release sign" (default:
145
- wordpress); implies --sign
58
+ wordpress); requires --sign or --sign-checksums
146
59
  --force Overwrite existing output files
147
60
 
148
- Entries are sorted and use a fixed timestamp and file mode, so the same files
149
- always give the same ZIP.
61
+ Entries are sorted and use a fixed timestamp and file mode. Embedded minisign
62
+ signatures include their signing time; other built-in metadata is deterministic.
150
63
 
151
64
  Examples:
152
65
  wp-release zip dist --slug my-plugin --checksums
@@ -163,9 +76,14 @@ Examples:
163
76
  ...sourceDirArgs,
164
77
  ...fileSelectionArgs,
165
78
  package: { type: 'string' },
166
- checksums: { type: 'boolean' },
79
+ ...checksumSigningArgs,
80
+ 'checksums-file': { type: 'string' },
81
+ 'checksums-format': { type: 'string' },
82
+ 'checksums-algorithms': { type: 'string', multiple: true },
83
+ 'embed-checksums': { type: 'boolean' },
84
+ 'no-embed-checksums': { type: 'boolean' },
85
+ 'no-sign-checksums': { type: 'boolean' },
167
86
  sign: { type: 'boolean' },
168
- 'sign-format': { type: 'string', multiple: true },
169
87
  force: { type: 'boolean' },
170
88
  },
171
89
  async run({ values, positionals, cwd, configFile, logger, }) {
@@ -179,21 +97,68 @@ Examples:
179
97
  output: getString(values, 'output'),
180
98
  filename: `${filename}.zip`,
181
99
  });
100
+ const loaded = await loadChangesetConfig({ cwd, configFile });
101
+ const configured = loaded?.config.integrity;
102
+ const algorithms = getStrings(values, 'checksums-algorithms').flatMap((value) => value.split(','));
103
+ const integrity = {
104
+ ...configured,
105
+ codec: getString(values, 'checksums-format') ?? configured?.codec,
106
+ ...(algorithms.length
107
+ ? { algorithms: algorithms }
108
+ : {}),
109
+ };
110
+ const codec = resolveManifestCodec(integrity.codec);
111
+ const embed = !getBoolean(values, 'no-embed-checksums') &&
112
+ (getBoolean(values, 'embed-checksums') ||
113
+ Boolean(configured?.embed));
114
+ const checksumsFile = checkChecksumsFile(getString(values, 'checksums-file') ??
115
+ configured?.file ??
116
+ (codec.id === 'wordpress-json'
117
+ ? basename(getChecksumsPath(zipPath))
118
+ : codec.filename));
119
+ const manifestSign = !getBoolean(values, 'no-sign-checksums') &&
120
+ (getBoolean(values, 'sign-checksums') ||
121
+ Boolean(configured?.sign));
122
+ const checksums = getBoolean(values, 'checksums') ||
123
+ embed ||
124
+ manifestSign ||
125
+ Boolean(getString(values, 'checksums-file') ?? configured?.file);
182
126
  const signFormats = getStrings(values, 'sign-format');
183
- const signing = getBoolean(values, 'sign') || signFormats.length
184
- ? {
185
- paths: [
186
- zipPath,
187
- ...(getBoolean(values, 'checksums')
188
- ? [getChecksumsPath(zipPath)]
189
- : []),
190
- ],
191
- formats: parseSignatureFormats(signFormats),
192
- force: getBoolean(values, 'force'),
193
- }
127
+ const configuredFormats = configured?.sign
128
+ ? configured.sign.formats ?? []
129
+ : [];
130
+ const formats = parseSignatureFormats(signFormats.length ? signFormats : configuredFormats);
131
+ if (signFormats.length &&
132
+ !manifestSign &&
133
+ !getBoolean(values, 'sign')) {
134
+ throw new ValidationError('--sign-format requires --sign or --sign-checksums; it does not enable signing.');
135
+ }
136
+ const signing = getBoolean(values, 'sign')
137
+ ? getArtifactSigning({
138
+ ...values,
139
+ 'sign-checksums': false,
140
+ 'sign-format': formats,
141
+ }, {
142
+ artifact: zipPath,
143
+ checksums: resolve(dirname(zipPath), checksumsFile),
144
+ })
194
145
  : undefined;
195
- if (signing) {
196
- checkSigningInput(signing);
146
+ const externalManifest = checksums && !embed
147
+ ? resolve(dirname(zipPath), checksumsFile)
148
+ : undefined;
149
+ const artifacts = [
150
+ zipPath,
151
+ ...(externalManifest ? [externalManifest] : []),
152
+ ...(signing
153
+ ? formats.map((format) => getSignaturePath(zipPath, format))
154
+ : []),
155
+ ...(externalManifest && manifestSign
156
+ ? formats.map((format) => getSignaturePath(externalManifest, format))
157
+ : []),
158
+ ];
159
+ if (new Set(artifacts).size !== artifacts.length ||
160
+ artifacts.some((path, index) => artifacts.some((other, otherIndex) => index !== otherIndex && other.startsWith(path + sep)))) {
161
+ throw new ValidationError('ZIP, manifest and signature destinations must be distinct.');
197
162
  }
198
163
  const zipped = await zipPackage({
199
164
  sourceDir: source.sourceDir,
@@ -205,7 +170,11 @@ Examples:
205
170
  slug: source.slug,
206
171
  version: source.version,
207
172
  ...(source.type ? { type: source.type } : {}),
208
- checksums: getBoolean(values, 'checksums'),
173
+ checksums,
174
+ checksumsFile: checksums ? checksumsFile : undefined,
175
+ embedChecksums: embed,
176
+ integrity,
177
+ signChecksums: manifestSign ? formats : undefined,
209
178
  force: getBoolean(values, 'force'),
210
179
  });
211
180
  logger.debug(`Archived ${zipped.files} files from ${source.sourceDir}.`);
@@ -219,9 +188,18 @@ Examples:
219
188
  signed?.signatures.forEach((path) => logger.info(path));
220
189
  const result = {
221
190
  ...zipped,
191
+ ...(zipped.checksumsSigning
192
+ ? {
193
+ signatures: zipped.checksumsSignatures,
194
+ publicKey: zipped.checksumsSigning.publicKey,
195
+ }
196
+ : {}),
222
197
  ...(signed
223
198
  ? {
224
- signatures: signed.signatures,
199
+ signatures: [
200
+ ...signed.signatures,
201
+ ...(zipped.checksumsSignatures ?? []),
202
+ ],
225
203
  publicKey: signed.publicKey,
226
204
  }
227
205
  : {}),
@@ -230,6 +208,13 @@ Examples:
230
208
  ...(source.package ? { package: source.package } : {}),
231
209
  };
232
210
  logger.summary((context) => getZipSummary(context, result));
211
+ const checksumsSigning = zipped.checksumsSigning;
212
+ if (checksumsSigning) {
213
+ logger.summary((context) => getSignSummary(context, {
214
+ ...checksumsSigning,
215
+ formats,
216
+ }));
217
+ }
233
218
  if (signed) {
234
219
  logger.summary((context) => getSignSummary(context, signed));
235
220
  }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Resolve missing destinations through their existing ancestors, including directory symlinks.
3
+ * @param file
4
+ */
5
+ export declare function canonicalI18nPath(file: string): Promise<string>;
6
+ /**
7
+ * Validate a complete output set before writes, with optional forbidden source paths.
8
+ * @param paths
9
+ * @param sources
10
+ */
11
+ export declare function validateI18nDestinations(paths: string[], sources?: string[]): Promise<void>;
12
+ /**
13
+ * Configured package-relative destinations must remain contained through symlink ancestors.
14
+ * @param root
15
+ * @param file
16
+ */
17
+ export declare function validateI18nContainment(root: string, file: string): Promise<void>;
18
+ /**
19
+ * Stage bytes beside the target, then replace it; failed writes preserve previous output.
20
+ * @param file
21
+ * @param bytes
22
+ */
23
+ export declare function writeI18nOutput(file: string, bytes: Buffer): Promise<void>;
@@ -0,0 +1,90 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { basename, dirname, isAbsolute, relative, resolve } from 'node:path';
4
+ import { ValidationError } from "../errors.js";
5
+ /**
6
+ * Resolve missing destinations through their existing ancestors, including directory symlinks.
7
+ * @param file
8
+ */
9
+ export async function canonicalI18nPath(file) {
10
+ let ancestor = resolve(file);
11
+ const missing = [];
12
+ let canonical;
13
+ while (!canonical) {
14
+ try {
15
+ canonical = await realpath(ancestor);
16
+ }
17
+ catch (error) {
18
+ if ('ENOENT' !== error.code) {
19
+ throw error;
20
+ }
21
+ missing.unshift(basename(ancestor));
22
+ ancestor = dirname(ancestor);
23
+ }
24
+ }
25
+ if (missing.length && !(await stat(ancestor)).isDirectory()) {
26
+ throw new ValidationError(`Output ancestor is not a directory: ${ancestor}.`);
27
+ }
28
+ return resolve(canonical, ...missing);
29
+ }
30
+ /**
31
+ * Validate a complete output set before writes, with optional forbidden source paths.
32
+ * @param paths
33
+ * @param sources
34
+ */
35
+ export async function validateI18nDestinations(paths, sources = []) {
36
+ const seen = new Set();
37
+ const comparisonKey = (path) => ['darwin', 'win32'].includes(process.platform)
38
+ ? path.toLowerCase()
39
+ : path;
40
+ const forbidden = new Set(await Promise.all(sources.map(async (source) => comparisonKey(await canonicalI18nPath(source)))));
41
+ for (const file of paths) {
42
+ const canonical = await canonicalI18nPath(file);
43
+ const key = comparisonKey(canonical);
44
+ if (seen.has(key) || forbidden.has(key)) {
45
+ throw new ValidationError(`Output collision or attempted source overwrite: ${file}.`);
46
+ }
47
+ seen.add(key);
48
+ const exists = await stat(canonical).catch((error) => {
49
+ if ('ENOENT' !== error.code) {
50
+ throw error;
51
+ }
52
+ return undefined;
53
+ });
54
+ if (exists && !exists.isFile()) {
55
+ throw new ValidationError(`Output is not a file: ${file}.`);
56
+ }
57
+ }
58
+ }
59
+ /**
60
+ * Configured package-relative destinations must remain contained through symlink ancestors.
61
+ * @param root
62
+ * @param file
63
+ */
64
+ export async function validateI18nContainment(root, file) {
65
+ const parent = await canonicalI18nPath(root);
66
+ const output = await canonicalI18nPath(file);
67
+ const path = relative(parent, output);
68
+ if (isAbsolute(path) ||
69
+ '..' === path ||
70
+ path.startsWith('../') ||
71
+ path.startsWith('..\\')) {
72
+ throw new ValidationError(`The i18n destination must stay inside the package directory: ${file}.`);
73
+ }
74
+ }
75
+ /**
76
+ * Stage bytes beside the target, then replace it; failed writes preserve previous output.
77
+ * @param file
78
+ * @param bytes
79
+ */
80
+ export async function writeI18nOutput(file, bytes) {
81
+ await mkdir(dirname(file), { recursive: true });
82
+ const temporary = `${file}.${randomUUID()}.tmp`;
83
+ try {
84
+ await writeFile(temporary, bytes, { flag: 'wx' });
85
+ await rename(temporary, file);
86
+ }
87
+ finally {
88
+ await rm(temporary, { force: true });
89
+ }
90
+ }
@@ -0,0 +1,7 @@
1
+ import type { Catalog, I18nDiagnostic } from './catalog.ts';
2
+ /**
3
+ * Read-only findings. Suppression applies to audit rules, never extraction failures.
4
+ * @param catalog
5
+ * @param suppress
6
+ */
7
+ export declare function auditCatalog(catalog: Catalog, suppress?: string[]): I18nDiagnostic[];
@@ -0,0 +1,69 @@
1
+ import { getPlaceholders } from "./placeholders.js";
2
+ function signature(values) {
3
+ return values
4
+ .map((value, index) => `${value.position ?? index + 1}:${value.type}`)
5
+ .sort()
6
+ .join(',');
7
+ }
8
+ /**
9
+ * Read-only findings. Suppression applies to audit rules, never extraction failures.
10
+ * @param catalog
11
+ * @param suppress
12
+ */
13
+ export function auditCatalog(catalog, suppress = []) {
14
+ const diagnostics = [];
15
+ for (const message of catalog.messages) {
16
+ const forms = [
17
+ message.id,
18
+ ...(undefined !== message.plural ? [message.plural] : []),
19
+ ];
20
+ const tokens = forms.map(getPlaceholders);
21
+ const codes = [];
22
+ if (tokens.some((values) => values.length) &&
23
+ !message.comments.some((comment) => /translators:/i.test(comment))) {
24
+ codes.push([
25
+ 'placeholder-comment',
26
+ 'Add a translators: comment explaining the placeholders.',
27
+ ]);
28
+ }
29
+ if (tokens.some((values) => values.length > 1 &&
30
+ values.some((value) => !value.position))) {
31
+ codes.push([
32
+ 'unordered-placeholders',
33
+ 'Use positional placeholders when a message contains multiple substitutions.',
34
+ ]);
35
+ }
36
+ if (tokens.length > 1 &&
37
+ signature(tokens[0] ?? []) !== signature(tokens[1] ?? [])) {
38
+ codes.push([
39
+ 'plural-placeholders',
40
+ 'Singular and plural placeholders must have compatible positions and types.',
41
+ ]);
42
+ }
43
+ if (forms.some((form) => !form.trim() ||
44
+ !form
45
+ .replace(/%%|%(?:\d+\$)?[-+ 0#]*(?:'[^\r\n])?\d*(?:\.\d+)?[bcdeEfFgGosuxX]/g, '')
46
+ .trim())) {
47
+ codes.push([
48
+ 'empty-message',
49
+ 'Avoid empty or placeholder-only translation messages.',
50
+ ]);
51
+ }
52
+ if (message.comments.filter((comment) => /translators:/i.test(comment)).length > 1) {
53
+ codes.push([
54
+ 'conflicting-comments',
55
+ 'Review different translator comments attached to the same message.',
56
+ ]);
57
+ }
58
+ for (const [code, description] of codes.filter(([findingCode]) => !suppress.includes(findingCode))) {
59
+ diagnostics.push({
60
+ file: '',
61
+ ...message.references[0],
62
+ code,
63
+ severity: 'warning',
64
+ message: `${description} Message: ${JSON.stringify(message.id)}`,
65
+ });
66
+ }
67
+ }
68
+ return diagnostics;
69
+ }
@@ -0,0 +1,54 @@
1
+ import type { I18nHeaders } from './options.ts';
2
+ /** A location relative to the extraction root, with one-based line/column. */
3
+ export interface SourceReference {
4
+ file: string;
5
+ line?: number;
6
+ column?: number;
7
+ }
8
+ export interface I18nDiagnostic extends SourceReference {
9
+ code: string;
10
+ severity: 'error' | 'warning';
11
+ message: string;
12
+ }
13
+ /** The shared gettext model; parser and serializer dependencies stay at the edges. */
14
+ export interface CatalogMessage {
15
+ id: string;
16
+ plural?: string;
17
+ context?: string;
18
+ translations: string[];
19
+ comments: string[];
20
+ flags: string[];
21
+ references: SourceReference[];
22
+ }
23
+ export interface ExtractedMessage extends CatalogMessage {
24
+ domain: string;
25
+ }
26
+ export interface ExtractionResult {
27
+ messages: ExtractedMessage[];
28
+ diagnostics: I18nDiagnostic[];
29
+ }
30
+ export interface Catalog {
31
+ domain: string;
32
+ /** Header overrides; `false` removes a default header. */
33
+ headers: I18nHeaders;
34
+ messages: CatalogMessage[];
35
+ }
36
+ export interface CatalogResult {
37
+ catalog: Catalog;
38
+ diagnostics: I18nDiagnostic[];
39
+ }
40
+ /**
41
+ * Merge a selected domain in extraction order, diagnosing incompatible plural definitions.
42
+ * @param extracted
43
+ * @param domain
44
+ * @param headers
45
+ * @param allDomains
46
+ */
47
+ export declare function aggregateCatalog(extracted: ExtractedMessage[], domain: string, headers?: I18nHeaders, allDomains?: boolean): CatalogResult;
48
+ /**
49
+ * Locale-independent ordering, stable on every supported platform.
50
+ * @param a
51
+ * @param b
52
+ */
53
+ export declare function compareText(a: string, b: string): number;
54
+ export declare function hasFatalDiagnostics(diagnostics: I18nDiagnostic[], strict?: boolean): boolean;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Merge a selected domain in extraction order, diagnosing incompatible plural definitions.
3
+ * @param extracted
4
+ * @param domain
5
+ * @param headers
6
+ * @param allDomains
7
+ */
8
+ export function aggregateCatalog(extracted, domain, headers = {}, allDomains = false) {
9
+ const entries = new Map();
10
+ const diagnostics = [];
11
+ for (const message of extracted.filter((entry) => allDomains || entry.domain === domain)) {
12
+ if (!message.id ||
13
+ /[\0\u0004]/.test(message.id) ||
14
+ /[\0\u0004]/.test(message.context ?? '')) {
15
+ diagnostics.push({
16
+ file: '',
17
+ ...message.references[0],
18
+ code: 'catalog-invalid-id',
19
+ severity: 'error',
20
+ message: 'Message IDs must be non-empty and must not contain reserved gettext separators.',
21
+ });
22
+ }
23
+ const key = JSON.stringify([message.context ?? '', message.id]);
24
+ const previous = entries.get(key);
25
+ if (previous) {
26
+ if (previous.plural !== message.plural) {
27
+ diagnostics.push({
28
+ code: 'catalog-plural-conflict',
29
+ severity: 'error',
30
+ message: `Conflicting plurals for ${JSON.stringify(message.id)}.`,
31
+ file: '',
32
+ ...message.references[0],
33
+ });
34
+ }
35
+ previous.references.push(...message.references);
36
+ previous.comments.push(...message.comments);
37
+ previous.flags.push(...message.flags);
38
+ }
39
+ else {
40
+ entries.set(key, {
41
+ id: message.id,
42
+ ...(undefined !== message.plural
43
+ ? { plural: message.plural }
44
+ : {}),
45
+ ...(undefined !== message.context
46
+ ? { context: message.context }
47
+ : {}),
48
+ translations: [...message.translations],
49
+ comments: [...message.comments],
50
+ flags: [...message.flags],
51
+ references: [...message.references],
52
+ });
53
+ }
54
+ }
55
+ // Extraction order keeps related strings together and gives translators their context.
56
+ const messages = [...entries.values()];
57
+ for (const message of messages) {
58
+ message.comments = [...new Set(message.comments)];
59
+ message.flags = [...new Set(message.flags)].sort(compareText);
60
+ const references = message.references.map((reference) => ({
61
+ ...reference,
62
+ file: reference.file.replaceAll('\\', '/'),
63
+ }));
64
+ message.references = [
65
+ ...new Map(references.map((reference) => [
66
+ JSON.stringify(reference),
67
+ reference,
68
+ ])).values(),
69
+ ];
70
+ }
71
+ return {
72
+ catalog: { domain, headers: { ...headers }, messages },
73
+ diagnostics,
74
+ };
75
+ }
76
+ /**
77
+ * Locale-independent ordering, stable on every supported platform.
78
+ * @param a
79
+ * @param b
80
+ */
81
+ export function compareText(a, b) {
82
+ return a < b ? -1 : Number(a > b);
83
+ }
84
+ export function hasFatalDiagnostics(diagnostics, strict = false) {
85
+ return diagnostics.some((diagnostic) => 'error' === diagnostic.severity || strict);
86
+ }
@@ -0,0 +1,27 @@
1
+ import type { ScriptTranslationMap } from './translation-json.ts';
2
+ export interface CompileTranslationsInput {
3
+ /** Absolute input PO path. */
4
+ sourcePath: string;
5
+ format: 'mo' | 'json' | 'php';
6
+ /** File for MO/PHP, directory for JSON; absolute when supplied. */
7
+ outputPath?: string;
8
+ domain?: string;
9
+ scriptMap?: ScriptTranslationMap;
10
+ check?: boolean;
11
+ }
12
+ export interface CompileTranslationsResult {
13
+ sourcePath: string;
14
+ locale: string;
15
+ messages: number;
16
+ outputs: Array<{
17
+ outputPath: string;
18
+ source?: string;
19
+ written: boolean;
20
+ current: boolean;
21
+ }>;
22
+ }
23
+ /**
24
+ * Parse/validate every proposed output before independently replacing completed files.
25
+ * @param input
26
+ */
27
+ export declare function compileTranslations(input: CompileTranslationsInput): Promise<CompileTranslationsResult>;