Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/i18n-inline-label-default-locale.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
"@objectstack/cli": patch
---

fix(cli): treat an inline `label:` as the default-locale source in i18n coverage

A fresh `npm create objectstack` scaffold reported 4 `i18n/missing-object` /
`i18n/missing-field` errors for its own `<ns>_note` object, even though the
template authors `label: 'Note'`, `pluralLabel: 'Notes'`, `label: 'Title'` and
`label: 'Body'` inline. The only way to silence them was to commit an `en`
bundle restating strings the metadata already carries.

The inline `label:` *is* the default-locale text: the runtime resolver falls
back to it when a bundle has no entry (`translateObject`), and `os i18n
extract` seeds bundles from it. Coverage now honours that contract — an inline
label satisfies the default locale, and a bundle is what *other* locales need.
Keys with no source string anywhere are no longer reported as i18n gaps; a
missing label is already `required/label`'s finding.

Non-default locales are unaffected: they still warn for every untranslated key
(`os lint` on `examples/app-todo` reports the same 79 warnings as before, with
its 39 default-locale errors gone). `os lint --include-platform` drops the
platform baseline's default-locale errors for the same reason — the platform
ships English labels inline — while keeping its non-default-locale warnings.
133 changes: 99 additions & 34 deletions packages/cli/src/utils/i18n-coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,21 @@
* 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';

Expand Down Expand Up @@ -150,34 +158,68 @@ interface ExpectedKey {
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): void {
out.push({ source, path, displayKey: path.join('.'), context });
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`);
if (obj.pluralLabel || obj.label) {
pushKey(keys, ['objects', objectName, 'pluralLabel'], 'object', `Object "${objectName}" pluralLabel`);
}
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`);
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 of Object.keys(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,
);
}
}
Expand All @@ -195,6 +237,7 @@ function collectExpectedKeys(config: any): ExpectedKey[] {
['objects', objectName, '_views', view.name, 'label'],
'view',
`View ${objectName}.${view.name} label`,
inlineText(view.label),
);
}

