Skip to content

Commit 2205863

Browse files
feat(spec, formula, plugin-email, lint): a mail template formats an amount in the record's own currency with currency:@path (#26937)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwB2LLTurKiujTuz772H4neEX)_ Fixes #26660 Item (c) of the card landed in #26799. This PR lands item (b): a mail template formats an amount in the record's own currency, with the code read from the data. Clause-②: yes Metadata-protocol: added Skills-docs: follow-up #26940 ADR-0087: not-required — nothing stored changes meaning: `currency:@path` was refused at save and at render until now, so no stored template or built artifact carries it, and every form accepted before renders exactly as before. ## 维护者速读 - **改了什么:** 邮件模板新增 `{{ 金额 | currency:@currency_code }}` 写法:币种代码从发送数据里读,一个模板就能按每条记录自己的币种显示金额;站内模板(流程通知、按钮文案)对这个写法按名拒收。 - **为什么改:** hotclm 这类按合同发提醒的应用,金额币种各不相同;此前只能写死一个币种或只显示裸数字。 - **风险与回滚:** 只放宽了一个以前被拒的写法,旧写法与旧拒收全部不变;数据里没有合法代码(缺失、null、小写)时渲染报 `TEMPLATE_INVALID`,绝不用美元顶替。回滚即 revert 本 PR。 - **先看:** `packages/formula/src/template-engine.ts`(`@` 标记与方言判定)、`packages/plugins/plugin-email/src/template-engine.ts`(取值与拒收)、`packages/formula/src/mail-hole-grammar.ts`(声明变量检查)。 - **两分钟验证:** `pnpm --filter @objectstack/plugin-email exec vitest run src/template-currency-code-path.render.test.ts`,预期 12 个测试全过:EUR/CNY 两条记录在 en-US、zh-CN 两个语言行各自按本币显示,三种坏代码均报 `TEMPLATE_INVALID`。 - **需要你做的:** 本 PR 改了 `skills/objectstack-formula/SKILL.md`(受管面 Tier H),需要维护者批准后由席位落地。 ## What changes **The rule, in one sentence** (`CURRENCY_ARGUMENT_RULE` in `@objectstack/formula`; the spec's `email_template` face descriptions, the lint rule's hint and every renderer refusal quote it): A `currency` argument is either a literal ISO 4217 code in three capital letters (`currency:EUR`) or, in a mail template only, `@` followed by a path into the send's data whose value is such a code (`currency:@currency_code`). The `@` mark keeps the path form apart from the literal for every input: a literal code never starts with `@`, and no hole path does. So every shape refused today stays refused, including the #26799 pins `currency:currency_code` and `currency:record.currency_code`. - **`@objectstack/formula`** (minor) - `currencyCodePath(formatterPart)` reads the marked form: `@` then a dotted path of word characters with no empty segment, unquoted, nothing between the mark and the path. - `judgeHoleFormatter` takes an optional dialect (`'in-app'` by default, `'mail'`). The mail dialect admits the marked form. The in-app dialect refuses it by name with the new rule `currency-code-path-mail-only` at compile and evaluate, so flow text slots, action and bulk-action copy, `titleFormat` and `promptTemplate` all refuse it (they refused it before as `currency-code-literal`). - `parseMailHole` carries the path as `codePath`. `judgeMailHoleGrammar(face, { variables })` refuses a marked path no declared variable names (itself, or a root it lies under), when the template declares any: rule `currency-code-path-undeclared`. - `currencyCodeValueRefusal(hole, path, value)` names a resolved value that is not an ISO 4217 code in three capitals exactly as stored. - `formatValue('currency', …)` is unchanged: it never reads a path, throws for a non-code argument, and formats in USD with no argument. - **`@objectstack/plugin-email`** (minor): the renderer resolves the path against the send's data and formats in that code, in all three faces, double- or triple-braced. A missing, `null`, lowercase or other value throws `TEMPLATE_INVALID: email template NAME (locale=L): …`, naming the hole, the path and the value; nothing is sent. `email-service.ts` changes a comment only: its existing wrap already prefixes the template and locale. - **`@objectstack/lint`** (minor): `email-template-hole-grammar` passes the template's declared `variables` to the judge, and its hint quotes the rule. `os validate`, `os build`, `os lint` and the `email_template` publish door accept a well-formed marked path and refuse an undeclared one (`422 INVALID_METADATA` at the door). - **`@objectstack/spec`** (minor, descriptions only): `EmailTemplateDefinitionSchema` `subject`, `bodyHtml` and `bodyText` state the mail hole grammar (at most one formatter per hole, a second chained formatter refused) and the rule; `variables[].name` states the declared-path check. The descriptions promise no refusal of an unknown formatter name, because none exists today (see Acceptance notes). Reference docs regenerated (`content/docs/references/system/email-template.mdx`). - **Docs and skill:** `content/docs/automation/email-templates.mdx` teaches the form, the render refusal, the save-time checks and the in-app refusal. `skills/objectstack-formula/SKILL.md` gains two lines (below). **Which dialects:** the mail dialect accepts `currency:@path`. The in-app dialect refuses it by name, because the doors that judge in-app text (`closedScopeTemplateRefusal`, the lint flow-path rules) check a hole's path against the roots a slot binds and never read an argument; accepting it there would let an argument's root reach the page unjudged. The three in-app spec describes (`flow-text-slot-template.ts`, `action-copy-template.ts`, `bulk-action.zod.ts`) stay true as written (they spell `currency:EUR` and `formatter[:arg]`), so they are unchanged. ## Why Measured at `6090bb73` (post-#26799) through `EmailService.renderTemplate` and the lint rule: `{{ planned_amount | currency:currency_code }}` is refused at save (1 finding, `email-template-hole-grammar`) and at render (`TEMPLATE_INVALID … gives the currency formatter the argument 'currency_code'`) for both `currency_code: 'eur'` and `'EUR'`. So no mail template could show an amount in the record's own currency. Data roots: a mail template's holes read the send's `data` with bare roots (`planned_amount`, `contract.name`); the in-app engine's roots are `record`, `previous`, `input`, `user` / `current_user` / `ctx`, `os` plus each slot's `extra` (flow variables; action `record` / `params` / `result`; bulk `count` and params). A root-spelled argument (`currency:record.currency_code`) has no meaning in mail and is refused by an existing pin, and a bare path is ambiguous against the literal; a marked form fits both dialects. ## Tests - `packages/formula/src/template-currency-code-path.test.ts` (new, 38): path reading (3 accepted, 10 rejected shapes); mail dialect admits and parses; malformed marks refused as `currency-code-literal` quoting the rule; declared-variable check (refused, exact, root, none declared, literal not judged); value refusal for missing / null / `eur` / `' EUR'` / number / object; in-app compile, `validateExpression` (`invalid-template`) and evaluate refuse by name; the formatter never reads a path; the spec's three face descriptions and the variable name description quote the rule (parity pin against the built spec). - `packages/plugins/plugin-email/src/template-currency-code-path.render.test.ts` (new, 12): one bundle with `en-US` and `zh-CN` rows, records in `EUR` and `CNY`: `€1,234.50` / `CN¥1,234.50` (en-US) and `€1,234.50` / `¥1,234.50` (zh-CN) in every face; `sendTemplate` delivers two records in their own currencies; an unresolvable path, a lowercase code and a null code are each refused in both locales with the `TEMPLATE_INVALID: email template contract.overdue (locale=L)` prefix, the hole and the value, no `$` in the message, nothing sent; the literal form and the bare-path refusal unchanged. - `packages/lint/src/validate-email-template-hole-grammar.currency-path.test.ts` (new, 13): accepted with no variables and with declared path or root; undeclared refused per face; malformed marks refused; hint quotes the rule; each `os` command; the runtime publish gate accepts and refuses. - `packages/metadata-protocol/src/runtime-authoring-gate.email-template-writes.test.ts` (+1): through `evaluateRuntimeAuthoringGate`, an undeclared marked path is refused with `code` `INVALID_METADATA`, `status` 422 and the issue; the declared path and a no-variables template publish. - The #26799 pins (`template-hole-grammar.test.ts`, `template-hole-grammar.render.test.ts`, `validate-email-template-hole-grammar.test.ts`, the door test's existing cases) are unchanged and green. ## Acceptance notes - **`currency` with no argument still formats in USD** (not changed here, per the dispatch). Census at `6090bb73` of a no-argument `| currency` hole, generated files, CHANGELOGs and release notes excluded: examples 0, seeds 0, skills 0, apps 0; docs 2 (`content/docs/automation/flows.mdx:472`, `content/docs/data-modeling/formulas.mdx:79`, both in-app); spec describes and comments 4 (`flow-text-slot-template.ts:23`, `io-node-config.zod.ts:161`, `:632`, `:686`); service-automation 2 (`builtin/notify-node.ts:810`, `builtin/template.ts:104`); refusal and migration prose that spells it as an example 2 (`formula/src/template-engine.ts`, the invalid-hole message; `spec/src/migrations/entries/semantic/18.flow-text-slot-single-brace-refused.ts:24`); tests pinning the USD output 4 (`formula/src/template-formatters.test.ts:19`, `formula/src/template-hole-grammar.test.ts:114`, `plugin-email/src/template-engine.test.ts:79`, `plugin-email/src/template-hole-grammar.render.test.ts:110`), plus fixtures that only carry the hole (`lint/src/validate-email-template-hole-grammar.test.ts:28`, `lint/src/validate-expressions.text-slot.test.ts:122`, `ui/packages/app-shell/.../flowTextSlots.doubleBrace-11824.test.tsx:122`). No shipped template relies on it. Seat ruling: a separate narrowing card refuses a no-argument `currency` at authoring, filed as #26939 with this census. - **Eval owed, filed as #26940** (not in this PR): `skills/objectstack-formula/evals/cel-predicates-formulas.json` should ask for an `email_template` that shows an overdue amount in each contract's own currency, and expect `{{ amount | currency:@currency_code }}` (not a bare path, not a hard-coded code), the code stored in capitals, and the path declared when the template declares `variables`. - **An unknown mail formatter is refused nowhere today:** `{{ x | nosuch }}` renders raw (`plugin-email/src/template-engine.ts`). Not fixed here; filed as #26948. The spec descriptions were reworded in this PR so they do not promise that refusal. - **In-app reading of a code from data** is not built: it would need the closed-scope root judges to read an argument's path. Noted, not filed. - **Skills, governed:** `skills/objectstack-formula/SKILL.md` makes this PR Tier H (maintainer approval). File 471 → 473 lines, 5627 → 5668 tokens (ceiling 5997, headroom 329 after); the shipped SKILL.md set 4606 → 4608 lines; bundle total 213518 → 213559 tokens. The two lines are the only place a skill teaches a `currency` argument. ## Verification <details><summary>Suites and typecheck at 511e09b (the head: d1fffa1 plus a merge of origin/main 5ce3404)</summary> Main moved `packages/spec`, `packages/lint` and `packages/metadata-protocol` (no file this PR touches), so after the merge (`scripts/pm/os-regen-merge.sh`, no conflict) the tree was rebuilt (`pnpm build`), `check:generated` reported every spec artifact current, and the six suites ran again. Under `os-verify-lock`, one `&&` chain, `VERDICT command-exit 0`: - `@objectstack/spec`: Test Files 738 passed (738), Tests 22101 passed, 1 todo - `@objectstack/formula`: 57 passed (57), 1522 passed - `@objectstack/plugin-email`: 41 passed (41), 724 passed - `@objectstack/lint`: 170 passed (170), 7483 passed - `@objectstack/metadata-protocol`: 273 passed, 4 skipped (277); 21694 passed, 26 skipped - `@objectstack/service-messaging`: 61 passed (61), 734 passed - `typecheck` for all six: exit 0. At d1fffa1 (before the merge) the same six were green too (spec 736 files / 22050 tests, lint 7479, metadata-protocol 271 files / 21669), and `pnpm exec eslint --no-inline-config` on the 11 changed `.ts` files exited 0. </details> <details><summary>Ablations (each restored to the HEAD blob, git diff HEAD empty)</summary> Run with `scripts/ablation-replace.mjs`; every mutation landed (anchor 1 → 0, blob changed) and was restored by blob hash. - Renderer value guard dropped (`plugin-email/src/template-engine.ts`, the suite reads source): 4 failed | 8 passed, exactly the refusal pins. The unresolvable-path case then formatted in USD, the defect the guard exists to stop. - In-app dialect check disabled (`formula/src/template-engine.ts`): 2 failed | 36 passed, the two in-app refusal pins. - Declared-variable check disabled (`formula/src/mail-hole-grammar.ts`): 1 failed | 37 passed, the undeclared pin. Formula's own suite reads source; the lint and door legs read formula's `dist` and were not ablated. </details> <details><summary>Reproduction on main</summary> At `6090bb73`: render with `currency_code: 'eur'` and `'EUR'` → `TEMPLATE_INVALID: email template contract.overdue (locale=en-US): the hole {{ planned_amount | currency:currency_code }} gives the currency formatter the argument …`; `validateEmailTemplateHoleGrammar` → 1 finding, `email-template-hole-grammar`. </details> <details><summary>Gate families</summary> - Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths) at 511e09b: 114 commands, run serially, exit codes captured before any pipe: **114 exit 0**. `--ran` reconciliation: "114 derived famil(ies) accounted for — 114 run, 0 NOT-MEASURED". - At d1fffa1 the same 114 ran: 112 exit 0, and `pnpm --filter @objectstack/spec run check:skill-examples` and `pnpm check:dual-build-cjs-loads` exited 3 (unbuilt packages); after `pnpm build` both exited 0 (267 prose examples type-check). - Extras at d1fffa1: `pnpm check:dev-prereqs` exit 0, `pnpm check:skill-frame-freshness` exit 0, `node scripts/check-platform-checklist.mjs --changed-from origin/main` exit 0, `pnpm check:error-code-casing` exit 0. `node scripts/check-skills-token-ratchet.mjs` exit 0 (the formula skill at 5668 of 5997). - Not run locally: the full dogfood battery (no `!` in the title, no `(narrowing)` arm); CI's required contexts run the rest. </details> <details><summary>Review round at 49258de (contract review 6455368454: S1, S2)</summary> One commit on top of 511e09b, text only: the `email_template` face descriptions now say "at most one formatter per hole (a second, chained formatter is refused)" instead of naming a formatter whitelist no door enforces (S2; the unknown-formatter gap is #26948), and the `email-template-hole-grammar` comment in `packages/lint/src/authoring-rules.ts` names the marked `@path` form and the undeclared-path check instead of a literal-only rule (S1). Reference docs regenerated (`check:generated --fix`, exit 0). - `pnpm build` under `os-verify-lock`: `VERDICT command-exit 0`, 116 of 116 tasks. - The six suites and their typechecks under `os-verify-lock`, one loop that exits on the first failure (`|| exit 1`), exit 0: spec 738 files / 22101 passed, 1 todo; formula 57 / 1522; plugin-email 41 / 724; lint 170 / 7483; metadata-protocol 273 passed, 4 skipped / 21694 passed, 26 skipped; service-messaging 61 / 734. - `dispatch-gates --commands` re-derived at 49258de: 114 commands, **114 exit 0**; `--ran`: "114 derived famil(ies) accounted for — 114 run, 0 NOT-MEASURED". `node scripts/check-skills-token-ratchet.mjs` exit 0. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufziJbRgRHSN9KrbF46h --- _Generated by [Claude Code](https://claude.ai/code/session_01WGufziJbRgRHSN9KrbF46h)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 12b5aec commit 2205863

