@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.
- package/README.md +124 -6
- package/dist/changeset-config.d.ts +5 -0
- package/dist/changeset-config.js +3 -0
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +10 -0
- 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/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 +22 -0
- package/dist/index.js +14 -0
- package/package.json +15 -3
|
@@ -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>;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { ValidationError } from "../errors.js";
|
|
4
|
+
import { parseTranslatedPo, compileMo } from "./po.js";
|
|
5
|
+
import { compileScriptTranslations } from "./translation-json.js";
|
|
6
|
+
import { compilePhpTranslations } from "./translation-php.js";
|
|
7
|
+
import { writeI18nOutput, validateI18nDestinations } from "./atomic-output.js";
|
|
8
|
+
/**
|
|
9
|
+
* Parse/validate every proposed output before independently replacing completed files.
|
|
10
|
+
* @param input
|
|
11
|
+
*/
|
|
12
|
+
export async function compileTranslations(input) {
|
|
13
|
+
const sourcePath = resolve(input.sourcePath);
|
|
14
|
+
if (!sourcePath.endsWith('.po')) {
|
|
15
|
+
throw new ValidationError('Translation compilation requires an input .po file.');
|
|
16
|
+
}
|
|
17
|
+
if ('json' !== input.format && input.scriptMap) {
|
|
18
|
+
throw new ValidationError('Script mapping only applies to WordPress JSON compilation.');
|
|
19
|
+
}
|
|
20
|
+
const catalog = parseTranslatedPo(await readFile(sourcePath), input.domain ?? '');
|
|
21
|
+
if ('json' === input.format && !input.domain) {
|
|
22
|
+
throw new ValidationError('WordPress JSON compilation requires an explicit text domain.');
|
|
23
|
+
}
|
|
24
|
+
const proposed = 'json' === input.format
|
|
25
|
+
? compileScriptTranslations(catalog, input.scriptMap).map((output) => ({
|
|
26
|
+
outputPath: join(input.outputPath ?? dirname(sourcePath), output.file),
|
|
27
|
+
source: output.source,
|
|
28
|
+
bytes: output.bytes,
|
|
29
|
+
}))
|
|
30
|
+
: [
|
|
31
|
+
{
|
|
32
|
+
outputPath: input.outputPath ??
|
|
33
|
+
sourcePath.replace(/\.po$/, 'mo' === input.format ? '.mo' : '.l10n.php'),
|
|
34
|
+
bytes: 'mo' === input.format
|
|
35
|
+
? compileMo(catalog)
|
|
36
|
+
: compilePhpTranslations(catalog),
|
|
37
|
+
},
|
|
38
|
+
];
|
|
39
|
+
proposed.forEach((output) => {
|
|
40
|
+
output.outputPath = resolve(output.outputPath);
|
|
41
|
+
});
|
|
42
|
+
await validateI18nDestinations(proposed.map((output) => output.outputPath), [sourcePath]);
|
|
43
|
+
const result = {
|
|
44
|
+
sourcePath,
|
|
45
|
+
locale: catalog.locale,
|
|
46
|
+
messages: catalog.messages.length,
|
|
47
|
+
outputs: [],
|
|
48
|
+
};
|
|
49
|
+
for (const output of proposed) {
|
|
50
|
+
const previous = await readFile(output.outputPath).catch((error) => {
|
|
51
|
+
if ('ENOENT' !== error.code) {
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
return undefined;
|
|
55
|
+
});
|
|
56
|
+
let current = previous?.equals(output.bytes) ?? false;
|
|
57
|
+
if (!input.check) {
|
|
58
|
+
await writeI18nOutput(output.outputPath, output.bytes);
|
|
59
|
+
current = true;
|
|
60
|
+
}
|
|
61
|
+
result.outputs.push({
|
|
62
|
+
outputPath: output.outputPath,
|
|
63
|
+
...('source' in output
|
|
64
|
+
? { source: output.source }
|
|
65
|
+
: {}),
|
|
66
|
+
written: !input.check,
|
|
67
|
+
current,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ExtractionResult, SourceReference } from './catalog.ts';
|
|
2
|
+
export interface ParseSourceInput {
|
|
3
|
+
contents: string;
|
|
4
|
+
file: string;
|
|
5
|
+
/** Domain being selected. Omitted domain arguments use WordPress's `default`. */
|
|
6
|
+
domain: string;
|
|
7
|
+
allDomains?: boolean;
|
|
8
|
+
/** Accept unbound canonical JS names in legacy source (default true). */
|
|
9
|
+
legacyGlobals?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export interface CallSignature {
|
|
12
|
+
id: number;
|
|
13
|
+
plural?: number;
|
|
14
|
+
context?: number;
|
|
15
|
+
domain: number;
|
|
16
|
+
parameters: string[];
|
|
17
|
+
}
|
|
18
|
+
export interface LexicalComment {
|
|
19
|
+
start: number;
|
|
20
|
+
end: number;
|
|
21
|
+
value: string;
|
|
22
|
+
}
|
|
23
|
+
export interface TranslationCall extends SourceReference {
|
|
24
|
+
start: number;
|
|
25
|
+
/** Start of the nearest containing statement. */
|
|
26
|
+
statementStart: number;
|
|
27
|
+
/** Starts of containing expressions, up to the nearest statement. */
|
|
28
|
+
commentAnchors?: number[];
|
|
29
|
+
name: string;
|
|
30
|
+
arguments: Array<string | undefined>;
|
|
31
|
+
signature: CallSignature;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Attach each adjacent translators comment only to the first translation call in a statement, before domain filtering.
|
|
35
|
+
* @param input
|
|
36
|
+
* @param calls
|
|
37
|
+
* @param comments
|
|
38
|
+
*/
|
|
39
|
+
export declare function extractCalls(input: ParseSourceInput, calls: TranslationCall[], comments: LexicalComment[]): ExtractionResult;
|
|
40
|
+
/**
|
|
41
|
+
* Syntax failures are data, never printed by a parser.
|
|
42
|
+
* @param file
|
|
43
|
+
* @param error
|
|
44
|
+
*/
|
|
45
|
+
export declare function syntaxFailure(file: string, error: unknown): ExtractionResult;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { getPlaceholders } from "./placeholders.js";
|
|
2
|
+
/**
|
|
3
|
+
* Attach each adjacent translators comment only to the first translation call in a statement, before domain filtering.
|
|
4
|
+
* @param input
|
|
5
|
+
* @param calls
|
|
6
|
+
* @param comments
|
|
7
|
+
*/
|
|
8
|
+
export function extractCalls(input, calls, comments) {
|
|
9
|
+
const result = { messages: [], diagnostics: [] };
|
|
10
|
+
const used = new Set();
|
|
11
|
+
for (const call of calls.sort((a, b) => a.start - b.start)) {
|
|
12
|
+
const signature = call.signature;
|
|
13
|
+
const domainValue = call.arguments[signature.domain];
|
|
14
|
+
// An omitted domain defaults to WordPress's own domain; an unresolved supplied value doesn't.
|
|
15
|
+
const domain = call.arguments.length <= signature.domain ? 'default' : domainValue;
|
|
16
|
+
const reference = {
|
|
17
|
+
file: call.file,
|
|
18
|
+
line: call.line,
|
|
19
|
+
column: call.column,
|
|
20
|
+
};
|
|
21
|
+
const adjacent = comments.filter((comment) => (call.commentAnchors ?? [call.start, call.statementStart]).some((anchor) => {
|
|
22
|
+
let offset = comment.end;
|
|
23
|
+
let gap = '';
|
|
24
|
+
for (const next of comments
|
|
25
|
+
.filter((candidate) => candidate.start >= comment.end &&
|
|
26
|
+
candidate.end <= anchor)
|
|
27
|
+
.sort((a, b) => a.start - b.start)) {
|
|
28
|
+
gap += input.contents.slice(offset, next.start);
|
|
29
|
+
offset = next.end;
|
|
30
|
+
}
|
|
31
|
+
gap += input.contents.slice(offset, anchor);
|
|
32
|
+
return comment.end <= anchor && /^[\s?:]*$/.test(gap);
|
|
33
|
+
}));
|
|
34
|
+
const translatorComments = adjacent.filter((comment) => !used.has(comment.start) &&
|
|
35
|
+
/translators:/i.test(comment.value));
|
|
36
|
+
translatorComments.forEach((comment) => used.add(comment.start));
|
|
37
|
+
if (undefined === domain ||
|
|
38
|
+
input.allDomains ||
|
|
39
|
+
input.domain === domain) {
|
|
40
|
+
const required = [
|
|
41
|
+
signature.id,
|
|
42
|
+
signature.plural,
|
|
43
|
+
signature.context,
|
|
44
|
+
].filter((index) => undefined !== index);
|
|
45
|
+
if (undefined === domain ||
|
|
46
|
+
required.some((index) => undefined === call.arguments[index])) {
|
|
47
|
+
result.diagnostics.push({
|
|
48
|
+
...reference,
|
|
49
|
+
code: 'dynamic-argument',
|
|
50
|
+
severity: 'warning',
|
|
51
|
+
message: `${call.name} requires static message IDs, context and domain; use string literals or static concatenation.`,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
const message = {
|
|
56
|
+
id: call.arguments[signature.id] ?? '',
|
|
57
|
+
domain,
|
|
58
|
+
...(undefined !== signature.plural
|
|
59
|
+
? { plural: call.arguments[signature.plural] }
|
|
60
|
+
: {}),
|
|
61
|
+
...(undefined !== signature.context
|
|
62
|
+
? { context: call.arguments[signature.context] }
|
|
63
|
+
: {}),
|
|
64
|
+
translations: [],
|
|
65
|
+
flags: [],
|
|
66
|
+
references: [reference],
|
|
67
|
+
comments: translatorComments.map((comment) => comment.value
|
|
68
|
+
.replace(/^\s*(?:\/\*+|\/\/|#)/, '')
|
|
69
|
+
.replace(/\*\/\s*$/, '')
|
|
70
|
+
.replace(/^\s*\*\s?/gm, '')
|
|
71
|
+
.trim()),
|
|
72
|
+
};
|
|
73
|
+
if ([message.id, message.plural ?? ''].some((form) => getPlaceholders(form).length)) {
|
|
74
|
+
message.flags.push(input.file.endsWith('.php')
|
|
75
|
+
? 'php-format'
|
|
76
|
+
: 'js-format');
|
|
77
|
+
}
|
|
78
|
+
result.messages.push(message);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Syntax failures are data, never printed by a parser.
|
|
86
|
+
* @param file
|
|
87
|
+
* @param error
|
|
88
|
+
*/
|
|
89
|
+
export function syntaxFailure(file, error) {
|
|
90
|
+
const failure = error;
|
|
91
|
+
const column = failure.loc?.column ?? failure.columnNumber;
|
|
92
|
+
return {
|
|
93
|
+
messages: [],
|
|
94
|
+
diagnostics: [
|
|
95
|
+
{
|
|
96
|
+
code: 'invalid-syntax',
|
|
97
|
+
severity: 'error',
|
|
98
|
+
file,
|
|
99
|
+
message: failure.message ?? String(error),
|
|
100
|
+
line: failure.loc?.line ?? failure.lineNumber,
|
|
101
|
+
column: undefined !== column ? column + 1 : undefined,
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { I18nOptions } from './options.ts';
|
|
2
|
+
/**
|
|
3
|
+
* Select source files with package-relative minimatch globs, never distribution rules.
|
|
4
|
+
* @param root
|
|
5
|
+
* @param input
|
|
6
|
+
*/
|
|
7
|
+
export declare function discoverI18nFiles(root: string, input?: I18nOptions): Promise<string[]>;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { minimatch } from 'minimatch';
|
|
4
|
+
import { ValidationError } from "../errors.js";
|
|
5
|
+
import { compareText } from "./catalog.js";
|
|
6
|
+
import { resolveI18nOptions } from "./options.js";
|
|
7
|
+
/**
|
|
8
|
+
* Select source files with package-relative minimatch globs, never distribution rules.
|
|
9
|
+
* @param root
|
|
10
|
+
* @param input
|
|
11
|
+
*/
|
|
12
|
+
export async function discoverI18nFiles(root, input = {}) {
|
|
13
|
+
const options = resolveI18nOptions(input);
|
|
14
|
+
const includes = options.include ?? [];
|
|
15
|
+
const excludes = options.exclude ?? [];
|
|
16
|
+
const matches = (file, patterns) => patterns.some((pattern) => minimatch(file, pattern, {
|
|
17
|
+
dot: true,
|
|
18
|
+
nocase: false,
|
|
19
|
+
nonegate: true,
|
|
20
|
+
}));
|
|
21
|
+
const files = [];
|
|
22
|
+
async function visit(prefix) {
|
|
23
|
+
const entries = await readdir(join(root, prefix), {
|
|
24
|
+
withFileTypes: true,
|
|
25
|
+
});
|
|
26
|
+
for (const entry of entries.sort((a, b) => compareText(a.name, b.name))) {
|
|
27
|
+
const file = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
28
|
+
if (entry.isSymbolicLink() &&
|
|
29
|
+
matches(file, includes) &&
|
|
30
|
+
!matches(file, excludes)) {
|
|
31
|
+
throw new ValidationError(`Selected source symlinks are unsupported: ${file}.`);
|
|
32
|
+
}
|
|
33
|
+
if (entry.isDirectory() && !matches(`${file}/`, excludes)) {
|
|
34
|
+
await visit(file);
|
|
35
|
+
}
|
|
36
|
+
else if (entry.isFile() &&
|
|
37
|
+
matches(file, includes) &&
|
|
38
|
+
!matches(file, excludes)) {
|
|
39
|
+
files.push(file);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
await visit('');
|
|
44
|
+
return files.sort(compareText);
|
|
45
|
+
}
|