Expand All @@ -205,13 +248,15 @@ function collectExpectedKeys(config: any): ExpectedKey[] {
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`);
if (action.confirmText) {
pushKey(keys, [...root, 'confirmText'], source, `Action ${ctxOwner} confirmText`);
}
if (action.successMessage) {
pushKey(keys, [...root, 'successMessage'], source, `Action ${ctxOwner} successMessage`);
}
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);
Expand All @@ -227,11 +272,20 @@ function collectExpectedKeys(config: any): ExpectedKey[] {
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`);
const desc = (entry as any).description;
if (typeof desc === 'string' && desc.length > 0) {
pushKey(out, ['metadataForms', type, 'description'], 'metadataForm', `Metadata form "${type}" description`);
}
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[] = [
Expand All @@ -241,11 +295,9 @@ function collectMetadataFormKeys(out: ExpectedKey[]): void {
for (const section of sections) {
if (!section || typeof section !== 'object') continue;
const sectionName = normalizeMetadataSectionName(section);
if (sectionName && typeof section.label === 'string') {
pushKey(out, ['metadataForms', type, 'sections', sectionName, 'label'], 'metadataForm', `Metadata form ${type}.sections.${sectionName} label`);
}
if (sectionName && typeof section.description === 'string' && section.description.length > 0) {
pushKey(out, ['metadataForms', type, 'sections', sectionName, 'description'], 'metadataForm', `Metadata form ${type}.sections.${sectionName} description`);
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);
Expand All @@ -259,13 +311,13 @@ function walkMetadataFormField(field: any, type: string, parentPath: string, out
const name = typeof field.field === 'string' ? field.field : undefined;
const path = name ? (parentPath ? `${parentPath}.${name}` : name) : parentPath;
if (path) {
pushKey(out, ['metadataForms', type, 'fields', path, 'label'], 'metadataForm', `Metadata form ${type}.fields.${path} label`);
if (typeof field.helpText === 'string' && field.helpText.length > 0) {
pushKey(out, ['metadataForms', type, 'fields', path, 'helpText'], 'metadataForm', `Metadata form ${type}.fields.${path} helpText`);
}
if (typeof field.placeholder === 'string' && field.placeholder.length > 0) {
pushKey(out, ['metadataForms', type, 'fields', path, 'placeholder'], 'metadataForm', `Metadata form ${type}.fields.${path} placeholder`);
}
// 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);
Expand Down Expand Up @@ -313,15 +365,28 @@ export function computeI18nCoverage(config: any, opts: CoverageOptions = {}): Co
activeLocales = discovered.includes(defaultLocale) ? discovered : [defaultLocale, ...discovered];
}

const expected = collectExpectedKeys(config);
// 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) {
const value = lookupKey(data, key.path);
// 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;
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/utils/i18n-extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,7 +446,7 @@ function walkFormField(field: any, type: string, parentPath: string, out: Expect
}

/** Match the metadata form renderer's fallback label for fields without an explicit label. */
function humanizeFieldPath(path: string): string {
export function humanizeFieldPath(path: string): string {
const leaf = path.split('.').pop() ?? path;
return leaf
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
Expand Down
103 changes: 102 additions & 1 deletion packages/cli/test/i18n-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,13 +138,27 @@ describe('computeI18nCoverage', () => {
});

it('raises errors when the default locale itself is incomplete', () => {
// A genuine default-locale gap: zh-CN carries a string that has no source
// text anywhere for `en` — neither inline on the action nor in the bundle.
const incomplete = JSON.parse(JSON.stringify(baseConfig));
delete incomplete.translations[0].en.objects.account._actions.merge_accounts.label;
delete incomplete.actions[0].label;
const report = computeI18nCoverage(incomplete, { defaultLocale: 'en' });
const enErrors = report.issues.filter((i) => i.locale === 'en' && i.severity === 'error');
expect(enErrors.some((i) => i.key === 'objects.account._actions.merge_accounts.label')).toBe(true);
});

it('does not fault the default locale for a string authored inline', () => {
// Dropping a bundle entry that merely restates the inline `label:` is not
// a gap — the runtime resolver falls back to the inline text. Regression
// guard for the blank template linting with i18n errors out of the box.
const inlineOnly = JSON.parse(JSON.stringify(baseConfig));
delete inlineOnly.translations[0].en.objects.account._actions.merge_accounts.label;
const report = computeI18nCoverage(inlineOnly, { defaultLocale: 'en' });
const enErrors = report.issues.filter((i) => i.locale === 'en' && i.severity === 'error');
expect(enErrors.map((i) => i.key)).not.toContain('objects.account._actions.merge_accounts.label');
});

it('honours an explicit --locales filter', () => {
const report = computeI18nCoverage(baseConfig, { defaultLocale: 'en', locales: ['ja-JP'] });
expect(report.locales.sort()).toEqual(['en', 'ja-JP']);
Expand All @@ -159,7 +173,94 @@ describe('computeI18nCoverage', () => {
const objectSources = new Set(['object', 'field', 'option', 'view', 'action', 'globalAction']);
expect(report.issues.filter((i) => objectSources.has(i.source))).toEqual([]);
expect(report.totals.expectedKeys).toBeGreaterThan(0); // metadataForms baseline
expect(report.issues.some((i) => i.key === 'metadataForms.flow.fields.name.label')).toBe(true);
});

describe('inline metadata as the default-locale source', () => {
// Shape of the `create-objectstack` blank template: inline labels, no
// translation bundle. It must lint clean (#3103).
const scaffold: any = {
objects: [
{
name: 'demo_note',
label: 'Note',
pluralLabel: 'Notes',
fields: { title: { label: 'Title' }, body: { label: 'Body' } },
},
],
translations: [],
};

it('reports no default-locale errors for a bundle-less scaffold', () => {
const report = computeI18nCoverage(scaffold, { defaultLocale: 'en' });
expect(report.issues.filter((i) => i.severity === 'error')).toEqual([]);
});

it('reports 100% default-locale coverage when every string is authored inline', () => {
const report = computeI18nCoverage(scaffold, { defaultLocale: 'en' });
expect(report.stats.find((s) => s.locale === 'en')!.coveragePercent).toBe(100);
});

it('still warns for a non-default locale — inline text is not a translation', () => {
const withZh = { ...scaffold, translations: [{ 'zh-CN': {} }] };
const report = computeI18nCoverage(withZh, { defaultLocale: 'en' });
const zhKeys = report.issues.filter((i) => i.locale === 'zh-CN').map((i) => i.key);
expect(zhKeys).toContain('objects.demo_note.label');
expect(zhKeys).toContain('objects.demo_note.fields.title.label');
});

it('treats the inline label as the source for whatever locale is the default', () => {
// Inline text is the source string, not "English" — a zh-CN-first
// project authoring inline Chinese labels is complete, not broken.
const zhFirst = {
objects: [{ name: 'demo_note', label: '备注', pluralLabel: '备注', fields: { title: { label: '标题' } } }],
translations: [],
};
const report = computeI18nCoverage(zhFirst, { defaultLocale: 'zh-CN' });
expect(report.issues.filter((i) => i.severity === 'error')).toEqual([]);
});

it('does not demand a pluralLabel translation when none is authored', () => {
const noPlural = {
objects: [{ name: 'demo_note', label: 'Note', fields: {} }],
translations: [],
};
const report = computeI18nCoverage(noPlural, { defaultLocale: 'en' });
expect(report.issues.map((i) => i.key)).not.toContain('objects.demo_note.pluralLabel');
});

it('leaves an unlabelled field to required/label rather than reporting an i18n gap', () => {
const unlabelled = {
objects: [{ name: 'demo_note', label: 'Note', fields: { title: {} } }],
translations: [],
};
const report = computeI18nCoverage(unlabelled, { defaultLocale: 'en' });
expect(report.issues.map((i) => i.key)).not.toContain('objects.demo_note.fields.title.label');
});

it('still expects a key that is authored only in a bundle', () => {
// No inline label, but zh-CN externalizes one — `en` genuinely lacks a
// source string for it, so the default-locale gate must still fire.
const bundleOnly = {
objects: [{ name: 'demo_note', label: 'Note', fields: { title: {} } }],
translations: [{ 'zh-CN': { objects: { demo_note: { fields: { title: { label: '标题' } } } } } }],
};
const report = computeI18nCoverage(bundleOnly, { defaultLocale: 'en' });
const enErrors = report.issues.filter((i) => i.locale === 'en' && i.severity === 'error');
expect(enErrors.map((i) => i.key)).toContain('objects.demo_note.fields.title.label');
});
});

it('counts the platform metadataForms baseline as covered for the default locale', () => {
// The registry authors those labels inline, so `en` needs no bundle. A
// non-default locale still owes a translation for each of them.
const report = computeI18nCoverage(
{ objects: [], views: [], actions: [], translations: [{ 'zh-CN': {} }] },
{ defaultLocale: 'en' },
);
expect(report.issues.filter((i) => i.locale === 'en')).toEqual([]);
const zh = report.issues.filter((i) => i.locale === 'zh-CN');
expect(zh.some((i) => i.key === 'metadataForms.flow.fields.name.label')).toBe(true);
expect(zh.every((i) => i.severity === 'warning')).toBe(true);
});

it('treats data.object as fallback for view objectName', () => {
Expand Down
Loading