Skip to content

Commit 010afef

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-25030-preflight-issuer-physical-read
2 parents 2a51c6a + eddffc3 commit 010afef

23 files changed

Lines changed: 1799 additions & 57 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/lint': minor
4+
'@objectstack/cli': minor
5+
---
6+
7+
feat(spec): a `page:tabs` item label gains a translation key, `pages.<page>.components.<tabsId>.items.<value>.label`
8+
9+
Clause-②: yes (widening)
10+
11+
- **Before.** `pages.<page>.components` is keyed by component id, and a `page:tabs` item is not a component and carries no id, so a tab label had no key. `items` was refused as an unknown key, and `translatePage` never read `items[].label`. A localized page kept its tab strip in the authoring language.
12+
- **Now.** `pages.<page>.components.<tabsId>.items.<value>.label` is a declared key. It is addressed by the tabs component's own `id` and the item's `value` (the stable `?tab=` token), and `translatePage` overlays it onto that item's `label`, across the locale chain. The entry is closed: `title`, `name` and `text` are renames onto `label`. An item without a `value` has no key, because its renderer token is index-derived and an array index is never a bundle key. Author a `value` on the item, or keep localizing that label with an inline locale map.
13+
- **`walkAddressedPageComponents`.** A visitor's edit to a panel's own keys (its `label`) now stands when the visitor keeps the panel list's authored shape. Panel children are still descended from the authored document.
14+
- **`objectstack validate`.** `@objectstack/lint`'s translation-reference check accepts the key over a declared `page:tabs` item `value`. It refuses it as `translation-target-unknown` over an unknown value (with "Did you mean"), over a component of another type (a `page:accordion` is pointed at the inline locale map, since its panels have no stable key), and under a component id the page does not declare.
15+
- **`os i18n extract` / coverage.** The extractor writes a slot for every valued tab item, and the coverage check demands it. An inline locale map on the item label is counted as authored, as before.
16+
- **Unchanged.** Every existing `pages` key, and an inline locale map on a tab label that the bundle does not key.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/rest': minor
4+
'@objectstack/lint': minor
5+
'@objectstack/cli': minor
6+
---
7+
8+
feat(spec): a form view's `title` is translated through `objects.<object>._views.<view>.title`, and `GET /forms/:slug` serves it in the visitor's language
9+
10+
Clause-②: yes (widening)
11+
12+
- **Before.** A form view's `title` (`FormViewSchema.title`) had no translation key. The `_views.<view>` entry named `title` as a rename onto `label`, `translateView` never read `config.title`, and `GET /forms/:slug` answered the top-level `label` from the untranslated view. A public form that declared a title served it in the authoring language to every visitor, so declaring one replaced a translated heading (the object label) with an untranslated one in every other locale.
13+
- **Now.** `objects.<object>._views.<view>.title` is a declared key, under the same `_views` node and view key as the view's `label`. `translateView` overlays it onto a served form item's `config.title`, on the same locale chain and override rule as every other view string: only a form that authors a title is translated, a missing key keeps the authored title, and a title edited on top of the packaged view keeps the edit. `heading` and `formTitle` are named as renames onto `title`.
14+
- **`GET /forms/:slug`.** The served form's `title` is the translated one, and the top-level `label` is taken from the translated view when the view authors a `label`. A view that authors none is still answered without one, so the console heading (`label`, then `form.title`, then the translated object label) is unchanged for it.
15+
- **`objectstack validate`.** `@objectstack/lint`'s translation-reference check accepts a `title` key over a form view (a `formViews.<key>` entry, the default `form`, or a `viewKind: 'form'` item) and refuses it, as `translation-target-unknown`, over a list view, pointing at the view's `label` key.
16+
- **`os i18n extract`.** The CLI extractor emits `_views.<view>.title` for each form a view container serves (keyed by the composer's runtime name) and for a `viewKind: 'form'` item, so the skeleton carries the slot and the i18n coverage gate reports an authored title a supported locale leaves untranslated.
17+
- **Unchanged.** Every other `_views` key, the view `label` and `description` reads, and a form without a title.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/core': patch
3+
---
4+
5+
fix(core): the import resolves a lookup cell by the target object's stored title field
6+
7+
Clause-②: no
8+
9+
- **Before.** A lookup cell was matched against the target by its id, then by the lookup's own `displayField`, then by a fixed list of keys (`name`, `title`, `label`, `full_name`, `email`, `username`). The target object's declared title field was never asked. So a cell that named the record by its title, such as `Wren` for a target whose title is `nickname`, answered `reference_not_found`, although the list export writes lookup cells by that same title.
10+
- **Now.** After the id and the lookup's `displayField`, the import asks the target's title field, resolved by ADR-0079's one rule (`resolveDisplayField`: `nameField`, then the deprecated `displayNameField` alias, then the derived title). It is read from the same object declaration the import already reads to check the target's exposure, so no extra metadata read is made. Two records that share the title answer `reference_ambiguous`, like the import's other ambiguous matches. The cell is never linked to the first hit.
11+
- **A formula title is not matched.** A `formula` field has no stored column, and the engine refuses a filter over it. The import no longer sends that query, for the title or for any other candidate the target declares as a formula, so a cell against a formula title still answers `reference_not_found`. How a formula title should be matched is still open.
12+
- **Unchanged.** The fixed fallback keys and their order. The exposure check still runs first: a target whose declared exposure does not serve `list` answers `reference_not_found` for every cell and is never read.

‎content/docs/references/system/translation.mdx‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ Translation data for a single object
9191
| **pluralLabel** | `string` | optional | Translated plural label |
9292
| **description** | `string` | optional | Translated object description |
9393
| **fields** | `Record<string, { label?: string; help?: string; placeholder?: string; options?: Record<string, string> }>` | optional | Field-level translations |
94-
| **_views** | `Record<string, { label?: string; description?: string; emptyState?: object; bulkActions?: Record<string, object> }>` | optional | View translations keyed by view name |
94+
| **_views** | `Record<string, { label?: string; description?: string; title?: string; emptyState?: object; … }>` | optional | View translations keyed by view name |
9595
| **_actions** | `Record<string, { label?: string; description?: string; confirmText?: string; successMessage?: string; … }>` | optional | Action translations keyed by action name |
9696
| **_sections** | `Record<string, { label?: string; description?: string }>` | optional | Section translations keyed by section name |
9797
| **_tabs** | `Record<string, { label?: string }>` | optional | Filter-preset tab translations keyed by tab name |
@@ -114,6 +114,7 @@ Translation data for a single field
114114
| :--- | :--- | :--- | :--- |
115115
| **label** | `string` | optional | Translated view label |
116116
| **description** | `string` | optional | Translated view description |
117+
| **title** | `string` | optional | Translated form view title — overlays a form view's own `title` (`FormViewSchema.title`); only a form that authors one is translated |
117118
| **emptyState** | `{ title?: string; message?: string }` | optional | Translated empty-state copy shown when the view has no rows |
118119
| **bulkActions** | `Record<string, { label?: string; confirmText?: string; confirmLabel?: string; params?: Record<string, object> }>` | optional | Selection-bar translations keyed by bulk-action def name (`BulkActionDefSchema.name`) |
119120

@@ -191,7 +192,7 @@ Translation data for a single object
191192
| **pluralLabel** | `string` | optional | Translated plural label |
192193
| **description** | `string` | optional | Translated object description |
193194
| **fields** | `Record<string, { label?: string; help?: string; placeholder?: string; options?: Record<string, string> }>` | optional | Field-level translations |
194-
| **_views** | `Record<string, { label?: string; description?: string; emptyState?: object; bulkActions?: Record<string, object> }>` | optional | View translations keyed by view name |
195+
| **_views** | `Record<string, { label?: string; description?: string; title?: string; emptyState?: object; … }>` | optional | View translations keyed by view name |
195196
| **_actions** | `Record<string, { label?: string; description?: string; confirmText?: string; successMessage?: string; … }>` | optional | Action translations keyed by action name |
196197
| **_sections** | `Record<string, { label?: string; description?: string }>` | optional | Section translations keyed by section name |
197198
| **_tabs** | `Record<string, { label?: string }>` | optional | Filter-preset tab translations keyed by tab name |
@@ -394,7 +395,7 @@ Translation data for a single object
394395
| **pluralLabel** | `string` | optional | Translated plural label |
395396
| **description** | `string` | optional | Translated object description |
396397
| **fields** | `Record<string, { label?: string; help?: string; placeholder?: string; options?: Record<string, string> }>` | optional | Field-level translations |
397-
| **_views** | `Record<string, { label?: string; description?: string; emptyState?: object; bulkActions?: Record<string, object> }>` | optional | View translations keyed by view name |
398+
| **_views** | `Record<string, { label?: string; description?: string; title?: string; emptyState?: object; … }>` | optional | View translations keyed by view name |
398399
| **_actions** | `Record<string, { label?: string; description?: string; confirmText?: string; successMessage?: string; … }>` | optional | Action translations keyed by action name |
399400
| **_sections** | `Record<string, { label?: string; description?: string }>` | optional | Section translations keyed by section name |
400401
| **_tabs** | `Record<string, { label?: string }>` | optional | Filter-preset tab translations keyed by tab name |
@@ -557,7 +558,7 @@ Translation data for a single object
557558
| **pluralLabel** | `string` | optional | Translated plural label |
558559
| **description** | `string` | optional | Translated object description |
559560
| **fields** | `Record<string, { label?: string; help?: string; placeholder?: string; options?: Record<string, string> }>` | optional | Field-level translations |
560-
| **_views** | `Record<string, { label?: string; description?: string; emptyState?: object; bulkActions?: Record<string, object> }>` | optional | View translations keyed by view name |
561+
| **_views** | `Record<string, { label?: string; description?: string; title?: string; emptyState?: object; … }>` | optional | View translations keyed by view name |
561562
| **_actions** | `Record<string, { label?: string; description?: string; confirmText?: string; successMessage?: string; … }>` | optional | Action translations keyed by action name |
562563
| **_sections** | `Record<string, { label?: string; description?: string }>` | optional | Section translations keyed by section name |
563564
| **_tabs** | `Record<string, { label?: string }>` | optional | Filter-preset tab translations keyed by tab name |

‎content/docs/ui/translations.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ export default defineStack({
8080
| Dataset dimension and measure labels | `datasets.<name>.dimensions.<dimension>.label` / `datasets.<name>.measures.<measure>.label` |
8181
| Page labels and `page:header` copy | `pages.<name>.label` / `description` / `title` / `subtitle` — on a `kind: 'slotted'` page the header under `slots.header` is the page's header |
8282
| Page component copy, by component id | `pages.<name>.components.<id>.title` / `description` / `label` / `placeholder` / `emptyText` — reached under `regions[].components[]` and `slots.<slot>`, through `properties.children` and a `page:tabs` / `page:accordion` panel's `items[].children` |
83+
| Page tab labels (`page:tabs` items) | `pages.<name>.components.<tabsId>.items.<value>.label` — keyed by the tabs component's `id` and the item's `value`; an item without a `value` has no key (localize it with an inline locale map), and `page:accordion` panels have none |
8384
| Screen-flow wizards (flow label, screen headings and body text, screen field copy) | `flows.<flow>.label` / `flows.<flow>.screens.<node_id>.title` / `.description` / `.fields.<field>.label` / `.placeholder` / `.inlineHelpText` — a screen field's help text is `inlineHelpText`, the key the field itself declares, not `help`; see the boundary note below |
8485
| Global actions, messages | `globalActions`, `messages` |
8586
| Settings UI shell copy (the source badge on a settings row) | `settingsCommon.sourceLabels.<layer>` — the per-namespace settings copy under `settings` is **platform-only**: an app bundle carrying it is refused by name, and the platform's own strings are translated in `@objectstack/service-settings`'s bundle |

‎docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate.
1919

2020
| Dir | Sites |
2121
|---|---|
22-
| `system/` | 376 |
22+
| `system/` | 377 |

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -602,8 +602,14 @@ export const ShowcaseTranslationBundle = {
602602
// "Contacts", so the object's own `pluralLabel` above is the right
603603
// word; the `全部…` spelling the task/project lists use belongs to
604604
// labels that actually say "All …".
605+
//
606+
// `_views.create.title` — the sparse `formViews.create` form's own
607+
// heading (`title: 'New contact'`), which `translateView` overlays
608+
// onto the served form's `config.title`. 新建联系人 follows the
609+
// 新建… shape a create heading takes.
605610
_views: {
606611
default: { label: '联系人' },
612+
create: { title: '新建联系人' },
607613
},
608614
// `_sections` — the four groups of the default edit form in
609615
// `ui/views/contact.view.ts`, plus the sparse `formViews.create`

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

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@
103103
* pages.<page>.label / .description
104104
* pages.<page>.title / .subtitle (from the page's `page:header` component)
105105
* pages.<page>.components.<id>.<key> (per-component copy, #6080)
106+
* pages.<page>.components.<tabsId>.items.<value>.label (a `page:tabs` item label)
106107
* flows.<flow>.label
107108
* flows.<flow>.screens.<node_id>.title (#7646 / #11287)
108109
* flows.<flow>.screens.<node_id>.description (#22507)
@@ -137,7 +138,9 @@ import {
137138
FLOW_SCREEN_COPY_KEYS,
138139
FLOW_SCREEN_FIELD_COPY_KEYS,
139140
FLOW_TERMINAL_MESSAGE_KEYS,
141+
PAGE_TABS_COMPONENT_TYPE,
140142
globalFilterKey,
143+
pageTabItemKey,
141144
walkAddressedPageComponents,
142145
} from '@objectstack/spec/system';
143146
import { FLOW_REGION_SLOTS_BY_TYPE, flowScreenFieldOptionKey } from '@objectstack/spec/automation';
@@ -421,6 +424,39 @@ function pushViewEntries(out: ExpectedEntry[], objectName: string, viewName: str
421424
pushBulkActionDefs(out, root, view, objectName);
422425
}
423426

427+
/**
428+
* Emit `_views.<view>.title` for every FORM view a view container serves —
429+
* each `formViews.<key>` entry and the default `form` — the key
430+
* `translateView` overlays a form's `title` (`FormViewSchema.title`) from.
431+
*
432+
* **The key is the composer's, asked, never re-derived.** A container form is
433+
* served as the item `expandViewContainer` expands it to, and `translateView`
434+
* keys its title by that item's registry name with the object prefix
435+
* stripped: `formViews.<key>` is `<key>` (renamed on a collision, e.g.
436+
* `default_2` beside a default list), the default `form` is its `name` or
437+
* `form`. Reading the expanded items is the same discipline
438+
* {@link defaultListViewKey} keeps for the default list, so the key written
439+
* here is the key the runtime reads.
440+
*
441+
* **Title only.** The other `_views` tails stay unemitted for a form: a
442+
* container's form entry carries no `label`, and its `description` is not
443+
* overlaid by anything a form renders. A form that authors no title still
444+
* records the key (`pushOptional`), so the coverage gate notices a bundle
445+
* that translates one the source never wrote.
446+
*/
447+
function pushContainerFormTitles(out: ExpectedEntry[], containerObject: string, container: any): void {
448+
if (!container?.form && !container?.formViews) return;
449+
const prefix = `${containerObject}.`;
450+
for (const item of expandViewContainer(containerObject, container)) {
451+
if (item.viewKind !== 'form') continue;
452+
const key = item.name.startsWith(prefix) ? item.name.slice(prefix.length) : item.name;
453+
const config = item.config as { title?: unknown } | undefined;
454+
pushOptional(out, ['objects', containerObject, '_views', key, 'title'], config?.title, 'view', {
455+
objectName: containerObject,
456+
});
457+
}
458+
}
459+
424460
/**
425461
* Emit `_views.<view>.bulkActions.<def>.*` for a list view's authored
426462
* `bulkActionDefs[]` (#14253's resolver, #14376's walk).
@@ -1169,6 +1205,22 @@ function emitPageComponentCopy(out: ExpectedEntry[], page: any, name: string): v
11691205
// records nothing (#14749).
11701206
pushEntry(out, ['pages', name, 'components', id as string, key], value, 'page');
11711207
}
1208+
// A `page:tabs` component's tab labels, keyed by each item's `value`
1209+
// under the tabs component's id — the key `translatePage` reads. An
1210+
// item without a `value` gets no slot: its renderer token is
1211+
// index-derived, and an array index is never a bundle key (#14749).
1212+
if (component.type === PAGE_TABS_COMPONENT_TYPE && Array.isArray(props.items)) {
1213+
for (const item of props.items as unknown[]) {
1214+
const value = pageTabItemKey(item);
1215+
if (value === undefined) continue;
1216+
pushEntry(
1217+
out,
1218+
['pages', name, 'components', id as string, 'items', value, 'label'],
1219+
(item as Record<string, unknown>).label,
1220+
'page',
1221+
);
1222+
}
1223+
}
11721224
}
11731225
return component;
11741226
});
@@ -1315,19 +1367,29 @@ export function collectExpectedEntries(
13151367
// `i18n/missing-view` had zero producers repo-wide while the ratchet
13161368
// reported green (#4123).
13171369
//
1318-
// `formViews` stays uncovered: form views have no counterpart in the
1319-
// `viewLabel` / `_views.*` resolver convention, so emitting keys for them
1320-
// would expect translations nothing reads.
1370+
// A container's form views (`formViews.*`, the default `form`) emit one
1371+
// `_views` tail, `title` — the form heading `translateView` overlays (see
1372+
// `pushContainerFormTitles`). Their `label` / `description` stay
1373+
// unemitted: a container form entry carries no label, and nothing a form
1374+
// renders reads a `_views` description.
13211375
const views: any[] = Array.isArray(config?.views) ? config.views : [];
13221376
for (const view of views) {
13231377
const containerObject = viewObjectName(view);
13241378
if (!containerObject) continue;
13251379

13261380
if (view.name) {
13271381
pushViewEntries(out, containerObject, view.name, view);
1382+
// A `viewKind: 'form'` item carries its form under `config`, title included.
1383+
if (view.viewKind === 'form' && view.config && typeof view.config === 'object') {
1384+
pushOptional(out, ['objects', containerObject, '_views', view.name, 'title'], view.config.title, 'view', {
1385+
objectName: containerObject,
1386+
});
1387+
}
13281388
continue;
13291389
}
13301390

1391+
pushContainerFormTitles(out, containerObject, view);
1392+
13311393
// The container's default list, keyed exactly as the runtime registry keys
13321394
// it — see `defaultListViewKey`. `undefined` means the container declares
13331395
// no default list; a key the container's own `listViews` also declares

0 commit comments

Comments
 (0)