Skip to content

Commit 1eac7ad

Browse files
fix(rest): GET /forms/:slug localizes a public form's section headings under Accept-Language (#23844)
Fixes #23497 Clause-②: no ## What `GET /forms/:slug` (`registerFormEndpoints` in `packages/rest/src/rest-server.ts`) now serves a public form's section `label` and `description` in the visitor's language under `Accept-Language`, next to the field labels it already translated. Before, a zh-CN anonymous visitor read the English section titles above Chinese field labels. That visitor cannot load the bundle in the browser, because the `/i18n` domain answers an anonymous caller 401. So the server is the only place these strings can be translated. The handler makes ONE extra call into the spec's view translator, `translateMetadataDocument('view', match.view, bundle, …)`. That call reaches `translateView`, which reads `objects.OBJECT._sections.NAME` since #23632 (landed as PR #23749). It runs beside the existing `objectSchema` call and uses the same bundle, the same locale options (`translateOptionsFor`), and the packaged view (`getPackagedViewBase`, looked up by the served item's registry name) as the override base. The translated `config` then goes through the unchanged lookup / master-detail / user strip. `packages/rest` has no `_sections` walk and no copy of the locale chain. `packages/spec` is not edited. What does not change: the response shape (`slug`, `object`, `label`, `form`, `objectSchema`), the top-level `label`, `Vary: Accept-Language`, the declared-field narrowing, the strip, and `POST /forms/:slug/submit`. ## Resumed branch: inherited commits, hunk by hunk This branch was started under the released claim and is continued here from `357c751a7c`, with `origin/main` merged (`dd9822efb`, a real merge commit). Every check below was re-run on this branch. None of the earlier run's verification is taken as done. - `ef0bea4e8` handler hunks (the `servedForm` binding, the view-translator call, the `config`-slot swap, the strip reading `servedForm`): **kept**. In `b4e56cfa5` the comment above the swap is **corrected** to say what the slot measurement below found: the protocol's view list serves view items only, and a form matched in any other slot is served as matched. - `ef0bea4e8` changeset `.changeset/23497-public-form-section-i18n.md` (`@objectstack/rest` patch): **kept**. Its claims were re-checked against the code: `translateOptionsFor` carries the locale, fallback chain and default locale, and the edited-heading claim is pinned below. - `980fb8d6e` and `357c751a7c`, the pin file: **kept**, then **extended** in `b4e56cfa5` and `8d4b00444`. The extension adds the real-protocol and per-slot pins below, gives the `select` fixture its options (the registry warned that it was functionally incomplete), and limits the real-protocol engine double to the members the route reads. ## The three form slots (dispatch assumption 1) `anonymousFormIntakeCandidates` finds an open form in three slots. `translateView` translates sections at a view's `config` only. | slot | conclusion | evidence | |:--|:--|:--| | a `viewKind: 'form'` item's `config` | **localized** | the zh-CN pins below, over a protocol double AND over the real protocol | | a container's `form` | **unreachable** at the route | see below | | a container's `formViews.KEY` | **unreachable** at the route | see below | Why the container slots never reach `GET /forms/:slug`: - **The read drops containers.** The route reads `p.getMetaItems({ type: 'view' })`. `ObjectStackProtocolImplementation` drops every `isAggregatedViewContainer` body from that list (`packages/metadata-protocol/src/protocol.ts`, the enumeration filter in `readFlattenedMetaItems`). A container is served only as the items `expandViewContainer` expands it to, and each of its form slots becomes a `viewKind: 'form'` item with the form under `config`. All three registrars expand this way: the objectql boot loop, the metadata artifact loader, and the runtime door's `expandRuntimeViewContainer`. - **No door writes a container slot beside a `viewKind`.** Measured with a scratch probe over `packages/spec` at this head, not committed. The stack `views:` channel refuses a body that has a `viewKind` (`isViewContainerShaped` is false, so the boot answers INVALID_METADATA 422). `ViewMetadataSchema` strips `form` from `viewKind + config + form`, so the parsed keys are `config, name, object, viewKind`, and it strips `formViews` the same way. The metadata write door stores that parsed body (`projectStorableViewBody`). The `viewItems:` channel registers the parsed `AssembledViewArtifactSchema` body. The probe also showed that the door refuses `viewKind + form` without `config`, and that strict `ViewItemSchema` refuses all three hybrid bodies. - **Pinned over the real protocol.** A new pin builds the real `ObjectStackProtocolImplementation` over a real `SchemaRegistry`. It registers a container with the default `form` and `formViews.contact`, both public, the way the boot registers it. It asserts that the view list the route reads carries no `form` or `formViews` key and serves the two forms as `ticket.form` / `ticket.contact` items with `publicLink` under `config`. Both slugs then answer zh-CN translated headings. The showcase's own public form (`examples/app-showcase`, `formViews.contact`) has this container shape. - **Boundary, pinned.** If the route is handed a container anyway (from a protocol double, or a row stored verbatim before the write door stored parsed bodies), it serves the matched form's own sections as authored, never the item's `config`. Two pins cover this (`form`, `formViews.contact`). No `out_of_scope_findings` entry follows from it, because no current producer reaches it (see Acceptance notes). ## Dispatch assumptions 2 and 3 - **2, the packaged view base: holds.** The real-protocol pin seeds an environment overlay row that edits `ticket.contact`'s `about` heading. The real `getPackagedViewBase('ticket.contact')` answers the shipped item (its heading is asserted to be the English original). Under zh-CN the edited heading is served as edited and the unedited description translates. Ablation M4 below turns exactly this pin and its double-based twin red. - **3, translate before the strip: holds, and the assertion stays.** Ablation M2 serves the translated `config` past the strip, the shape a translate-after-strip ordering produces. It turns red the four assertions that check a translated section still lost its lookup field. ## Ablations (one-time, uncommitted; `scripts/ablation-replace.mjs` WRAP mode with a trap-backed restore) The pin file imports `./rest-server` from `src`, so no rebuild is needed. Each leg was confirmed on disk by the tool's anchor count (1 to 0) and a blob change. Each restore was proven by the blob matching HEAD `730c2aaa03f2` and an empty `git diff HEAD`, and the tree was clean afterwards. All legs ran at `b4e56cfa5`, whose `rest-server.ts` blob is unchanged at the final head. | leg | mutation | red | green | |:--|:--|:--|:--| | M1 | swap line replaced by `void view` (the fix ablated) | 5: every zh-CN translated-heading pin, double and real protocol | 9, all controls | | M2 | `form: servedForm !== match.form ? servedForm : safeForm` | 4: translated section keeps its lookup field, the strip control, real-protocol fields x2 | 10 | | M3 | swap without the `config`-slot guard | 2: the two container-slot boundary pins | 12 | | M4 | the view call's `packagedBase` set to `undefined` | 2: the override pin over the double and over the real protocol | 12 | ## Verification (final head `8d4b00444`) - Pin file `packages/rest/src/public-form-section-i18n.test.ts`: 14 passed. Vitest's file line reads `(14 tests) 82ms`, which is the reading the 10 s per-file line grades. Import is 13.5 s on a shared box. - `pnpm --filter @objectstack/rest typecheck`: exit 0. `check:test-typecheck` reads 0 errors, and the test program lists this file (`--listFiles`). - `pnpm --filter @objectstack/rest test`: exit 0, 286 files, 5890 passed and 326 skipped. This ran at `b4e56cfa5`. Between that head and the final one, only this pin file's engine double changed. - Dependency closure built first: `pnpm --filter '@objectstack/rest...' build`, exit 0. - `pnpm exec eslint --no-inline-config` over the two changed source files: exit 0. The repo-wide run is this PR's `Lint & Repo Gates` check-run. - Gate families: `node scripts/pm/dispatch-gates.mjs --commands` (merge base `1ecb08a1c`) derived 66 commands. All 66 ran at `8d4b00444`, and each exited 0. `--ran` reports `66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN`, a derived zero with every exit code recorded. Two gates needed a step first. `check-plugin-teardown-shape --self-test` needs its pinned fixture commit, which a shallow fetch supplied. `check:dual-build-cjs-loads` needs every package built; `check:type-check-debt`'s own closure build supplied that before the run. - The engine-double ledgers are untouched. The real-protocol double carries only `registry` and `find`. A one-off proxy probe recorded the members the protocol read on these paths: `find`, `registry`, and the probes `writeEpoch` and `getDriverForObject`. It never read `findOne`. `check:engine-double-contract` and `check:where-matcher` both exited 0. ## Acceptance notes - A `view` row stored verbatim before the write door stored parsed bodies could still carry a container slot beside a `viewKind`. The route answers such a row's form as matched and untranslated (pinned above). No current door writes that shape, so nothing is filed. Carrier: none. - No live server boot was run. The real-protocol pin covers the composed read the route performs. - The branch is two commits behind `origin/main` `0cfd0c49b`. Those commits touch `plugin-auth` and `scripts/ci`, disjoint from this diff, so main was not merged again. ## Consumer objectstack-ai/objectui#12333 is waiting on this card and remains open. Once a release carrying this change is installable there, only the header of objectui's `FormPage.sectionLabelI18n.test.tsx` pin needs amending. The view-level field `placeholder` / `helpText` question is not touched here; it is the card's "Not this card", routed to `domain:spec`. --- _Generated by [Claude Code](https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 66d456d commit 1eac7ad

