@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
package/src/glossary.ts
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const GLOSSARY_FILE = '.i18n/glossary.json';
|
|
5
|
+
|
|
6
|
+
export type MatchMode = 'prefix' | 'exact' | 'substring';
|
|
7
|
+
|
|
8
|
+
export interface GlossaryTerm {
|
|
9
|
+
/** The word as it appears in the source locale. */
|
|
10
|
+
source: string;
|
|
11
|
+
/** Accepted renderings per locale. Locales left out are not checked. */
|
|
12
|
+
targets: Record<string, string[]>;
|
|
13
|
+
/** Default `prefix`, which tolerates suffix inflection: panier → paniers. */
|
|
14
|
+
match: MatchMode;
|
|
15
|
+
caseSensitive: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface Glossary {
|
|
19
|
+
version: 1;
|
|
20
|
+
/** Tokens that must survive verbatim: brand and product names, protocols. */
|
|
21
|
+
doNotTranslate: string[];
|
|
22
|
+
terms: GlossaryTerm[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class GlossaryError extends Error {}
|
|
26
|
+
|
|
27
|
+
export function glossaryPath(root: string, explicit?: string): string {
|
|
28
|
+
return resolve(root, explicit ?? GLOSSARY_FILE);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function fail(file: string, message: string): never {
|
|
32
|
+
throw new GlossaryError(`Invalid glossary ${file}\n ${message}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function asStringArray(file: string, value: unknown, where: string): string[] {
|
|
36
|
+
if (!Array.isArray(value) || value.some((v) => typeof v !== 'string')) {
|
|
37
|
+
fail(file, `${where} must be an array of strings`);
|
|
38
|
+
}
|
|
39
|
+
return value as string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function loadGlossary(file: string): Glossary | null {
|
|
43
|
+
if (!existsSync(file)) return null;
|
|
44
|
+
|
|
45
|
+
let parsed: unknown;
|
|
46
|
+
try {
|
|
47
|
+
parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
48
|
+
} catch (err) {
|
|
49
|
+
fail(file, err instanceof Error ? err.message : String(err));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (typeof parsed !== 'object' || parsed === null) fail(file, 'expected an object');
|
|
53
|
+
const raw = parsed as Record<string, unknown>;
|
|
54
|
+
if (raw['version'] !== 1) fail(file, 'expected "version": 1');
|
|
55
|
+
|
|
56
|
+
const doNotTranslate =
|
|
57
|
+
raw['doNotTranslate'] === undefined
|
|
58
|
+
? []
|
|
59
|
+
: asStringArray(file, raw['doNotTranslate'], 'doNotTranslate');
|
|
60
|
+
|
|
61
|
+
const termsRaw = raw['terms'] === undefined ? [] : raw['terms'];
|
|
62
|
+
if (!Array.isArray(termsRaw)) fail(file, 'terms must be an array');
|
|
63
|
+
|
|
64
|
+
const terms: GlossaryTerm[] = termsRaw.map((entry, index) => {
|
|
65
|
+
const where = `terms[${index}]`;
|
|
66
|
+
if (typeof entry !== 'object' || entry === null) fail(file, `${where} must be an object`);
|
|
67
|
+
const term = entry as Record<string, unknown>;
|
|
68
|
+
|
|
69
|
+
const source = term['source'];
|
|
70
|
+
if (typeof source !== 'string' || source === '') {
|
|
71
|
+
fail(file, `${where}.source must be a non-empty string`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const targetsRaw = term['targets'];
|
|
75
|
+
if (typeof targetsRaw !== 'object' || targetsRaw === null || Array.isArray(targetsRaw)) {
|
|
76
|
+
fail(file, `${where}.targets must be an object keyed by locale`);
|
|
77
|
+
}
|
|
78
|
+
const targets: Record<string, string[]> = {};
|
|
79
|
+
for (const [locale, value] of Object.entries(targetsRaw as Record<string, unknown>)) {
|
|
80
|
+
const forms = asStringArray(file, value, `${where}.targets.${locale}`);
|
|
81
|
+
if (forms.length === 0) fail(file, `${where}.targets.${locale} must list at least one form`);
|
|
82
|
+
targets[locale] = forms;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const match = term['match'] ?? 'prefix';
|
|
86
|
+
if (match !== 'prefix' && match !== 'exact' && match !== 'substring') {
|
|
87
|
+
fail(file, `${where}.match must be "prefix", "exact" or "substring"`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const caseSensitive = term['caseSensitive'] ?? false;
|
|
91
|
+
if (typeof caseSensitive !== 'boolean') fail(file, `${where}.caseSensitive must be a boolean`);
|
|
92
|
+
|
|
93
|
+
return { source, targets, match, caseSensitive };
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return { version: 1, doNotTranslate, terms };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Matching
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
const WORDLIKE = /[\p{L}\p{N}]/u;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Scripts written without spaces, where a term is normally surrounded by other
|
|
107
|
+
* letters and boundary checks would never match.
|
|
108
|
+
*/
|
|
109
|
+
const NO_WORD_BREAKS =
|
|
110
|
+
/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Thai}\p{Script=Lao}\p{Script=Khmer}\p{Script=Myanmar}]/u;
|
|
111
|
+
|
|
112
|
+
export function usesWordBoundaries(term: string): boolean {
|
|
113
|
+
return !NO_WORD_BREAKS.test(term);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Whether `term` occurs in `text`.
|
|
118
|
+
*
|
|
119
|
+
* `prefix` is the default because suffix inflection is the common case: a
|
|
120
|
+
* glossary saying "panier" should accept "paniers", and one saying "корзин"
|
|
121
|
+
* should accept every case ending.
|
|
122
|
+
*/
|
|
123
|
+
export function containsTerm(
|
|
124
|
+
text: string,
|
|
125
|
+
term: string,
|
|
126
|
+
match: MatchMode = 'prefix',
|
|
127
|
+
caseSensitive = false,
|
|
128
|
+
): boolean {
|
|
129
|
+
if (term === '') return false;
|
|
130
|
+
|
|
131
|
+
const haystack = caseSensitive ? text : text.toLowerCase();
|
|
132
|
+
const needle = caseSensitive ? term : term.toLowerCase();
|
|
133
|
+
|
|
134
|
+
if (match === 'substring' || !usesWordBoundaries(term)) {
|
|
135
|
+
return haystack.includes(needle);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let from = 0;
|
|
139
|
+
for (;;) {
|
|
140
|
+
const at = haystack.indexOf(needle, from);
|
|
141
|
+
if (at === -1) return false;
|
|
142
|
+
|
|
143
|
+
const before = at === 0 ? '' : haystack[at - 1]!;
|
|
144
|
+
const startsWord = before === '' || !WORDLIKE.test(before);
|
|
145
|
+
|
|
146
|
+
if (startsWord) {
|
|
147
|
+
if (match === 'prefix') return true;
|
|
148
|
+
const afterIndex = at + needle.length;
|
|
149
|
+
const after = afterIndex >= haystack.length ? '' : haystack[afterIndex]!;
|
|
150
|
+
if (after === '' || !WORDLIKE.test(after)) return true;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
from = at + 1;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface TermExpectation {
|
|
158
|
+
term: GlossaryTerm;
|
|
159
|
+
accepted: string[];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Terms that apply to this source string for this locale, with their forms. */
|
|
163
|
+
export function expectationsFor(
|
|
164
|
+
glossary: Glossary,
|
|
165
|
+
locale: string,
|
|
166
|
+
sourceValue: string,
|
|
167
|
+
): TermExpectation[] {
|
|
168
|
+
const out: TermExpectation[] = [];
|
|
169
|
+
for (const term of glossary.terms) {
|
|
170
|
+
const accepted = term.targets[locale];
|
|
171
|
+
if (!accepted) continue;
|
|
172
|
+
if (!containsTerm(sourceValue, term.source, term.match, term.caseSensitive)) continue;
|
|
173
|
+
out.push({ term, accepted });
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Do-not-translate tokens present in the source but missing from the target. */
|
|
179
|
+
export function missingVerbatim(
|
|
180
|
+
glossary: Glossary,
|
|
181
|
+
sourceValue: string,
|
|
182
|
+
targetValue: string,
|
|
183
|
+
): string[] {
|
|
184
|
+
const missing: string[] = [];
|
|
185
|
+
for (const token of glossary.doNotTranslate) {
|
|
186
|
+
// Brand names carry their capitalisation, so this comparison is exact.
|
|
187
|
+
if (!containsTerm(sourceValue, token, 'exact', true)) continue;
|
|
188
|
+
if (!containsTerm(targetValue, token, 'exact', true)) missing.push(token);
|
|
189
|
+
}
|
|
190
|
+
return missing;
|
|
191
|
+
}
|
package/src/lengths.ts
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { looksLikeIcuPlural } from './plurals.js';
|
|
4
|
+
|
|
5
|
+
export const LIMITS_FILE = '.i18n/limits.json';
|
|
6
|
+
|
|
7
|
+
export interface LimitPattern {
|
|
8
|
+
match: string;
|
|
9
|
+
max: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface Limits {
|
|
13
|
+
version: 1;
|
|
14
|
+
/** Applied to every measurable key when set. */
|
|
15
|
+
default: number | null;
|
|
16
|
+
keys: Record<string, number>;
|
|
17
|
+
patterns: LimitPattern[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class LimitsError extends Error {}
|
|
21
|
+
|
|
22
|
+
export function limitsPath(root: string, explicit?: string): string {
|
|
23
|
+
return resolve(root, explicit ?? LIMITS_FILE);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function fail(file: string, message: string): never {
|
|
27
|
+
throw new LimitsError(`Invalid limits ${file}\n ${message}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function asMax(file: string, value: unknown, where: string): number {
|
|
31
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
|
|
32
|
+
fail(file, `${where} must be a positive integer`);
|
|
33
|
+
}
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function loadLimits(file: string): Limits | null {
|
|
38
|
+
if (!existsSync(file)) return null;
|
|
39
|
+
|
|
40
|
+
let parsed: unknown;
|
|
41
|
+
try {
|
|
42
|
+
parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
43
|
+
} catch (err) {
|
|
44
|
+
fail(file, err instanceof Error ? err.message : String(err));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (typeof parsed !== 'object' || parsed === null) fail(file, 'expected an object');
|
|
48
|
+
const raw = parsed as Record<string, unknown>;
|
|
49
|
+
if (raw['version'] !== 1) fail(file, 'expected "version": 1');
|
|
50
|
+
|
|
51
|
+
const fallback =
|
|
52
|
+
raw['default'] === undefined || raw['default'] === null
|
|
53
|
+
? null
|
|
54
|
+
: asMax(file, raw['default'], 'default');
|
|
55
|
+
|
|
56
|
+
const keys: Record<string, number> = {};
|
|
57
|
+
if (raw['keys'] !== undefined) {
|
|
58
|
+
if (typeof raw['keys'] !== 'object' || raw['keys'] === null || Array.isArray(raw['keys'])) {
|
|
59
|
+
fail(file, 'keys must be an object mapping key to maximum width');
|
|
60
|
+
}
|
|
61
|
+
for (const [key, value] of Object.entries(raw['keys'] as Record<string, unknown>)) {
|
|
62
|
+
keys[key] = asMax(file, value, `keys.${key}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const patterns: LimitPattern[] = [];
|
|
67
|
+
if (raw['patterns'] !== undefined) {
|
|
68
|
+
if (!Array.isArray(raw['patterns'])) fail(file, 'patterns must be an array');
|
|
69
|
+
raw['patterns'].forEach((entry, index) => {
|
|
70
|
+
const where = `patterns[${index}]`;
|
|
71
|
+
if (typeof entry !== 'object' || entry === null) fail(file, `${where} must be an object`);
|
|
72
|
+
const pattern = entry as Record<string, unknown>;
|
|
73
|
+
if (typeof pattern['match'] !== 'string' || pattern['match'] === '') {
|
|
74
|
+
fail(file, `${where}.match must be a non-empty string`);
|
|
75
|
+
}
|
|
76
|
+
patterns.push({
|
|
77
|
+
match: pattern['match'],
|
|
78
|
+
max: asMax(file, pattern['max'], `${where}.max`),
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { version: 1, default: fallback, keys, patterns };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function globToRegExp(glob: string): RegExp {
|
|
87
|
+
// Split on the wildcard first, so the escaping pass never has to carry a
|
|
88
|
+
// placeholder character through the string.
|
|
89
|
+
const pattern = glob
|
|
90
|
+
.split('*')
|
|
91
|
+
.map((part) => part.replace(/[.+?^${}()|[\]\\]/g, '\\$&'))
|
|
92
|
+
.join('.*');
|
|
93
|
+
return new RegExp(`^${pattern}$`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const patternCache = new Map<string, RegExp>();
|
|
97
|
+
|
|
98
|
+
/** Exact keys win over patterns, and the first matching pattern wins over the default. */
|
|
99
|
+
export function limitFor(limits: Limits, key: string): number | null {
|
|
100
|
+
const exact = limits.keys[key];
|
|
101
|
+
if (exact !== undefined) return exact;
|
|
102
|
+
|
|
103
|
+
for (const pattern of limits.patterns) {
|
|
104
|
+
let re = patternCache.get(pattern.match);
|
|
105
|
+
if (!re) {
|
|
106
|
+
re = globToRegExp(pattern.match);
|
|
107
|
+
patternCache.set(pattern.match, re);
|
|
108
|
+
}
|
|
109
|
+
if (re.test(key)) return pattern.max;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return limits.default;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Width
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
const COMBINING = /\p{M}/u;
|
|
120
|
+
|
|
121
|
+
const ZERO_WIDTH = new Set([0x200b, 0x200c, 0x200d, 0x200e, 0x200f, 0xfeff]);
|
|
122
|
+
|
|
123
|
+
/** East Asian Wide and Fullwidth blocks, which occupy two columns. */
|
|
124
|
+
const WIDE_RANGES: Array<[number, number]> = [
|
|
125
|
+
[0x1100, 0x115f],
|
|
126
|
+
[0x2e80, 0x303e],
|
|
127
|
+
[0x3041, 0x33ff],
|
|
128
|
+
[0x3400, 0x4dbf],
|
|
129
|
+
[0x4e00, 0x9fff],
|
|
130
|
+
[0xa000, 0xa4cf],
|
|
131
|
+
[0xa960, 0xa97f],
|
|
132
|
+
[0xac00, 0xd7a3],
|
|
133
|
+
[0xf900, 0xfaff],
|
|
134
|
+
[0xfe10, 0xfe19],
|
|
135
|
+
[0xfe30, 0xfe6f],
|
|
136
|
+
[0xff00, 0xff60],
|
|
137
|
+
[0xffe0, 0xffe6],
|
|
138
|
+
[0x17000, 0x18aff],
|
|
139
|
+
[0x1b000, 0x1b12f],
|
|
140
|
+
[0x1f300, 0x1f64f],
|
|
141
|
+
[0x1f900, 0x1f9ff],
|
|
142
|
+
[0x20000, 0x3fffd],
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
function isWide(cp: number): boolean {
|
|
146
|
+
for (const [start, end] of WIDE_RANGES) {
|
|
147
|
+
if (cp < start) return false;
|
|
148
|
+
if (cp <= end) return true;
|
|
149
|
+
}
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Width in display columns rather than UTF-16 units: a CJK character occupies
|
|
155
|
+
* two columns, combining marks none. Counting `.length` would report Japanese
|
|
156
|
+
* as half its real size and any emoji as double.
|
|
157
|
+
*/
|
|
158
|
+
export function displayWidth(text: string): number {
|
|
159
|
+
let width = 0;
|
|
160
|
+
for (const ch of text) {
|
|
161
|
+
const cp = ch.codePointAt(0)!;
|
|
162
|
+
if (ZERO_WIDTH.has(cp) || (cp >= 0xfe00 && cp <= 0xfe0f)) continue;
|
|
163
|
+
if (COMBINING.test(ch)) continue;
|
|
164
|
+
width += isWide(cp) ? 2 : 1;
|
|
165
|
+
}
|
|
166
|
+
return width;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Splits on Laravel's top-level pipes, which select between forms. */
|
|
170
|
+
function segments(text: string): string[] {
|
|
171
|
+
const out: string[] = [];
|
|
172
|
+
let depth = 0;
|
|
173
|
+
let current = '';
|
|
174
|
+
for (const ch of text) {
|
|
175
|
+
if (ch === '{' || ch === '[') depth++;
|
|
176
|
+
else if (ch === '}' || ch === ']') depth = Math.max(0, depth - 1);
|
|
177
|
+
if (ch === '|' && depth === 0) {
|
|
178
|
+
out.push(current);
|
|
179
|
+
current = '';
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
current += ch;
|
|
183
|
+
}
|
|
184
|
+
out.push(current);
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* An ICU plural carries every branch in one string but shows one at a time, and
|
|
190
|
+
* its selectors are not displayed at all, so measuring it would be meaningless.
|
|
191
|
+
*/
|
|
192
|
+
export function measurable(text: string): boolean {
|
|
193
|
+
return !looksLikeIcuPlural(text);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The widest thing the user can actually end up seeing. */
|
|
197
|
+
export function renderedWidth(text: string, laravel: boolean): number {
|
|
198
|
+
if (!laravel) return displayWidth(text);
|
|
199
|
+
return Math.max(...segments(text).map((segment) => displayWidth(segment.trim())));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Conventional translation-expansion allowances: short strings grow much more
|
|
204
|
+
* in relative terms than long ones, so a single ratio would either miss real
|
|
205
|
+
* overflow or flag every button. These are rules of thumb, not a standard.
|
|
206
|
+
*/
|
|
207
|
+
export function allowanceFor(sourceWidth: number): number {
|
|
208
|
+
if (sourceWidth <= 10) return 3;
|
|
209
|
+
if (sourceWidth <= 20) return 2;
|
|
210
|
+
if (sourceWidth <= 30) return 1.8;
|
|
211
|
+
if (sourceWidth <= 50) return 1.6;
|
|
212
|
+
if (sourceWidth <= 70) return 1.4;
|
|
213
|
+
return 1.3;
|
|
214
|
+
}
|