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
64 changes: 64 additions & 0 deletions .changeset/i18n-walker-object-sections.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
---
"@objectstack/cli": minor
"@objectstack/lint": minor
---

fix(cli): the i18n walker collects `objects.<o>._sections` — section headings are gated and scaffolded like every other label (#5405)

An object's SECTION headings were the one declared, resolved, rendered
translation surface the shared i18n walker had no kind for.
`ExpectedEntry['source']` listed `object | field | option | view | action |
globalAction | app | navigation | dashboard | widget | page` plus two
`metadataForm*` kinds — and those two cover **Studio metadata forms**
(`metadataForms.<type>.sections.*`, hidden behind `--include-platform`), not app
objects. So `objects.<o>._sections.<s>.label` was structurally unreachable in
both directions: `os i18n extract` never scaffolded a heading, and
`os lint` could not report one missing.

The surface itself was never in doubt. `ObjectTranslationDataSchema` declares
`_sections` (with `sections` as an authoring alias), and `@object-ui/i18n`'s
`sectionLabel` resolves it for `record:details`, for `ObjectForm`/`ModalForm`
and for the field-group designer. Only the walker disagreed — which is exactly
the drift `collectExpectedEntries` was consolidated to prevent (#3370).

Measured downstream before this landed: 85 sections across 15 objects, **2 of
85** translated in `ja-JP` and in `es-ES` — English headings on essentially
every record page and form — with `objectstack lint` reporting **zero** i18n
warnings for both locales.

**What is collected.** A `section` kind, from the two independent surfaces that
both resolve to the same key — a heading is expected if *either* declares it,
and one heading is one expected key however many declare it:

- **`fieldGroups` × field `group`** — the fields decide which sections exist and
`fieldGroups[].label` supplies the source text. Membership is read through
`deriveFieldGroupLayout` (ADR-0085 §5), the same shared derivation the
renderers consume, so a group nothing visible references — or a `group:` no
`fieldGroups` entry declares — produces no heading and therefore no expected
key. The trailing ungrouped bucket renders without chrome and is skipped.
- **A named `sections[]`** on a form view (including a view container's default
`form`) or inside a record page's component tree.

A section with no `name` is skipped: every renderer guards the lookup on it
(`s?.name ? sectionLabel(…) : s?.label`), so it is untranslatable by
construction and demanding a bundle entry for it would be noise. A group that
declares no `label` still yields a scaffold key seeded from its own name, but no
coverage finding — nobody authored that text.

**What you get.** `os lint` gains an `i18n/missing-section` category — user
metadata, so it is reported without `--include-platform` — and `os i18n
extract` scaffolds the headings for free, because the gate and the extractor
read the one walker. A project that declares no locales still reports nothing;
the gate stays opt-in.

**`@objectstack/lint`** now exports its shared page traversal
(`walkPageComponents`, `isSourceAuthoredPage`, `WalkedComponent`) so the CLI
consumes it instead of growing a private copy — that walk exists precisely
because duplicating it produced a dead rule once already (#3583). Reusing it is
also what makes the page half correct rather than merely present: it reaches
`slots.<slot>` and the untyped nesting a record page really uses
(`page:tabs` → `properties.items[].children[]` → `record:details`), skips
source-authored pages whose `regions` are a derived cache, and resolves each
component's OWN binding (`dataSource.object` → `properties.object` → the page's
`object`) — so a re-bound `record:details` keys its headings under the object it
actually shows.
34 changes: 34 additions & 0 deletions examples/app-showcase/src/system/translations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,27 @@ export const ShowcaseTranslationBundle = {
successMessage: '已为整个选中集重算工时。',
},
},
// Section headings of the four form-view projections in
// `ui/views/task.view.ts` (tabbed / wizard / split). Each section
// there declares a stable `name`, which is the only thing that makes
// the heading translatable — `ObjectForm` looks it up as
// `objects.showcase_task._sections.<name>.label` and otherwise renders
// the English `label` verbatim. Wording follows the vocabulary the
// rest of this bundle already uses (任务 / 负责人 / 进度), rather than
// introducing a second word per idea.
_sections: {
// tabbed
overview: { label: '概览' },
schedule: { label: '排期' },
details: { label: '详细信息' },
// wizard steps
step_basics: { label: '基本信息' },
step_assign: { label: '指派' },
step_schedule: { label: '排期' },
// split panes
split_task: { label: '任务' },
split_schedule: { label: '排期' },
},
},
showcase_account: {
label: '客户',
Expand Down Expand Up @@ -343,6 +364,19 @@ export const ShowcaseTranslationBundle = {
org_chart: { label: '组织架构图' },
},
},
// `_sections` only, on the same footing as the two `_views`-only blocks
// above: this ADR-0085 fixture has no zh-CN block of its own and its
// object/field debt sits inside the ratchet's baseline. Its two
// `fieldGroups` headings are what the coverage walker newly surfaces
// (#5405), and translating exactly those is what keeps the gate from
// widening; the rest is left exactly as it was. `财务信息` follows the
// group's own description ("Financial fields"), not the bare word Money.
showcase_semantic_zoo: {
_sections: {
basics: { label: '基本信息' },
money: { label: '财务信息' },
},
},
showcase_field_zoo: {
label: '字段动物园', pluralLabel: '字段动物园',
fields: { f_lookups: { label: '查找 → 客户(多值)' } },
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/src/utils/i18n-coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export interface CoverageIssue {
| 'object'
| 'field'
| 'option'
| 'section'
| 'view'
| 'action'
| 'globalAction'
Expand Down Expand Up @@ -191,6 +192,11 @@ const COVERAGE_SOURCE: Record<ExpectedEntry['source'], CoverageIssue['source']>
object: 'object',
field: 'field',
option: 'option',
// An object section heading (`objects.<o>._sections.<s>.label`) is the
// user's own metadata, not the Studio-form baseline — it keeps its own
// bucket so `os lint` reports it as `i18n/missing-section` rather than
// folding it away with `--include-platform`.
section: 'section',
view: 'view',
action: 'action',
globalAction: 'globalAction',
Expand All @@ -208,6 +214,7 @@ const SOURCE_NOUN: Record<CoverageIssue['source'], string> = {
object: 'Object',
field: 'Field',
option: 'Option',
section: 'Section',
view: 'View',
action: 'Action',
globalAction: 'Global action',
Expand Down
170 changes: 170 additions & 0 deletions packages/cli/src/utils/i18n-extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
* objects.<name>.fields.<field>.help
* objects.<name>.fields.<field>.placeholder
* objects.<name>.fields.<field>.options.<value>
* objects.<name>._sections.<section>.label
* objects.<name>._views.<view>.label
* objects.<name>._views.<view>.description
* objects.<name>._views.<view>.emptyState.title / .message
Expand Down Expand Up @@ -64,6 +65,8 @@
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 { deriveFieldGroupLayout } from '@objectstack/spec/data';
import { walkPageComponents } from '@objectstack/lint';

// ─── Public types ──────────────────────────────────────────────────────

Expand Down Expand Up @@ -95,6 +98,7 @@ export interface ExpectedEntry {
| 'object'
| 'field'
| 'option'
| 'section'
| 'view'
| 'action'
| 'globalAction'
Expand Down Expand Up @@ -332,6 +336,165 @@ function pushActionResultDialog(
}
}

// ─── Object sections (`objects.<o>._sections.<section>.label`) ─────────
//
// A section heading is authored in TWO independent places and rendered from
// both, so a walk that reads only one of them under-reports (#5405):
//
// (a) the object's `fieldGroups` semantic role (ADR-0085 §5) — the fields
// are the authority for which sections exist (a declared group nothing
// references never renders), `fieldGroups[].label` supplies the source
// text. Rendered by `RecordDetailView` via
// `deriveFieldGroupDetailSections` and by `ObjectFormDesigner`, both of
// which look the heading up as `sectionLabel(object, group.key, …)`.
//
// (b) authored `sections[]` on a form view or inside a record page's
// component tree, keyed by the section's own `name`. Rendered by
// `plugin-form`'s `ObjectForm`/`ModalForm` and `plugin-detail`'s
// `record:details`.
//
// Both resolve through the SAME convention —
// `objects.<object>._sections.<name>.label`, `useObjectLabel.sectionLabel` in
// `@object-ui/i18n` — against the `_sections` slot `ObjectTranslationDataSchema`
// declares. So one expected key per (object, section), whichever source found
// it first.
//
// A section with no `name` is deliberately skipped: every renderer guards the
// lookup on it (`s?.name ? sectionLabel(...) : s?.label`), so a nameless
// section is untranslatable by construction and demanding a bundle entry for
// it would be noise. The `_sections` schema says the same ("Each section in
// the page schema must declare a stable `name` for the lookup to fire").

/** object name → section name → source label (undefined = none authored). */
type SectionIndex = Map<string, Map<string, string | undefined>>;

/**
* Record one (object, section) pair.
*
* One heading, one key — however many surfaces declare it. The first AUTHORED
* label wins, and a later authored one upgrades an entry recorded without any
* (a group that declares no `label` of its own, whose heading text the page
* section carries): `inline` must report the text the reader actually sees,
* whichever surface happens to be walked first.
*/
function addSection(index: SectionIndex, objectName: unknown, sectionName: unknown, label: unknown): void {
if (typeof objectName !== 'string' || objectName.length === 0) return;
if (typeof sectionName !== 'string' || sectionName.length === 0) return;
let sections = index.get(objectName);
if (!sections) index.set(objectName, (sections = new Map()));
const authored = inlineText(label);
if (!sections.has(sectionName)) sections.set(sectionName, authored);
else if (sections.get(sectionName) === undefined && authored !== undefined) {
sections.set(sectionName, authored);
}
}

/** Read a `sections[]` array (form view / component props) into the index. */
function addSectionList(index: SectionIndex, sections: unknown, objectName: unknown): void {
if (!Array.isArray(sections)) return;
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
// `inlineText` drops it to "nothing authored in plain text".
addSection(index, objectName, s.name, s.label ?? s.title);
}
}

/**
* Emit `objects.<object>._sections.<section>.label` for every section the
* stack renders, from both authoring surfaces.
*/
function walkObjectSections(config: any, out: ExpectedEntry[]): void {
const index: SectionIndex = new Map();

// (a) `fieldGroups` × field `group` membership. `deriveFieldGroupLayout` is
// the shared derivation the renderers themselves consume, so "which
// groups become sections" is decided in exactly one place: a group no
// visible field references, or one that is referenced but never
// declared, produces no heading and therefore no expected key.
const objects: any[] = Array.isArray(config?.objects) ? config.objects : [];
for (const obj of objects) {
if (!obj?.name) continue;
const derived = deriveFieldGroupLayout(obj);
if (!derived) continue;
// The derivation substitutes the key for a missing label; read the
// declared label back so `inline` stays honest about what was authored.
const declaredLabels = new Map<string, unknown>();
for (const group of Array.isArray(obj.fieldGroups) ? obj.fieldGroups : []) {
if (group && typeof group === 'object' && typeof group.key === 'string') {
declaredLabels.set(group.key, group.label);
}
}
for (const section of derived) {
// The trailing ungrouped bucket carries no key — it renders without
// chrome, so there is no heading to translate.
if (section.key === undefined) continue;
addSection(index, obj.name, section.key, declaredLabels.get(section.key));
}
}

// (b) authored form-view sections.
const views: any[] = Array.isArray(config?.views) ? config.views : [];
for (const view of views) {
const containerObject = viewObjectName(view);
addSectionList(index, view?.sections, containerObject);
if (view?.form && typeof view.form === 'object') {
addSectionList(index, view.form.sections, viewObjectName(view.form) ?? containerObject);
}
if (view?.formViews && typeof view.formViews === 'object') {
for (const form of Object.values<any>(view.formViews)) {
if (!form || typeof form !== 'object') continue;
addSectionList(index, form.sections, viewObjectName(form) ?? containerObject);
}
}
}

// (b) authored page sections — a record page's `record:details`.
//
// Reuses `@objectstack/lint`'s shared page traversal rather than growing a
// private copy. That walk exists precisely because duplicating it produced a
// dead rule once already (#3583): components hang off `regions[].components`
// AND `slots.<slot>` (which may be a bare component, not an array), sub-trees
// live inside the untyped `properties` bag (`page:tabs` →
// `properties.items[].children`, `page:card` → `properties.body`/`.footer`),
// and source-authored pages (`kind: 'html' | 'react' | 'jsx'`) hold only a
// DERIVED region cache that the author never wrote — scaffolding translation
// keys off that cache would invent an authoring surface.
//
// It also resolves each component's OWN binding
// (`dataSource.object` → `properties.object` → the page's `object`), which a
// page-level-only binding would get wrong rather than merely miss: a
// `record:details` retargeted at another object would key its headings under
// the page's object, and no bundle entry there would ever resolve.
const pages: any[] = Array.isArray(config?.pages) ? config.pages : [];
for (let pi = 0; pi < pages.length; pi++) {
const page = pages[pi];
if (!page || typeof page !== 'object') continue;
for (const walked of walkPageComponents(page, `pages[${pi}]`)) {
if (!walked.objectName) continue;
const props = walked.component.properties;
if (!props || typeof props !== 'object' || Array.isArray(props)) continue;
addSectionList(index, (props as Record<string, unknown>).sections, walked.objectName);
}
}

for (const [objectName, sections] of index) {
for (const [sectionName, label] of sections) {
pushDerived(
out,
['objects', objectName, '_sections', sectionName, 'label'],
// Seed mirrors the renderer's own fallback (`s.label || s.name`).
label ?? sectionName,
label,
'section',
{ objectName },
);
}
}
}

/** Collect every translatable entry from a normalized stack config. */
export function collectExpectedEntries(config: any): ExpectedEntry[] {
const out: ExpectedEntry[] = [];
Expand Down Expand Up @@ -539,6 +702,13 @@ export function collectExpectedEntries(config: any): ExpectedEntry[] {
}
}

// ── Object sections (fieldGroups + authored form/page sections) ───
// Deliberately a pass of its own: the two authoring surfaces live in
// `objects`, `views` and `pages`, and one section may be declared by more
// than one of them — collecting first and emitting once keeps a heading
// from being counted twice against coverage.
walkObjectSections(config, out);

// ── Metadata configuration forms (Studio admin UI) ────────────────
// Registry-driven: always included, independent of stack config. These
// emit under `metadataForms.<type>.*` so the generic renderer can pick
Expand Down
Loading
Loading