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
33 changes: 33 additions & 0 deletions .changeset/tame-donkeys-repeat.md
Original file line number Diff line number Diff line change
@@ -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.<object>._sections.<name>.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.
10 changes: 7 additions & 3 deletions packages/cli/src/utils/i18n-extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
// `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);
}
}

Expand Down
23 changes: 21 additions & 2 deletions packages/cli/test/i18n-section-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'] },
],
},
},
Expand All @@ -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
Expand Down
41 changes: 27 additions & 14 deletions packages/lint/src/validate-translatable-sections.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) => ({
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.
Expand Down
10 changes: 6 additions & 4 deletions packages/lint/src/validate-translatable-sections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading