Skip to content

Commit b2a2b76

Browse files
fix(plugin-email): an option hole prints the option label in each recipient's language (#30781)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9D4Zm7YVoaJnZBvaJejVjwmV)_ Fixes #30730 Clause-②: no Metadata-protocol: none domain:server#5 card thread · session `session_016WV4czh582ycXhGxZNVPVi` · branch `claude/project-thread-9ookwa` (the card thread's platform-assigned branch, named in the re-posted claim on #30730). Built on PR #30727's head `98f61ec6` (branch `claude/project-thread-d8v0qv`, the spec half), then `origin/main` `b3098661` merged in. Until #30727 lands, this PR's diff against `main` also carries #30727's commits; the plugin-email half is the last three commits. ## 维护者速读 - 改了什么:邮件模板里的 `{{ stage | option:opportunity.stage }}` 现在真正按收件人的语言印出选项标签:同一封商机滞留通知,英文同事读到 Needs Analysis,中文同事读到「需求分析」;定时流程以系统身份发出也一样逐人翻译。 - 为什么改:规格半(PR #30727)让这种写法通过保存校验,但邮件服务从不准备标签表,所以每次发送都以裸 `RangeError` 失败、一封也发不出;HotCRM 至今只能手写各语言标签表。 - 风险与回滚:只影响写了 `option` 的模板;对象不存在、字段不存在、字段没有选项(含空选项列表)都整封拒发并报 `TEMPLATE_INVALID`,不会发出错误内容。回滚即还原本 PR(之后这类模板回到发不出去的状态)。 - 先看:`packages/plugins/plugin-email/src/option-label-table.ts`(建表与拒绝);`src/email-service.ts` 中 `resolveAndRenderTemplate` 新增的建表段(语言取发送的 `locale`);`src/template-engine.ts` 的 `option` 判定。 - 两分钟验证:`pnpm --filter @objectstack/plugin-email exec vitest run src/template-option-formatter.render.test.ts src/template-option-labels.integration.test.ts`,预期 2 个文件 20 例全绿,其中 zh-CN 收件人读到「需求分析」「赢单」,en-US 读到 Needs Analysis。 - 需要你做的:无,席位落地(与 #30727 背靠背,规格半先落)。 ## What changes - **The label table (`option-label-table.ts`, new).** `buildOptionLabelTable(faces, locale, sources)` collects the `object.field` keys the template's `option` holes name (`parseMailHole(...).optionField`), reads each named object's SERVED definition (`IDataEngine.getObject`, where a picklist-bound field carries its resolved list and its `picklist` name), and builds, per key, stored value to label, with the i18n service's bundle laid over the option-declaring fields by `translateObject` with the same `fallbackChain` / `defaultLocale` options. That is the step sequence of `@objectstack/objectql`'s `readerOptionLabels` (`formula-option-labels.ts`), the path a formula's `label()` reads; see Acceptance notes for why it is taken through the same two published functions rather than imported. A test holds the two answers equal. - **Which locale.** The send's own `locale` (the recipient's; the messaging channels resolve it per recipient and pass it to `sendTemplate` / `renderTemplate`), trimmed as the template ladder reads it. Never the resolved row's locale and never an execution context's, so a scheduled flow running as the system still mails each recipient in their language. No locale reads the declared labels (ruling F, parameter 3). - **Refusals.** Before any face renders, every face is judged with `judgeMailHoleGrammar(face, { objects, liveUniverse: true })` against the served objects the holes name: an object nothing serves (`option-object-unknown`), a field the object does not have (`option-field-unknown`), a field with no options (`option-field-no-options`). Each throws `TEMPLATE_INVALID`, prefixed with the template and locale as every renderer refusal is, and the whole send is refused: nothing reaches the transport. **Decided here (the carry note leaves it to this card):** a field whose served option list is empty (`options: []`, which the formula side answers with a `{}` table and no refusal) is judged as a field with no options and refused the same way; the renderer also refuses a `{}` table handed to it directly. - **The renderer (`template-engine.ts`).** `RenderOptions.optionLabels` (new) is passed to `formatValue`. For an `option` hole the renderer asks `optionTableRefusal` before `formatValue` and prefixes `TEMPLATE_INVALID`, the way it asks `currencyDefaultRefusal`; so a send with no table (a host with no data engine) is refused `TEMPLATE_INVALID` with the formula's sentence, never a bare `RangeError`. - **The wiring (`email-service.ts`, `email-plugin.ts`).** `EmailService.setOptionLabelSources(read)` (new, not exported from the package index); the plugin wires it in `init` to read the data engine (`objectql`, else `data`) and the `i18n` service at each send (resolve where used: a service registered later is seen, nothing is recorded). The table is built once per send for the three faces, and only when a face has an `option` hole. - Changeset: `@objectstack/plugin-email` patch. ## Tests - `src/template-option-formatter.render.test.ts` (new), through `EmailService.renderTemplate` / `sendTemplate` with a recording transport: - an `en-US` and a `zh-CN` recipient each read their own label on all three faces (subject, HTML double and triple brace, text); - a scheduled flow running as the system: one `data` object built once, sent to `en-US`, `zh-CN`, `en-US` recipients with no `sentBy` and no organization, each reading their own label, the payload unchanged; - no locale reads the declared labels even when the resolved row is `zh-CN`; a value outside the options prints as stored and a multi-select labels each value; a picklist-bound field reads the shared list's translation; the sources are asked once, only for the named object; - refusals: an unknown object, an unknown field, a field with no options, an empty option list, each `TEMPLATE_INVALID` naming the template and hole, with nothing sent; no sources wired is `TEMPLATE_INVALID` with `optionTableRefusal`'s sentence; the renderer refuses an empty table; a template with no `option` hole never asks for the sources; - parity: for `zh-CN`, `zh`, `en-US` and no locale, the mail table's stage labels equal what a real `ObjectQL` engine's formula `label(record.stage)` reads for the same object and i18n service. - `src/template-option-labels.integration.test.ts` (new): the real `EmailServicePlugin` booted on an `ObjectKernel` with `ObjectQLPlugin`, an app declaring the object and templates, and an `i18n` service: `en-US` and `zh-CN` recipients read `Needs Analysis` and `需求分析`; a template naming an object the deployment does not serve is refused `TEMPLATE_INVALID` and nothing is sent. ## Acceptance notes - **Why not import `readerOptionLabels`.** `@objectstack/objectql` is only a dev dependency of `@objectstack/plugin-email`, so no export of `formula-option-labels.ts` reaches it at runtime, and promoting it to a runtime dependency is a `package.json` / lockfile change outside this card's surface. So `formula-option-labels.ts` is untouched (no export added), and the table takes the same steps through the same published functions (`translateObject` from `@objectstack/spec/system`, `declaresOptions` from `@objectstack/formula`), as `service-analytics`' `translateSelectOptions` already does. The parity describe holds the answers equal; a later move of the shared steps into one module both packages depend on would delete the copy. - **The system-run pin is at the plugin-email layer.** It drives `sendTemplate` the way the `email` channel does after fan-out (one payload, the recipient's `locale`, no user, no organization). The fan-out itself (`service-automation` notify, `service-messaging` channels) is outside this card's file surface and is not driven here; the channels already pass the recipient's locale (decision analysis 6658266694, reading 1). - **`Clause-②: no`, `Metadata-protocol: none`, re-derived from this diff.** What an author can write is unchanged here: the `option` formatter, its grammar and its authoring checks are #30727's (which declares `yes (widening)` / `added`). This diff makes the runtime deliver what that grammar promises; no public export of the package changes (`EmailService` is not exported from the index) and `IEmailService` is unchanged. - Skills follow-up for the formatter is #30731 (the spec half's); nothing in `skills/**` changes here. ## Verification At head `f5fee1af`, after a whole-tree `turbo run build` (119/119 tasks, `os-verify-lock` VERDICT command-exit 0). <details><summary>Reproduction on PR #30727's head 98f61ec (before this change)</summary> - `EmailService.sendTemplate` of a template with `{{ stage | option:opportunity.stage }}`, `en-US` and `zh-CN`: both threw `RangeError: the hole option:opportunity.stage prints the option labels of opportunity.stage, and no label table for that field reached this render ...`, outside the `TEMPLATE_INVALID` vocabulary. - The new integration test on that head (plugin-email sources restored to `98f61ec6`): 2 failed / 2, the first with that `RangeError`, the second "expected TEMPLATE_INVALID ... but got 'the hole option:mol_deal.stage prints…'". </details> <details><summary>Package suite and typecheck</summary> - `pnpm --filter @objectstack/plugin-email test && pnpm --filter @objectstack/plugin-email typecheck` under `os-verify-lock`: Test Files 50 passed (50), Tests 825 passed (825), VERDICT command-exit 0. - `eslint --no-inline-config` on the six changed `.ts` files: exit 0. </details> <details><summary>Ablation (scripts/ablation-replace.mjs; the tests import src, no dist involved)</summary> - Per-recipient lookup removed: `buildOptionLabelTable(..., preferred || undefined, sources)` to `(..., undefined, sources)` in `email-service.ts` (anchor 1 to 0, blob `61d0dba1` to `fa994bdd`): 5 failed / 15 passed (zh-CN faces, the system-run fan-out, the out-of-options/multi-select zh-CN case, the picklist case, the booted en-US + zh-CN case). Restored: blob `61d0dba1` equals HEAD, `git diff HEAD` empty. </details> <details><summary>Derived gates (dispatch-gates --commands)</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `f5fee1af`: 105 families derived, every one run, every one exit 0 (after the whole-tree build, a deepened clone and the pinned teardown-shape commit fetched). - `dispatch-gates --ran`: 105 derived, 105 run, 0 NOT-MEASURED, 0 UNRUN. - Beyond the derivation: `pnpm lint` exit 0, `pnpm check:cli-command-ids` exit 0, `pnpm check:adr-symbol-anchors` exit 0. - CI check-runs on this PR: in progress at open. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_016WV4czh582ycXhGxZNVPVi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a59d00a commit b2a2b76