19 files changed

Lines changed: 785 additions & 53 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/formula': minor
3+
---
4+
5+
feat(formula): the mail hole grammar admits `currency:@path`, which reads the ISO 4217 code from the send's data; the in-app template dialect refuses that form by name
6+
7+
Clause-②: yes
8+
Metadata-protocol: added
9+
10+
A mail template can now format each record's amount in that record's own currency: `{{ planned_amount | currency:@currency_code }}`. The rule, exported as `CURRENCY_ARGUMENT_RULE`: a `currency` argument is either a literal ISO 4217 code in three capital letters (`currency:EUR`) or, in a mail template only, `@` followed by a path into the send's data whose value is such a code (`currency:@currency_code`).
11+
12+
- **Widened:** `parseMailHole` and `judgeMailHoleGrammar` admit `currency:@path` when the path is a dotted path of word characters with no empty segment, written right after the `@` and not in quotes. The parsed hole carries the path as `codePath`.
13+
- **Unchanged refusals:** a bare argument is still never read as a path, so `currency:currency_code`, `currency:record.currency_code`, a lowercase or empty code and a double-quoted code stay refused (`currency-code-literal`); so do `currency:@`, `currency:@ x`, `currency:'@x'` and `currency:@a..b`. In the mail dialect that refusal now names the marked form as the way to read a code from the data.
14+
- **Declared variables:** `judgeMailHoleGrammar(face, { variables })` refuses a marked path that none of the template's declared variables names, itself or as a root it lies under (`currency-code-path-undeclared`), when the template declares any.
15+
- **In-app dialect:** `templateEngine` (flow text slots, action and bulk-action copy, `titleFormat`, `promptTemplate`) refuses `currency:@path` at compile and evaluate with its own rule, `currency-code-path-mail-only`: the doors that judge that text check a hole's path against the roots a slot binds and never read an argument. It was refused there before too, as `currency-code-literal`.
16+
- **The value:** `currencyCodeValueRefusal(hole, path, value)` names a resolved value that is not an ISO 4217 code in three capital letters exactly as stored (missing, `null`, `eur`, `' EUR'`, a number). `formatValue('currency', …)` still never reads a path and still throws for a non-code argument; no argument still formats in US dollars.
17+
18+
New exports: `CURRENCY_ARGUMENT_RULE`, `currencyCodePath`, `currencyCodeValueRefusal`, and the types `TemplateHoleDialect` and `MailHoleGrammarOptions`. `judgeHoleFormatter` takes an optional fifth argument, the dialect (`'in-app'` by default, `'mail'`), and `TemplateHoleGrammarRule` gains `currency-code-path-mail-only` and `currency-code-path-undeclared`.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/lint': minor
3+
---
4+
5+
feat(lint): `email-template-hole-grammar` accepts `currency:@path` and refuses a marked path the template's declared variables do not declare
6+
7+
Clause-②: yes
8+
Metadata-protocol: added
9+
10+
`os validate`, `os build`, `os lint` and the `email_template` publish door accept a mail template hole such as `{{ planned_amount | currency:@currency_code }}`, which reads the ISO 4217 code from the send's data, when its path is well-formed. When the template declares `variables`, a marked path that none of them declares, itself or as a root it lies under (`contract` declares `contract.currency_code`), is refused at `error` (a `422 INVALID_METADATA` at the publish door), naming the hole and the path. A template that declares no variables has the path judged at render.
11+
12+
Everything the rule refused before stays refused: a bare path (`currency:currency_code`), a lowercase or empty code, a quoted or empty marked path, and a second formatter. The finding's hint now quotes the argument rule, `CURRENCY_ARGUMENT_RULE` from `@objectstack/formula`.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/plugin-email': minor
3+
---
4+
5+
feat(plugin-email): a mail template formats an amount in the record's own currency with `currency:@path`
6+
7+
Clause-②: yes
8+
Metadata-protocol: added
9+
10+
`{{ planned_amount | currency:@currency_code }}` formats the amount in the currency whose ISO 4217 code the send's `data` holds at `currency_code`, so one template, in every locale row of its bundle, serves records in many currencies. With `currency_code: 'EUR'` it renders `€1,234.50` in `en-US`; with `'CNY'` it renders `CN¥1,234.50` in `en-US` and `¥1,234.50` in `zh-CN`. The path is resolved like a hole's own path, in all three faces, double- or triple-braced.
11+
12+
The value at the path must be the code in three capital letters, as stored. A missing or `null` value, a lowercase code (`eur`) or any other value makes `renderTemplate` and `sendTemplate` throw `TEMPLATE_INVALID: email template NAME (locale=L): …`, naming the hole, the path and the value, and send nothing. The amount is never formatted in a default currency in its place.
13+
14+
Unchanged: a literal code (`currency:EUR`) formats in that code, no argument formats in US dollars, and a bare path (`currency:currency_code`) is still refused with `TEMPLATE_INVALID`.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
docs(spec): the `email_template` faces state the mail hole grammar, including `currency:@path`, which reads the ISO 4217 code from the send's data
6+
7+
Clause-②: yes
8+
Metadata-protocol: added
9+
10+
`EmailTemplateDefinitionSchema`'s `subject`, `bodyHtml` and `bodyText` descriptions now state the hole grammar a mail template is rendered and judged with: holes are `{{ path }}` or `{{ path | formatter[:arg] }}` over the send's `data`, with one formatter per hole, and a `currency` argument is either a literal ISO 4217 code in three capital letters (`currency:EUR`) or, in a mail template only, `@` followed by a path into the send's data whose value is such a code (`currency:@currency_code`). The `variables[].name` description adds that, when a template declares any variable, the path of a `currency:@path` argument must be one of them or lie under one.
11+
12+
Only descriptions change; no key, type or default does. The form itself is judged by `@objectstack/formula`, refused or accepted at authoring by `@objectstack/lint` and rendered by `@objectstack/plugin-email`.

