Skip to content

Commit c91894a

Browse files
committed
docs(spec): state the one resolution order for a navigation entry's label
BaseNavItemSchema.label's JSDoc and describe now name the id-keyed bundle step translateApp runs at the /meta boundary, the inline locale map, the authored text and render-time inheritance, in that order, and say a present label is never replaced by its target's label nor translated by matching its text. A new pin holds translateApp to the id key. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 05a7547 commit c91894a

2 files changed

Lines changed: 114 additions & 9 deletions

File tree

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Pin: `translateApp` localizes a navigation entry's label by the entry's
5+
* `id`, never by the label's text — the "never translated by matching its
6+
* text" half of the order `BaseNavItemSchema.label` states in `ui/app.zod.ts`.
7+
*
8+
* Every entry below carries a label EQUAL to its target's machine name, the
9+
* one shape a text-matching rule would treat as "not customised". The bundle
10+
* holds every text-keyed route such a rule could take: the target's own
11+
* translation (`objects.<object>.label`, `objects.<object>._views.<view>.label`,
12+
* `dashboards.<dashboard>.label`) and a navigation key spelled like the text.
13+
* With no key for the entry's `id`, the label must come back unchanged; with
14+
* one, the answer must be that key's value and nothing else.
15+
*/
16+
17+
import { describe, it, expect } from 'vitest';
18+
import { translateApp } from './i18n-resolver';
19+
import { TranslationBundleSchema, type TranslationBundle } from './translation.zod';
20+
import { AppSchema } from '../ui/app.zod';
21+
22+
const app = {
23+
name: 'crm',
24+
label: 'CRM',
25+
navigation: [
26+
{ id: 'nav_account', type: 'object', objectName: 'account', label: 'account' },
27+
{ id: 'nav_pipeline', type: 'object', objectName: 'opportunity', viewName: 'pipeline', label: 'pipeline' },
28+
{ id: 'nav_sales_overview', type: 'dashboard', dashboardName: 'sales_overview', label: 'sales_overview' },
29+
],
30+
};
31+
32+
/** Every route a text match could take, and no key for any entry's `id`. */
33+
const textKeyedOnly: TranslationBundle = {
34+
'zh-CN': {
35+
objects: {
36+
account: { label: '客户' },
37+
opportunity: { label: '商机', _views: { pipeline: { label: '销售管道' } } },
38+
},
39+
dashboards: { sales_overview: { label: '销售总览' } },
40+
apps: {
41+
crm: {
42+
label: '客户关系',
43+
navigation: {
44+
account: { label: '按文本-客户' },
45+
pipeline: { label: '按文本-管道' },
46+
sales_overview: { label: '按文本-总览' },
47+
},
48+
},
49+
},
50+
},
51+
};
52+
53+
/** The same bundle plus one key per entry `id`. */
54+
const withIdKeys: TranslationBundle = {
55+
'zh-CN': {
56+
...textKeyedOnly['zh-CN'],
57+
apps: {
58+
crm: {
59+
label: '客户关系',
60+
navigation: {
61+
...textKeyedOnly['zh-CN'].apps!.crm.navigation,
62+
nav_account: { label: '我的客户' },
63+
nav_pipeline: { label: '我的管道' },
64+
nav_sales_overview: { label: '我的总览' },
65+
},
66+
},
67+
},
68+
},
69+
};
70+
71+
const labels = (out: typeof app) => out.navigation.map((n) => n.label);
72+
73+
describe('translateApp — a nav label equal to its target name localizes only through the entry id', () => {
74+
it('both fixtures are authorable: the app and the bundles parse', () => {
75+
expect(AppSchema.safeParse(app).success).toBe(true);
76+
expect(TranslationBundleSchema.safeParse(textKeyedOnly).success).toBe(true);
77+
expect(TranslationBundleSchema.safeParse(withIdKeys).success).toBe(true);
78+
});
79+
80+
it('with no key for the entry id, the label comes back unchanged', () => {
81+
const out = translateApp(app, textKeyedOnly, { locale: 'zh-CN' });
82+
expect(labels(out)).toEqual(['account', 'pipeline', 'sales_overview']);
83+
});
84+
85+
it('with a key for the entry id, the label is that key and nothing else', () => {
86+
const out = translateApp(app, withIdKeys, { locale: 'zh-CN' });
87+
expect(labels(out)).toEqual(['我的客户', '我的管道', '我的总览']);
88+
});
89+
});

‎packages/spec/src/ui/app.zod.ts‎

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -307,20 +307,36 @@ const BaseNavItemSchema = z.object({
307307
* Display label — OPTIONAL since the cloud#2021 / objectui#9868 letter-A
308308
* ruling, and optional in the ACCEPT sense only: an entry still shows text.
309309
*
310-
* Absent ⇒ the entry inherits, **at render time**, the CURRENT label of what
311-
* it opens — the view's label when it names a view and that view is labelled,
312-
* else the object's / dashboard's label. Present ⇒ rendered verbatim, never
313-
* overwritten. Nothing is stored for the absent case: there is no `inherited`
314-
* flag and no materialised copy, which is what makes a renamed target show
315-
* its new name on the next render rather than a stale snapshot.
310+
* Resolved in ONE order, keyed by the entry's identity:
311+
* 1. the bundle entry `apps.<app>.navigation.<id>.label` for the active
312+
* locale chain — applied at the `/meta` boundary by `translateApp`
313+
* (`system/i18n-resolver.ts`), the one place this step runs, over the
314+
* app's `navigation` tree (it does not walk `areas`);
315+
* 2. else a present label, the author's source text: its inline locale
316+
* map's value for that locale, else the text as authored;
317+
* 3. else (absent) the CURRENT label of what the entry opens, **at render
318+
* time** — the view's label when it names a view and that view is
319+
* labelled, else the object's / dashboard's label — localized by the
320+
* target's own translation.
321+
*
322+
* A present label is never replaced by its target's label and never
323+
* translated by matching its text (pinned in
324+
* `system/i18n-resolver.nav-label-identity.test.ts`). Nothing is stored for
325+
* the absent case: there is no `inherited` flag and no materialised copy,
326+
* which is what makes a renamed target show its new name on the next render
327+
* rather than a stale snapshot.
316328
*
317329
* The rule this relaxes — *every real destination must have identity and
318330
* text* — still holds: identity is the target, text is inherited at render.
319331
*/
320332
label: I18nLabelSchema.optional().describe(
321-
'Display proper label. Optional: absent ⇒ the entry inherits the CURRENT label of what it opens at render '
322-
+ "time — the view's label when it names a view and that view is labelled, else the object's / dashboard's "
323-
+ 'label; a present label renders verbatim and is never overwritten. Every real destination must have identity '
333+
'Display proper label. Optional. Resolved in one order: (1) the bundle entry '
334+
+ "`apps.<app>.navigation.<id>.label` for the active locale chain, keyed by this entry's `id` — `translateApp` "
335+
+ "applies it at the `/meta` boundary, over the app's `navigation` tree (not `areas`); (2) else a present label "
336+
+ "as authored — its inline locale map's value for that locale, else its text; (3) else (absent) the CURRENT "
337+
+ "label of what the entry opens, at render time — the view's label when it names a labelled view, else the "
338+
+ "object's / dashboard's label — localized by the target's own translation. A present label is never replaced "
339+
+ "by its target's label and never translated by matching its text. Every real destination must have identity "
324340
+ 'and text: identity is the target, text is inherited at render. No stored inherited flag; nothing is '
325341
+ 'materialised for the absent case.',
326342
),

0 commit comments

Comments
 (0)