Repository navigation
Commit 1ac24f2
fix(spec): translateView localizes a form view's sections through objects.OBJECT._sections.NAME (#23749)
Fixes #23632
Clause-②: no
## What this does
`translateView` (and `translateMetadataDocument('view', …)`, which
dispatches to it) now localizes a served form view's `sections`. Each
named section's `label` and `description` resolve through
`objects.OBJECT._sections.NAME.label` / `.description`, on the same
`localeChain` / `pickData` every other resolver in
`packages/spec/src/system/i18n-resolver.ts` uses. No new export: the
card's first option ("`translateView` covers a form view's `sections`").
- A missing key keeps the authored text, as the sibling resolvers do.
- A section with no `name` has no key and is returned as the same
object. Its label is never slugged into a key: the slug-of-label
fallback in the `metadataForms.TYPE.sections` walk belongs to a
different namespace and a different rule, and the translation linter
refuses that spelling for `_sections`.
- An explicit override beats the catalog (#20731, ADR-0029 D9.2a). Each
section is found in `packagedBase.config.sections` by the `name` the
bundle addresses it by, through the existing `packagedPart` finder (its
key union gains `'sections'`). The comparison is the existing
`valueOverridesPackagedBase`, with no second copy. A section the base
does not carry was authored after the fact and keeps its strings. With
no base supplied, the catalog applies.
- Only authored copy is translated (see the decision note below).
- The object is the form's own binding (`config.data.object`) first,
then the view's. That is the order the i18n extractor
(`walkObjectSections`) and the translation linter (`collectViewRecord`)
already use for form sections, so the key read here is the key they
scaffold and accept.
`config` is rebuilt only when a def or a section actually moved. A view
with no section translation keeps its `config` reference, as before.
## Measured readings (dispatch Zone 2)
All against `origin/main` `534ca13eb` (the branch base).
1. **`translateView` never read `sections`: confirmed.** From
`translateView` to `translateMetadataDocument`, the base holds 0
occurrences of `sections`. The resolver's only other `sections` walk is
`resolveMetadataFormLabels` (`metadataForms.TYPE.sections`). It is a
different namespace with a slug-of-label fallback, so it is not the same
rule and was not reused.
2. **The key is declared.** `translation.zod.ts` declares `_sections`
(`label`, `description`) on `ObjectTranslationDataSchema`, and
`component.zod.ts` declares section `name` as the `_sections` anchor
(`record:details` and `object-form` sections). The form view's own
`FormSectionSchema.name` (`ui/view.zod.ts`) says only "Stable section
identifier for i18n lookup". Its TSDoc points at
`metadataForms.TYPE.sections.NAME`, which see the acceptance notes
below.
3. **Where a served form view carries its sections: `config.sections`.**
`getMetaItems({ type: 'view' })` filters every aggregated container out
(`metadata-protocol` `protocol.ts`, "Never surface the aggregated …
container"). So every element `GET /forms/:slug` scans is a ViewItem,
and `anonymousFormIntakeCandidates`'s `config` arm (`viewKind ===
'form'`) is the one that matches there: `match.form ===
match.view.config`. `ListViewSchema` refuses a `sections` key
(`unrecognized_keys`, measured against the built package), so
`config.sections` exists only on the form arm, and no `viewKind` gate is
needed.
- **The call #23497 makes:** `translateMetadataDocument('view',
match.view, bundle, { ...RestServer.translateOptionsFor(i18n, locale),
packagedBase: this.packagedObjectBase(p, 'view', match.view.name) })`.
The route then serves the translated view's `config` as `form`, before
its existing field strip. That is the same pair of arguments the object
half of the same handler already passes.
4. **Override rule: holds, with a stable identity.** A section's
packaged counterpart is found by `name`, the key the bundle addresses. A
nameless section is never translated, so it needs no identity.
5. **The `metadataForms` walk: a different rule**, see 1.
6. **Consumers** (`git grep -l
"translateMetadataDocument\|translateView" -- packages
':!packages/spec'`): the runtime callers are in `packages/rest/src`
(`meta-item-read-gate.ts`, `rest-server.ts`).
`metadata-protocol/src/protocol.ts` names `translateView` in a comment
only. The rest are tests (`objectql`, `platform-objects`,
`plugin-approvals`, `service-i18n`, `cli`). Readings are under Tests.
## Decision: the catalog never adds a heading or a description
A section that authors no `label` stays without one, and the same holds
for `description`, even when the bundle carries `_sections.NAME.label`.
The card's pins do not decide this case, so here is the reason. The
public form renderer (objectui `FormPage`) draws a heading only under
`sec.label &&`, and its comment rules that "a translation cannot conjure
a heading the author did not write". A label added here would draw a
heading on the anonymous route only. The authenticated `FormPage` path
would draw none for the same form. Pinned, and ablated below. The
alternative is to overlay whatever the bundle carries, the way the
view's own `description` is overlaid. That would be a one-line change if
ruled otherwise.
## Tests
- New `packages/spec/src/system/i18n-resolver.form-sections.test.ts`, 13
cases. The fixtures are spec-legal: the container passes
`ViewSchema.safeParse` and each bundle object node passes
`ObjectTranslationDataSchema.safeParse`. The served documents come from
`expandViewContainer`. Cases: the card's three pins, the
only-authored-copy rule, an inline locale-map heading, the form's own
binding, the locale chain (BCP-47 ladder, declared fallback chain,
default locale), `config` reference identity and no mutation, and four
override-rule cases (unedited, edited per string, not in base, no base).
- Readings, all at head `4abffa0fb`, each command run through
`scripts/pm/os-verify-lock.sh`:
- `pnpm --filter @objectstack/spec exec vitest run --project local
--maxWorkers=2`: 680 files, 20769 passed, 1 todo.
- `pnpm --filter @objectstack/spec typecheck`: exit 0.
`check:test-typecheck` OK, and the new test file adds no signature to
`test-typecheck-debt.json`.
- `pnpm --filter @objectstack/spec check:generated` (after a real `pnpm
--filter @objectstack/spec build`): all 14 artifacts up to date,
`check:api-surface` included. So no export, parameter or type widens,
and `Clause-②: no` holds as measured.
- Consumer tests that read translated views, built against this head's
`dist` (`turbo run build --filter='@objectstack/rest^...'`). The checks
below ran per package, by file:
- `@objectstack/rest`, 9 files, 166 passed: `meta-view-container-i18n`,
`meta-dashboard-view-i18n-explicit-override`,
`meta-i18n-declared-fallback-chain`, `meta-plural-i18n`, and the five
`public-form-*` suites.
- `@objectstack/objectql`, 2 files, 16 passed:
`protocol-packaged-view-base`, `engine-validation-locale-negotiation`.
- `@objectstack/metadata-protocol`, 2 files, 242 passed:
`protocol.graft-folded-form-sections`,
`view-container-runtime-expansion`. Its source has no runtime caller of
`translateView`, only a comment.
- `@objectstack/platform-objects` `invite-user-action-copy`, 34 passed.
`@objectstack/service-i18n` `authored-translations`, 16 passed.
- No consumer fixture asserted an untranslated section.
## Gates
The local subset is `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` on this diff: 84 families. Each exit
code was captured before any pipe, and the record was reconciled with
`--ran`: `84 derived famil(ies) accounted for — 83 run, 1 NOT-MEASURED`.
83 exit 0. The one NOT MEASURED is `pnpm check:dual-build-cjs-loads`,
exit 3, PREREQUISITE NOT MET: it reads every package's `dist/`, and this
worktree built only the closure of `@objectstack/rest`. CI runs it. Two
families first answered exit 3 and gave their verdict, exit 0, once
their prerequisites were met: `check-plugin-teardown-shape --self-test`
after fetching its pinned fixture commit, and
`check:doc-formula-expressions` / `check:lean-entry-closure` once their
packages were built. The local-only `check:dev-prereqs` gives exit 0.
Every family CI runs is read from this PR's check runs, not re-run here.
Not done before opening: `origin/main` is 7 commits past this branch's
base. None of them touches a file in this diff or `i18n-resolver.ts` /
`view.zod.ts`: the spec files they change are migration entries and
`api/protocol.zod.ts`. This diff changes no generated artifact either.
The PR's CI runs on the merge ref.
## Ablation (one per rule, each restored and proven by blob hash == HEAD
and an empty `git diff HEAD`)
The suite imports `./i18n-resolver` relatively (source, not `dist/`), so
no rebuild sits between mutation and run. Each mutation went through
`scripts/ablation-replace.mjs`, whose anchor must hit exactly once.
| mutation | result |
|:---|:---|
| `translateFormSections` returns its input (`return changed ? next :
sections;` → `return sections;`) | 8 failed / 5 passed. The 5 that stay
green are the ones whose expectation is "unchanged". |
| object binding ignores `config.data.object` | 2 failed (the
retargeted-form case and the label-only-key case) / 11 passed |
| only-authored-copy guard removed | 1 failed (`never adds a heading or
a description`) / 12 passed |
## Acceptance notes (observations, not filed)
- **`FormSectionSchema.name` TSDoc names the wrong namespace for an
object-bound form view.** It says a bundle targets the section via
`metadataForms.TYPE.sections.NAME`. That holds for a metadata-editing
form (`defineForm({ schemaId })`). The renderers, the extractor, the
linter and now this resolver all address an object-bound form view's
section under `objects.OBJECT._sections.NAME`. The `.describe()` string
names no namespace. Doc drift outside this card's file surface. Carrier:
none.
- **Neither the extractor nor the linter walks a ViewItem record's
`config.sections`.** `walkObjectSections` and `collectViewRecord` read a
view record's top-level `sections`, `form` and `formViews`, never
`config.sections` of an authored `{ name, object, viewKind: 'form',
config }` record. No source producer of such a form record exists in
this tree (`git grep "viewKind: 'form'"` over `examples`,
`packages/platform-objects`, `packages/plugins`, `apps`: zero hits
outside tests), so nothing reaches it. Read-only inference, not filed.
Carrier: none.
- **A localized `/meta/view` read now serves section copy translated.**
This is the intended reach. An editor that read a view with a locale and
saved that body back would persist the translation. The view `label` and
the bulk-action defs already have that exposure on the same read, so
this widens it to one more key and does not change its kind.
- **The other half of the card.** `GET /forms/:slug` still serves `form`
untranslated until #23497 makes the call above. #23497 is not addressed
here.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01HjR8RUwTPQ3TgbfKihmxp9)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent d176b0a commit 1ac24f2
3 files changed
Lines changed: 365 additions & 6 deletions
File tree
- .changeset
- packages/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 | + | |
| 18 | + | |
| 19 | + | |
| 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 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
0 commit comments