@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,156 @@
|
|
|
1
|
+
import { ValidationError } from "../errors.js";
|
|
2
|
+
export const DEFAULT_I18N_INCLUDE = [
|
|
3
|
+
'**/*.{php,js,jsx,ts,tsx,mjs,cjs,mts,cts}',
|
|
4
|
+
'**/block.json',
|
|
5
|
+
'**/theme.json',
|
|
6
|
+
'styles/**/*.json',
|
|
7
|
+
];
|
|
8
|
+
export const DEFAULT_I18N_EXCLUDE = [
|
|
9
|
+
'**/node_modules/**',
|
|
10
|
+
'**/vendor/**',
|
|
11
|
+
'**/.*/**',
|
|
12
|
+
'**/tests/**',
|
|
13
|
+
'**/test/**',
|
|
14
|
+
'**/__tests__/**',
|
|
15
|
+
'**/*.test.*',
|
|
16
|
+
'**/*.spec.*',
|
|
17
|
+
'**/*.min.*',
|
|
18
|
+
'**/*.{pot,po,mo}',
|
|
19
|
+
'**/*.l10n.php',
|
|
20
|
+
];
|
|
21
|
+
/**
|
|
22
|
+
* Validate external config independently of extraction and distribution file settings.
|
|
23
|
+
* @param input
|
|
24
|
+
* @param label
|
|
25
|
+
*/
|
|
26
|
+
export function validateI18nOptions(input, label = 'i18n') {
|
|
27
|
+
if (undefined === input) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (!input || 'object' !== typeof input || Array.isArray(input)) {
|
|
31
|
+
throw new ValidationError(`${label} must be an object.`);
|
|
32
|
+
}
|
|
33
|
+
const options = input;
|
|
34
|
+
const allowed = [
|
|
35
|
+
'enabled',
|
|
36
|
+
'domain',
|
|
37
|
+
'allDomains',
|
|
38
|
+
'include',
|
|
39
|
+
'exclude',
|
|
40
|
+
'destination',
|
|
41
|
+
'headers',
|
|
42
|
+
'fileComment',
|
|
43
|
+
'references',
|
|
44
|
+
'strict',
|
|
45
|
+
'legacyGlobals',
|
|
46
|
+
'suppress',
|
|
47
|
+
];
|
|
48
|
+
for (const key of Object.keys(options)) {
|
|
49
|
+
if (!allowed.includes(key)) {
|
|
50
|
+
throw new ValidationError(`${label}.${key} is not a supported extraction option.`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
for (const key of [
|
|
54
|
+
'enabled',
|
|
55
|
+
'allDomains',
|
|
56
|
+
'references',
|
|
57
|
+
'strict',
|
|
58
|
+
'legacyGlobals',
|
|
59
|
+
]) {
|
|
60
|
+
if (undefined !== options[key] &&
|
|
61
|
+
'boolean' !== typeof options[key]) {
|
|
62
|
+
throw new ValidationError(`${label}.${key} must be a boolean.`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
for (const key of ['domain', 'destination']) {
|
|
66
|
+
if (undefined !== options[key] &&
|
|
67
|
+
('string' !== typeof options[key] || !options[key]?.trim())) {
|
|
68
|
+
throw new ValidationError(`${label}.${key} must be a non-empty string.`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (undefined !== options.fileComment) {
|
|
72
|
+
validateI18nFileComment(options.fileComment, `${label}.fileComment`);
|
|
73
|
+
}
|
|
74
|
+
for (const key of ['include', 'exclude', 'suppress']) {
|
|
75
|
+
const value = options[key];
|
|
76
|
+
if (undefined !== value &&
|
|
77
|
+
(!Array.isArray(value) ||
|
|
78
|
+
value.some((entry) => 'string' !== typeof entry || !entry.trim()))) {
|
|
79
|
+
throw new ValidationError(`${label}.${key} must be a list of non-empty strings.`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (undefined !== options.headers) {
|
|
83
|
+
validateI18nHeaders(options.headers, `${label}.headers`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Validate static text to prepend as a POT file comment.
|
|
88
|
+
* @param fileComment
|
|
89
|
+
* @param label
|
|
90
|
+
*/
|
|
91
|
+
export function validateI18nFileComment(fileComment, label = 'i18n.fileComment') {
|
|
92
|
+
if ('string' !== typeof fileComment ||
|
|
93
|
+
!fileComment.trim() ||
|
|
94
|
+
fileComment.includes('\0')) {
|
|
95
|
+
throw new ValidationError(`${label} must be a non-empty string without NUL characters.`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Validate gettext header names and values independently of extraction options.
|
|
100
|
+
*
|
|
101
|
+
* @param headers Header values, or `false` to remove a header.
|
|
102
|
+
* @param label Config path for error messages.
|
|
103
|
+
* @throws {ValidationError} For invalid names or values, or a removed Content-Type.
|
|
104
|
+
*/
|
|
105
|
+
export function validateI18nHeaders(headers, label = 'i18n.headers') {
|
|
106
|
+
if (!headers ||
|
|
107
|
+
'object' !== typeof headers ||
|
|
108
|
+
Array.isArray(headers) ||
|
|
109
|
+
Object.entries(headers).some(([key, value]) => !/^[A-Za-z][A-Za-z0-9-]*$/.test(key) ||
|
|
110
|
+
(false !== value &&
|
|
111
|
+
('string' !== typeof value || /[\r\n\0]/.test(value))))) {
|
|
112
|
+
throw new ValidationError(`${label} must contain gettext header names with single-line string values, or false to remove a header.`);
|
|
113
|
+
}
|
|
114
|
+
if (Object.entries(headers).some(([key, value]) => false === value && 'content-type' === key.toLowerCase())) {
|
|
115
|
+
throw new ValidationError(`${label} can't remove Content-Type, which declares the UTF-8 charset of the catalog.`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Scalars and lists replace inherited values; header dictionaries merge by key.
|
|
120
|
+
* @param layers Extraction settings in increasing precedence.
|
|
121
|
+
*/
|
|
122
|
+
export function resolveI18nOptions(...layers) {
|
|
123
|
+
let options = {
|
|
124
|
+
enabled: true,
|
|
125
|
+
include: DEFAULT_I18N_INCLUDE,
|
|
126
|
+
exclude: DEFAULT_I18N_EXCLUDE,
|
|
127
|
+
references: true,
|
|
128
|
+
legacyGlobals: true,
|
|
129
|
+
};
|
|
130
|
+
for (const layer of layers) {
|
|
131
|
+
validateI18nOptions(layer);
|
|
132
|
+
if (layer) {
|
|
133
|
+
options = {
|
|
134
|
+
...options,
|
|
135
|
+
...layer,
|
|
136
|
+
headers: mergeI18nHeaders(options.headers, layer.headers),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return options;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Merge gettext header names case-insensitively, preserving deliberate empty values and removals.
|
|
144
|
+
*
|
|
145
|
+
* @param layers Header values in increasing precedence.
|
|
146
|
+
* @return Merged headers, with `false` for removed headers so that later layers can still set them.
|
|
147
|
+
*/
|
|
148
|
+
export function mergeI18nHeaders(...layers) {
|
|
149
|
+
const headers = new Map();
|
|
150
|
+
for (const layer of layers) {
|
|
151
|
+
for (const [key, value] of Object.entries(layer ?? {})) {
|
|
152
|
+
headers.set(key.toLowerCase(), [key, value]);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return Object.fromEntries(headers.values());
|
|
156
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { I18nOptions } from './options.ts';
|
|
2
|
+
export interface I18nPackageInput {
|
|
3
|
+
cwd: string;
|
|
4
|
+
configFile?: string;
|
|
5
|
+
package?: string;
|
|
6
|
+
all?: boolean;
|
|
7
|
+
sourceDir?: string;
|
|
8
|
+
/** An explicit absolute output path; only supported for one package. */
|
|
9
|
+
outputPath?: string;
|
|
10
|
+
options?: I18nOptions;
|
|
11
|
+
/** Audit selection need not validate output destinations. */
|
|
12
|
+
validateOutputs?: boolean;
|
|
13
|
+
}
|
|
14
|
+
export interface I18nPackage {
|
|
15
|
+
package?: string;
|
|
16
|
+
sourceDir: string;
|
|
17
|
+
outputPath: string;
|
|
18
|
+
options: I18nOptions;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The config adapter resolves package directories and options before invoking primitives.
|
|
22
|
+
* @param input
|
|
23
|
+
*/
|
|
24
|
+
export declare function resolveI18nPackages(input: I18nPackageInput): Promise<I18nPackage[]>;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { basename, resolve } from 'node:path';
|
|
2
|
+
import { loadChangesetConfig } from "../changeset-config.js";
|
|
3
|
+
import { discoverConfiguredPackages, findPackage, } from "../changeset-packages.js";
|
|
4
|
+
import { ReleaseRepository } from "../release-repository.js";
|
|
5
|
+
import { readPackageMeta } from "../package-meta.js";
|
|
6
|
+
import { ValidationError } from "../errors.js";
|
|
7
|
+
import { validateI18nContainment } from "./atomic-output.js";
|
|
8
|
+
import { resolveI18nOptions } from "./options.js";
|
|
9
|
+
import { resolvePotPath, validatePotDestinations } from "./generate.js";
|
|
10
|
+
/**
|
|
11
|
+
* The config adapter resolves package directories and options before invoking primitives.
|
|
12
|
+
* @param input
|
|
13
|
+
*/
|
|
14
|
+
export async function resolveI18nPackages(input) {
|
|
15
|
+
if ((input.package && input.all) ||
|
|
16
|
+
((input.package || input.all) && input.sourceDir) ||
|
|
17
|
+
(input.all && input.outputPath)) {
|
|
18
|
+
throw new ValidationError('Choose a source directory, --package, or --all. --output requires one package.');
|
|
19
|
+
}
|
|
20
|
+
if ('' === input.package?.trim()) {
|
|
21
|
+
throw new ValidationError('--package must not be empty.');
|
|
22
|
+
}
|
|
23
|
+
const packages = [];
|
|
24
|
+
if (input.package || input.all) {
|
|
25
|
+
const loaded = await loadChangesetConfig(input);
|
|
26
|
+
if (!loaded) {
|
|
27
|
+
throw new ValidationError('--package and --all require wp-release configuration.');
|
|
28
|
+
}
|
|
29
|
+
const repository = new ReleaseRepository({
|
|
30
|
+
cwd: input.cwd,
|
|
31
|
+
});
|
|
32
|
+
const configured = await discoverConfiguredPackages(repository, loaded.config);
|
|
33
|
+
let selected = configured;
|
|
34
|
+
if (input.package) {
|
|
35
|
+
const match = findPackage(configured, input.package);
|
|
36
|
+
if (!match) {
|
|
37
|
+
throw new ValidationError(`Unknown package: ${input.package}.`);
|
|
38
|
+
}
|
|
39
|
+
selected = [match];
|
|
40
|
+
}
|
|
41
|
+
for (const pkg of selected) {
|
|
42
|
+
const options = resolveI18nOptions(loaded.config.i18n, pkg.group.i18n, input.options);
|
|
43
|
+
const meta = await readPackageMeta({
|
|
44
|
+
dir: pkg.directory,
|
|
45
|
+
slug: basename(pkg.directory),
|
|
46
|
+
});
|
|
47
|
+
if (input.package && (!options.enabled || !meta)) {
|
|
48
|
+
throw new ValidationError(`Package ${pkg.name} is disabled for i18n or has no WordPress headers.`);
|
|
49
|
+
}
|
|
50
|
+
if (options.enabled && meta) {
|
|
51
|
+
packages.push({
|
|
52
|
+
package: pkg.name,
|
|
53
|
+
sourceDir: pkg.directory,
|
|
54
|
+
options,
|
|
55
|
+
outputPath: input.outputPath ??
|
|
56
|
+
resolvePotPath(pkg.directory, meta, options),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
const sourceDir = resolve(input.cwd, input.sourceDir ?? '.');
|
|
63
|
+
const meta = await readPackageMeta({
|
|
64
|
+
dir: sourceDir,
|
|
65
|
+
slug: basename(sourceDir),
|
|
66
|
+
});
|
|
67
|
+
if (!meta) {
|
|
68
|
+
throw new ValidationError(`No WordPress plugin or theme headers found in ${sourceDir}.`);
|
|
69
|
+
}
|
|
70
|
+
const options = resolveI18nOptions(input.options);
|
|
71
|
+
packages.push({
|
|
72
|
+
sourceDir,
|
|
73
|
+
options,
|
|
74
|
+
outputPath: input.outputPath ?? resolvePotPath(sourceDir, meta, options),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
if (false !== input.validateOutputs) {
|
|
78
|
+
await validatePotDestinations(packages.map((pkg) => pkg.outputPath));
|
|
79
|
+
if (!input.outputPath) {
|
|
80
|
+
for (const pkg of packages) {
|
|
81
|
+
await validateI18nContainment(pkg.sourceDir, pkg.outputPath);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return packages;
|
|
86
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { CallSignature, ParseSourceInput } from './extraction.ts';
|
|
2
|
+
import type { ExtractionResult } from './catalog.ts';
|
|
3
|
+
/** PHP argument names and positions follow the WordPress l10n.php signatures. */
|
|
4
|
+
export declare const PHP_TRANSLATION_FUNCTIONS: Record<string, CallSignature>;
|
|
5
|
+
/**
|
|
6
|
+
* Parse PHP without executing it; namespaced fallback calls follow known file-local bindings.
|
|
7
|
+
* @param input
|
|
8
|
+
*/
|
|
9
|
+
export declare function parsePhpTranslations(input: ParseSourceInput): ExtractionResult;
|
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;
|