diff --git a/.changeset/views-translation-key-runtime-identity.md b/.changeset/views-translation-key-runtime-identity.md new file mode 100644 index 0000000000..ed77125557 --- /dev/null +++ b/.changeset/views-translation-key-runtime-identity.md @@ -0,0 +1,45 @@ +--- +'@objectstack/cli': minor +--- + +i18n 提取器改用运行时视图身份来命名 `_views` 翻译键(#5164 第 1 棒 / cli 段) + +**BREAKING(已发布翻译包的键):容器默认 `list` 的 `_views` 键由 `list` 改为运行时裸键。** + +`objectstack i18n extract` / `os lint` 过去用 `view.list.name ?? 'list'` 推导一个 +`defineView` 容器默认列表的翻译键,而运行时注册表(`expandViewContainer`, +`packages/spec/src/ui/view.zod.ts`)给同一个视图的身份是 `.default`。两者是 +两个不同的字符串,于是「只声明了默认 `list`、没有 `listViews`」的应用会拿到一份键为 +`list` 的翻译骨架、一个键为 `default` 的运行时视图,界面上永远显示英文原文 —— 无论作者 +按哪一侧编写都命中不了。 + +提取器现在**向组装器本身查询**这个键,而不是第二次自行推导。因此它同时继承了组装器 +仅有的两条规则: + +- 无 `name` 的默认列表键为 `default`(不再是 `list`);带 `name` 的沿用作者的 `name`; +- 结构上与某个 `listViews` 条目**完全相同**的默认列表会被组装器按签名折叠进该条目, + 因此它没有自己的键 —— 提取器不再为它多写一个谁也读不到的骨架条目 + (`examples/app-crm` 正是这个形状:`list` 与 `listViews.all` 同签名,真实键是 `all`)。 + +维护者 2026-08-06 裁决:`_views` 翻译键的 canonical 拼写 = 运行时身份的裸键。 + +## 升级:翻译包键的 FROM → TO + +只影响用 `defineView({ list: … })` 声明了**默认列表**、且为它写过翻译的包。 + +| 容器形状 | FROM | TO | +|---|---|---| +| 默认 `list`,无 `name` | `objects.._views.list.*` | `objects.._views.default.*` | +| 默认 `list`,带 `name: 'x'` | `objects.._views.x.*` | 不变 | +| 默认 `list` 与某个 `listViews.` 结构相同 | `objects.._views.list.*` | 删除(`_views..*` 已经覆盖它) | + +一行修法:把 `_views.list` 改名为 `_views.default`;如果同一对象下已经有一个与默认列表 +`type`/`label`/`columns` 完全一致的 `listViews` 条目,则直接删掉 `_views.list`。 +重新跑一次 `objectstack i18n extract`(merge 模式保留已有译文)会得到正确的骨架。 + +本仓库自带示例已随迁:`examples/app-showcase` 5 个块、`examples/app-todo` 2 个语言包 +改名为 `default`;`examples/app-crm` 3 个折叠形状的 `_views.list` 块删除。 +`check:i18n-coverage` 棘轮基线经实测无需调整(需求侧与译文侧同批改名,覆盖数不变)。 + +`packages/lint` 的 `collectViewRecord` 收窄(#6038)与 objectui `viewSuffixes` 去第二 +候选(objectui#3502)是本裁决的第 2、3 棒,不在本次变更内。 diff --git a/examples/app-crm/src/translations/crm.translation.ts b/examples/app-crm/src/translations/crm.translation.ts index 7b27956961..0a0e1dfde8 100644 --- a/examples/app-crm/src/translations/crm.translation.ts +++ b/examples/app-crm/src/translations/crm.translation.ts @@ -126,8 +126,13 @@ export const CrmTranslationBundle = defineTranslationBundle({ discount_percent: { label: '折扣 (%)' }, owner_id: { label: '负责人' }, }, + // No `default` key here, deliberately (#5164). This container's default + // `list` is structurally identical to `listViews.all`, so the composer + // COLLAPSES the two and the only registry entry is + // `crm_opportunity.all` — `all` below already carries its label. The + // `list` key that used to sit here was dead weight that looked correct: + // it matched no runtime view under either spelling. _views: { - list: { label: '全部商机' }, all: { label: '全部商机' }, pipeline: { label: '商机看板' }, }, @@ -143,8 +148,8 @@ export const CrmTranslationBundle = defineTranslationBundle({ lead_score: { label: '线索评分' }, source: { label: '来源' }, }, + // `list` removed — collapsed into `all`, see crm_opportunity (#5164). _views: { - list: { label: '全部线索' }, all: { label: '全部线索' }, pipeline: { label: '线索看板' }, }, @@ -160,8 +165,8 @@ export const CrmTranslationBundle = defineTranslationBundle({ contact: { label: '联系人' }, opportunity: { label: '商机' }, }, + // `list` removed — collapsed into `all`, see crm_opportunity (#5164). _views: { - list: { label: '全部活动' }, all: { label: '全部活动' }, calendar: { label: '活动日历' }, }, diff --git a/examples/app-showcase/src/system/translations/index.ts b/examples/app-showcase/src/system/translations/index.ts index 4fed39c841..c9a0e91748 100644 --- a/examples/app-showcase/src/system/translations/index.ts +++ b/examples/app-showcase/src/system/translations/index.ts @@ -184,8 +184,13 @@ export const ShowcaseTranslationBundle = { start_date: { label: '开始日期' }, end_date: { label: '结束日期' }, }, + // `default` — the container's DEFAULT list. `defineView({ list })` + // declares it without a `name`, and the composer therefore registers it + // as `.default`; `_views` keys are that bare runtime key + // (#5164, ruled 2026-08-06). It read `list` until then, which no + // lookup could ever reach. _views: { - list: { label: '全部项目' }, + default: { label: '全部项目' }, by_status: { label: '按状态' }, budget_chart: { label: '按客户预算' }, }, @@ -223,7 +228,8 @@ export const ShowcaseTranslationBundle = { sync_error: { label: '同步错误' }, }, _views: { - list: { label: '全部任务' }, + // The default list — keyed `default`, see showcase_project above. + default: { label: '全部任务' }, in_progress: { label: '进行中' }, urgent: { label: '紧急' }, done: { label: '已完成' }, @@ -307,13 +313,16 @@ export const ShowcaseTranslationBundle = { // array, so nothing (runtime or gate) could reach these two surfaces. // Registering the container is what makes them translatable at all. // - // `_views.list` — the container's DEFAULT list, which the resolver ids - // as `list` (`primary.name || 'list'`). Its source label is the bare - // plural "Contacts", so the object's own `pluralLabel` above is the - // right word; the `全部…` spelling the task/project lists use belongs to + // `_views.default` — the container's DEFAULT list. It is declared + // without a `name`, so the composer registers it as + // `showcase_contact.default` and the bundle key is that bare runtime + // key (#5164, ruled 2026-08-06); it read `list` until then, a spelling + // no lookup could reach. Its source label is the bare plural + // "Contacts", so the object's own `pluralLabel` above is the right + // word; the `全部…` spelling the task/project lists use belongs to // labels that actually say "All …". _views: { - list: { label: '联系人' }, + default: { label: '联系人' }, }, // `_sections` — the four groups of the default edit form in // `ui/views/contact.view.ts`. Each declares a stable `name`, which is @@ -381,15 +390,16 @@ export const ShowcaseTranslationBundle = { // their object/field debt is inside the ratchet's baseline. Translating // the VIEW labels the coverage fix newly surfaces is what keeps the gate // from widening; the rest is left exactly as it was. + // (`default` = the container's DEFAULT list — see showcase_project.) showcase_inquiry: { _views: { - list: { label: '客户询问' }, + default: { label: '客户询问' }, triage: { label: '询问分流' }, }, }, showcase_business_unit: { _views: { - list: { label: '全部单元' }, + default: { label: '全部单元' }, org_chart: { label: '组织架构图' }, }, }, diff --git a/examples/app-todo/src/translations/ja-JP.ts b/examples/app-todo/src/translations/ja-JP.ts index 2f0c7bf5c8..82aeed834a 100644 --- a/examples/app-todo/src/translations/ja-JP.ts +++ b/examples/app-todo/src/translations/ja-JP.ts @@ -78,8 +78,9 @@ export const jaJP: TranslationData = { notes: { label: 'メモ' }, category_color: { label: 'カテゴリ色' }, }, + // `default` — the container's DEFAULT list; see the zh-CN bundle (#5164). _views: { - list: { label: 'すべてのタスク' }, + default: { label: 'すべてのタスク' }, overdue: { label: '期限切れのタスク' }, due_today: { label: '本日期限' }, }, diff --git a/examples/app-todo/src/translations/zh-CN.ts b/examples/app-todo/src/translations/zh-CN.ts index 1ef14e60b9..27acb342a6 100644 --- a/examples/app-todo/src/translations/zh-CN.ts +++ b/examples/app-todo/src/translations/zh-CN.ts @@ -82,8 +82,13 @@ export const zhCN: TranslationData = { notes: { label: '备注' }, category_color: { label: '分类颜色' }, }, + // `default` — the container's DEFAULT list. `defineView({ list })` + // declares it without a `name`, so the composer registers it as + // `todo_task.default` and the bundle key is that bare runtime key + // (#5164, ruled 2026-08-06). It read `list` until then — a spelling + // no lookup could reach. _views: { - list: { label: '全部任务' }, + default: { label: '全部任务' }, overdue: { label: '逾期任务' }, due_today: { label: '今日到期' }, }, diff --git a/packages/cli/src/utils/i18n-extract.ts b/packages/cli/src/utils/i18n-extract.ts index c9c39502ee..23fd76fe87 100644 --- a/packages/cli/src/utils/i18n-extract.ts +++ b/packages/cli/src/utils/i18n-extract.ts @@ -66,6 +66,7 @@ import type { TranslationBundle, TranslationData } from '@objectstack/spec/syste import { METADATA_FORM_REGISTRY } from '@objectstack/spec/system'; import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; import { deriveFieldGroupLayout } from '@objectstack/spec/data'; +import { expandViewContainer } from '@objectstack/spec/ui'; import { walkPageComponents } from '@objectstack/lint'; // ─── Public types ────────────────────────────────────────────────────── @@ -174,6 +175,48 @@ function viewObjectName(view: any): string | undefined { ); } +/** + * The bare `_views` key the RUNTIME assigns to a container's default `list`. + * + * Asked of the composer (`expandViewContainer`, `spec/src/ui/view.zod.ts`) + * rather than re-derived here — that function is the single producer of a + * view's runtime identity, and the whole point of #5164 is that a second + * derivation drifts from it. This walker used to spell the key + * `view.list.name ?? 'list'` while the composer named the very same view + * `.default`, so a container declaring only a default `list` got a + * bundle skeleton keyed `list`, a registry entry keyed `default`, and an + * English label on screen forever. Ruled 2026-08-06 (#5164): canonical = the + * runtime identity's bare key. + * + * Two facts live in the composer and nowhere else, both load-bearing here: + * + * 1. a nameless default list is keyed **`default`** (never `list`), and a + * named one keeps the author's `list.name`; + * 2. a default list whose STRUCTURE merely restates a `listViews` entry is + * **collapsed into that entry** and has no key of its own — the + * `examples/app-crm` shape, where `list` is signature-identical to + * `listViews.all` and the live key is therefore `all`. This returns `all` + * there, and the caller skips the emit because the `listViews` loop + * already covered it. Emitting a second key for the collapsed view would + * scaffold a translation no lookup can reach — the same defect one shape + * over. + * + * A key renamed by a collision (`default` → `default_2`, when `listViews` + * already claimed `default`) is returned as renamed, because that rename is + * the registry key too. + * + * Returns `undefined` when the container declares no default `list`. + */ +function defaultListViewKey(object: string, container: any): string | undefined { + if (!container?.list || typeof container.list !== 'object') return undefined; + const item = expandViewContainer(object, container).find( + (i) => i.viewKind === 'list' && i.isDefault, + ); + if (!item) return undefined; + const prefix = `${object}.`; + return item.name.startsWith(prefix) ? item.name.slice(prefix.length) : item.name; +} + /** * Emit label / description / emptyState for ONE view under * `objects.._views..*` — the convention the runtime resolver @@ -609,11 +652,20 @@ export function collectExpectedEntries(config: any): ExpectedEntry[] { continue; } - // The container's default list. The console ids an unnamed default as - // `primary.name || 'list'` (app-shell `ObjectView`), so it resolves under - // `_views.list`. + // The container's default list, keyed exactly as the runtime registry keys + // it — see `defaultListViewKey`. `undefined` means the container declares + // no default list; a key the container's own `listViews` also declares + // means the composer collapsed the two, and the loop below emits it. if (view.list && typeof view.list === 'object') { - pushViewEntries(out, viewObjectName(view.list) ?? containerObject, view.list.name ?? 'list', view.list); + const key = defaultListViewKey(containerObject, view); + const collapsedIntoListViews = + key !== undefined + && view.listViews + && typeof view.listViews === 'object' + && Object.prototype.hasOwnProperty.call(view.listViews, key); + if (key !== undefined && !collapsedIntoListViews) { + pushViewEntries(out, viewObjectName(view.list) ?? containerObject, key, view.list); + } } if (view.listViews && typeof view.listViews === 'object') { for (const [viewName, raw] of Object.entries(view.listViews)) { diff --git a/packages/cli/test/i18n-extract-view-key-identity.test.ts b/packages/cli/test/i18n-extract-view-key-identity.test.ts new file mode 100644 index 0000000000..f60f30c74c --- /dev/null +++ b/packages/cli/test/i18n-extract-view-key-identity.test.ts @@ -0,0 +1,138 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +// +// #5164 — the `_views` bundle key a container's DEFAULT `list` gets from the +// i18n extractor must be the SAME STRING the runtime registry assigns it. +// +// Three surfaces used to derive that key independently and disagree: the +// composer (`expandViewContainer`) named a nameless default list +// `.default`, this extractor wrote `_views.list`, and the lint accepted +// both. An app declaring only a default `list` therefore got a bundle skeleton +// keyed `list`, a runtime view keyed `default`, and an English label on screen +// forever. Maintainer ruling 2026-08-06: canonical = the runtime identity's +// bare key; the extractor converges on the composer. +// +// These tests use the COMPOSER ITSELF as the oracle rather than hard-coding the +// expected spelling, so they pin the invariant ("same key") and not one side's +// current opinion of it. Hard-coded spellings are asserted too, but only where +// the string is the thing under discussion. +import { describe, it, expect } from 'vitest'; +import { expandViewContainer } from '@objectstack/spec/ui'; +import { collectExpectedEntries } from '../src/utils/i18n-extract'; + +/** Every `_views` key the extractor scaffolds for `object`, in emit order. */ +function extractorViewKeys(object: string, container: any): string[] { + const prefix = `objects.${object}._views.`; + const keys: string[] = []; + for (const entry of collectExpectedEntries({ views: [container] })) { + const path = entry.path.join('.'); + if (!path.startsWith(prefix) || !path.endsWith('.label')) continue; + keys.push(path.slice(prefix.length, -'.label'.length)); + } + return keys; +} + +/** Every LIST-family view key the runtime registry assigns for `object`. */ +function runtimeListViewKeys(object: string, container: any): string[] { + return expandViewContainer(object, container) + .filter((item) => item.viewKind === 'list') + .map((item) => item.name.slice(`${object}.`.length)); +} + +const data = { provider: 'object', object: 'task' } as const; + +describe('_views keys the extractor scaffolds == the keys the runtime registry assigns (#5164)', () => { + it('a default-only `list` container is keyed `default`, never `list`', () => { + // The exact shape measured in the issue body. + const container = { + list: { label: 'All Tasks', type: 'grid', data, columns: ['title'] }, + }; + + expect(runtimeListViewKeys('task', container)).toEqual(['default']); + expect(extractorViewKeys('task', container)).toEqual(['default']); + + const paths = collectExpectedEntries({ views: [container] }).map((e) => e.path.join('.')); + expect(paths).toContain('objects.task._views.default.label'); + expect(paths).not.toContain('objects.task._views.list.label'); + }); + + it('carries the default list\'s own label onto the `default` key', () => { + const container = { + list: { label: 'All Tasks', type: 'grid', data, columns: ['title'] }, + }; + const byPath = Object.fromEntries( + collectExpectedEntries({ views: [container] }).map((e) => [e.path.join('.'), e.sourceValue]), + ); + expect(byPath['objects.task._views.default.label']).toBe('All Tasks'); + }); + + it('a default list that STRUCTURALLY restates a `listViews` entry gets no key of its own', () => { + // The `examples/app-crm` shape: the composer collapses the two by signature, + // so `crm_*.all` is the only registry entry and a second scaffolded key + // would be a translation no lookup can reach — the same defect one shape over. + const view = { label: 'All', type: 'grid', data, columns: ['title'] }; + const container = { list: { ...view }, listViews: { all: { ...view } } }; + + expect(runtimeListViewKeys('task', container)).toEqual(['all']); + expect(extractorViewKeys('task', container)).toEqual(['all']); + }); + + it('a default list carrying an explicit `name` keeps that name on both sides', () => { + const container = { + list: { name: 'recent', label: 'Recent', type: 'grid', data, columns: ['title'] }, + }; + + expect(runtimeListViewKeys('task', container)).toEqual(['recent']); + expect(extractorViewKeys('task', container)).toEqual(['recent']); + }); + + it('a default list alongside a distinct `listViews` entry keys both, same set as the registry', () => { + const container = { + list: { label: 'All', type: 'grid', data, columns: ['title'] }, + listViews: { + open: { label: 'Open', type: 'grid', data, columns: ['title', 'status'] }, + }, + }; + + expect(runtimeListViewKeys('task', container).sort()).toEqual(['default', 'open']); + expect(extractorViewKeys('task', container).sort()).toEqual(['default', 'open']); + }); + + it('a `listViews.default` collision renames the default list, and the skeleton follows the rename', () => { + // The composer keeps the registry key unique (`default` → `default_2`) and + // stamps a rename warning. The bundle key IS the registry key, so the + // scaffold has to follow it rather than keep pointing at the taken name. + const container = { + list: { label: 'All', type: 'grid', data, columns: ['title'] }, + listViews: { + default: { label: 'Curated', type: 'grid', data, columns: ['title', 'status'] }, + }, + }; + + expect(runtimeListViewKeys('task', container).sort()).toEqual(['default', 'default_2']); + expect(extractorViewKeys('task', container).sort()).toEqual(['default', 'default_2']); + }); + + it('the container\'s default FORM contributes no `_views` key on either side of the comparison', () => { + // The extractor deliberately leaves form views out of `_views` (they have + // no counterpart in the `viewLabel` resolver convention), so there is no + // sibling `'list'`-style defaulting site to converge for the form face — + // the composer's `.form` identity is simply not scaffolded. + const container = { + list: { label: 'All', type: 'grid', data, columns: ['title'] }, + form: { label: 'Edit', type: 'simple', data, sections: [] }, + }; + + expect(runtimeListViewKeys('task', container)).toEqual(['default']); + expect(extractorViewKeys('task', container)).toEqual(['default']); + expect(expandViewContainer('task', container).map((i) => i.name)).toContain('task.form'); + }); + + it('a container with no default `list` at all scaffolds only its `listViews` keys', () => { + const container = { + objectName: 'task', + listViews: { open: { label: 'Open', type: 'grid', data, columns: ['title'] } }, + }; + + expect(extractorViewKeys('task', container)).toEqual(['open']); + }); +});