Skip to content

Commit b3c1f3c

Browse files
Jack Qclaude
andauthored
fix(cli): key _views translations by the runtime view identity (#5164) (#6124)
The i18n extractor derived a container's default-list translation key as `view.list.name ?? 'list'`, while the composer that assigns the runtime view identity (`expandViewContainer`) names the same view `<object>.default`. An app declaring only a default `list` therefore got a bundle skeleton keyed `list`, a registry entry keyed `default`, and an English label on screen forever — no authoring side could ever make it resolve. Maintainer ruling 2026-08-06: canonical = the runtime identity's bare key. This is leg 1 of 3 (cli); `packages/lint` narrowing is #6038, objectui `viewSuffixes` is objectui#3502. The extractor now ASKS the composer for the key instead of re-deriving it, so no third dialect can drift back in. That also inherits the composer's signature dedupe: a default list that merely restates a `listViews` entry is collapsed into it and gets no key of its own — the `examples/app-crm` shape, whose `_views.list` entry was dead weight that looked correct. Migrated in lockstep: app-showcase (5 blocks) and app-todo (2 bundles) renamed to `default`; app-crm (3 collapsed blocks) removed. `check:i18n-coverage` measured green with the baseline unchanged — both ends moved together, so no count did. Claude-Session: https://claude.ai/code/session_01Wbxm29qPKnLf44AbSxizqW Co-authored-by: Claude <noreply@anthropic.com>
1 parent 64cd010 commit b3c1f3c

7 files changed

Lines changed: 274 additions & 18 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
'@objectstack/cli': minor
3+
---
4+
5+
i18n 提取器改用运行时视图身份来命名 `_views` 翻译键(#5164 第 1 棒 / cli 段)
6+
7+
**BREAKING(已发布翻译包的键):容器默认 `list` 的 `_views` 键由 `list` 改为运行时裸键。**
8+
9+
`objectstack i18n extract` / `os lint` 过去用 `view.list.name ?? 'list'` 推导一个
10+
`defineView` 容器默认列表的翻译键,而运行时注册表(`expandViewContainer`,
11+
`packages/spec/src/ui/view.zod.ts`)给同一个视图的身份是 `<object>.default`。两者是
12+
两个不同的字符串,于是「只声明了默认 `list`、没有 `listViews`」的应用会拿到一份键为
13+
`list` 的翻译骨架、一个键为 `default` 的运行时视图,界面上永远显示英文原文 —— 无论作者
14+
按哪一侧编写都命中不了。
15+
16+
提取器现在**向组装器本身查询**这个键,而不是第二次自行推导。因此它同时继承了组装器
17+
仅有的两条规则:
18+
19+
- 无 `name` 的默认列表键为 `default`(不再是 `list`);带 `name` 的沿用作者的 `name`;
20+
- 结构上与某个 `listViews` 条目**完全相同**的默认列表会被组装器按签名折叠进该条目,
21+
因此它没有自己的键 —— 提取器不再为它多写一个谁也读不到的骨架条目
22+
(`examples/app-crm` 正是这个形状:`list` 与 `listViews.all` 同签名,真实键是 `all`)。
23+
24+
维护者 2026-08-06 裁决:`_views` 翻译键的 canonical 拼写 = 运行时身份的裸键。
25+
26+
## 升级:翻译包键的 FROM → TO
27+
28+
只影响用 `defineView({ list: … })` 声明了**默认列表**、且为它写过翻译的包。
29+
30+
| 容器形状 | FROM | TO |
31+
|---|---|---|
32+
| 默认 `list`,无 `name` | `objects.<object>._views.list.*` | `objects.<object>._views.default.*` |
33+
| 默认 `list`,带 `name: 'x'` | `objects.<object>._views.x.*` | 不变 |
34+
| 默认 `list` 与某个 `listViews.<k>` 结构相同 | `objects.<object>._views.list.*` | 删除(`_views.<k>.*` 已经覆盖它) |
35+
36+
一行修法:把 `_views.list` 改名为 `_views.default`;如果同一对象下已经有一个与默认列表
37+
`type`/`label`/`columns` 完全一致的 `listViews` 条目,则直接删掉 `_views.list`。
38+
重新跑一次 `objectstack i18n extract`(merge 模式保留已有译文)会得到正确的骨架。
39+
40+
本仓库自带示例已随迁:`examples/app-showcase` 5 个块、`examples/app-todo` 2 个语言包
41+
改名为 `default`;`examples/app-crm` 3 个折叠形状的 `_views.list` 块删除。
42+
`check:i18n-coverage` 棘轮基线经实测无需调整(需求侧与译文侧同批改名,覆盖数不变)。
43+
44+
`packages/lint` 的 `collectViewRecord` 收窄(#6038)与 objectui `viewSuffixes` 去第二
45+
候选(objectui#3502)是本裁决的第 2、3 棒,不在本次变更内。

‎examples/app-crm/src/translations/crm.translation.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,13 @@ export const CrmTranslationBundle = defineTranslationBundle({
126126
discount_percent: { label: '折扣 (%)' },
127127
owner_id: { label: '负责人' },
128128
},
129+
// No `default` key here, deliberately (#5164). This container's default
130+
// `list` is structurally identical to `listViews.all`, so the composer
131+
// COLLAPSES the two and the only registry entry is
132+
// `crm_opportunity.all` — `all` below already carries its label. The
133+
// `list` key that used to sit here was dead weight that looked correct:
134+
// it matched no runtime view under either spelling.
129135
_views: {
130-
list: { label: '全部商机' },
131136
all: { label: '全部商机' },
132137
pipeline: { label: '商机看板' },
133138
},
@@ -143,8 +148,8 @@ export const CrmTranslationBundle = defineTranslationBundle({
143148
lead_score: { label: '线索评分' },
144149
source: { label: '来源' },
145150
},
151+
// `list` removed — collapsed into `all`, see crm_opportunity (#5164).
146152
_views: {
147-
list: { label: '全部线索' },
148153
all: { label: '全部线索' },
149154
pipeline: { label: '线索看板' },
150155
},
@@ -160,8 +165,8 @@ export const CrmTranslationBundle = defineTranslationBundle({
160165
contact: { label: '联系人' },
161166
opportunity: { label: '商机' },
162167
},
168+
// `list` removed — collapsed into `all`, see crm_opportunity (#5164).
163169
_views: {
164-
list: { label: '全部活动' },
165170
all: { label: '全部活动' },
166171
calendar: { label: '活动日历' },
167172
},

‎examples/app-showcase/src/system/translations/index.ts‎

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -184,8 +184,13 @@ export const ShowcaseTranslationBundle = {
184184
start_date: { label: '开始日期' },
185185
end_date: { label: '结束日期' },
186186
},
187+
// `default` — the container's DEFAULT list. `defineView({ list })`
188+
// declares it without a `name`, and the composer therefore registers it
189+
// as `<object>.default`; `_views` keys are that bare runtime key
190+
// (#5164, ruled 2026-08-06). It read `list` until then, which no
191+
// lookup could ever reach.
187192
_views: {
188-
list: { label: '全部项目' },
193+
default: { label: '全部项目' },
189194
by_status: { label: '按状态' },
190195
budget_chart: { label: '按客户预算' },
191196
},
@@ -223,7 +228,8 @@ export const ShowcaseTranslationBundle = {
223228
sync_error: { label: '同步错误' },
224229
},
225230
_views: {
226-
list: { label: '全部任务' },
231+
// The default list — keyed `default`, see showcase_project above.
232+
default: { label: '全部任务' },
227233
in_progress: { label: '进行中' },
228234
urgent: { label: '紧急' },
229235
done: { label: '已完成' },
@@ -307,13 +313,16 @@ export const ShowcaseTranslationBundle = {
307313
// array, so nothing (runtime or gate) could reach these two surfaces.
308314
// Registering the container is what makes them translatable at all.
309315
//
310-
// `_views.list` — the container's DEFAULT list, which the resolver ids
311-
// as `list` (`primary.name || 'list'`). Its source label is the bare
312-
// plural "Contacts", so the object's own `pluralLabel` above is the
313-
// right word; the `全部…` spelling the task/project lists use belongs to
316+
// `_views.default` — the container's DEFAULT list. It is declared
317+
// without a `name`, so the composer registers it as
318+
// `showcase_contact.default` and the bundle key is that bare runtime
319+
// key (#5164, ruled 2026-08-06); it read `list` until then, a spelling
320+
// no lookup could reach. Its source label is the bare plural
321+
// "Contacts", so the object's own `pluralLabel` above is the right
322+
// word; the `全部…` spelling the task/project lists use belongs to
314323
// labels that actually say "All …".
315324
_views: {
316-
list: { label: '联系人' },
325+
default: { label: '联系人' },
317326
},
318327
// `_sections` — the four groups of the default edit form in
319328
// `ui/views/contact.view.ts`. Each declares a stable `name`, which is
@@ -381,15 +390,16 @@ export const ShowcaseTranslationBundle = {
381390
// their object/field debt is inside the ratchet's baseline. Translating
382391
// the VIEW labels the coverage fix newly surfaces is what keeps the gate
383392
// from widening; the rest is left exactly as it was.
393+
// (`default` = the container's DEFAULT list — see showcase_project.)
384394
showcase_inquiry: {
385395
_views: {
386-
list: { label: '客户询问' },
396+
default: { label: '客户询问' },
387397
triage: { label: '询问分流' },
388398
},
389399
},
390400
showcase_business_unit: {
391401
_views: {
392-
list: { label: '全部单元' },
402+
default: { label: '全部单元' },
393403
org_chart: { label: '组织架构图' },
394404
},
395405
},

‎examples/app-todo/src/translations/ja-JP.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,9 @@ export const jaJP: TranslationData = {
7878
notes: { label: 'メモ' },
7979
category_color: { label: 'カテゴリ色' },
8080
},
81+
// `default` — the container's DEFAULT list; see the zh-CN bundle (#5164).
8182
_views: {
82-
list: { label: 'すべてのタスク' },
83+
default: { label: 'すべてのタスク' },
8384
overdue: { label: '期限切れのタスク' },
8485
due_today: { label: '本日期限' },
8586
},

‎examples/app-todo/src/translations/zh-CN.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,13 @@ export const zhCN: TranslationData = {
8282
notes: { label: '备注' },
8383
category_color: { label: '分类颜色' },
8484
},
85+
// `default` — the container's DEFAULT list. `defineView({ list })`
86+
// declares it without a `name`, so the composer registers it as
87+
// `todo_task.default` and the bundle key is that bare runtime key
88+
// (#5164, ruled 2026-08-06). It read `list` until then — a spelling
89+
// no lookup could reach.
8590
_views: {
86-
list: { label: '全部任务' },
91+
default: { label: '全部任务' },
8792
overdue: { label: '逾期任务' },
8893
due_today: { label: '今日到期' },
8994
},

‎packages/cli/src/utils/i18n-extract.ts‎

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ import type { TranslationBundle, TranslationData } from '@objectstack/spec/syste
6666
import { METADATA_FORM_REGISTRY } from '@objectstack/spec/system';
6767
import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel';
6868
import { deriveFieldGroupLayout } from '@objectstack/spec/data';
69+
import { expandViewContainer } from '@objectstack/spec/ui';
6970
import { walkPageComponents } from '@objectstack/lint';
7071

7172
// ─── Public types ──────────────────────────────────────────────────────
@@ -174,6 +175,48 @@ function viewObjectName(view: any): string | undefined {
174175
);
175176
}
176177

178+
/**
179+
* The bare `_views` key the RUNTIME assigns to a container's default `list`.
180+
*
181+
* Asked of the composer (`expandViewContainer`, `spec/src/ui/view.zod.ts`)
182+
* rather than re-derived here — that function is the single producer of a
183+
* view's runtime identity, and the whole point of #5164 is that a second
184+
* derivation drifts from it. This walker used to spell the key
185+
* `view.list.name ?? 'list'` while the composer named the very same view
186+
* `<object>.default`, so a container declaring only a default `list` got a
187+
* bundle skeleton keyed `list`, a registry entry keyed `default`, and an
188+
* English label on screen forever. Ruled 2026-08-06 (#5164): canonical = the
189+
* runtime identity's bare key.
190+
*
191+
* Two facts live in the composer and nowhere else, both load-bearing here:
192+
*
193+
* 1. a nameless default list is keyed **`default`** (never `list`), and a
194+
* named one keeps the author's `list.name`;
195+
* 2. a default list whose STRUCTURE merely restates a `listViews` entry is
196+
* **collapsed into that entry** and has no key of its own — the
197+
* `examples/app-crm` shape, where `list` is signature-identical to
198+
* `listViews.all` and the live key is therefore `all`. This returns `all`
199+
* there, and the caller skips the emit because the `listViews` loop
200+
* already covered it. Emitting a second key for the collapsed view would
201+
* scaffold a translation no lookup can reach — the same defect one shape
202+
* over.
203+
*
204+
* A key renamed by a collision (`default` → `default_2`, when `listViews`
205+
* already claimed `default`) is returned as renamed, because that rename is
206+
* the registry key too.
207+
*
208+
* Returns `undefined` when the container declares no default `list`.
209+
*/
210+
function defaultListViewKey(object: string, container: any): string | undefined {
211+
if (!container?.list || typeof container.list !== 'object') return undefined;
212+
const item = expandViewContainer(object, container).find(
213+
(i) => i.viewKind === 'list' && i.isDefault,
214+
);
215+
if (!item) return undefined;
216+
const prefix = `${object}.`;
217+
return item.name.startsWith(prefix) ? item.name.slice(prefix.length) : item.name;
218+
}
219+
177220
/**
178221
* Emit label / description / emptyState for ONE view under
179222
* `objects.<object>._views.<viewName>.*` — the convention the runtime resolver
@@ -609,11 +652,20 @@ export function collectExpectedEntries(config: any): ExpectedEntry[] {
609652
continue;
610653
}
611654

612-
// The container's default list. The console ids an unnamed default as
613-
// `primary.name || 'list'` (app-shell `ObjectView`), so it resolves under
614-
// `_views.list`.
655+
// The container's default list, keyed exactly as the runtime registry keys
656+
// it — see `defaultListViewKey`. `undefined` means the container declares
657+
// no default list; a key the container's own `listViews` also declares
658+
// means the composer collapsed the two, and the loop below emits it.
615659
if (view.list && typeof view.list === 'object') {
616-
pushViewEntries(out, viewObjectName(view.list) ?? containerObject, view.list.name ?? 'list', view.list);
660+
const key = defaultListViewKey(containerObject, view);
661+
const collapsedIntoListViews =
662+
key !== undefined
663+
&& view.listViews
664+
&& typeof view.listViews === 'object'
665+
&& Object.prototype.hasOwnProperty.call(view.listViews, key);
666+
if (key !== undefined && !collapsedIntoListViews) {
667+
pushViewEntries(out, viewObjectName(view.list) ?? containerObject, key, view.list);
668+
}
617669
}
618670
if (view.listViews && typeof view.listViews === 'object') {
619671
for (const [viewName, raw] of Object.entries<any>(view.listViews)) {
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #5164 — the `_views` bundle key a container's DEFAULT `list` gets from the
4+
// i18n extractor must be the SAME STRING the runtime registry assigns it.
5+
//
6+
// Three surfaces used to derive that key independently and disagree: the
7+
// composer (`expandViewContainer`) named a nameless default list
8+
// `<object>.default`, this extractor wrote `_views.list`, and the lint accepted
9+
// both. An app declaring only a default `list` therefore got a bundle skeleton
10+
// keyed `list`, a runtime view keyed `default`, and an English label on screen
11+
// forever. Maintainer ruling 2026-08-06: canonical = the runtime identity's
12+
// bare key; the extractor converges on the composer.
13+
//
14+
// These tests use the COMPOSER ITSELF as the oracle rather than hard-coding the
15+
// expected spelling, so they pin the invariant ("same key") and not one side's
16+
// current opinion of it. Hard-coded spellings are asserted too, but only where
17+
// the string is the thing under discussion.
18+
import { describe, it, expect } from 'vitest';
19+
import { expandViewContainer } from '@objectstack/spec/ui';
20+
import { collectExpectedEntries } from '../src/utils/i18n-extract';
21+
22+
/** Every `_views` key the extractor scaffolds for `object`, in emit order. */
23+
function extractorViewKeys(object: string, container: any): string[] {
24+
const prefix = `objects.${object}._views.`;
25+
const keys: string[] = [];
26+
for (const entry of collectExpectedEntries({ views: [container] })) {
27+
const path = entry.path.join('.');
28+
if (!path.startsWith(prefix) || !path.endsWith('.label')) continue;
29+
keys.push(path.slice(prefix.length, -'.label'.length));
30+
}
31+
return keys;
32+
}
33+
34+
/** Every LIST-family view key the runtime registry assigns for `object`. */
35+
function runtimeListViewKeys(object: string, container: any): string[] {
36+
return expandViewContainer(object, container)
37+
.filter((item) => item.viewKind === 'list')
38+
.map((item) => item.name.slice(`${object}.`.length));
39+
}
40+
41+
const data = { provider: 'object', object: 'task' } as const;
42+
43+
describe('_views keys the extractor scaffolds == the keys the runtime registry assigns (#5164)', () => {
44+
it('a default-only `list` container is keyed `default`, never `list`', () => {
45+
// The exact shape measured in the issue body.
46+
const container = {
47+
list: { label: 'All Tasks', type: 'grid', data, columns: ['title'] },
48+
};
49+
50+
expect(runtimeListViewKeys('task', container)).toEqual(['default']);
51+
expect(extractorViewKeys('task', container)).toEqual(['default']);
52+
53+
const paths = collectExpectedEntries({ views: [container] }).map((e) => e.path.join('.'));
54+
expect(paths).toContain('objects.task._views.default.label');
55+
expect(paths).not.toContain('objects.task._views.list.label');
56+
});
57+
58+
it('carries the default list\'s own label onto the `default` key', () => {
59+
const container = {
60+
list: { label: 'All Tasks', type: 'grid', data, columns: ['title'] },
61+
};
62+
const byPath = Object.fromEntries(
63+
collectExpectedEntries({ views: [container] }).map((e) => [e.path.join('.'), e.sourceValue]),
64+
);
65+
expect(byPath['objects.task._views.default.label']).toBe('All Tasks');
66+
});
67+
68+
it('a default list that STRUCTURALLY restates a `listViews` entry gets no key of its own', () => {
69+
// The `examples/app-crm` shape: the composer collapses the two by signature,
70+
// so `crm_*.all` is the only registry entry and a second scaffolded key
71+
// would be a translation no lookup can reach — the same defect one shape over.
72+
const view = { label: 'All', type: 'grid', data, columns: ['title'] };
73+
const container = { list: { ...view }, listViews: { all: { ...view } } };
74+
75+
expect(runtimeListViewKeys('task', container)).toEqual(['all']);
76+
expect(extractorViewKeys('task', container)).toEqual(['all']);
77+
});
78+
79+
it('a default list carrying an explicit `name` keeps that name on both sides', () => {
80+
const container = {
81+
list: { name: 'recent', label: 'Recent', type: 'grid', data, columns: ['title'] },
82+
};
83+
84+
expect(runtimeListViewKeys('task', container)).toEqual(['recent']);
85+
expect(extractorViewKeys('task', container)).toEqual(['recent']);
86+
});
87+
88+
it('a default list alongside a distinct `listViews` entry keys both, same set as the registry', () => {
89+
const container = {
90+
list: { label: 'All', type: 'grid', data, columns: ['title'] },
91+
listViews: {
92+
open: { label: 'Open', type: 'grid', data, columns: ['title', 'status'] },
93+
},
94+
};
95+
96+
expect(runtimeListViewKeys('task', container).sort()).toEqual(['default', 'open']);
97+
expect(extractorViewKeys('task', container).sort()).toEqual(['default', 'open']);
98+
});
99+
100+
it('a `listViews.default` collision renames the default list, and the skeleton follows the rename', () => {
101+
// The composer keeps the registry key unique (`default` → `default_2`) and
102+
// stamps a rename warning. The bundle key IS the registry key, so the
103+
// scaffold has to follow it rather than keep pointing at the taken name.
104+
const container = {
105+
list: { label: 'All', type: 'grid', data, columns: ['title'] },
106+
listViews: {
107+
default: { label: 'Curated', type: 'grid', data, columns: ['title', 'status'] },
108+
},
109+
};
110+
111+
expect(runtimeListViewKeys('task', container).sort()).toEqual(['default', 'default_2']);
112+
expect(extractorViewKeys('task', container).sort()).toEqual(['default', 'default_2']);
113+
});
114+
115+
it('the container\'s default FORM contributes no `_views` key on either side of the comparison', () => {
116+
// The extractor deliberately leaves form views out of `_views` (they have
117+
// no counterpart in the `viewLabel` resolver convention), so there is no
118+
// sibling `'list'`-style defaulting site to converge for the form face —
119+
// the composer's `<object>.form` identity is simply not scaffolded.
120+
const container = {
121+
list: { label: 'All', type: 'grid', data, columns: ['title'] },
122+
form: { label: 'Edit', type: 'simple', data, sections: [] },
123+
};
124+
125+
expect(runtimeListViewKeys('task', container)).toEqual(['default']);
126+
expect(extractorViewKeys('task', container)).toEqual(['default']);
127+
expect(expandViewContainer('task', container).map((i) => i.name)).toContain('task.form');
128+
});
129+
130+
it('a container with no default `list` at all scaffolds only its `listViews` keys', () => {
131+
const container = {
132+
objectName: 'task',
133+
listViews: { open: { label: 'Open', type: 'grid', data, columns: ['title'] } },
134+
};
135+
136+
expect(extractorViewKeys('task', container)).toEqual(['open']);
137+
});
138+
});

0 commit comments

Comments
 (0)