‎content/docs/automation/email-templates.mdx‎

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -88,14 +88,37 @@ never needs triple braces.
8888
chaining. Decide the value in the caller that supplies the data — a default, a
8989
second format — and pass the finished value as its own variable.
9090

91-
**The `currency` argument is a literal code.** `currency:EUR` formats in euros,
92-
and `currency` with no argument formats in US dollars. The argument is an ISO
93-
4217 code in three capital letters, never a path: `currency:currency_code` does
94-
not read the record's `currency_code`. `os validate`, `os build`, `os lint` and
95-
the metadata publish door refuse any other argument (`email-template-hole-grammar`,
96-
a `422 INVALID_METADATA` at the publish door), naming the hole and the rule. To
97-
show an amount in each record's own currency, format it in the caller that
98-
supplies the data and pass the formatted text as its own variable.
91+
**The `currency` argument is a literal code or a marked path.** A `currency`
92+
argument is either a literal ISO 4217 code in three capital letters
93+
(`currency:EUR`) or, in a mail template only, `@` followed by a path into the
94+
send's data whose value is such a code (`currency:@currency_code`). `currency`
95+
with no argument formats in US dollars.
96+
97+
The marked path is how one template shows each record's amount in that record's
98+
own currency:
99+
100+
```ts
101+
subject: 'Instalment overdue: {{ planned_amount | currency:@currency_code }}',
102+
```
103+
104+
With `data` holding `planned_amount: 1234.5` and `currency_code: 'EUR'` it renders
105+
`€1,234.50` in `en-US`; with `currency_code: 'CNY'` it renders `CN¥1,234.50`. The
106+
value at the path must be the code in capitals, as stored: a missing or `null`
107+
value, a lowercase code (`eur`) or anything else makes the render fail with
108+
`TEMPLATE_INVALID`, naming the template, its locale, the hole and the value. The
109+
amount is never formatted in a default currency in its place.
110+
111+
A bare argument is never read as a path: `currency:currency_code` is refused,
112+
and so are a lowercase or empty code, a quoted path and an `@` with no path
113+
after it. `os validate`, `os build`, `os lint` and the metadata publish door
114+
refuse these (`email-template-hole-grammar`, a `422 INVALID_METADATA` at the
115+
publish door), naming the hole and the rule. When the template declares
116+
`variables`, the same doors also refuse a marked path that none of them
117+
declares (a variable `contract` declares `contract.currency_code`).
118+
119+
The `@` form belongs to mail templates. In-app template text (flow
120+
notifications, action and bulk-action copy) takes a literal code only and
121+
refuses `currency:@path` by name.
99122

