diff --git a/.changeset/29221-i18n-extract-field-help-empty-link.md b/.changeset/29221-i18n-extract-field-help-empty-link.md new file mode 100644 index 00000000000..0665042af42 --- /dev/null +++ b/.changeset/29221-i18n-extract-field-help-empty-link.md @@ -0,0 +1,14 @@ +--- +'@objectstack/cli': patch +--- + +fix(cli): `os i18n extract` scaffolds a field's description when its `inlineHelpText` is empty + +Clause-②: no +Metadata-protocol: none + +Nothing changes in what a metadata author can write. What changes is the seed `os i18n extract` (and the `os i18n check` coverage gate) takes for a field's `objects.OBJECT.fields.FIELD.help` entry. + +- **Before.** The seed was `help ?? inlineHelpText ?? description`, which stops at an empty string. A field with `inlineHelpText: ''` (or `help: ''`) and a `description` got a help entry with no seed, so the description, which is the line the console draws for that field, was never offered for translation. +- **Now.** The first link that carries authored text wins: an empty string reads as absent, as it does in the console's help line (`inlineHelpText || description`). Whitespace-only text still counts as authored, as it does in the console. +- **Unchanged.** `help` still wins over `inlineHelpText`, and a non-empty `inlineHelpText` still wins over `description`. A field whose links are all empty still gets the seed-less entry it got before. diff --git a/packages/cli/src/utils/i18n-extract.ts b/packages/cli/src/utils/i18n-extract.ts index 33cb3382ee6..21178839abf 100644 --- a/packages/cli/src/utils/i18n-extract.ts +++ b/packages/cli/src/utils/i18n-extract.ts @@ -1483,10 +1483,20 @@ export function collectExpectedEntries( // `inlineHelpText` is the key `FieldSchema` spells help text with, so it // is the authored default-locale text of `help` — after `help`, before // `description` — exactly as the authored label is for `label` (#15711). + // + // The FIRST AUTHORED link wins, not the first defined one: the console + // draws `inlineHelpText || description` (`fieldHelpText` in + // `@object-ui/fields`), so an empty `inlineHelpText` reads as absent + // there and the description is the line people see. A `??` chain + // stopped at that `''` and scaffolded no seed, leaving the drawn + // description out of the bundle (#29221). `asAuthoredLabel` is the one + // "authored" test this file uses (a non-empty string or a non-empty + // inline locale map); whitespace-only text stays authored, as it does + // for the console's `||` and the resolver's non-empty check. pushOptional( out, ['objects', objectName, 'fields', fieldName, 'help'], - field.help ?? field.inlineHelpText ?? field.description, + asAuthoredLabel(field.help) ?? asAuthoredLabel(field.inlineHelpText) ?? asAuthoredLabel(field.description), 'field', { objectName }, ); diff --git a/packages/cli/test/i18n-extract-inline-help-text.test.ts b/packages/cli/test/i18n-extract-inline-help-text.test.ts index 8d9989f6c3c..73ec3234cd6 100644 --- a/packages/cli/test/i18n-extract-inline-help-text.test.ts +++ b/packages/cli/test/i18n-extract-inline-help-text.test.ts @@ -62,3 +62,72 @@ describe('the field help key takes `inlineHelpText` as its default-locale source expect(helpIssues(report, 'zh-CN').length).toBe(1); }); }); + +// The extractor seeds the key from the FIRST AUTHORED link of +// `help`, `inlineHelpText`, `description`. The console draws a field's help +// line from `inlineHelpText || description` (`fieldHelpText` in +// `@object-ui/fields`), so an empty string reads as absent there; a `??` chain +// stopped at `inlineHelpText: ''` and scaffolded no seed, leaving the drawn +// description out of the bundle (#29221). +describe('the field help key skips an empty link, as the console does', () => { + const entryOf = (field: Record) => + collectExpectedEntries(stack(field, [])).find((e) => e.path.join('.') === HELP_KEY); + + /** The console's reader (`fieldHelpText` in `@object-ui/fields`), restated. */ + const consoleHelpLine = (field: Record) => + (field.inlineHelpText as string | undefined) || (field.description as string | undefined) || undefined; + + it('`inlineHelpText: \'\'` beside a description scaffolds the description', () => { + const entry = entryOf({ inlineHelpText: '', description: 'B' }); + expect(entry?.sourceValue).toBe('B'); + expect(entry?.inline).toBe('B'); + }); + + it('`help: \'\'` beside a description scaffolds the description', () => { + const entry = entryOf({ help: '', description: 'B' }); + expect(entry?.sourceValue).toBe('B'); + expect(entry?.inline).toBe('B'); + }); + + it('`help: \'\'` beside an `inlineHelpText` scaffolds the `inlineHelpText`', () => { + expect(entryOf({ help: '', inlineHelpText: 'I', description: 'B' })?.sourceValue).toBe('I'); + }); + + it('a non-empty `inlineHelpText` still wins over the description', () => { + expect(entryOf({ inlineHelpText: 'I', description: 'B' })?.sourceValue).toBe('I'); + }); + + it('`help` still wins over both', () => { + expect(entryOf({ help: 'H', inlineHelpText: 'I', description: 'B' })?.sourceValue).toBe('H'); + expect(entryOf({ help: 'H', inlineHelpText: '', description: 'B' })?.sourceValue).toBe('H'); + }); + + it('whitespace-only `inlineHelpText` stays the source: the console\'s `||` draws it too', () => { + const field = { inlineHelpText: ' ', description: 'B' }; + expect(entryOf(field)?.sourceValue).toBe(' '); + expect(consoleHelpLine(field)).toBe(' '); + }); + + it('every link empty still emits the seed-less entry, as before', () => { + const entry = entryOf({ help: '', inlineHelpText: '', description: '' }); + expect(entry).toBeDefined(); + expect(entry?.sourceValue).toBeUndefined(); + expect(entry?.inline).toBeUndefined(); + }); + + it('agrees with the line the console draws, for every field that authors no `help`', () => { + const shapes: Record[] = [ + { description: 'B' }, + { inlineHelpText: 'I' }, + { inlineHelpText: 'I', description: 'B' }, + { inlineHelpText: '', description: 'B' }, + { inlineHelpText: 'I', description: '' }, + ]; + for (const field of shapes) expect(entryOf(field)?.sourceValue).toBe(consoleHelpLine(field)); + }); + + it('the coverage gate counts the default locale as covered by the scaffolded description', () => { + const report = computeI18nCoverage(stack({ inlineHelpText: '', description: '说明' }, [enHelp]), { strict: true }); + expect(helpIssues(report, 'zh-CN')).toEqual([]); + }); +});