@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/i18n/php.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { Engine } from 'php-parser';
|
|
2
|
+
import { extractCalls, syntaxFailure } from "./extraction.js";
|
|
3
|
+
const simple = {
|
|
4
|
+
id: 0,
|
|
5
|
+
domain: 1,
|
|
6
|
+
parameters: ['text', 'domain'],
|
|
7
|
+
};
|
|
8
|
+
const contextual = {
|
|
9
|
+
id: 0,
|
|
10
|
+
context: 1,
|
|
11
|
+
domain: 2,
|
|
12
|
+
parameters: ['text', 'context', 'domain'],
|
|
13
|
+
};
|
|
14
|
+
const plural = {
|
|
15
|
+
id: 0,
|
|
16
|
+
plural: 1,
|
|
17
|
+
domain: 3,
|
|
18
|
+
parameters: ['single', 'plural', 'number', 'domain'],
|
|
19
|
+
};
|
|
20
|
+
const contextualPlural = {
|
|
21
|
+
id: 0,
|
|
22
|
+
plural: 1,
|
|
23
|
+
context: 3,
|
|
24
|
+
domain: 4,
|
|
25
|
+
parameters: ['single', 'plural', 'number', 'context', 'domain'],
|
|
26
|
+
};
|
|
27
|
+
/** PHP argument names and positions follow the WordPress l10n.php signatures. */
|
|
28
|
+
export const PHP_TRANSLATION_FUNCTIONS = {
|
|
29
|
+
__: simple,
|
|
30
|
+
_e: simple,
|
|
31
|
+
esc_attr__: simple,
|
|
32
|
+
esc_attr_e: simple,
|
|
33
|
+
esc_html__: simple,
|
|
34
|
+
esc_html_e: simple,
|
|
35
|
+
_x: contextual,
|
|
36
|
+
_ex: contextual,
|
|
37
|
+
esc_attr_x: contextual,
|
|
38
|
+
esc_html_x: contextual,
|
|
39
|
+
_n: plural,
|
|
40
|
+
_nx: contextualPlural,
|
|
41
|
+
_n_noop: {
|
|
42
|
+
id: 0,
|
|
43
|
+
plural: 1,
|
|
44
|
+
domain: 2,
|
|
45
|
+
parameters: ['singular', 'plural', 'domain'],
|
|
46
|
+
},
|
|
47
|
+
_nx_noop: {
|
|
48
|
+
id: 0,
|
|
49
|
+
plural: 1,
|
|
50
|
+
context: 2,
|
|
51
|
+
domain: 3,
|
|
52
|
+
parameters: ['singular', 'plural', 'context', 'domain'],
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
function nodes(node) {
|
|
56
|
+
return Object.entries(node)
|
|
57
|
+
.filter(([key]) => ![
|
|
58
|
+
'loc',
|
|
59
|
+
'comments',
|
|
60
|
+
'leadingComments',
|
|
61
|
+
'trailingComments',
|
|
62
|
+
].includes(key))
|
|
63
|
+
.flatMap(([, value]) => {
|
|
64
|
+
const values = Array.isArray(value) ? value : [value];
|
|
65
|
+
return values.filter((child) => !!child && 'object' === typeof child && 'kind' in child);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
function nameOf(node) {
|
|
69
|
+
if ('string' === typeof node?.name) {
|
|
70
|
+
return node.name;
|
|
71
|
+
}
|
|
72
|
+
return 'string' === typeof node?.name?.name ? node.name.name : '';
|
|
73
|
+
}
|
|
74
|
+
function staticString(node) {
|
|
75
|
+
if (node &&
|
|
76
|
+
['string', 'nowdoc'].includes(node.kind) &&
|
|
77
|
+
'string' === typeof node.value) {
|
|
78
|
+
return node.value;
|
|
79
|
+
}
|
|
80
|
+
if ('bin' === node?.kind && '.' === node.type) {
|
|
81
|
+
const left = staticString(node.left);
|
|
82
|
+
const right = staticString(node.right);
|
|
83
|
+
return undefined !== left && undefined !== right
|
|
84
|
+
? left + right
|
|
85
|
+
: undefined;
|
|
86
|
+
}
|
|
87
|
+
if ('encapsed' === node?.kind &&
|
|
88
|
+
'heredoc' === node.type &&
|
|
89
|
+
Array.isArray(node.value)) {
|
|
90
|
+
const parts = node.value.map((part) => staticString(part.expression));
|
|
91
|
+
return parts.every((part) => undefined !== part)
|
|
92
|
+
? parts.join('')
|
|
93
|
+
: undefined;
|
|
94
|
+
}
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
function bindingsFor(root) {
|
|
98
|
+
const imports = new Map();
|
|
99
|
+
const functions = new Set();
|
|
100
|
+
function collect(node) {
|
|
101
|
+
if ('function' === node.kind) {
|
|
102
|
+
functions.add(nameOf(node).toLowerCase());
|
|
103
|
+
}
|
|
104
|
+
if ('usegroup' === node.kind) {
|
|
105
|
+
for (const item of node.items ?? []) {
|
|
106
|
+
if ('function' === (item.type ?? node.type)) {
|
|
107
|
+
const imported = [nameOf(node), nameOf(item)]
|
|
108
|
+
.filter(Boolean)
|
|
109
|
+
.join('\\')
|
|
110
|
+
.replace(/^\\/, '')
|
|
111
|
+
.toLowerCase();
|
|
112
|
+
imports.set((nameOf(item.alias) ||
|
|
113
|
+
imported.split('\\').at(-1) ||
|
|
114
|
+
'').toLowerCase(), imported);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if ('namespace' !== node.kind || node === root) {
|
|
119
|
+
nodes(node).forEach(collect);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
collect(root);
|
|
123
|
+
return { imports, functions };
|
|
124
|
+
}
|
|
125
|
+
function resolveFunction(node, bindings, globalFunctions) {
|
|
126
|
+
if ('name' !== node?.kind) {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
const name = nameOf(node).toLowerCase();
|
|
130
|
+
if (name.startsWith('\\')) {
|
|
131
|
+
return globalFunctions.has(name.slice(1))
|
|
132
|
+
? undefined
|
|
133
|
+
: name.slice(1);
|
|
134
|
+
}
|
|
135
|
+
if (name.includes('\\') || 'rn' === node.resolution) {
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
if (bindings.imports.has(name)) {
|
|
139
|
+
const imported = bindings.imports.get(name);
|
|
140
|
+
return imported && !globalFunctions.has(imported)
|
|
141
|
+
? imported
|
|
142
|
+
: undefined;
|
|
143
|
+
}
|
|
144
|
+
return bindings.functions.has(name) || globalFunctions.has(name)
|
|
145
|
+
? undefined
|
|
146
|
+
: name;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Parse PHP without executing it; namespaced fallback calls follow known file-local bindings.
|
|
150
|
+
* @param input
|
|
151
|
+
*/
|
|
152
|
+
export function parsePhpTranslations(input) {
|
|
153
|
+
try {
|
|
154
|
+
const parser = new Engine({
|
|
155
|
+
parser: { extractDoc: true, version: '8.3' },
|
|
156
|
+
ast: { withPositions: true },
|
|
157
|
+
});
|
|
158
|
+
const ast = parser.parseCode(input.contents, input.file);
|
|
159
|
+
const declarations = new Map();
|
|
160
|
+
function collectDeclarations(node, namespace) {
|
|
161
|
+
const currentNamespace = 'namespace' === node.kind
|
|
162
|
+
? nameOf(node).toLowerCase()
|
|
163
|
+
: namespace;
|
|
164
|
+
if ('function' === node.kind) {
|
|
165
|
+
const names = declarations.get(currentNamespace) ?? new Set();
|
|
166
|
+
names.add(nameOf(node).toLowerCase());
|
|
167
|
+
declarations.set(currentNamespace, names);
|
|
168
|
+
}
|
|
169
|
+
nodes(node).forEach((child) => collectDeclarations(child, currentNamespace));
|
|
170
|
+
}
|
|
171
|
+
collectDeclarations(ast, '');
|
|
172
|
+
const globalFunctions = declarations.get('') ?? new Set();
|
|
173
|
+
const calls = [];
|
|
174
|
+
function visit(node, bindings, statementStart, anchors) {
|
|
175
|
+
const current = 'namespace' === node.kind
|
|
176
|
+
? {
|
|
177
|
+
...bindingsFor(node),
|
|
178
|
+
functions: declarations.get(nameOf(node).toLowerCase()) ?? new Set(),
|
|
179
|
+
}
|
|
180
|
+
: bindings;
|
|
181
|
+
const start = node.loc?.start.offset ?? statementStart;
|
|
182
|
+
const statement = [
|
|
183
|
+
'expressionstatement',
|
|
184
|
+
'echo',
|
|
185
|
+
'return',
|
|
186
|
+
'throw',
|
|
187
|
+
'if',
|
|
188
|
+
'while',
|
|
189
|
+
'for',
|
|
190
|
+
'foreach',
|
|
191
|
+
].includes(node.kind)
|
|
192
|
+
? start
|
|
193
|
+
: statementStart;
|
|
194
|
+
const currentAnchors = statement === start ? [start] : [start, ...anchors];
|
|
195
|
+
if ('call' === node.kind) {
|
|
196
|
+
const name = resolveFunction(node.what, current, globalFunctions);
|
|
197
|
+
const signature = name && Object.hasOwn(PHP_TRANSLATION_FUNCTIONS, name)
|
|
198
|
+
? PHP_TRANSLATION_FUNCTIONS[name]
|
|
199
|
+
: undefined;
|
|
200
|
+
if (name && signature) {
|
|
201
|
+
const args = [];
|
|
202
|
+
for (const [index, argument] of (node.arguments ?? []).entries()) {
|
|
203
|
+
const position = 'namedargument' === argument.kind
|
|
204
|
+
? signature.parameters.indexOf(nameOf(argument))
|
|
205
|
+
: index;
|
|
206
|
+
if (position >= 0) {
|
|
207
|
+
args[position] = staticString('namedargument' === argument.kind
|
|
208
|
+
? argument.value
|
|
209
|
+
: argument);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
calls.push({
|
|
213
|
+
name,
|
|
214
|
+
signature,
|
|
215
|
+
arguments: args,
|
|
216
|
+
file: input.file,
|
|
217
|
+
line: node.loc?.start.line,
|
|
218
|
+
column: (node.loc?.start.column ?? 0) + 1,
|
|
219
|
+
start,
|
|
220
|
+
statementStart: statement,
|
|
221
|
+
commentAnchors: currentAnchors,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
nodes(node).forEach((child) => visit(child, current, statement, currentAnchors));
|
|
226
|
+
}
|
|
227
|
+
visit(ast, bindingsFor(ast), 0, []);
|
|
228
|
+
const comments = (ast.comments ?? []).map((comment) => ({
|
|
229
|
+
start: comment.loc?.start.offset ?? 0,
|
|
230
|
+
end: comment.loc?.end.offset ?? 0,
|
|
231
|
+
value: String(comment.value ?? ''),
|
|
232
|
+
}));
|
|
233
|
+
return extractCalls(input, calls, comments);
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
return syntaxFailure(input.file, error);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PHP sprintf and JS sprintf-compatible forms; literal %% consumes no argument.
|
|
3
|
+
* @param text
|
|
4
|
+
*/
|
|
5
|
+
export function getPlaceholders(text) {
|
|
6
|
+
const matches = text.matchAll(/%%|%(?:(\d+)\$)?[-+ 0#]*(?:'[^\r\n])?\d*(?:\.\d+)?([bcdeEfFgGosuxX])/g);
|
|
7
|
+
return [...matches]
|
|
8
|
+
.filter((match) => '%%' !== match[0])
|
|
9
|
+
.map((match) => ({
|
|
10
|
+
...(match[1] ? { position: Number(match[1]) } : {}),
|
|
11
|
+
type: match[2] ?? '',
|
|
12
|
+
}));
|
|
13
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Catalog, CatalogMessage } from './catalog.ts';
|
|
2
|
+
export interface TranslatedCatalog extends Catalog {
|
|
3
|
+
/** Headers parsed from the PO, always with string values. */
|
|
4
|
+
headers: Record<string, string>;
|
|
5
|
+
locale: string;
|
|
6
|
+
pluralForms: string;
|
|
7
|
+
nplurals: number;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Parse translated PO as data, filtering fuzzy, obsolete and untranslated entries.
|
|
11
|
+
* @param contents
|
|
12
|
+
* @param domain
|
|
13
|
+
*/
|
|
14
|
+
export declare function parseTranslatedPo(contents: Buffer | string, domain: string): TranslatedCatalog;
|
|
15
|
+
/**
|
|
16
|
+
* WordPress/Jed lookup key uses context + EOT + singular, even for plural messages.
|
|
17
|
+
* @param message
|
|
18
|
+
*/
|
|
19
|
+
export declare function translationKey(message: CatalogMessage): string;
|
|
20
|
+
/**
|
|
21
|
+
* Compile MO through gettext-parser; never write or mutate the input PO.
|
|
22
|
+
* @param catalog
|
|
23
|
+
*/
|
|
24
|
+
export declare function compileMo(catalog: TranslatedCatalog): Buffer;
|
package/dist/i18n/po.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { po, mo } from 'gettext-parser';
|
|
2
|
+
import postfixModule from '@tannin/postfix';
|
|
3
|
+
import { ValidationError } from "../errors.js";
|
|
4
|
+
import { compareText } from "./catalog.js";
|
|
5
|
+
const postfix = postfixModule;
|
|
6
|
+
/**
|
|
7
|
+
* Validate gettext plural expressions with Tannin's parser, without eval or Function.
|
|
8
|
+
* @param header
|
|
9
|
+
*/
|
|
10
|
+
function validatePluralForms(header) {
|
|
11
|
+
const match = /^\s*nplurals\s*=\s*([1-9]\d*)\s*;\s*plural\s*=\s*([^;]+)\s*;?\s*$/.exec(header);
|
|
12
|
+
if (!match || Number(match[1]) > 100) {
|
|
13
|
+
throw new ValidationError('PO requires a valid Plural-Forms header with nplurals and a plural expression.');
|
|
14
|
+
}
|
|
15
|
+
const expression = match[2] ?? '';
|
|
16
|
+
if (/[^\dn\s!%*/+<>=&|?:()\-]/.test(expression)) {
|
|
17
|
+
throw new ValidationError('Plural-Forms contains an unsupported expression token.');
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
let depth = 0;
|
|
21
|
+
for (const term of postfix(expression)) {
|
|
22
|
+
if ('n' === term || /^\d+$/.test(String(term))) {
|
|
23
|
+
depth++;
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
if (![
|
|
27
|
+
'!',
|
|
28
|
+
'*',
|
|
29
|
+
'/',
|
|
30
|
+
'%',
|
|
31
|
+
'+',
|
|
32
|
+
'-',
|
|
33
|
+
'<',
|
|
34
|
+
'<=',
|
|
35
|
+
'>',
|
|
36
|
+
'>=',
|
|
37
|
+
'==',
|
|
38
|
+
'!=',
|
|
39
|
+
'&&',
|
|
40
|
+
'||',
|
|
41
|
+
'?:',
|
|
42
|
+
].includes(term)) {
|
|
43
|
+
throw new Error('Unsupported plural operator.');
|
|
44
|
+
}
|
|
45
|
+
let arity = 2;
|
|
46
|
+
if ('!' === term) {
|
|
47
|
+
arity = 1;
|
|
48
|
+
}
|
|
49
|
+
if ('?:' === term) {
|
|
50
|
+
arity = 3;
|
|
51
|
+
}
|
|
52
|
+
if (depth < arity) {
|
|
53
|
+
throw new Error('Invalid operand count.');
|
|
54
|
+
}
|
|
55
|
+
depth -= arity - 1;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (1 !== depth) {
|
|
59
|
+
throw new Error('Incomplete expression.');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
throw new ValidationError(`Malformed Plural-Forms expression: ${String(error)}`);
|
|
64
|
+
}
|
|
65
|
+
return Number(match[1]);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Parse translated PO as data, filtering fuzzy, obsolete and untranslated entries.
|
|
69
|
+
* @param contents
|
|
70
|
+
* @param domain
|
|
71
|
+
*/
|
|
72
|
+
export function parseTranslatedPo(contents, domain) {
|
|
73
|
+
let parsed;
|
|
74
|
+
try {
|
|
75
|
+
parsed = po.parse(contents, {
|
|
76
|
+
validation: true,
|
|
77
|
+
defaultCharset: 'UTF-8',
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
throw new ValidationError(`Malformed PO catalog: ${String(error)}`);
|
|
82
|
+
}
|
|
83
|
+
if (!parsed.headers || !parsed.translations) {
|
|
84
|
+
throw new ValidationError('Malformed PO catalog: missing gettext headers or translations.');
|
|
85
|
+
}
|
|
86
|
+
const headers = Object.fromEntries(Object.entries(parsed.headers).map(([key, value]) => [
|
|
87
|
+
key.toLowerCase(),
|
|
88
|
+
value,
|
|
89
|
+
]));
|
|
90
|
+
const locale = headers.language;
|
|
91
|
+
if (!locale || !/^[A-Za-z][A-Za-z0-9_@.-]*$/.test(locale)) {
|
|
92
|
+
throw new ValidationError('PO requires a non-empty valid Language header.');
|
|
93
|
+
}
|
|
94
|
+
const pluralForms = headers['plural-forms'] ?? '';
|
|
95
|
+
const nplurals = validatePluralForms(pluralForms);
|
|
96
|
+
const messages = [];
|
|
97
|
+
for (const entry of Object.values(parsed.translations).flatMap((context) => Object.values(context))) {
|
|
98
|
+
const flags = (entry.comments?.flag ?? '')
|
|
99
|
+
.split(',')
|
|
100
|
+
.map((flag) => flag.trim())
|
|
101
|
+
.filter(Boolean);
|
|
102
|
+
if (entry.msgid &&
|
|
103
|
+
!entry.obsolete &&
|
|
104
|
+
!flags.includes('fuzzy') &&
|
|
105
|
+
entry.msgstr.every((translation) => !!translation)) {
|
|
106
|
+
if (entry.msgstr.length !==
|
|
107
|
+
(undefined !== entry.msgid_plural ? nplurals : 1) ||
|
|
108
|
+
/[\0\u0004]/.test(entry.msgid) ||
|
|
109
|
+
/[\0\u0004]/.test(entry.msgctxt ?? '') ||
|
|
110
|
+
entry.msgstr.some((value) => value.includes('\0'))) {
|
|
111
|
+
throw new ValidationError(`Invalid translation forms or reserved separators for ${JSON.stringify(entry.msgid)}.`);
|
|
112
|
+
}
|
|
113
|
+
const references = (entry.comments?.reference ?? '')
|
|
114
|
+
.split(/\s+/)
|
|
115
|
+
.filter(Boolean)
|
|
116
|
+
.map((reference) => {
|
|
117
|
+
const location = /^(.*?)(?::(\d+))?$/.exec(reference);
|
|
118
|
+
return {
|
|
119
|
+
file: location?.[1] ?? reference,
|
|
120
|
+
...(location?.[2]
|
|
121
|
+
? { line: Number(location[2]) }
|
|
122
|
+
: {}),
|
|
123
|
+
};
|
|
124
|
+
});
|
|
125
|
+
messages.push({
|
|
126
|
+
id: entry.msgid,
|
|
127
|
+
...(undefined !== entry.msgid_plural
|
|
128
|
+
? { plural: entry.msgid_plural }
|
|
129
|
+
: {}),
|
|
130
|
+
...(undefined !== entry.msgctxt
|
|
131
|
+
? { context: entry.msgctxt }
|
|
132
|
+
: {}),
|
|
133
|
+
translations: [...entry.msgstr],
|
|
134
|
+
flags,
|
|
135
|
+
references,
|
|
136
|
+
comments: (entry.comments?.extracted ?? '')
|
|
137
|
+
.split('\n')
|
|
138
|
+
.filter(Boolean),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
messages.sort((a, b) => compareText(translationKey(a), translationKey(b)));
|
|
143
|
+
return { domain, headers, messages, locale, pluralForms, nplurals };
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* WordPress/Jed lookup key uses context + EOT + singular, even for plural messages.
|
|
147
|
+
* @param message
|
|
148
|
+
*/
|
|
149
|
+
export function translationKey(message) {
|
|
150
|
+
return (message.context ? `${message.context}\u0004` : '') + message.id;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Compile MO through gettext-parser; never write or mutate the input PO.
|
|
154
|
+
* @param catalog
|
|
155
|
+
*/
|
|
156
|
+
export function compileMo(catalog) {
|
|
157
|
+
const table = {
|
|
158
|
+
charset: 'UTF-8',
|
|
159
|
+
headers: {
|
|
160
|
+
...catalog.headers,
|
|
161
|
+
'content-type': 'text/plain; charset=UTF-8',
|
|
162
|
+
},
|
|
163
|
+
translations: Object.create(null),
|
|
164
|
+
};
|
|
165
|
+
for (const message of catalog.messages) {
|
|
166
|
+
const context = message.context ?? '';
|
|
167
|
+
table.translations[context] ??= Object.create(null);
|
|
168
|
+
table.translations[context][message.id] = {
|
|
169
|
+
msgid: message.id,
|
|
170
|
+
...(undefined !== message.context
|
|
171
|
+
? { msgctxt: message.context }
|
|
172
|
+
: {}),
|
|
173
|
+
...(undefined !== message.plural
|
|
174
|
+
? { msgid_plural: message.plural }
|
|
175
|
+
: {}),
|
|
176
|
+
msgstr: [...message.translations],
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
return mo.compile(table);
|
|
180
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Catalog } from './catalog.ts';
|
|
2
|
+
/**
|
|
3
|
+
* Serialize deterministic UTF-8 POT bytes in the gettext layout, with no wall-clock data.
|
|
4
|
+
*
|
|
5
|
+
* Messages keep the catalog order, and each entry lists extracted comments, references and flags before its strings.
|
|
6
|
+
*
|
|
7
|
+
* @param catalog Catalog to serialize.
|
|
8
|
+
* @param options Serialization options.
|
|
9
|
+
* @param options.references Whether to include source references (default true).
|
|
10
|
+
* @param options.fileComment Static text to prepend as gettext comments.
|
|
11
|
+
* @return POT file contents.
|
|
12
|
+
*/
|
|
13
|
+
export declare function serializePot(catalog: Catalog, options?: {
|
|
14
|
+
references?: boolean;
|
|
15
|
+
fileComment?: string;
|
|
16
|
+
}): Buffer;
|
package/dist/i18n/pot.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { mergeI18nHeaders, validateI18nFileComment, validateI18nHeaders, } from "./options.js";
|
|
2
|
+
const ESCAPES = {
|
|
3
|
+
'\\': '\\\\',
|
|
4
|
+
'"': '\\"',
|
|
5
|
+
'\x07': '\\a',
|
|
6
|
+
'\b': '\\b',
|
|
7
|
+
'\f': '\\f',
|
|
8
|
+
'\n': '\\n',
|
|
9
|
+
'\r': '\\r',
|
|
10
|
+
'\t': '\\t',
|
|
11
|
+
'\v': '\\v',
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Serialize deterministic UTF-8 POT bytes in the gettext layout, with no wall-clock data.
|
|
15
|
+
*
|
|
16
|
+
* Messages keep the catalog order, and each entry lists extracted comments, references and flags before its strings.
|
|
17
|
+
*
|
|
18
|
+
* @param catalog Catalog to serialize.
|
|
19
|
+
* @param options Serialization options.
|
|
20
|
+
* @param options.references Whether to include source references (default true).
|
|
21
|
+
* @param options.fileComment Static text to prepend as gettext comments.
|
|
22
|
+
* @return POT file contents.
|
|
23
|
+
*/
|
|
24
|
+
export function serializePot(catalog, options = {}) {
|
|
25
|
+
validateI18nHeaders(catalog.headers);
|
|
26
|
+
if (undefined !== options.fileComment) {
|
|
27
|
+
validateI18nFileComment(options.fileComment);
|
|
28
|
+
}
|
|
29
|
+
const fileComment = undefined === options.fileComment
|
|
30
|
+
? ''
|
|
31
|
+
: options.fileComment
|
|
32
|
+
.split(/\r\n|\r|\n/)
|
|
33
|
+
.map((line) => `# ${line}\n`)
|
|
34
|
+
.join('');
|
|
35
|
+
const entries = catalog.messages.map((message) => serializeMessage(message, false !== options.references));
|
|
36
|
+
return Buffer.from(fileComment + [serializeHeaders(catalog), ...entries].join('\n'));
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Serialize the header entry: the default headers in a fixed order, overridden in place or removed with `false`, then custom headers in the given order.
|
|
40
|
+
*
|
|
41
|
+
* @param catalog Catalog with the domain and header overrides.
|
|
42
|
+
* @return Header entry.
|
|
43
|
+
*/
|
|
44
|
+
function serializeHeaders(catalog) {
|
|
45
|
+
const defaults = {
|
|
46
|
+
'Project-Id-Version': catalog.domain,
|
|
47
|
+
'Report-Msgid-Bugs-To': '',
|
|
48
|
+
'Last-Translator': '',
|
|
49
|
+
'Language-Team': '',
|
|
50
|
+
'MIME-Version': '1.0',
|
|
51
|
+
'Content-Type': 'text/plain; charset=UTF-8',
|
|
52
|
+
'Content-Transfer-Encoding': '8bit',
|
|
53
|
+
'POT-Creation-Date': '',
|
|
54
|
+
'PO-Revision-Date': '',
|
|
55
|
+
'X-Domain': catalog.domain,
|
|
56
|
+
};
|
|
57
|
+
const headers = Object.entries(mergeI18nHeaders(defaults, catalog.headers))
|
|
58
|
+
.filter(([, value]) => false !== value)
|
|
59
|
+
.map(([name, value]) => `"${escape(`${name}: ${value}\n`)}"\n`);
|
|
60
|
+
return `msgid ""\nmsgstr ""\n${headers.join('')}`;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Serialize one message entry.
|
|
64
|
+
*
|
|
65
|
+
* @param message Catalog message.
|
|
66
|
+
* @param references Whether to include source references.
|
|
67
|
+
* @return Message entry.
|
|
68
|
+
*/
|
|
69
|
+
function serializeMessage(message, references) {
|
|
70
|
+
const lines = [
|
|
71
|
+
...message.comments.flatMap((comment) => comment.split(/\r\n|\r|\n/).map((line) => `#. ${line}`)),
|
|
72
|
+
...(references
|
|
73
|
+
? [
|
|
74
|
+
...new Set(message.references.map((reference) => `#: ${reference.file}${reference.line ? `:${reference.line}` : ''}`)),
|
|
75
|
+
]
|
|
76
|
+
: []),
|
|
77
|
+
...(message.flags.length
|
|
78
|
+
? [`#, ${message.flags.join(', ')}`]
|
|
79
|
+
: []),
|
|
80
|
+
...(undefined !== message.context
|
|
81
|
+
? [poString('msgctxt', message.context)]
|
|
82
|
+
: []),
|
|
83
|
+
poString('msgid', message.id),
|
|
84
|
+
];
|
|
85
|
+
if (undefined === message.plural) {
|
|
86
|
+
lines.push('msgstr ""');
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
lines.push(poString('msgid_plural', message.plural), 'msgstr[0] ""', 'msgstr[1] ""');
|
|
90
|
+
}
|
|
91
|
+
return lines.join('\n') + '\n';
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Format a gettext keyword and string, splitting strings with newlines into one line per segment.
|
|
95
|
+
*
|
|
96
|
+
* @param keyword Gettext keyword, like msgid.
|
|
97
|
+
* @param value String value.
|
|
98
|
+
* @return Serialized keyword lines.
|
|
99
|
+
*/
|
|
100
|
+
function poString(keyword, value) {
|
|
101
|
+
if (!value.includes('\n')) {
|
|
102
|
+
return `${keyword} "${escape(value)}"`;
|
|
103
|
+
}
|
|
104
|
+
return [
|
|
105
|
+
`${keyword} ""`,
|
|
106
|
+
...value
|
|
107
|
+
.split(/(?<=\n)/)
|
|
108
|
+
.map((segment) => `"${escape(segment)}"`),
|
|
109
|
+
].join('\n');
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Escape a string for a quoted gettext string.
|
|
113
|
+
*
|
|
114
|
+
* @param value Raw string.
|
|
115
|
+
* @return Escaped string.
|
|
116
|
+
*/
|
|
117
|
+
function escape(value) {
|
|
118
|
+
return value.replace(/[\\"\x07\b\f\n\r\t\v]/g, (character) => ESCAPES[character] ?? character);
|
|
119
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { TranslatedCatalog } from './po.ts';
|
|
2
|
+
/** Many-to-many source references to runtime scripts, both relative to the package. */
|
|
3
|
+
export type ScriptTranslationMap = Record<string, string[]>;
|
|
4
|
+
export interface ScriptTranslationOutput {
|
|
5
|
+
file: string;
|
|
6
|
+
source: string;
|
|
7
|
+
bytes: Buffer;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Compile deterministic Jed catalogs using explicit source/build mapping or unbundled identity paths.
|
|
11
|
+
* @param catalog
|
|
12
|
+
* @param mapping
|
|
13
|
+
*/
|
|
14
|
+
export declare function compileScriptTranslations(catalog: TranslatedCatalog, mapping?: ScriptTranslationMap): ScriptTranslationOutput[];
|