100123
**Where a hole may sit in `bodyHtml`.** The renderer's escaping is right for
101124
element text and for a quoted attribute value that holds plain text, so those

‎content/docs/references/system/email-template.mdx‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,9 @@ const result = EmailTemplateDefinitionSchema.parse(data);
4949
| **label** | `string` | ✅ | Display label |
5050
| **category** | `never` | optional | [REMOVED] `email_template.category` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — nothing read it: the Studio listing never offered the filter facet it promised, and delivery never depended on it. Delete the key. A template is found by its `name`, so group templates with a dotted namespace prefix instead (`auth.password_reset`, `crm.welcome`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
5151
| **locale** | `string` | optional (default: `"en-US"`) | BCP-47 locale (e.g. en-US, zh-CN) — the bundle key the resolver matches EXACTLY. A call that NAMES a locale gets exactly one retry rung, the literal `en-US`, with no language-subtag folding: `en` and `en-US` are different ROWS of one bundle and neither reaches the other, so a bundle with no `en-US` row has no fallback floor for those calls and every recipient locale it does not carry a row for raises TEMPLATE_NOT_FOUND, which is permanent — the delivery dead-letters with no retry. A call naming NO locale is the other case and does not dead-letter: it drops to the bundle's lowest locale tag and renders that silently. Your stack's own `i18n.defaultLocale` is the wrong tag here unless it is spelled `en-US`. |
52-
| **subject** | `string` | ✅ | Subject template |
53-
| **bodyHtml** | `string` | ✅ | HTML body template |
54-
| **bodyText** | `string` | optional | Plain-text body template (auto-derived from HTML when omitted) |
52+
| **subject** | `string` | ✅ | Subject template. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over the send's `data`, with at most one formatter per hole (a second, chained formatter is refused). A `currency` argument is either a literal ISO 4217 code in three capital letters (`currency:EUR`) or, in a mail template only, `@` followed by a path into the send's data whose value is such a code (`currency:@currency_code`). |
53+
| **bodyHtml** | `string` | ✅ | HTML body template. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over the send's `data`, with at most one formatter per hole (a second, chained formatter is refused). A `currency` argument is either a literal ISO 4217 code in three capital letters (`currency:EUR`) or, in a mail template only, `@` followed by a path into the send's data whose value is such a code (`currency:@currency_code`). |
54+
| **bodyText** | `string` | optional | Plain-text body template (auto-derived from HTML when omitted). Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over the send's `data`, with at most one formatter per hole (a second, chained formatter is refused). A `currency` argument is either a literal ISO 4217 code in three capital letters (`currency:EUR`) or, in a mail template only, `@` followed by a path into the send's data whose value is such a code (`currency:@currency_code`). |
5555
| **variables** | `{ name: string; type?: Enum<'string' \| 'number' \| 'boolean' \| 'date' \| 'url' \| 'user' \| 'record'>; required?: boolean; description?: string }[]` | optional (default: `[]`) | |
5656
| **fromOverride** | `{ name?: string; address: string }` | optional | |
5757
| **replyTo** | `string` | optional | |
@@ -71,7 +71,7 @@ const result = EmailTemplateDefinitionSchema.parse(data);
7171

7272
| Property | Type | Required | Description |
7373
| :--- | :--- | :--- | :--- |
74-
| **name** | `string` | ✅ | Variable name as referenced in placeholders (snake_case or dotted path) |
74+
| **name** | `string` | ✅ | Variable name as referenced in placeholders (snake_case or dotted path). When the template declares any variable, the path of a `currency:@path` argument must be one of them or lie under one. |
7575
| **type** | `Enum<'string' \| 'number' \| 'boolean' \| 'date' \| 'url' \| 'user' \| 'record'>` | optional (default: `"string"`) | |
7676
| **required** | `boolean` | optional (default: `false`) | |
7777
| **description** | `string` | optional | Author hint shown in Studio |
@@ -93,7 +93,7 @@ const result = EmailTemplateDefinitionSchema.parse(data);
9393

9494
| Property | Type | Required | Description |
9595
| :--- | :--- | :--- | :--- |
96-
| **name** | `string` | ✅ | Variable name as referenced in placeholders (snake_case or dotted path) |
96+
| **name** | `string` | ✅ | Variable name as referenced in placeholders (snake_case or dotted path). When the template declares any variable, the path of a `currency:@path` argument must be one of them or lie under one. |
9797
| **type** | `Enum<'string' \| 'number' \| 'boolean' \| 'date' \| 'url' \| 'user' \| 'record'>` | optional (default: `"string"`) | |
9898
| **required** | `boolean` | optional (default: `false`) | |
9999
| **description** | `string` | optional | Author hint shown in Studio |

‎packages/formula/src/index.ts‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,20 @@ export type {
6363
ParseCelToAstOptions,
6464
} from './cel-engine';
6565
export { cronEngine } from './cron-engine';
66-
export { templateEngine, TEMPLATE_FORMATTERS, formatValue, judgeHoleFormatter } from './template-engine';
67-
export type { TemplateHoleGrammarRefusal, TemplateHoleGrammarRule } from './template-engine';
66+
export {
67+
templateEngine,
68+
TEMPLATE_FORMATTERS,
69+
formatValue,
70+
judgeHoleFormatter,
71+
CURRENCY_ARGUMENT_RULE,
72+
currencyCodePath,
73+
currencyCodeValueRefusal,
74+
} from './template-engine';
75+
export type { TemplateHoleDialect, TemplateHoleGrammarRefusal, TemplateHoleGrammarRule } from './template-engine';
6876
// The mail template hole grammar — one copy, read by `@objectstack/plugin-email`'s
6977
// renderer, the placement judge below and `@objectstack/lint`'s grammar rule.
7078
export { mailHolePattern, parseMailHole, judgeMailHoleGrammar } from './mail-hole-grammar';
71-
export type { ParsedMailHole, MailHoleGrammarFinding } from './mail-hole-grammar';
79+
export type { ParsedMailHole, MailHoleGrammarFinding, MailHoleGrammarOptions } from './mail-hole-grammar';
7280
// #25189 — where a hole may sit in a mail template's HTML face (ruling
7381
// 6335348115 on #25082, F1 A2 / F3 C2). Here because both readers already
7482
// depend on this package and on nothing else they share: `@objectstack/lint`'s

‎packages/formula/src/mail-hole-grammar.ts‎

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,19 @@
2424
* literal ISO 4217 code. A refused hole is a hole — it is found, located and
2525
* named — and the renderer refuses to render it rather than print a wrong
2626
* value or formatter syntax.
27+
*
28+
* One argument form is the mail dialect's own: `currency:@path` reads the
29+
* ISO 4217 code from the send's data (`CURRENCY_ARGUMENT_RULE`), so one
30+
* template formats each record's amount in that record's currency. The path
31+
* after the `@` is the same dotted path a hole names. Here it is parsed
32+
* ({@link ParsedMailHole}.`codePath`), checked against the template's declared
33+
* `variables` when it declares any ({@link judgeMailHoleGrammar}), and the
34+
* renderer resolves it and refuses a value that is not a code
35+
* (`currencyCodeValueRefusal`) — it never formats the amount in a default
36+
* currency in its place.
2737
*/
2838

29-
import { judgeHoleFormatter, type TemplateHoleGrammarRefusal } from './template-engine';
39+
import { CURRENCY_ARGUMENT_RULE, currencyCodePath, judgeHoleFormatter, type TemplateHoleGrammarRefusal } from './template-engine';
3040

3141
/** 1=open (`{{` or `{{{`), 2=brace-free inner body, 3=close (`}}` or `}}}`). */
3242
const MAIL_HOLE_SOURCE = String.raw`(\{\{\{?)([^{}]*)(\}\}\}?)`;
@@ -52,6 +62,12 @@ export interface ParsedMailHole {
5262
formatter?: string;
5363
/** The formatter's argument, quotes removed; absent when none was written. */
5464
arg?: string;
65+
/**
66+
* Set for `currency:@path`: the path into the send's data the currency code is
67+
* read from (`currency_code`). The renderer resolves it; `arg` keeps the text
68+
* as written (`@currency_code`).
69+
*/
70+
codePath?: string;
5571
/** Set when the grammar refuses the hole: it is not rendered, and authoring refuses it. */
5672
refusal?: TemplateHoleGrammarRefusal;
5773
}
@@ -76,20 +92,42 @@ export function parseMailHole(inner: string, hole = `{{${inner}}}`): ParsedMailH
7692
// renderer used to leave it on the page as written: a chained hole is
7793
// still a hole, refused by the one-formatter rule.
7894
const name = formatterPart.split(/[:|]/, 1)[0].trim();
79-
const refusal = judgeHoleFormatter(hole, formatterPart, name, undefined);
95+
const refusal = judgeHoleFormatter(hole, formatterPart, name, undefined, 'mail');
8096
return refusal ? { path, formatter: name, refusal } : null;
8197
}
8298
const formatter = m[1];
8399
const arg = m[2];
84-
const refusal = judgeHoleFormatter(hole, formatterPart, formatter, arg);
100+
const refusal = judgeHoleFormatter(hole, formatterPart, formatter, arg, 'mail');
101+
const codePath = refusal ? undefined : currencyCodePath(formatterPart);
85102
return {
86103
path,
87104
formatter,
88105
...(arg !== undefined ? { arg } : {}),
106+
...(codePath !== undefined ? { codePath } : {}),
89107
...(refusal ? { refusal } : {}),
90108
};
91109
}
92110

