-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathi18n-coverage.ts
More file actions
428 lines (396 loc) · 16.9 KB
/
Copy pathi18n-coverage.ts
File metadata and controls
428 lines (396 loc) · 16.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
/**
* I18n Coverage Detector
*
* Walks a normalized stack config and computes the set of translation keys
* that *should* exist for every registered locale (object labels & plural
* labels, field labels, select-option labels, view labels, action labels +
* confirm + success messages, including object-less actions resolved through
* the top-level `globalActions` namespace). Compares the expected set against
* the actual translation bundles attached to the stack and reports any keys
* that are missing or set to an empty string.
*
* The inline `label:` in the metadata is the *source* string, authored in the
* default locale: the runtime resolver falls back to it when a bundle carries
* no entry, and `os i18n extract` seeds bundles from it. So an inline label
* satisfies the default locale on its own — a bundle is what other locales
* need. Keys with no source string anywhere are not reported here; a missing
* label is `required/label`'s finding.
*
* Pure: no filesystem or network. Safe to invoke from `os lint`, `os i18n
* check`, IDE tooling, and unit tests.
*/
import type { TranslationBundle, TranslationData } from '@objectstack/spec/system';
import { METADATA_FORM_REGISTRY } from '@objectstack/spec/system';
import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel';
import { humanizeFieldPath } from './i18n-extract.js';
export type CoverageSeverity = 'error' | 'warning';
export interface CoverageIssue {
severity: CoverageSeverity;
/** BCP-47 locale code where the key is missing. */
locale: string;
/** Dot-path of the missing key (e.g. `objects.account._views.all_accounts.label`). */
key: string;
/** Source kind: object / field / option / view / action / globalAction / metadataForm. */
source: 'object' | 'field' | 'option' | 'view' | 'action' | 'globalAction' | 'metadataForm';
/** Human-readable explanation. */
message: string;
}
export interface CoverageStats {
locale: string;
expected: number;
translated: number;
missing: number;
/** Coverage percent rounded to one decimal (0–100). */
coveragePercent: number;
}
export interface CoverageReport {
/** Locales discovered across all bundles attached to the stack. */
locales: string[];
/** Default / source-of-truth locale (errors are raised against this one). */
defaultLocale: string;
/** Per-locale coverage statistics. */
stats: CoverageStats[];
/** Per-issue listing (errors + warnings, locale-scoped). */
issues: CoverageIssue[];
/** Aggregate counts. */
totals: {
expectedKeys: number;
issues: number;
errors: number;
warnings: number;
};
}
export interface CoverageOptions {
/**
* The locale that *must* be translated. Missing keys here surface as
* errors; missing keys in other locales surface as warnings. Defaults to
* `'en'`.
*/
defaultLocale?: string;
/**
* Restrict the check to this set of locales (in addition to the default
* locale). When omitted, every locale that appears in any bundle is
* checked.
*/
locales?: string[];
/**
* When `true`, missing keys in non-default locales are also reported as
* errors. Useful for CI gates that demand full translation parity.
*/
strict?: boolean;
}
// ─── Bundle helpers ────────────────────────────────────────────────────
function mergeData(target: TranslationData | undefined, source: TranslationData): TranslationData {
if (!target) return JSON.parse(JSON.stringify(source));
// shallow object merge across the four well-known sub-records is enough for
// coverage detection; we never need a deep merge of leaf strings because
// duplicates are accepted (last-write-wins).
const out: TranslationData = { ...target };
if (source.objects) {
out.objects = { ...(out.objects ?? {}) };
for (const [name, data] of Object.entries(source.objects)) {
out.objects[name] = {
...(out.objects[name] ?? {}),
...data,
fields: { ...(out.objects[name]?.fields ?? {}), ...(data.fields ?? {}) },
_views: { ...(out.objects[name]?._views ?? {}), ...(data._views ?? {}) },
_actions: { ...(out.objects[name]?._actions ?? {}), ...(data._actions ?? {}) },
} as any;
}
}
if (source.globalActions) {
out.globalActions = { ...(out.globalActions ?? {}), ...source.globalActions };
}
if (source.apps) out.apps = { ...(out.apps ?? {}), ...source.apps };
if (source.messages) out.messages = { ...(out.messages ?? {}), ...source.messages };
if ((source as any).metadataForms) {
const tgt: Record<string, any> = { ...((out as any).metadataForms ?? {}) };
for (const [type, data] of Object.entries((source as any).metadataForms)) {
const existing = tgt[type] ?? {};
const incoming = (data ?? {}) as any;
tgt[type] = {
...existing,
...incoming,
sections: { ...(existing.sections ?? {}), ...(incoming.sections ?? {}) },
fields: { ...(existing.fields ?? {}), ...(incoming.fields ?? {}) },
};
}
(out as any).metadataForms = tgt;
}
return out;
}
function flattenBundles(bundles: TranslationBundle[]): { merged: TranslationBundle; locales: string[] } {
const merged: Record<string, TranslationData> = {};
const localesSet = new Set<string>();
for (const bundle of bundles) {
if (!bundle || typeof bundle !== 'object') continue;
for (const [locale, data] of Object.entries(bundle)) {
if (!data || typeof data !== 'object') continue;
localesSet.add(locale);
merged[locale] = mergeData(merged[locale], data as TranslationData);
}
}
return { merged, locales: Array.from(localesSet).sort() };
}
function viewObjectName(view: any): string | undefined {
return view?.objectName ?? view?.object ?? view?.data?.object;
}
// ─── Expected key extraction ───────────────────────────────────────────
interface ExpectedKey {
source: CoverageIssue['source'];
/** Lookup path expressed as an array of segments. */
path: string[];
/** Friendly display key (joined with dots). */
displayKey: string;
/** Description shown in the issue message when the key is missing. */
context: string;
/**
* The source string authored inline in the metadata (`label: 'Note'`), when
* there is one. This *is* the default-locale text — see `computeI18nCoverage`.
*/
inline?: string;
}
function pushKey(
out: ExpectedKey[],
path: string[],
source: CoverageIssue['source'],
context: string,
inline?: string,
): void {
out.push({ source, path, displayKey: path.join('.'), context, inline });
}
/** Narrow to a usable source string; empty strings are not authored text. */
function inlineText(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined;
}
/**
* Collects every key a translation bundle *may* carry, paired with the inline
* source string the metadata already authors for it. Callers drop the keys that
* are authored nowhere — see `computeI18nCoverage`.
*/
function collectExpectedKeys(config: any): ExpectedKey[] {
const keys: ExpectedKey[] = [];
const objects: any[] = Array.isArray(config?.objects) ? config.objects : [];
for (const obj of objects) {
if (!obj?.name) continue;
const objectName = obj.name as string;
pushKey(keys, ['objects', objectName, 'label'], 'object', `Object "${objectName}" label`, inlineText(obj.label));
pushKey(
keys,
['objects', objectName, 'pluralLabel'],
'object',
`Object "${objectName}" pluralLabel`,
inlineText(obj.pluralLabel),
);
if (obj.fields && typeof obj.fields === 'object') {
for (const [fieldName, field] of Object.entries<any>(obj.fields)) {
pushKey(
keys,
['objects', objectName, 'fields', fieldName, 'label'],
'field',
`Field ${objectName}.${fieldName} label`,
inlineText(field?.label),
);
const opts = field?.options;
if (opts && typeof opts === 'object' && !Array.isArray(opts)) {
for (const [optionKey, optionLabel] of Object.entries<any>(opts)) {
// Mirrors the extractor: an option's source text is its label, or
// its own value when the map holds no label string.
pushKey(
keys,
['objects', objectName, 'fields', fieldName, 'options', optionKey],
'option',
`Option ${objectName}.${fieldName}.${optionKey}`,
inlineText(optionLabel) ?? optionKey,
);
}
}
}
}
}
const views: any[] = Array.isArray(config?.views) ? config.views : [];
for (const view of views) {
if (!view?.name) continue;
const objectName = viewObjectName(view);
if (!objectName) continue;
pushKey(
keys,
['objects', objectName, '_views', view.name, 'label'],
'view',
`View ${objectName}.${view.name} label`,
inlineText(view.label),
);
}
const actions: any[] = Array.isArray(config?.actions) ? config.actions : [];
for (const action of actions) {
if (!action?.name) continue;
const objectName = action.objectName ?? action.object;
const root = objectName ? ['objects', objectName, '_actions', action.name] : ['globalActions', action.name];
const source: CoverageIssue['source'] = objectName ? 'action' : 'globalAction';
const ctxOwner = objectName ? `${objectName}.${action.name}` : action.name;
pushKey(keys, [...root, 'label'], source, `Action ${ctxOwner} label`, inlineText(action.label));
pushKey(keys, [...root, 'confirmText'], source, `Action ${ctxOwner} confirmText`, inlineText(action.confirmText));
pushKey(
keys,
[...root, 'successMessage'],
source,
`Action ${ctxOwner} successMessage`,
inlineText(action.successMessage),
);
}
collectMetadataFormKeys(keys);
return keys;
}
/**
* Walks the canonical METADATA_FORM_REGISTRY + DEFAULT_METADATA_TYPE_REGISTRY
* and pushes every translation key the resolver may look up under
* `metadataForms.*`. Mirrors the extractor walker so coverage stays in lock-
* step with what `os i18n extract` generates.
*/
function collectMetadataFormKeys(out: ExpectedKey[]): void {
for (const entry of DEFAULT_METADATA_TYPE_REGISTRY) {
const type = entry.type;
pushKey(
out,
['metadataForms', type, 'label'],
'metadataForm',
`Metadata form "${type}" label`,
inlineText((entry as any).label) ?? type,
);
pushKey(
out,
['metadataForms', type, 'description'],
'metadataForm',
`Metadata form "${type}" description`,
inlineText((entry as any).description),
);
}
for (const [type, form] of Object.entries(METADATA_FORM_REGISTRY)) {
const sections: any[] = [
...(Array.isArray((form as any)?.sections) ? (form as any).sections : []),
...(Array.isArray((form as any)?.groups) ? (form as any).groups : []),
];
for (const section of sections) {
if (!section || typeof section !== 'object') continue;
const sectionName = normalizeMetadataSectionName(section);
if (sectionName) {
pushKey(out, ['metadataForms', type, 'sections', sectionName, 'label'], 'metadataForm', `Metadata form ${type}.sections.${sectionName} label`, inlineText(section.label));
pushKey(out, ['metadataForms', type, 'sections', sectionName, 'description'], 'metadataForm', `Metadata form ${type}.sections.${sectionName} description`, inlineText(section.description));
}
if (Array.isArray(section.fields)) {
for (const child of section.fields) walkMetadataFormField(child, type, '', out);
}
}
}
}
function walkMetadataFormField(field: any, type: string, parentPath: string, out: ExpectedKey[]): void {
if (!field || typeof field !== 'object') return;
const name = typeof field.field === 'string' ? field.field : undefined;
const path = name ? (parentPath ? `${parentPath}.${name}` : name) : parentPath;
if (path) {
// Platform form fields routinely omit `label` and let the renderer
// humanize the field path ("name" → "Name"). That derived text is the
// source string — the field is not unlabelled — so other locales still
// owe it a translation. Mirrors the extractor's seed value.
pushKey(out, ['metadataForms', type, 'fields', path, 'label'], 'metadataForm', `Metadata form ${type}.fields.${path} label`, inlineText(field.label) ?? humanizeFieldPath(path));
pushKey(out, ['metadataForms', type, 'fields', path, 'helpText'], 'metadataForm', `Metadata form ${type}.fields.${path} helpText`, inlineText(field.helpText));
pushKey(out, ['metadataForms', type, 'fields', path, 'placeholder'], 'metadataForm', `Metadata form ${type}.fields.${path} placeholder`, inlineText(field.placeholder));
}
if (Array.isArray(field.fields)) {
for (const child of field.fields) walkMetadataFormField(child, type, path, out);
}
}
function normalizeMetadataSectionName(section: any): string | undefined {
if (typeof section.name === 'string' && section.name.length > 0) return section.name;
if (typeof section.label !== 'string') return undefined;
return section.label
.toLowerCase()
.replace(/&/g, 'and')
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
}
// ─── Lookup ────────────────────────────────────────────────────────────
function lookupKey(data: TranslationData | undefined, path: string[]): string | undefined {
let current: any = data;
for (const segment of path) {
if (!current || typeof current !== 'object') return undefined;
current = current[segment];
}
return typeof current === 'string' && current.length > 0 ? current : undefined;
}
// ─── Public API ────────────────────────────────────────────────────────
/**
* Compute a coverage report for a normalized stack config.
*/
export function computeI18nCoverage(config: any, opts: CoverageOptions = {}): CoverageReport {
const defaultLocale = opts.defaultLocale ?? 'en';
const bundles: TranslationBundle[] = Array.isArray(config?.translations) ? config.translations : [];
const { merged, locales: discovered } = flattenBundles(bundles);
let activeLocales: string[];
if (opts.locales && opts.locales.length > 0) {
const set = new Set<string>([defaultLocale, ...opts.locales]);
activeLocales = Array.from(set);
} else if (discovered.length === 0) {
activeLocales = [defaultLocale];
} else {
activeLocales = discovered.includes(defaultLocale) ? discovered : [defaultLocale, ...discovered];
}
// A key is only worth translating if a source string is authored somewhere:
// inline in the metadata, or in some bundle (a project may externalize a
// string it never wrote inline — other locales still owe a translation for
// it). A key authored in neither place has no text to translate at all; a
// missing label is `required/label`'s finding, not an i18n gap.
const authoredInBundle = (path: string[]): boolean =>
Object.values(merged).some((data) => lookupKey(data, path) !== undefined);
const expected = collectExpectedKeys(config).filter(
(key) => key.inline !== undefined || authoredInBundle(key.path),
);
const issues: CoverageIssue[] = [];
const stats: CoverageStats[] = [];
for (const locale of activeLocales) {
const data = merged[locale];
let translated = 0;
for (const key of expected) {
// The inline `label:` IS the default-locale text — the runtime resolver
// falls back to it (i18n-resolver `translateObject`), and `os i18n
// extract` seeds bundles from it. Demanding a default-locale bundle entry
// that merely restates it reports a gap that does not exist.
const value = lookupKey(data, key.path) ?? (locale === defaultLocale ? key.inline : undefined);
if (value !== undefined) {
translated += 1;
continue;
}
const isError = locale === defaultLocale || opts.strict === true;
issues.push({
severity: isError ? 'error' : 'warning',
locale,
key: key.displayKey,
source: key.source,
message: `${key.context} missing translation for locale "${locale}"`,
});
}
const missing = expected.length - translated;
stats.push({
locale,
expected: expected.length,
translated,
missing,
coveragePercent: expected.length === 0 ? 100 : Math.round((translated / expected.length) * 1000) / 10,
});
}
const errors = issues.filter((i) => i.severity === 'error').length;
const warnings = issues.length - errors;
return {
locales: activeLocales,
defaultLocale,
stats,
issues,
totals: {
expectedKeys: expected.length,
issues: issues.length,
errors,
warnings,
},
};
}