@katerynakhar/i18n-keeper 0.16.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/LICENSE +21 -0
- package/README.md +666 -0
- package/dist/apply.d.ts +66 -0
- package/dist/apply.js +254 -0
- package/dist/apply.js.map +1 -0
- package/dist/check.d.ts +5 -0
- package/dist/check.js +326 -0
- package/dist/check.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +525 -0
- package/dist/cli.js.map +1 -0
- package/dist/formats/error.d.ts +12 -0
- package/dist/formats/error.js +19 -0
- package/dist/formats/error.js.map +1 -0
- package/dist/formats/flatten.d.ts +6 -0
- package/dist/formats/flatten.js +26 -0
- package/dist/formats/flatten.js.map +1 -0
- package/dist/formats/json-write.d.ts +7 -0
- package/dist/formats/json-write.js +55 -0
- package/dist/formats/json-write.js.map +1 -0
- package/dist/formats/json.d.ts +6 -0
- package/dist/formats/json.js +21 -0
- package/dist/formats/json.js.map +1 -0
- package/dist/formats/php-write.d.ts +16 -0
- package/dist/formats/php-write.js +130 -0
- package/dist/formats/php-write.js.map +1 -0
- package/dist/formats/php.d.ts +44 -0
- package/dist/formats/php.js +356 -0
- package/dist/formats/php.js.map +1 -0
- package/dist/formats/po-write.d.ts +3 -0
- package/dist/formats/po-write.js +200 -0
- package/dist/formats/po-write.js.map +1 -0
- package/dist/formats/po.d.ts +30 -0
- package/dist/formats/po.js +202 -0
- package/dist/formats/po.js.map +1 -0
- package/dist/formats/write.d.ts +29 -0
- package/dist/formats/write.js +19 -0
- package/dist/formats/write.js.map +1 -0
- package/dist/formats/yaml-write.d.ts +12 -0
- package/dist/formats/yaml-write.js +42 -0
- package/dist/formats/yaml-write.js.map +1 -0
- package/dist/formats/yaml.d.ts +18 -0
- package/dist/formats/yaml.js +51 -0
- package/dist/formats/yaml.js.map +1 -0
- package/dist/glossary.d.ts +38 -0
- package/dist/glossary.js +140 -0
- package/dist/glossary.js.map +1 -0
- package/dist/lengths.d.ts +37 -0
- package/dist/lengths.js +195 -0
- package/dist/lengths.js.map +1 -0
- package/dist/mcp.d.ts +2 -0
- package/dist/mcp.js +374 -0
- package/dist/mcp.js.map +1 -0
- package/dist/memory.d.ts +72 -0
- package/dist/memory.js +162 -0
- package/dist/memory.js.map +1 -0
- package/dist/placeholders.d.ts +15 -0
- package/dist/placeholders.js +103 -0
- package/dist/placeholders.js.map +1 -0
- package/dist/plurals.d.ts +59 -0
- package/dist/plurals.js +280 -0
- package/dist/plurals.js.map +1 -0
- package/dist/report.d.ts +2 -0
- package/dist/report.js +126 -0
- package/dist/report.js.map +1 -0
- package/dist/scan.d.ts +23 -0
- package/dist/scan.js +250 -0
- package/dist/scan.js.map +1 -0
- package/dist/translate.d.ts +93 -0
- package/dist/translate.js +369 -0
- package/dist/translate.js.map +1 -0
- package/dist/types.d.ts +88 -0
- package/dist/types.js +50 -0
- package/dist/types.js.map +1 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +28 -0
- package/dist/version.js.map +1 -0
- package/package.json +77 -0
- package/src/apply.ts +334 -0
- package/src/check.ts +468 -0
- package/src/cli.ts +637 -0
- package/src/formats/error.ts +19 -0
- package/src/formats/flatten.ts +38 -0
- package/src/formats/json-write.ts +66 -0
- package/src/formats/json.ts +27 -0
- package/src/formats/php-write.ts +146 -0
- package/src/formats/php.ts +414 -0
- package/src/formats/po-write.ts +230 -0
- package/src/formats/po.ts +233 -0
- package/src/formats/write.ts +41 -0
- package/src/formats/yaml-write.ts +53 -0
- package/src/formats/yaml.ts +62 -0
- package/src/glossary.ts +191 -0
- package/src/lengths.ts +214 -0
- package/src/mcp.ts +451 -0
- package/src/memory.ts +227 -0
- package/src/placeholders.ts +123 -0
- package/src/plurals.ts +320 -0
- package/src/report.ts +162 -0
- package/src/scan.ts +280 -0
- package/src/translate.ts +486 -0
- package/src/types.ts +136 -0
- package/src/version.ts +27 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { FormatError } from './error.js';
|
|
2
|
+
import type { Edit, WriteOutcome } from './write.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* JSON is the one format that can be re-serialised without losing anything —
|
|
6
|
+
* it has no comments, no anchors and no styles to preserve. Key order is kept
|
|
7
|
+
* because setting an existing key leaves it where it is, and new keys append.
|
|
8
|
+
*/
|
|
9
|
+
export function writeJsonLocale(
|
|
10
|
+
content: string,
|
|
11
|
+
file: string,
|
|
12
|
+
_locale: string,
|
|
13
|
+
edits: Edit[],
|
|
14
|
+
): WriteOutcome {
|
|
15
|
+
let root: Record<string, unknown> = {};
|
|
16
|
+
|
|
17
|
+
if (content.trim() !== '') {
|
|
18
|
+
let parsed: unknown;
|
|
19
|
+
try {
|
|
20
|
+
parsed = JSON.parse(content);
|
|
21
|
+
} catch (err) {
|
|
22
|
+
throw new FormatError(file, err instanceof Error ? err.message : String(err));
|
|
23
|
+
}
|
|
24
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
25
|
+
throw new FormatError(file, 'expected an object at the top level');
|
|
26
|
+
}
|
|
27
|
+
root = parsed as Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const skipped: Array<{ key: string; reason: string }> = [];
|
|
31
|
+
|
|
32
|
+
for (const edit of edits) {
|
|
33
|
+
const path = edit.key.split('.');
|
|
34
|
+
let node = root;
|
|
35
|
+
let blocked: string | null = null;
|
|
36
|
+
|
|
37
|
+
for (const [index, segment] of path.entries()) {
|
|
38
|
+
if (index === path.length - 1) {
|
|
39
|
+
const existing = node[segment];
|
|
40
|
+
if (existing !== undefined && typeof existing === 'object' && existing !== null) {
|
|
41
|
+
blocked = `${segment} holds a collection, not a string`;
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
node[segment] = edit.value;
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const next = node[segment];
|
|
49
|
+
if (next === undefined) {
|
|
50
|
+
const created: Record<string, unknown> = {};
|
|
51
|
+
node[segment] = created;
|
|
52
|
+
node = created;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (typeof next !== 'object' || next === null || Array.isArray(next)) {
|
|
56
|
+
blocked = `${segment} already holds a value`;
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
node = next as Record<string, unknown>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (blocked) skipped.push({ key: edit.key, reason: blocked });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { content: `${JSON.stringify(root, null, 2)}\n`, skipped };
|
|
66
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import type { ReadTarget } from '../types.js';
|
|
3
|
+
import { FormatError } from './error.js';
|
|
4
|
+
import { flattenValue } from './flatten.js';
|
|
5
|
+
|
|
6
|
+
export class ParseError extends FormatError {}
|
|
7
|
+
|
|
8
|
+
/** Flattens one JSON locale file into dot-notation keys under an optional namespace. */
|
|
9
|
+
export function readJsonLocale(
|
|
10
|
+
file: string,
|
|
11
|
+
namespace: string,
|
|
12
|
+
_locale: string,
|
|
13
|
+
_isSource: boolean,
|
|
14
|
+
target: ReadTarget,
|
|
15
|
+
): void {
|
|
16
|
+
let parsed: unknown;
|
|
17
|
+
try {
|
|
18
|
+
parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
19
|
+
} catch (err) {
|
|
20
|
+
throw new ParseError(file, err instanceof Error ? err.message : String(err));
|
|
21
|
+
}
|
|
22
|
+
if (Array.isArray(parsed)) {
|
|
23
|
+
target.skipped.push({ file, reason: 'the file holds a list, not keyed translations' });
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
flattenValue(parsed, namespace, file, target.leaves, target.containers);
|
|
27
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { parsePhpLayout, type ArraySlot, type ArrayStyle } from './php.js';
|
|
2
|
+
import { spliceAll, type Edit, type WriteOutcome } from './write.js';
|
|
3
|
+
|
|
4
|
+
/** Nested levels that have to be created get this much extra indentation. */
|
|
5
|
+
const INDENT_STEP = ' ';
|
|
6
|
+
|
|
7
|
+
const SHORT_ESCAPES = new Map<number, string>([
|
|
8
|
+
[0x0a, '\\n'],
|
|
9
|
+
[0x0d, '\\r'],
|
|
10
|
+
[0x09, '\\t'],
|
|
11
|
+
[0x0b, '\\v'],
|
|
12
|
+
[0x0c, '\\f'],
|
|
13
|
+
[0x1b, '\\e'],
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
function hasControlCharacter(value: string): boolean {
|
|
17
|
+
for (const ch of value) {
|
|
18
|
+
if (ch.codePointAt(0)! < 0x20) return true;
|
|
19
|
+
}
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A PHP string literal for an arbitrary value.
|
|
25
|
+
*
|
|
26
|
+
* Single quotes are preferred because nothing interpolates inside them: `$` and
|
|
27
|
+
* Laravel's `:name` stay literal, which is exactly what a language file wants.
|
|
28
|
+
* Control characters have no single-quoted spelling, so those fall back to
|
|
29
|
+
* double quotes with escapes.
|
|
30
|
+
*/
|
|
31
|
+
export function phpLiteral(value: string): string {
|
|
32
|
+
if (!hasControlCharacter(value)) {
|
|
33
|
+
return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let out = '';
|
|
37
|
+
for (const ch of value) {
|
|
38
|
+
const cp = ch.codePointAt(0)!;
|
|
39
|
+
if (cp >= 0x20) {
|
|
40
|
+
if (ch === '\\' || ch === '"' || ch === '$') out += `\\${ch}`;
|
|
41
|
+
else out += ch;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
out += SHORT_ESCAPES.get(cp) ?? `\\x${cp.toString(16).padStart(2, '0')}`;
|
|
45
|
+
}
|
|
46
|
+
return `"${out}"`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const OPEN: Record<ArrayStyle, string> = { bracket: '[', array: 'array(' };
|
|
50
|
+
const CLOSE: Record<ArrayStyle, string> = { bracket: ']', array: ')' };
|
|
51
|
+
|
|
52
|
+
/** `'a' => ['b' => 'value'],` for however many levels are missing. */
|
|
53
|
+
function nestedEntry(keys: string[], value: string, indent: string, style: ArrayStyle): string {
|
|
54
|
+
const [head, ...rest] = keys;
|
|
55
|
+
if (rest.length === 0) return `${indent}${phpLiteral(head!)} => ${phpLiteral(value)},`;
|
|
56
|
+
return [
|
|
57
|
+
`${indent}${phpLiteral(head!)} => ${OPEN[style]}`,
|
|
58
|
+
nestedEntry(rest, value, indent + INDENT_STEP, style),
|
|
59
|
+
`${indent}${CLOSE[style]},`,
|
|
60
|
+
].join('\n');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* An empty language file shaped like the one it is being translated from, so a
|
|
65
|
+
* project written with `array()` does not suddenly gain a file that is not.
|
|
66
|
+
*/
|
|
67
|
+
export function blankPhpFile(sourceContent: string | null): string {
|
|
68
|
+
let style: ArrayStyle = 'bracket';
|
|
69
|
+
if (sourceContent !== null) {
|
|
70
|
+
try {
|
|
71
|
+
style = parsePhpLayout(sourceContent, '<source>').layout.arrays.get('')?.style ?? 'bracket';
|
|
72
|
+
} catch {
|
|
73
|
+
// An unreadable source is no reason to refuse to create the target.
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return `<?php\n\nreturn ${OPEN[style]}\n${CLOSE[style]};\n`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The deepest array that already exists on the way to `key`. */
|
|
80
|
+
function parentFor(
|
|
81
|
+
arrays: Map<string, ArraySlot>,
|
|
82
|
+
key: string,
|
|
83
|
+
): { slot: ArraySlot; remaining: string[] } | null {
|
|
84
|
+
const parts = key.split('.');
|
|
85
|
+
for (let depth = parts.length - 1; depth >= 0; depth--) {
|
|
86
|
+
const slot = arrays.get(parts.slice(0, depth).join('.'));
|
|
87
|
+
if (slot) return { slot, remaining: parts.slice(depth) };
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function writePhpLocale(
|
|
93
|
+
content: string,
|
|
94
|
+
file: string,
|
|
95
|
+
_locale: string,
|
|
96
|
+
edits: Edit[],
|
|
97
|
+
): WriteOutcome {
|
|
98
|
+
const { layout } = parsePhpLayout(content, file);
|
|
99
|
+
const skipped: Array<{ key: string; reason: string }> = [];
|
|
100
|
+
const splices: Array<{ start: number; end: number; text: string }> = [];
|
|
101
|
+
|
|
102
|
+
// Several new keys can belong in the same array and share one insertion
|
|
103
|
+
// point, so they are collected and emitted together, in order.
|
|
104
|
+
const insertions = new Map<number, { slot: ArraySlot; lines: string[] }>();
|
|
105
|
+
|
|
106
|
+
for (const edit of edits) {
|
|
107
|
+
const span = layout.values.get(edit.key);
|
|
108
|
+
if (span) {
|
|
109
|
+
splices.push({ start: span.start, end: span.end, text: phpLiteral(edit.value) });
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (layout.arrays.has(edit.key)) {
|
|
114
|
+
skipped.push({ key: edit.key, reason: 'the key holds an array, not a string' });
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const parent = parentFor(layout.arrays, edit.key);
|
|
119
|
+
if (!parent) {
|
|
120
|
+
skipped.push({ key: edit.key, reason: 'no array to insert into' });
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const entry = nestedEntry(parent.remaining, edit.value, parent.slot.indent, parent.slot.style);
|
|
125
|
+
const bucket = insertions.get(parent.slot.insertAt);
|
|
126
|
+
if (bucket) bucket.lines.push(entry);
|
|
127
|
+
else insertions.set(parent.slot.insertAt, { slot: parent.slot, lines: [entry] });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
for (const [at, { slot, lines }] of insertions) {
|
|
131
|
+
const body = lines.join('\n');
|
|
132
|
+
// An empty array usually already has a newline before its closing bracket;
|
|
133
|
+
// adding a second one would leave a blank line in the file.
|
|
134
|
+
const closingOnNextLine = content.slice(at).startsWith('\n');
|
|
135
|
+
const text = slot.empty
|
|
136
|
+
? closingOnNextLine
|
|
137
|
+
? `\n${body}`
|
|
138
|
+
: `\n${body}\n`
|
|
139
|
+
: slot.hasTrailingComma
|
|
140
|
+
? `\n${body}`
|
|
141
|
+
: `,\n${body}`;
|
|
142
|
+
splices.push({ start: at, end: at, text });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return { content: spliceAll(content, splices), skipped };
|
|
146
|
+
}
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import type { ReadTarget } from '../types.js';
|
|
3
|
+
import { FormatError } from './error.js';
|
|
4
|
+
import { flattenValue, type PlainValue } from './flatten.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Parses the `<?php return [...];` subset that Laravel language files use.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately a parser and not an evaluator: locale files come from the
|
|
10
|
+
* repository being linted, so running them would mean executing untrusted code,
|
|
11
|
+
* and it would force PHP onto every machine running the linter. Anything
|
|
12
|
+
* outside the literal subset is a clear error rather than a guess.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export class PhpParseError extends FormatError {
|
|
16
|
+
constructor(file: string, line: number, message: string) {
|
|
17
|
+
super(file, message, line);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const IDENT_START = /[A-Za-z_\x80-]/;
|
|
22
|
+
|
|
23
|
+
/** Where a value sits in the source text, so it can be replaced in place. */
|
|
24
|
+
export interface ValueSpan {
|
|
25
|
+
start: number;
|
|
26
|
+
end: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** `array(...)` still turns up in older projects; new entries should match. */
|
|
30
|
+
export type ArrayStyle = 'bracket' | 'array';
|
|
31
|
+
|
|
32
|
+
/** Where a new entry can be inserted into an existing array. */
|
|
33
|
+
export interface ArraySlot {
|
|
34
|
+
style: ArrayStyle;
|
|
35
|
+
/** Just past the last entry's value, or just past the opening bracket. */
|
|
36
|
+
insertAt: number;
|
|
37
|
+
/** Indentation the entries use, so an inserted one lines up. */
|
|
38
|
+
indent: string;
|
|
39
|
+
hasTrailingComma: boolean;
|
|
40
|
+
empty: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface PhpLayout {
|
|
44
|
+
/** Dot path -> the span of its value expression. */
|
|
45
|
+
values: Map<string, ValueSpan>;
|
|
46
|
+
/** Dot path of every array ('' for the returned one) -> where to insert. */
|
|
47
|
+
arrays: Map<string, ArraySlot>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
class Parser {
|
|
51
|
+
private pos = 0;
|
|
52
|
+
readonly layout: PhpLayout = { values: new Map(), arrays: new Map() };
|
|
53
|
+
|
|
54
|
+
constructor(
|
|
55
|
+
private readonly text: string,
|
|
56
|
+
private readonly file: string,
|
|
57
|
+
) {}
|
|
58
|
+
|
|
59
|
+
/** The indentation of the line `at` sits on. */
|
|
60
|
+
private indentAt(at: number): string {
|
|
61
|
+
const lineStart = this.text.lastIndexOf('\n', at - 1) + 1;
|
|
62
|
+
const match = /^[ \t]*/.exec(this.text.slice(lineStart, at));
|
|
63
|
+
return match ? match[0] : '';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private lineAt(pos: number): number {
|
|
67
|
+
let line = 1;
|
|
68
|
+
for (let i = 0; i < pos && i < this.text.length; i++) {
|
|
69
|
+
if (this.text[i] === '\n') line++;
|
|
70
|
+
}
|
|
71
|
+
return line;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private error(message: string, pos = this.pos): never {
|
|
75
|
+
throw new PhpParseError(this.file, this.lineAt(pos), message);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private atEnd(): boolean {
|
|
79
|
+
return this.pos >= this.text.length;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Whitespace and all three PHP comment styles. */
|
|
83
|
+
private skipTrivia(): void {
|
|
84
|
+
for (;;) {
|
|
85
|
+
while (!this.atEnd() && /\s/.test(this.text[this.pos]!)) this.pos++;
|
|
86
|
+
|
|
87
|
+
if (this.text.startsWith('//', this.pos) || this.text.startsWith('#', this.pos)) {
|
|
88
|
+
const nl = this.text.indexOf('\n', this.pos);
|
|
89
|
+
this.pos = nl === -1 ? this.text.length : nl + 1;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (this.text.startsWith('/*', this.pos)) {
|
|
93
|
+
const end = this.text.indexOf('*/', this.pos + 2);
|
|
94
|
+
if (end === -1) this.error('Unterminated block comment');
|
|
95
|
+
this.pos = end + 2;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private eat(token: string): boolean {
|
|
103
|
+
this.skipTrivia();
|
|
104
|
+
if (this.text.startsWith(token, this.pos)) {
|
|
105
|
+
this.pos += token.length;
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private eatKeyword(word: string): boolean {
|
|
112
|
+
this.skipTrivia();
|
|
113
|
+
const slice = this.text.slice(this.pos, this.pos + word.length);
|
|
114
|
+
if (slice.toLowerCase() !== word) return false;
|
|
115
|
+
const next = this.text[this.pos + word.length];
|
|
116
|
+
if (next !== undefined && /[\w]/.test(next)) return false;
|
|
117
|
+
this.pos += word.length;
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private expect(token: string, what = token): void {
|
|
122
|
+
if (!this.eat(token)) this.error(`Expected ${what}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
parseFile(): PlainValue {
|
|
126
|
+
const open = this.text.indexOf('<?php');
|
|
127
|
+
if (open === -1) this.error('Missing <?php opening tag', 0);
|
|
128
|
+
this.pos = open + '<?php'.length;
|
|
129
|
+
|
|
130
|
+
// `declare(strict_types=1);` and similar preamble statements.
|
|
131
|
+
while (this.eatKeyword('declare')) {
|
|
132
|
+
const close = this.text.indexOf(')', this.pos);
|
|
133
|
+
if (close === -1) this.error('Unterminated declare()');
|
|
134
|
+
this.pos = close + 1;
|
|
135
|
+
this.eat(';');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!this.eatKeyword('return')) {
|
|
139
|
+
this.error('Expected `return` — a language file must return an array');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const value = this.parseValue('');
|
|
143
|
+
this.expect(';');
|
|
144
|
+
|
|
145
|
+
this.skipTrivia();
|
|
146
|
+
if (this.eat('?>')) this.skipTrivia();
|
|
147
|
+
if (!this.atEnd()) this.error('Unexpected content after the returned array');
|
|
148
|
+
|
|
149
|
+
return value;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private parseValue(path: string): PlainValue {
|
|
153
|
+
this.skipTrivia();
|
|
154
|
+
if (this.atEnd()) this.error('Unexpected end of file');
|
|
155
|
+
|
|
156
|
+
const ch = this.text[this.pos]!;
|
|
157
|
+
|
|
158
|
+
if (ch === '[') {
|
|
159
|
+
this.pos++;
|
|
160
|
+
return this.parseArrayBody(']', path);
|
|
161
|
+
}
|
|
162
|
+
if (this.eatKeyword('array')) {
|
|
163
|
+
this.expect('(', '( after array');
|
|
164
|
+
return this.parseArrayBody(')', path);
|
|
165
|
+
}
|
|
166
|
+
if (ch === "'") return this.parseSingleQuoted();
|
|
167
|
+
if (ch === '"') return this.parseDoubleQuoted();
|
|
168
|
+
if (this.eatKeyword('true')) return true;
|
|
169
|
+
if (this.eatKeyword('false')) return false;
|
|
170
|
+
if (this.eatKeyword('null')) return null;
|
|
171
|
+
if (/[-+\d.]/.test(ch)) return this.parseNumber();
|
|
172
|
+
|
|
173
|
+
if (ch === '$') this.error('Variables are not supported in language files');
|
|
174
|
+
if (this.text.startsWith('<<<', this.pos)) {
|
|
175
|
+
this.error('Heredoc/nowdoc strings are not supported');
|
|
176
|
+
}
|
|
177
|
+
if (IDENT_START.test(ch)) this.error('Constants and function calls are not supported');
|
|
178
|
+
|
|
179
|
+
this.error(`Unexpected character ${JSON.stringify(ch)}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private parseArrayBody(closing: string, path: string): PlainValue {
|
|
183
|
+
const entries: Array<[string | number, PlainValue]> = [];
|
|
184
|
+
let nextIndex = 0;
|
|
185
|
+
let sawStringKey = false;
|
|
186
|
+
|
|
187
|
+
const afterOpen = this.pos;
|
|
188
|
+
let indent = '';
|
|
189
|
+
let lastValueEnd = afterOpen;
|
|
190
|
+
let hasTrailingComma = false;
|
|
191
|
+
|
|
192
|
+
for (;;) {
|
|
193
|
+
this.skipTrivia();
|
|
194
|
+
if (this.eat(closing)) break;
|
|
195
|
+
if (this.atEnd()) this.error(`Unterminated array, expected ${closing}`);
|
|
196
|
+
|
|
197
|
+
const start = this.pos;
|
|
198
|
+
if (indent === '') indent = this.indentAt(start);
|
|
199
|
+
const first = this.parseValue(path);
|
|
200
|
+
|
|
201
|
+
if (this.eat('=>')) {
|
|
202
|
+
let key: string | number;
|
|
203
|
+
if (typeof first === 'string') {
|
|
204
|
+
sawStringKey = true;
|
|
205
|
+
key = first;
|
|
206
|
+
} else if (typeof first === 'number' && Number.isInteger(first)) {
|
|
207
|
+
nextIndex = Math.max(nextIndex, first + 1);
|
|
208
|
+
key = first;
|
|
209
|
+
} else {
|
|
210
|
+
this.error('Array keys must be strings or integers', start);
|
|
211
|
+
}
|
|
212
|
+
entries.push([key, this.recordValue(path, String(key))]);
|
|
213
|
+
} else {
|
|
214
|
+
const key = nextIndex++;
|
|
215
|
+
// A list entry's value was already consumed as `first`; its span runs
|
|
216
|
+
// from where the entry started to where parsing left off.
|
|
217
|
+
this.layout.values.set(join(path, String(key)), { start, end: this.pos });
|
|
218
|
+
entries.push([key, first]);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
lastValueEnd = this.pos;
|
|
222
|
+
this.skipTrivia();
|
|
223
|
+
if (this.eat(',')) {
|
|
224
|
+
hasTrailingComma = true;
|
|
225
|
+
// Past the comma, so an inserted entry lands after it rather than
|
|
226
|
+
// between the value and its separator.
|
|
227
|
+
lastValueEnd = this.pos;
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
hasTrailingComma = false;
|
|
231
|
+
this.expect(closing, `, or ${closing}`);
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
this.layout.arrays.set(path, {
|
|
236
|
+
style: closing === ')' ? 'array' : 'bracket',
|
|
237
|
+
insertAt: entries.length === 0 ? afterOpen : lastValueEnd,
|
|
238
|
+
indent: indent || ' ',
|
|
239
|
+
hasTrailingComma,
|
|
240
|
+
empty: entries.length === 0,
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// PHP arrays are ordered maps; a purely sequential one is a JS array.
|
|
244
|
+
const sequential =
|
|
245
|
+
!sawStringKey && entries.every(([key], i) => typeof key === 'number' && key === i);
|
|
246
|
+
if (sequential) return entries.map(([, value]) => value);
|
|
247
|
+
|
|
248
|
+
const object: Record<string, PlainValue> = {};
|
|
249
|
+
for (const [key, value] of entries) object[String(key)] = value;
|
|
250
|
+
return object;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private recordValue(path: string, key: string): PlainValue {
|
|
254
|
+
this.skipTrivia();
|
|
255
|
+
const start = this.pos;
|
|
256
|
+
const value = this.parseValue(join(path, key));
|
|
257
|
+
this.layout.values.set(join(path, key), { start, end: this.pos });
|
|
258
|
+
return value;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private parseNumber(): number {
|
|
262
|
+
this.skipTrivia();
|
|
263
|
+
const match = /^[-+]?(?:\d[\d_]*\.?[\d_]*|\.\d[\d_]*)(?:[eE][-+]?\d+)?/.exec(
|
|
264
|
+
this.text.slice(this.pos),
|
|
265
|
+
);
|
|
266
|
+
if (!match || match[0] === '') this.error('Malformed number');
|
|
267
|
+
this.pos += match[0].length;
|
|
268
|
+
return Number(match[0].replace(/_/g, ''));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Single quotes escape only \' and \\; everything else is literal. */
|
|
272
|
+
private parseSingleQuoted(): string {
|
|
273
|
+
const start = this.pos;
|
|
274
|
+
this.pos++;
|
|
275
|
+
let out = '';
|
|
276
|
+
while (!this.atEnd()) {
|
|
277
|
+
const ch = this.text[this.pos]!;
|
|
278
|
+
if (ch === '\\') {
|
|
279
|
+
const next = this.text[this.pos + 1];
|
|
280
|
+
if (next === "'" || next === '\\') {
|
|
281
|
+
out += next;
|
|
282
|
+
this.pos += 2;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
out += ch;
|
|
286
|
+
this.pos++;
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (ch === "'") {
|
|
290
|
+
this.pos++;
|
|
291
|
+
return out;
|
|
292
|
+
}
|
|
293
|
+
out += ch;
|
|
294
|
+
this.pos++;
|
|
295
|
+
}
|
|
296
|
+
this.error('Unterminated string', start);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private parseDoubleQuoted(): string {
|
|
300
|
+
const start = this.pos;
|
|
301
|
+
this.pos++;
|
|
302
|
+
let out = '';
|
|
303
|
+
while (!this.atEnd()) {
|
|
304
|
+
const ch = this.text[this.pos]!;
|
|
305
|
+
|
|
306
|
+
if (ch === '"') {
|
|
307
|
+
this.pos++;
|
|
308
|
+
return out;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (ch === '$') {
|
|
312
|
+
const next = this.text[this.pos + 1];
|
|
313
|
+
if (next !== undefined && (IDENT_START.test(next) || next === '{')) {
|
|
314
|
+
this.error('Variable interpolation is not supported in language files');
|
|
315
|
+
}
|
|
316
|
+
out += ch;
|
|
317
|
+
this.pos++;
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (ch === '{' && this.text[this.pos + 1] === '$') {
|
|
322
|
+
this.error('Variable interpolation is not supported in language files');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (ch === '\\') {
|
|
326
|
+
out += this.readEscape();
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
out += ch;
|
|
331
|
+
this.pos++;
|
|
332
|
+
}
|
|
333
|
+
this.error('Unterminated string', start);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
private readEscape(): string {
|
|
337
|
+
const next = this.text[this.pos + 1];
|
|
338
|
+
if (next === undefined) this.error('Unterminated escape sequence');
|
|
339
|
+
|
|
340
|
+
const simple: Record<string, string> = {
|
|
341
|
+
n: '\n',
|
|
342
|
+
r: '\r',
|
|
343
|
+
t: '\t',
|
|
344
|
+
v: '\v',
|
|
345
|
+
f: '\f',
|
|
346
|
+
e: '\x1b',
|
|
347
|
+
'\\': '\\',
|
|
348
|
+
$: '$',
|
|
349
|
+
'"': '"',
|
|
350
|
+
};
|
|
351
|
+
if (next in simple) {
|
|
352
|
+
this.pos += 2;
|
|
353
|
+
return simple[next]!;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (next === 'x') {
|
|
357
|
+
const match = /^[0-9A-Fa-f]{1,2}/.exec(this.text.slice(this.pos + 2));
|
|
358
|
+
if (match) {
|
|
359
|
+
this.pos += 2 + match[0].length;
|
|
360
|
+
return String.fromCharCode(Number.parseInt(match[0], 16));
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (next === 'u' && this.text[this.pos + 2] === '{') {
|
|
365
|
+
const close = this.text.indexOf('}', this.pos + 3);
|
|
366
|
+
const hex = close === -1 ? '' : this.text.slice(this.pos + 3, close);
|
|
367
|
+
if (close !== -1 && /^[0-9A-Fa-f]+$/.test(hex)) {
|
|
368
|
+
this.pos = close + 1;
|
|
369
|
+
return String.fromCodePoint(Number.parseInt(hex, 16));
|
|
370
|
+
}
|
|
371
|
+
this.error('Malformed \\u{...} escape');
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const octal = /^[0-7]{1,3}/.exec(this.text.slice(this.pos + 1));
|
|
375
|
+
if (octal) {
|
|
376
|
+
this.pos += 1 + octal[0].length;
|
|
377
|
+
return String.fromCharCode(Number.parseInt(octal[0], 8) & 0xff);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// PHP keeps an unrecognised escape as a literal backslash plus the character.
|
|
381
|
+
this.pos += 1;
|
|
382
|
+
return '\\';
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function join(path: string, key: string): string {
|
|
387
|
+
return path ? `${path}.${key}` : key;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export function parsePhp(text: string, file: string): PlainValue {
|
|
391
|
+
return new Parser(text, file).parseFile();
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Parses and also reports where every value and array sits in the text. */
|
|
395
|
+
export function parsePhpLayout(text: string, file: string): { value: PlainValue; layout: PhpLayout } {
|
|
396
|
+
const parser = new Parser(text, file);
|
|
397
|
+
const value = parser.parseFile();
|
|
398
|
+
return { value, layout: parser.layout };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export function readPhpLocale(
|
|
402
|
+
file: string,
|
|
403
|
+
namespace: string,
|
|
404
|
+
_locale: string,
|
|
405
|
+
_isSource: boolean,
|
|
406
|
+
target: ReadTarget,
|
|
407
|
+
): void {
|
|
408
|
+
const parsed = parsePhp(readFileSync(file, 'utf8'), file);
|
|
409
|
+
if (Array.isArray(parsed)) {
|
|
410
|
+
target.skipped.push({ file, reason: 'the file holds a list, not keyed translations' });
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
flattenValue(parsed, namespace, file, target.leaves, target.containers);
|
|
414
|
+
}
|