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
45 changes: 45 additions & 0 deletions .changeset/views-translation-key-runtime-identity.md
Original file line number Diff line number Diff line change
@@ -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`)给同一个视图的身份是 `<object>.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.<object>._views.list.*` | `objects.<object>._views.default.*` |
| 默认 `list`,带 `name: 'x'` | `objects.<object>._views.x.*` | 不变 |
| 默认 `list` 与某个 `listViews.<k>` 结构相同 | `objects.<object>._views.list.*` | 删除(`_views.<k>.*` 已经覆盖它) |

一行修法:把 `_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 棒,不在本次变更内。
11 changes: 8 additions & 3 deletions examples/app-crm/src/translations/crm.translation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '商机看板' },
},
Expand All @@ -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: '线索看板' },
},
Expand All @@ -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: '活动日历' },
},
Expand Down
28 changes: 19 additions & 9 deletions examples/app-showcase/src/system/translations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<object>.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: '按客户预算' },
},
Expand Down Expand Up @@ -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: '已完成' },
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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: '组织架构图' },
},
},
Expand Down
3 changes: 2 additions & 1 deletion examples/app-todo/src/translations/ja-JP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '本日期限' },
},
Expand Down
7 changes: 6 additions & 1 deletion examples/app-todo/src/translations/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '今日到期' },
},
Expand Down
60 changes: 56 additions & 4 deletions packages/cli/src/utils/i18n-extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────────
Expand Down Expand Up @@ -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
* `<object>.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.<object>._views.<viewName>.*` — the convention the runtime resolver
Expand Down Expand Up @@ -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<any>(view.listViews)) {
Expand Down
138 changes: 138 additions & 0 deletions packages/cli/test/i18n-extract-view-key-identity.test.ts
Original file line number Diff line number Diff line change
@@ -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
// `<object>.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 `<object>.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']);
});
});
Loading