7 files changed

Lines changed: 704 additions & 4 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/plugin-email': patch
3+
---
4+
5+
fix(plugin-email): a mail template's `{{ stage | option:opportunity.stage }}` hole prints the option's label in each recipient's language, and a hole the deployment cannot label refuses the send `TEMPLATE_INVALID`
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- **What was wrong.** A template using the `option` formatter passed every authoring door, and then every send of it failed: the email service handed the formatter no label table, so `sendTemplate` and `renderTemplate` threw a bare `RangeError` outside the `TEMPLATE_INVALID` vocabulary, and nothing was sent.
11+
- **What changes.** Before rendering, the email service builds a label table for each `object.field` the template's `option` holes name, once per send, from the deployment's served object (`IDataEngine.getObject`) with the `i18n` service's bundle laid over it by `translateObject` — the path `GET /meta/object/:name` serves the object with and a formula's `label()` reads. The locale is the send's own `locale`, the recipient's, never an execution context's: a scheduled flow that runs as the system still mails each recipient in their language. A send that names no locale reads the declared labels. A value outside the field's options prints as stored; a multi-select value prints each label, joined with `, `.
12+
- **What is refused.** An object the deployment does not serve, a field the object does not have, a field that declares no options, and a field whose option list is empty refuse the whole send with `TEMPLATE_INVALID`, naming the template, the hole and the rule, before any face is rendered; nothing reaches the transport. A host with no data engine builds no table, and such a hole is refused `TEMPLATE_INVALID` by the renderer, with the formula's `optionTableRefusal` sentence.

