Repository navigation
Commit eddffc3
feat(spec,rest,lint,cli): a form view's title is translated through objects.OBJECT._views.VIEW.title, and GET /forms/:slug serves it in the visitor's language (#25035)
Closes #24969
Clause-②: yes (widening)
## What changes
A public form could not be titled in more than one language.
`FormViewSchema.title` had no translation key: the `_views.VIEW`
translation entry named `title` as a rename onto `label`,
`translateView` never read `config.title`, and `GET /forms/:slug`
answered the top-level `label` from the matched view before translation.
A form that declared a title served it in the authoring language, so
declaring one replaced a translated heading (the object label) with an
untranslated one in every other locale.
- **Spec** (`packages/spec/src/system/translation.zod.ts`):
`objects.OBJECT._views.VIEW.title` is a declared key. The `title ->
label` alias on that entry is removed, and `heading` and `formTitle` are
named as renames onto `title`.
- **Resolver** (`packages/spec/src/system/i18n-resolver.ts`):
`translateView` overlays the key onto a served form item's
`config.title` (new `translateFormTitle`), through the same one `_views`
read (`lookupViewText`) as the view's `label` and `description`. It
follows the rules every other view string follows: only an authored
title is translated (a form without one gains none), a missing key keeps
the authored title, and an explicit override beats the catalog against
the packaged view (ADR-0029 D9.2a, `valueOverridesPackagedBase`).
- **REST** (`packages/rest/src/form-routes.ts`): the served `form.title`
is the translated one, because the route already serves the translated
view's `config`. The top-level `label` is now taken from the translated
view when the view authors a `label`. When the view authors none, the
route still answers none, because the translator falls back to the
view's machine name and that must not shadow the title. The console's
`payload.label ?? form.title ?? objectSchema.label` stays meaningful:
all three arrive translated, in the same order.
- **`objectstack validate`**
(`packages/lint/src/validate-translation-references.ts`): a new `forms`
fact set holds the `_views` keys of every form the composer serves:
`formViews.KEY` entries, the default `form` (`form` or its `name`, asked
of `expandViewContainer`), and `viewKind: 'form'` items. A `title` key
over a form resolves. Over a list view it is refused, as
`translation-target-unknown`, with: `View "all_leads" of object
"crm_lead" is not a form view, so no title resolves this key.` The fix
line points at `_views.VIEW.label`. The default form's key is legal for
`title` alone, and its other tails stay non-keys as before.
- **CLI extractor** (`packages/cli/src/utils/i18n-extract.ts`): it emits
`_views.VIEW.title` for every form a container serves, keyed by the
composer's runtime name (new `pushContainerFormTitles`), and for a
`viewKind: 'form'` item. So `os i18n extract` scaffolds the slot, and
`check:i18n-coverage` reports an authored title that a supported locale
leaves untranslated.
- **Showcase**: `examples/app-showcase` authors `title: 'New contact'`
on `formViews.create`. The new slot raised its untranslated count by one
(414 to 415), so the zh-CN bundle now carries `_views.create.title`.
## Why its own key, not the view's `label` key
The card asked for one of two options: reuse `_views.VIEW.label` if
`title` aliases `label`, or add a key. I read the schemas. The `title ->
label` alias exists only on the TRANSLATION entry (a "did you mean" for
a refused key). The authored shapes keep them separate:
- `ViewItem.label` names the view.
- `FormViewSchema.title` is the heading the form draws.
- A container's form entries (`formViews.KEY`, `form`) carry no `label`
at all, because `FormViewSchema` declares none. `expandViewContainer`
stamps `label: v.label`, which is undefined.
- A `viewKind: 'form'` item can author both, with different text, and
the console prefers `label`.
So they are two source strings. If both used one key, one translation
would stand for two different strings. The new key mirrors the authored
key, the way `emptyState.title` does. It sits in the same `_views` node
and uses the same view key, so it adds no second key convention.
## Tests
Measured at head `6f43b6b2e0`, which is merged with `origin/main`
`73432299c0`.
- **Repro first.** Commit `87d4974eab` adds the tests before the fix.
- **Reverse verification.** After committing the fix, I restored the
five source files to base `7532ea3c08`, in the working tree only. The
mutation was confirmed on disk: `translateFormTitle` count 0,
`forms.add` count 0, `pushContainerFormTitles` count 0, `servedLabel`
count 0. I rebuilt spec, and `ablation-dist-preflight --absent` found
the marker in 0 of 232 built files. Results:
- spec: `6 failed | 162 passed`. The new resolver cases failed, and so
did the `title` and `heading` schema cases.
- rest: `3 failed | 6 passed`. These were the translated title, the
label-not-machine-name case and the authored-label case. The "no title"
controls stayed green.
- lint: `3 failed | 188 passed`.
- cli: `4 failed | 1 passed`.
- Restored with `git checkout HEAD -- PATHS`. `git diff HEAD` was empty
and `git status --porcelain` was clean. After the spec rebuild, the
preflight found the marker in 6 built files.
- **Controls.**
- A form with no title gains none, and `/forms/:slug` still answers no
`label`, so the heading falls back to the translated object label
(`工单`).
- The `label` key does not reach the title, and the title key does not
reach the `label`.
- An `en` request is served as authored.
- A title edited by an environment overlay keeps the edit. This runs on
the real `ObjectStackProtocolImplementation` over a real
`SchemaRegistry`.
- A list view's `label` key is still accepted.
- The `_views` entry stays closed.
- **Package runs.**
- Pre-merge, at `32124e9b0a` plus the showcase commit:
- `pnpm --filter @objectstack/spec test`: 704 files, 21381 passed. `pnpm
--filter @objectstack/spec typecheck` passed too.
- `pnpm --filter @objectstack/rest test`: 308 files, 6166 passed.
- `pnpm --filter @objectstack/lint test`: 152 files, 7030 passed.
- `pnpm --filter @objectstack/cli exec vitest run --project unit`: 4654
of 4655 passed. The one failure was `boot-preparation-parity.test.ts`,
which hit its 5000 ms timeout under shared-box load. It passes alone,
and this diff does not touch `serve.ts` or that file.
- After the merge, re-run on `6f43b6b2e0`:
- the touched suites passed: spec 3 files/181, rest 2 files/23, lint 1
file/191, and cli 5 files/102 (including `boot-preparation-parity`);
- `typecheck` passed for rest, lint and cli.
- The cli `integration` tier is declared to CI.
- **Generated artifacts.** `pnpm --filter @objectstack/spec
check:generated` reports all 14 up to date. `--fix` regenerated
`content/docs/references/system/translation.mdx`.
`check:authorable-surface`, `check:liveness` and
`check:strictness-ledger` were already green; the `objects` liveness row
is `live` and undrilled.
- **i18n gates.**
- `pnpm check:i18n`: OK, 10 packages in sync.
- `pnpm check:i18n-coverage`: OK, 14 configs, 621 baselined, none new.
- `pnpm check:i18n-walk-parity`: OK.
- `pnpm check:i18n-stale-fill`: OK.
- `pnpm check:nul-bytes`: OK.
- **Derived gates.** `dispatch-gates --commands` derived 117 commands on
`6f43b6b2e0`, the same list as before the merge. 116 exited 0. `pnpm
check:type-check-debt` is NOT MEASURED: its closure build queued on the
shared verify lock behind a sibling run and timed out (exit 99, then
exit 3). `check:type-check-coverage` exited 0, and the rest, lint and
cli `typecheck` runs above passed.
- **eslint**, narrowed to the 11 touched source and test files
(`--no-inline-config --format json`): 11 files, 0 errors, 0 warnings,
and no file was ignored. The config declares no type-aware linting (no
`parserOptions.project`), so this diff cannot change the verdict on any
untouched file.
## Review tier
`packages/spec` is a published schema, so this PR owes a
`CONTRACT_REVIEW_TIER` review of record before it enters the queue
(pm-dispatch `references/contract-review.md`). It touches no governed
surface, so no Tier S or Tier H landing rule applies.
## Acceptance notes
- **Open PRs on overlapping files.** I checked these after the merge.
Only #24947 touches `i18n-extract.ts`, in `pushBulkActionDefs`
(`confirmText`), and the hunks do not overlap. No other open PR touches
`translation.zod.ts`, `i18n-resolver.ts`, `form-routes.ts` or
`validate-translation-references.ts`. #24968 is not open as a PR yet.
- **Inference, not measured: a form `formViews.KEY` that retargets
another object** (its own `data.object`). The lint rule files its
`_views` key under the form's own binding, as it already does for that
form's `label`. `translateView` and the new extractor slot key it under
the container object (`ExpandedViewItem.object`). The `label` key
already has the same split. No shipped example retargets a form view.
- **Inference, not measured: a `viewKind: 'form'` item.** It is a
`ViewItem` whose `name` is spec-qualified as `OBJECT.KEY`. The
extractor's `view.name` branch and the lint rule both use the raw `name`
as the `_views` key, while `translateView` strips the object prefix. The
new title slot follows that branch's existing key, so it inherits the
behaviour of `label` there. No shipped example authors one.
- **The default form's `_views.form.label` and `description`** are read
by `translateView` for the served `OBJECT.form` item, but lint still
calls them non-keys. Its documented reason is that `_views.*` is a
list-view convention. This PR registers that key for `title` only and
leaves the earlier decision alone.
- `FormViewSchema.title`'s own `.describe()` was not edited to name the
key. The translation face documents the key, and editing the describe
would churn the JSON schema and docs for no behaviour change.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 30d21f6 commit eddffc3
13 files changed
Lines changed: 833 additions & 17 deletions
File tree
- .changeset
- content/docs/references/system
- examples/app-showcase/src/system/translations
- packages
- cli
- src/utils
- test
- lint/src
- rest/src
- spec/src/system
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
91 | 91 | | |
92 | 92 | | |
93 | 93 | | |
94 | | - | |
| 94 | + | |
95 | 95 | | |
96 | 96 | | |
97 | 97 | | |
| |||
114 | 114 | | |
115 | 115 | | |
116 | 116 | | |
| 117 | + | |
117 | 118 | | |
118 | 119 | | |
119 | 120 | | |
| |||
191 | 192 | | |
192 | 193 | | |
193 | 194 | | |
194 | | - | |
| 195 | + | |
195 | 196 | | |
196 | 197 | | |
197 | 198 | | |
| |||
394 | 395 | | |
395 | 396 | | |
396 | 397 | | |
397 | | - | |
| 398 | + | |
398 | 399 | | |
399 | 400 | | |
400 | 401 | | |
| |||
557 | 558 | | |
558 | 559 | | |
559 | 560 | | |
560 | | - | |
| 561 | + | |
561 | 562 | | |
562 | 563 | | |
563 | 564 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
602 | 602 | | |
603 | 603 | | |
604 | 604 | | |
| 605 | + | |
| 606 | + | |
| 607 | + | |
| 608 | + | |
| 609 | + | |
605 | 610 | | |
606 | 611 | | |
| 612 | + | |
607 | 613 | | |
608 | 614 | | |
609 | 615 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
424 | 424 | | |
425 | 425 | | |
426 | 426 | | |
| 427 | + | |
| 428 | + | |
| 429 | + | |
| 430 | + | |
| 431 | + | |
| 432 | + | |
| 433 | + | |
| 434 | + | |
| 435 | + | |
| 436 | + | |
| 437 | + | |
| 438 | + | |
| 439 | + | |
| 440 | + | |
| 441 | + | |
| 442 | + | |
| 443 | + | |
| 444 | + | |
| 445 | + | |
| 446 | + | |
| 447 | + | |
| 448 | + | |
| 449 | + | |
| 450 | + | |
| 451 | + | |
| 452 | + | |
| 453 | + | |
| 454 | + | |
| 455 | + | |
| 456 | + | |
| 457 | + | |
| 458 | + | |
| 459 | + | |
427 | 460 | | |
428 | 461 | | |
429 | 462 | | |
| |||
1334 | 1367 | | |
1335 | 1368 | | |
1336 | 1369 | | |
1337 | | - | |
1338 | | - | |
1339 | | - | |
| 1370 | + | |
| 1371 | + | |
| 1372 | + | |
| 1373 | + | |
| 1374 | + | |
1340 | 1375 | | |
1341 | 1376 | | |
1342 | 1377 | | |
1343 | 1378 | | |
1344 | 1379 | | |
1345 | 1380 | | |
1346 | 1381 | | |
| 1382 | + | |
| 1383 | + | |
| 1384 | + | |
| 1385 | + | |
| 1386 | + | |
| 1387 | + | |
1347 | 1388 | | |
1348 | 1389 | | |
1349 | 1390 | | |
| 1391 | + | |
| 1392 | + | |
1350 | 1393 | | |
1351 | 1394 | | |
1352 | 1395 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
0 commit comments