diff --git a/.changeset/tame-donkeys-repeat.md b/.changeset/tame-donkeys-repeat.md new file mode 100644 index 0000000000..dc5c9f6e3d --- /dev/null +++ b/.changeset/tame-donkeys-repeat.md @@ -0,0 +1,33 @@ +--- +'@objectstack/cli': patch +'@objectstack/lint': patch +--- + +i18n section headings: read only the declared `label` spelling, never `title` + +`record:details` sections and form-view sections declare exactly one heading key — +`label` (`RecordDetailsProps.sections[]` and `FormSectionSchema`; #5611 settled this +by declaring `label` and deliberately NOT declaring `title`). Two consumers still +read `label ?? title`: + +- `os i18n extract` / `os lint`'s coverage walk (`i18n-extract.ts`) scaffolded + `objects.._sections..label` from a `title`; +- the `translation-section-name-missing` lint rule accepted a `title` as the + heading it reports on. + +Both now read `label` only. Per Prime Directive #12 the tolerance was the bug: a +consumer that reads an undeclared spelling turns it into a second de-facto contract, +and here it did so on the loudest possible surface — the extractor would seed a +translation bundle key from a spelling the schema rejects, teaching the wrong key to +every translator downstream. + +FROM → TO: a section authored as `{ name: 'timeline', title: 'Timeline' }` becomes +`{ name: 'timeline', label: 'Timeline' }`. No migration is expected in practice — +every `record:details` section in this repo and in `packages/platform-objects` +already authors `label` (~12 sections, zero `title`). + +Behaviour change if you do author `title`: the heading is treated as absent. The +extractor still emits the section's expected key (it is derived from `name`) but +seeds it with the section name instead of your `title` text, and the lint rule no +longer reports that section. Rename the key to `label` — which is also what the +schema itself will tell you, since `title` is not a declared key there. diff --git a/packages/cli/src/utils/i18n-extract.ts b/packages/cli/src/utils/i18n-extract.ts index 36e7dff897..a231d636de 100644 --- a/packages/cli/src/utils/i18n-extract.ts +++ b/packages/cli/src/utils/i18n-extract.ts @@ -439,10 +439,14 @@ function addSectionList(index: SectionIndex, sections: unknown, objectName: unkn for (const section of sections) { if (!section || typeof section !== 'object') continue; const s = section as Record; - // `record:details` reads `title ?? label`, form views author `label`; a - // localized-map label (`{ en, 'zh-CN' }`) is already multilingual and + // `label` is the ONE heading spelling both surfaces declare — + // `RecordDetailsProps.sections[]` and `FormSectionSchema` (#5611, #5730). + // A `title` here is off-spec and deliberately unread: reading it would + // scaffold a bundle key for a heading the schema rejects, which is how a + // consumer-side tolerance grows into a second de-facto contract (PD #12). + // A localized-map label (`{ en, 'zh-CN' }`) is already multilingual and // `inlineText` drops it to "nothing authored in plain text". - addSection(index, objectName, s.name, s.label ?? s.title); + addSection(index, objectName, s.name, s.label); } } diff --git a/packages/cli/test/i18n-section-coverage.test.ts b/packages/cli/test/i18n-section-coverage.test.ts index 734efb13cf..53e7f9105d 100644 --- a/packages/cli/test/i18n-section-coverage.test.ts +++ b/packages/cli/test/i18n-section-coverage.test.ts @@ -217,7 +217,7 @@ describe('authored record-page sections', () => { properties: { sections: [ { name: 'overview', label: 'Overview', fields: ['name'] }, - { name: 'timeline', title: 'Timeline', fields: ['close_date'] }, + { name: 'timeline', label: 'Timeline', fields: ['close_date'] }, ], }, }, @@ -236,11 +236,30 @@ describe('authored record-page sections', () => { ]); }); - it('reads `title` as the source text too — that is what `record:details` renders', () => { + it('reads `label` as the source text', () => { const timeline = sectionEntries({ pages: [slottedPage] }).find((e) => e.path[3] === 'timeline'); expect(timeline?.inline).toBe('Timeline'); }); + it('does NOT read an off-spec `title` as the source text — the key is still emitted, empty', () => { + // #5730: `label` is the only heading spelling `RecordDetailsProps.sections[]` + // declares (#5611). The walk keys sections off `name`, so a `title`-only + // section STILL contributes its expected key — what it must not contribute + // is source text, because scaffolding `"Timeline"` into a bundle from an + // off-spec key is how the second spelling would become self-documenting. + // Asserting the key survives is what keeps this non-vacuous: the entry is + // present and its `inline` is empty, not absent because nothing was walked. + const titled = JSON.parse(JSON.stringify(slottedPage)); + const sections = titled.slots.tabs.properties.items[0].children[0].properties.sections; + sections[1] = { name: 'timeline', title: 'Timeline', fields: ['close_date'] }; + + const entries = sectionEntries({ pages: [titled] }); + expect(entries.map((e) => e.path.join('.'))).toContain( + 'objects.crm_opportunity._sections.timeline.label', + ); + expect(entries.find((e) => e.path[3] === 'timeline')?.inline).toBeUndefined(); + }); + it('reaches the plain `regions[].components[]` shape, and never mines a page REGION name', () => { // `PageSchema.aliases` maps `sections` → `regions`, and a region carries a // `name` exactly like a section does. The shared walk enters at diff --git a/packages/lint/src/validate-translatable-sections.test.ts b/packages/lint/src/validate-translatable-sections.test.ts index 69d7050b08..7efed244f6 100644 --- a/packages/lint/src/validate-translatable-sections.test.ts +++ b/packages/lint/src/validate-translatable-sections.test.ts @@ -314,24 +314,37 @@ describe('validateTranslatableSections — what it deliberately leaves alone', ( expect(findings).toEqual([]); }); - it('reads a detail section\'s `title` as its heading', () => { - // `record:details` reads `title ?? label` — a nameless section with only a - // `title` renders a heading just the same. - const findings = validateTranslatableSections({ - objects: [crmCase], - pages: [ - { - name: 'case_detail', - object: 'crm_case', - regions: [{ components: [{ type: 'record:details', properties: { sections: [{ title: 'SLA' }] } }] }], - }, - ], - translations: caseTranslated, - }); + // #5730: `label` is the only heading spelling `RecordDetailsProps.sections[]` + // declares (#5611). The rule used to read `label ?? title`, which meant an + // off-spec `title` earned a translation-shaped warning — telling the author + // their `title` heading needed a `name` to be translatable, i.e. teaching the + // spelling the schema rejects. The two cases below are one pin, deliberately + // PAIRED: the `label` case proves the walk reaches this component at all, so + // the `title` case's empty result is the tolerance being gone and not the + // fixture failing to arrive (#5046's "green because nothing was produced"). + const namelessDetailSection = (section: Record) => ({ + objects: [crmCase], + pages: [ + { + name: 'case_detail', + object: 'crm_case', + regions: [{ components: [{ type: 'record:details', properties: { sections: [section] } }] }], + }, + ], + translations: caseTranslated, + }); + + it('reads a detail section\'s `label` as its heading', () => { + const findings = validateTranslatableSections(namelessDetailSection({ label: 'SLA' })); expect(findings).toHaveLength(1); expect(findings[0].where).toContain('section "SLA"'); }); + it('does NOT read a detail section\'s off-spec `title` as its heading', () => { + const findings = validateTranslatableSections(namelessDetailSection({ title: 'SLA' })); + expect(findings).toEqual([]); + }); + it('keys a retargeted component under the object it actually binds', () => { // A `record:details` pointed at another object keys its headings THERE, so // the gate must consult that object's translations, not the page's. diff --git a/packages/lint/src/validate-translatable-sections.ts b/packages/lint/src/validate-translatable-sections.ts index c65931fcb0..30d8691db2 100644 --- a/packages/lint/src/validate-translatable-sections.ts +++ b/packages/lint/src/validate-translatable-sections.ts @@ -323,10 +323,12 @@ export function validateTranslatableSections(stack: AnyRec): TranslatableSection const section = site.sections[i]; if (!isRec(section)) continue; if (strName(section.name)) continue; - // `record:details` reads `title ?? label`; form views author `label`. A - // section with neither has no heading rendered at all, so there is - // nothing untranslated to report — that is `required/label`'s question. - const heading = strName(section.label) ?? strName(section.title); + // `label` is the ONE heading spelling both surfaces declare — + // `RecordDetailsProps.sections[]` and `FormSectionSchema` (#5611, #5730). + // A section with no `label` has no heading the schema recognises, so + // there is nothing untranslated to report — that is `required/label`'s + // question, and an off-spec `title` is its business, not this rule's. + const heading = strName(section.label); if (!heading) continue; const slug = suggestedName(heading);