‎packages/plugins/plugin-email/src/email-plugin.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -595,6 +595,18 @@ export class EmailServicePlugin implements Plugin {
595595
return i18n?.getFallbackLocale?.();
596596
});
597597

598+
// A template's `option` holes read the served objects and the i18n
599+
// service, resolved at each send (resolve where it is used): an engine or
600+
// an i18n service registered after this plugin is seen, and a host with
601+
// no data engine builds no table, so such a hole is refused at render.
602+
this.service.setOptionLabelSources(() => {
603+
const engine = resolveDataEngine(ctx) as { getObject?: (name: string) => unknown } | null;
604+
if (!engine || typeof engine.getObject !== 'function') return undefined;
605+
let i18n: II18nService | undefined;
606+
try { i18n = ctx.getService<II18nService>('i18n'); } catch { i18n = undefined; }
607+
return { getObject: (name) => engine.getObject!(name), ...(i18n ? { i18n } : {}) };
608+
});
609+
598610
// [#23946] Persistence + the template loader are wired HERE, before the
599611
// service is handed out, and not at this plugin's `kernel:ready`. A
600612
// plugin that STARTS before this one runs its own `kernel:ready` hooks

‎packages/plugins/plugin-email/src/email-service.ts‎

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ import type {
1515
IQueueService,
1616
QueueBackoffPolicy,
1717
} from '@objectstack/spec/contracts';
18-
import { renderTemplate, renderPlainTextTemplate, requireVars, htmlToText } from './template-engine.js';
18+
import { renderTemplate, renderPlainTextTemplate, requireVars, htmlToText, type RenderOptions } from './template-engine.js';
19+
import { buildOptionLabelTable, type OptionLabelSources } from './option-label-table.js';
1920
import {
2021
SYS_EMAIL_ATTACHMENT_LIMIT_BYTES,
2122
encodeAttachmentsForRow,
@@ -679,6 +680,14 @@ export class EmailService implements IEmailService {
679680
/** [#28880] The stack's declared fallback locale, read at each resolve. Unset ⇒ none declared. */
680681
private templateFallbackLocale?: () => string | undefined;
681682

683+
/**
684+
* Where an `option` hole's labels come from (the served objects and the
685+
* i18n service), read at each render. Unset, or answering `undefined`, ⇒ no
686+
* label table is built, and a template with an `option` hole is refused
687+
* `TEMPLATE_INVALID` by the renderer.
688+
*/
689+
private optionLabelSources?: () => OptionLabelSources | undefined;
690+
682691
/**
683692
* Set once queue delivery has been reported as unavailable, so the
684693
* degradation is stated at the FIRST send and not on every one
@@ -718,6 +727,16 @@ export class EmailService implements IEmailService {
718727
this.templateFallbackLocale = read;
719728
}
720729

730+
/**
731+
* Wire where a template's `{{ stage | option:opportunity.stage }}` holes
732+
* read their option labels: the served object by name and the i18n
733+
* service. Read at each render, so services registered after this one are
734+
* seen.
735+
*/
736+
setOptionLabelSources(read: () => OptionLabelSources | undefined): void {
737+
this.optionLabelSources = read;
738+
}
739+
721740
private declaredFallbackLocale(): string | undefined {
722741
try {
723742
const declared = this.templateFallbackLocale?.();
@@ -1572,13 +1591,37 @@ export class EmailService implements IEmailService {
15721591
// `' zh-CN '` must not reach `Intl`, which throws a RangeError on it and
15731592
// took the whole send down.
15741593
const locale = preferred || row.locale;
1575-
const renderOpts = {
1594+
const renderOpts: RenderOptions = {
15761595
...(locale ? { locale } : {}),
15771596
...(input.timezone ? { timeZone: input.timezone } : {}),
15781597
// The tenant default a `{{ amount | currency }}` hole formats in. The
15791598
// caller supplies it; absent, such a hole refuses below, never USD.
15801599
...(input.currency ? { currency: input.currency } : {}),
15811600
};
1601+
// An `option` hole prints a select value as its option's label in the
1602+
// RECIPIENT's language (ruling F on the option-label card): the table is
1603+
// built here, once for the three faces, from the served objects in the
1604+
// send's own locale — `preferred`, the recipient's, never the row's
1605+
// fallback and never an execution context's, so a scheduled flow running
1606+
// as the system still mails each recipient in their language. No locale ⇒
1607+
// the declared labels. An object, field or option list the table cannot be
1608+
// built from refuses the whole send `TEMPLATE_INVALID`, before any face is
1609+
// rendered; with no sources wired the renderer refuses the hole instead.
1610+
let optionLabels: ReturnType<typeof buildOptionLabelTable>;
1611+
try {
1612+
const sources = this.optionLabelSources?.();
1613+
optionLabels = sources
1614+
? buildOptionLabelTable([row.subject, row.body_html, row.body_text], preferred || undefined, sources)
1615+
: undefined;
1616+
} catch (err: any) {
1617+
const message = String(err?.message ?? err);
1618+
if (!message.startsWith('TEMPLATE_INVALID: ')) throw err;
1619+
throw new Error(
1620+
`TEMPLATE_INVALID: email template ${input.template} (locale=${row.locale}): `
1621+
+ message.slice('TEMPLATE_INVALID: '.length),
1622+
);
1623+
}
1624+
if (optionLabels) renderOpts.optionLabels = optionLabels;
15821625
// Each face is rendered in ITS OWN encoding. Only `body_html` is markup,
15831626
// so only it HTML-escapes its `{{x}}` holes. The subject (a mail header,
15841627
// an inbox title) and `body_text` (the plain-text part, an inbox body) are
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* # The label table a mail template's `option` holes read
5+
*
6+
* `{{ stage | option:opportunity.stage }}` prints a select field's stored
7+
* value as its option's label, in the RECIPIENT's language (ruling F on the
8+
* option-label card, parameter 3). `@objectstack/formula`'s `option`
9+
* formatter only reads a table it is handed (`OptionLabelTable`, keyed by the
10+
* hole's `object.field`); this module is where the mail renderer builds that
11+
* table, once per send, before the synchronous render of its three faces.
12+
*
13+
* ## One translation path
14+
*
15+
* The labels are the ones the object's metadata is served with in the
16+
* recipient's locale: the SERVED object's declared `options` (a
17+
* picklist-bound field carries its resolved list there), with the translation
18+
* bundle laid over them by `translateObject` (`@objectstack/spec/system`) —
19+
* the function behind `GET /meta/object/:name`, built from the i18n service
20+
* with the same locale options. It is the path `@objectstack/objectql`'s
21+
* `readerOptionLabels` (`formula-option-labels.ts`) gives a formula's
22+
* `label()`, step for step; that module is not reachable from here
23+
* (`@objectstack/objectql` is only this package's dev dependency), so the
24+
* steps are taken through the same two published functions, `translateObject`
25+
* and `declaresOptions`, and `option-label-table.test.ts` holds the two
26+
* answers equal. ⛔ No translation key is spelled here.
27+
*
28+
* ## Which locale
29+
*
30+
* The send's own `locale` — the recipient's, which the messaging channels
31+
* resolve per recipient and hand to `sendTemplate` / `renderTemplate`. Never an
32+
* execution context's: a scheduled flow runs as the system, with no reader,
33+
* and still mails each recipient in their own language. A send that names no
34+
* locale gets the DECLARED labels.
35+
*
36+
* ## Refusals
37+
*
38+
* Judged against the deployment's served objects with
39+
* `judgeMailHoleGrammar(face, { objects, liveUniverse: true })`, the sentences
40+
* the authoring doors show: an object no one serves, a field the object does
41+
* not have, a field with no options — and, decided here, a field whose served
42+
* option list is EMPTY, which has no option to label a value with either.
43+
* Each is `TEMPLATE_INVALID` and refuses the whole send, before any face is
44+
* rendered.
45+
*/
46+
47+
import { declaresOptions, judgeMailHoleGrammar, mailHolePattern, parseMailHole } from '@objectstack/formula';
48+
import type { OptionLabelTable } from '@objectstack/formula';
49+
import { translateObject, type ObjectFieldLike, type TranslationBundle } from '@objectstack/spec/system';
50+
51+
/** The slice of `II18nService` the labels are translated through — what the metadata route reads. */
52+
export interface OptionLabelI18n {
53+
getLocales?: () => string[];
54+
getTranslations?: (locale: string) => Record<string, unknown>;
55+
getFallbackLocale?: () => string | undefined;
56+
getDefaultLocale?: () => string | undefined;
57+
}
58+
59+
/**
60+
* Where a send's option labels come from, resolved at each send (a service
61+
* registered after the email plugin is seen): the served object by name
62+
* (`IDataEngine.getObject`, the registry's answer — what
63+
* `GET /meta/object/:name` serves before translation) and the i18n service.
64+
*/
65+
export interface OptionLabelSources {
66+
getObject: (name: string) => unknown;
67+
i18n?: OptionLabelI18n;
68+
}
69+
70+
/** The `object.field` keys the `option` holes of `faces` name, in first-seen order. */
71+
export function optionHoleKeys(faces: ReadonlyArray<string | null | undefined>): Array<{ key: string; object: string; field: string }> {
72+
const seen = new Map<string, { key: string; object: string; field: string }>();
73+
for (const face of faces) {
74+
if (typeof face !== 'string' || face === '') continue;
75+
for (const m of face.matchAll(mailHolePattern())) {
76+
const ref = parseMailHole(m[2], m[0])?.optionField;
77+
if (!ref) continue;
78+
const key = `${ref.object}.${ref.field}`;
79+
if (!seen.has(key)) seen.set(key, { key, object: ref.object, field: ref.field });
80+
}
81+
}
82+
return [...seen.values()];
83+
}
84+
85+
/** The fields of a served object, by name, when it carries a fields map. */
86+
function fieldsOf(schema: unknown): Record<string, ObjectFieldLike> | undefined {
87+
const fields = (schema as { fields?: unknown } | undefined)?.fields;
88+
if (!fields || typeof fields !== 'object' || Array.isArray(fields)) return undefined;
89+
return fields as Record<string, ObjectFieldLike>;
90+
}
91+
92+
/** Stored value (as text) → label, read off one field's option list. */
93+
function labelsOf(def: ObjectFieldLike | undefined): Record<string, string> {
94+
const labels: Record<string, string> = {};
95+
for (const option of Array.isArray(def?.options) ? def.options : []) {
96+
if (!option || typeof option !== 'object' || option.value === undefined || option.value === null) continue;
97+
if (typeof option.label === 'string' && option.label.length > 0) labels[String(option.value)] = option.label;
98+
}
99+
return labels;
100+
}
101+
102+
/**
103+
* The bundle the metadata route translates with — every locale the service
104+
* holds, keyed by locale — or `undefined` when the service holds none.
105+
*/
106+
function bundleOf(i18n: OptionLabelI18n): TranslationBundle | undefined {
107+
if (typeof i18n.getLocales !== 'function' || typeof i18n.getTranslations !== 'function') return undefined;
108+
const bundle: Record<string, unknown> = {};
109+
for (const locale of i18n.getLocales()) {
110+
const data = i18n.getTranslations(locale);
111+
if (data && typeof data === 'object') bundle[locale] = data;
112+
}
113+
return Object.keys(bundle).length > 0 ? (bundle as TranslationBundle) : undefined;
114+
}
115+
116+
/**
117+
* The option-declaring fields of `schema`, translated into `locale` as the
118+
* object is served in it. No locale, no i18n service, or one holding no
119+
* translations: the declared fields. A misbehaving service never fails the
120+
* send — the declared labels stand, as `readerOptionLabels` answers.
121+
*/
122+
function servedOptionFields(
123+
name: string,
124+
optionFields: Record<string, ObjectFieldLike>,
125+
locale: string | undefined,
126+
i18n: OptionLabelI18n | undefined,
127+
): Record<string, ObjectFieldLike> {
128+
if (!locale || !i18n || Object.keys(optionFields).length === 0) return optionFields;
129+
try {
130+
const bundle = bundleOf(i18n);
131+
if (!bundle) return optionFields;
132+
const fallback = typeof i18n.getFallbackLocale === 'function' ? i18n.getFallbackLocale() : undefined;
133+
const defaultLocale = typeof i18n.getDefaultLocale === 'function' ? i18n.getDefaultLocale() : undefined;
134+
const translated = translateObject({ name, fields: optionFields }, bundle, {
135+
locale,
136+
...(typeof fallback === 'string' && fallback.length > 0 ? { fallbackChain: [fallback] } : {}),
137+
...(typeof defaultLocale === 'string' && defaultLocale.length > 0 ? { defaultLocale } : {}),
138+
});
139+
const fields = fieldsOf(translated);
140+
return fields ?? optionFields;
141+
} catch {
142+
return optionFields;
143+
}
144+
}
145+
146+
/**
147+
* The label table the `option` holes of `faces` read, in `locale` (the
148+
* recipient's; `undefined` ⇒ the declared labels), or `undefined` when no face
149+
* has an `option` hole. Throws `TEMPLATE_INVALID: …` when a hole names an
150+
* object the deployment does not serve, a field that object does not have, or
151+
* a field with no options (none declared, or an empty list).
152+
*/
153+
export function buildOptionLabelTable(
154+
faces: ReadonlyArray<string | null | undefined>,
155+
locale: string | undefined,
156+
sources: OptionLabelSources,
157+
): OptionLabelTable | undefined {
158+
const keys = optionHoleKeys(faces);
159+
if (keys.length === 0) return undefined;
160+
161+
// The served object of every name a hole gives, judged as the live universe:
162+
// a name nothing serves is unknown here, whatever its prefix.
163+
const schemas = new Map<string, { name: string; fields: Record<string, ObjectFieldLike> }>();
164+
const objects = new Map<string, ReadonlyMap<string, boolean>>();
165+
for (const { object } of keys) {
166+
if (objects.has(object)) continue;
167+
const schema = sources.getObject(object);
168+
const fields = schema === undefined || schema === null ? undefined : fieldsOf(schema);
169+
if (!fields) continue;
170+
const servedName = (schema as { name?: unknown }).name;
171+
schemas.set(object, { name: typeof servedName === 'string' && servedName ? servedName : object, fields });
172+
// An option list with no option in it labels nothing: judged as a field
173+
// with no options, so the send is refused rather than print every value
174+
// as stored under a formatter that promises a label.
175+
objects.set(object, new Map(Object.entries(fields).map(([field, def]) => [
176+
field,
177+
declaresOptions(def) && Object.keys(labelsOf(def)).length > 0,
178+
])));
179+
}
180+
for (const face of faces) {
181+
if (typeof face !== 'string' || face === '') continue;
182+
const [finding] = judgeMailHoleGrammar(face, { objects, liveUniverse: true });
183+
if (finding) throw new Error(`TEMPLATE_INVALID: ${finding.refusal.message}`);
184+
}
185+
186+
const table: Record<string, Record<string, string>> = {};
187+
const translatedBy = new Map<string, Record<string, ObjectFieldLike>>();
188+
for (const { key, object, field } of keys) {
189+
const schema = schemas.get(object)!;
190+
let served = translatedBy.get(object);
191+
if (!served) {
192+
const optionFields: Record<string, ObjectFieldLike> = {};
193+
for (const [name, def] of Object.entries(schema.fields)) if (declaresOptions(def)) optionFields[name] = def;
194+
served = servedOptionFields(schema.name, optionFields, locale, sources.i18n);
195+
translatedBy.set(object, served);
196+
}
197+
table[key] = labelsOf(served[field]);
198+
}
199+
return table;
200+
}

0 commit comments

Comments
 (0)