111+
/**
112+
* True when a `currency:@path` path is one the template declares: a declared
113+
* variable names the path itself, or a root it lies under (`contract`
114+
* declares `contract.currency_code`).
115+
*/
116+
function isDeclaredPath(path: string, variables: readonly string[]): boolean {
117+
return variables.some((name) => name === path || path.startsWith(`${name}.`));
118+
}
119+
120+
/** What {@link judgeMailHoleGrammar} judges a face against, beyond its own text. */
121+
export interface MailHoleGrammarOptions {
122+
/**
123+
* The names of the template's declared `variables`. When the template
124+
* declares any, a `currency:@path` hole whose path none of them declares is
125+
* refused (`currency-code-path-undeclared`). Absent or empty, the path is
126+
* judged at render, where a code that does not resolve is refused.
127+
*/
128+
variables?: readonly string[];
129+
}
130+
93131
/** One hole of a mail template face that the grammar refuses. */
94132
export interface MailHoleGrammarFinding {
95133
/** Offset of the hole's first brace in the face. */
@@ -100,12 +138,28 @@ export interface MailHoleGrammarFinding {
100138
}
101139

102140
/** Every hole in one mail template face (`subject`, `bodyText` or `bodyHtml`) the grammar refuses, in order. */
103-
export function judgeMailHoleGrammar(face: string): MailHoleGrammarFinding[] {
141+
export function judgeMailHoleGrammar(face: string, options: MailHoleGrammarOptions = {}): MailHoleGrammarFinding[] {
104142
if (typeof face !== 'string' || face === '') return [];
143+
const variables = options.variables ?? [];
105144
const findings: MailHoleGrammarFinding[] = [];
106145
for (const m of face.matchAll(mailHolePattern())) {
107146
const parsed = parseMailHole(m[2], m[0]);
108-
if (parsed?.refusal) findings.push({ start: m.index ?? 0, text: m[0], refusal: parsed.refusal });
147+
if (parsed?.refusal) {
148+
findings.push({ start: m.index ?? 0, text: m[0], refusal: parsed.refusal });
149+
} else if (parsed?.codePath !== undefined && variables.length > 0 && !isDeclaredPath(parsed.codePath, variables)) {
150+
findings.push({
151+
start: m.index ?? 0,
152+
text: m[0],
153+
refusal: {
154+
rule: 'currency-code-path-undeclared',
155+
message:
156+
`the hole ${m[0]} reads its currency code from \`${parsed.codePath}\`, which this template's `
157+
+ `\`variables\` do not declare (it declares ${variables.map((v) => `\`${v}\``).join(', ')}). `
158+
+ `${CURRENCY_ARGUMENT_RULE} Declare \`${parsed.codePath}\` beside them, or read the code from a path `
159+
+ 'they declare.',
160+
},
161+
});
162+
}
109163
}
110164
return findings;
111165
}

0 commit comments

Comments
 (0)