@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.
- package/README.md +366 -39
- package/dist/changeset-config.d.ts +8 -0
- package/dist/changeset-config.js +3 -0
- package/dist/changeset-release.d.ts +9 -3
- package/dist/changeset-release.js +23 -14
- package/dist/cli.d.ts +6 -0
- package/dist/cli.js +12 -0
- package/dist/commands/artifact-signing.d.ts +20 -0
- package/dist/commands/artifact-signing.js +42 -0
- package/dist/commands/build.d.ts +32 -0
- package/dist/commands/build.js +91 -0
- package/dist/commands/changeset-add.d.ts +55 -6
- package/dist/commands/changeset-add.js +12 -3
- package/dist/commands/changeset-args.js +2 -2
- package/dist/commands/changeset-project-args.d.ts +63 -0
- package/dist/commands/changeset-project-args.js +114 -0
- package/dist/commands/changeset-status.d.ts +50 -1
- package/dist/commands/changeset-status.js +4 -2
- package/dist/commands/changeset-tag.d.ts +55 -6
- package/dist/commands/changeset-tag.js +7 -4
- package/dist/commands/changeset-version.d.ts +55 -6
- package/dist/commands/changeset-version.js +4 -2
- package/dist/commands/checksums.d.ts +4 -1
- package/dist/commands/checksums.js +27 -6
- package/dist/commands/i18n-audit.d.ts +2 -0
- package/dist/commands/i18n-audit.js +2 -0
- package/dist/commands/i18n-compile.d.ts +6 -0
- package/dist/commands/i18n-compile.js +76 -0
- package/dist/commands/i18n-json.d.ts +2 -0
- package/dist/commands/i18n-json.js +2 -0
- package/dist/commands/i18n-mo.d.ts +2 -0
- package/dist/commands/i18n-mo.js +2 -0
- package/dist/commands/i18n-php.d.ts +2 -0
- package/dist/commands/i18n-php.js +2 -0
- package/dist/commands/i18n-pot.d.ts +2 -0
- package/dist/commands/i18n-pot.js +2 -0
- package/dist/commands/i18n.d.ts +12 -0
- package/dist/commands/i18n.js +174 -0
- package/dist/commands/package-source.d.ts +43 -0
- package/dist/commands/package-source.js +101 -0
- package/dist/commands/verify.d.ts +7 -1
- package/dist/commands/verify.js +53 -17
- package/dist/commands/zip.d.ts +20 -8
- package/dist/commands/zip.js +109 -124
- package/dist/i18n/atomic-output.d.ts +23 -0
- package/dist/i18n/atomic-output.js +90 -0
- package/dist/i18n/audit.d.ts +7 -0
- package/dist/i18n/audit.js +69 -0
- package/dist/i18n/catalog.d.ts +54 -0
- package/dist/i18n/catalog.js +86 -0
- package/dist/i18n/compile.d.ts +27 -0
- package/dist/i18n/compile.js +71 -0
- package/dist/i18n/extraction.d.ts +45 -0
- package/dist/i18n/extraction.js +105 -0
- package/dist/i18n/files.d.ts +7 -0
- package/dist/i18n/files.js +45 -0
- package/dist/i18n/generate.d.ts +49 -0
- package/dist/i18n/generate.js +240 -0
- package/dist/i18n/javascript.d.ts +7 -0
- package/dist/i18n/javascript.js +143 -0
- package/dist/i18n/metadata.d.ts +16 -0
- package/dist/i18n/metadata.js +117 -0
- package/dist/i18n/options.d.ts +52 -0
- package/dist/i18n/options.js +156 -0
- package/dist/i18n/packages.d.ts +24 -0
- package/dist/i18n/packages.js +86 -0
- package/dist/i18n/php.d.ts +9 -0
- package/dist/i18n/php.js +238 -0
- package/dist/i18n/placeholders.d.ts +9 -0
- package/dist/i18n/placeholders.js +13 -0
- package/dist/i18n/po.d.ts +24 -0
- package/dist/i18n/po.js +180 -0
- package/dist/i18n/pot.d.ts +16 -0
- package/dist/i18n/pot.js +119 -0
- package/dist/i18n/translation-json.d.ts +14 -0
- package/dist/i18n/translation-json.js +96 -0
- package/dist/i18n/translation-php.d.ts +6 -0
- package/dist/i18n/translation-php.js +35 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +16 -0
- package/dist/integrity.d.ts +33 -0
- package/dist/integrity.js +115 -0
- package/dist/output-files.d.ts +7 -0
- package/dist/output-files.js +25 -0
- package/dist/package-build.d.ts +33 -0
- package/dist/package-build.js +112 -0
- package/dist/package-files.d.ts +25 -0
- package/dist/package-files.js +24 -0
- package/dist/package-zip.d.ts +14 -2
- package/dist/package-zip.js +129 -38
- package/dist/release-tags.js +6 -0
- package/dist/single-project.d.ts +32 -0
- package/dist/single-project.js +144 -0
- package/dist/summary.d.ts +9 -0
- package/dist/summary.js +25 -0
- package/dist/zip.d.ts +3 -0
- package/dist/zip.js +16 -6
- package/package.json +20 -3
package/dist/commands/zip.js
CHANGED
|
@@ -1,108 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { checksumSigningArgs, getArtifactSigning } from "./artifact-signing.js";
|
|
2
2
|
import { ValidationError } from "../errors.js";
|
|
3
|
-
import {
|
|
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,
|
|
9
|
+
import { SIGNING_KEY_ENV, signFiles, getSignaturePath } from "../signing.js";
|
|
9
10
|
import { getSignSummary, getZipSummary } from "../summary.js";
|
|
10
|
-
import {
|
|
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
|
-
--
|
|
139
|
-
the
|
|
140
|
-
|
|
141
|
-
|
|
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);
|
|
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
|
|
149
|
-
|
|
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
|
-
|
|
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
|
|
184
|
-
?
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
196
|
-
|
|
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
|
|
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:
|
|
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>;
|