3 files changed

Lines changed: 419 additions & 6 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
fix(rest): `GET /forms/:slug` serves a public form's section headings in the visitor's language, beside the field labels it already translated
6+
7+
Clause-②: no
8+
9+
An anonymous visitor of a published form cannot load the translation bundle in the browser, so the server is the only place its section titles can be translated. The route translated `objectSchema` (the field labels) under `Accept-Language` and returned each form section as authored, so a zh-CN visitor read English section titles over Chinese field labels. It now localizes each section's `label` and `description` through the same `objects.OBJECT._sections.NAME` keys the signed-in console reads, with the request's locale, the deployment's fallback chain and default locale, and the packaged view as the override base.
10+
11+
With `Accept-Language: zh-CN` over a form whose section `about` is labelled `Tell us about yourself`, and a zh-CN bundle carrying `objects.crm_lead._sections.about.label: 介绍一下你自己`:
12+
13+
- FROM: `form.sections[0].label` is `Tell us about yourself`, and `objectSchema.fields.first_name.label` is `名`.
14+
- TO: `form.sections[0].label` is `介绍一下你自己`; the field label is unchanged (`名`). A section `description` follows the same key's `description`.
15+
16+
What does not change: a section whose key the bundle lacks keeps its authored text; a section with no `name` is served as authored; a section that authors no heading is not given one; a heading edited on top of the packaged view keeps the edit. The response shape (`slug`, `object`, `label`, `form`, `objectSchema`), its top-level `label`, `Vary: Accept-Language`, the declared-field narrowing, the lookup / master-detail / user strip and `POST /forms/:slug/submit` are unchanged.

0 commit comments

Comments
 (0)