Skip to content

Commit 40320f1

Browse files
feat(formula, plugin-email, lint)!: a mail template hole whose formatter the whitelist does not name is refused by name, never rendered raw (#27217)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBPZ1A9daZqRh23Ponr9G4xe)_ Fixes #26948 Clause-②: no (narrowing) Metadata-protocol: narrowed Skills-docs: none — `skills/objectstack-formula/SKILL.md:444` already teaches the formatter whitelist as `TEMPLATE_FORMATTERS`, which is exactly the rule this PR enforces, and no `skills/**` file teaches or shows a mail hole whose formatter is outside it (grep over `skills/**` for `formatter`, `email template` and `emailTemplates`: none of the hits teaches a mail formatter name); the docs page `content/docs/automation/email-templates.mdx` gains the refusal here. ## 维护者速读 - 改了什么:邮件模板里写错或不存在的格式化器(如 `{{ amount | curency:EUR }}`)现在在 `os validate` / `os build` / `os lint` 和元数据发布门(`422 INVALID_METADATA`)被按名拒绝,并列出全部可用格式化器;已存的旧模板渲染时报 `TEMPLATE_INVALID`,不再发出原始值。 - 为什么改:原来邮件照常发出,金额处显示 `1234.5`、日期处显示原始时间戳,作者毫无提示;应用内模板早就拒绝这种写法,两种方言不一致。 - 风险与回滚:收窄(BREAKING),只影响拼错格式化器的模板;仓内出厂模板、示例、种子、技能零命中。回滚即 revert 本 PR。 - 先看:`packages/formula/src/template-engine.ts`(`judgeHoleFormatter` 新增的 `known-formatter` 规则)、`packages/plugins/plugin-email/src/template-engine.ts`(去掉原始值回退)、`content/docs/automation/email-templates.mdx`。 - 两分钟验证:`pnpm --filter @objectstack/lint exec vitest run src/validate-email-template-hole-grammar.unknown-formatter.test.ts`,预期 8/8 通过(三个面、三条命令、发布门各被拒)。 - 需要你做的:无,席位落地(须在 #26937 之后落地,届时合入 main)。 ## What changes - **`@objectstack/formula`, `judgeHoleFormatter`** (`template-engine.ts`): a third rule, `known-formatter`, judged after `one-formatter` and before the `currency` argument. A hole whose formatter is a single word that `TEMPLATE_FORMATTERS` does not hold is refused, and the sentence names the hole and lists every known formatter. `TemplateHoleGrammarRule` gains `'known-formatter'`. The check reads the exported list, so an inherited object key (`toString`, `constructor`) is not a formatter either. - **The mail grammar** (`mail-hole-grammar.ts`): no code change. `parseMailHole` already calls `judgeHoleFormatter`, so it now carries the refusal and `judgeMailHoleGrammar` reports the hole. Only its header names the new rule. - **`@objectstack/plugin-email` renderer** (`template-engine.ts`): the `// unknown formatter → raw` fallback is gone. A refused hole throws `TEMPLATE_INVALID` from the grammar as the other refusals do, and `EmailService` prefixes the template and locale. If `formatValue` ever answered `undefined` for a name the grammar passed, the render throws `TEMPLATE_INVALID` too, so a raw value is never printed in its place. - **`@objectstack/lint` `email-template-hole-grammar`**: no code change (it reads `judgeMailHoleGrammar`). It now refuses the hole at `os validate`, `os build`, `os lint` and the runtime publish gate. Only its header comment names the new case. - **The in-app dialect**: its own parse already refused an unknown formatter and keeps its own sentence. One side effect: an inherited object key (`{{ x | toString }}`) used to pass `!FORMATTERS[name]` and run as a formatter, printing `[object Object]`. Now the shared rule refuses it. - **Docs**: `content/docs/automation/email-templates.mdx` gains a paragraph on the rule. The `TEMPLATE_INVALID` TSDoc on `IEmailService.sendTemplate` / `renderTemplate` (`packages/spec/src/contracts/email-service.ts`) names the third hole it refuses. - **Changesets**: formula, plugin-email and lint `minor` (narrowed, `(narrowing)` arm, ADR-0087 `not-required (no-migration-prescription)`: a conversion cannot know which formatter was meant), spec `patch` (TSDoc only, `none`). `@objectstack/metadata-protocol` gets only a new test file, which is not in its published `files`, so it has no entry. ### Census (on `origin/main` 72629f3) I grepped every `{{ path | name` hole in the repo, excluding CHANGELOGs, release notes and generated translations. Then I checked each mail template carrier by hand: the showcase and app emails, `plugin-email` `auth-templates.ts`, the spec seeds, dogfood and downstream fixtures, the upgrade-harness SQL fixtures and `skills/**`. Examples 0, seeds 0, skills 0, apps 0. The only mail holes with a formatter are the `datetime:long` holes in `auth-templates.ts` and the docs examples, and all of them use known formatters. The unknown names the grep found (`bogus`, `nope`, `shout`, `no_such_formatter`) are in-app dialect tests. They are refused there already and their results do not change. ### Merge order with #26937 and #26939 **Serial with #26937 (merged at `22058635`).** This branch kept its hunks out of #26937's, and merged `main` once #26937 landed (`9b72c4e1`, no rebase). The one conflict was `TemplateHoleGrammarRule`. It now has five members: `one-formatter`, `known-formatter`, `currency-code-literal`, `currency-code-path-mail-only` and `currency-code-path-undeclared`. `judgeHoleFormatter` keeps #26937's `dialect` parameter, and `known-formatter` sits after `one-formatter` and before the currency block. `395c096e` adds the unknown-formatter clause to `MAIL_TEMPLATE_HOLES` and its comment (the `CURRENCY_ARGUMENT_RULE` sentence stays verbatim), and to the `validateEmailTemplateHoleGrammar` roster comment in `authoring-rules.ts`. It also widens the spec changeset and corrects the `IEmailService` TSDoc. `97613711` regenerates `content/docs/references/system/email-template.mdx`. **Residual (follow-up #27225).** This PR refuses an unknown one-word formatter. Formatter text that is not one word (`{{ x | date-long }}`, `{{ x | currency :EUR }}`, `{{ x | two words }}`) fails `FILTER_RE` / `WORD_RE`, so `parseMailHole` answers `null`. Every mail door then accepts it and the renderer prints the hole verbatim (pinned at `text-field-html-outlets.pin.test.ts:284`), while the in-app dialect refuses it. #27225 carries that. ## Tests - New `packages/formula/src/template-unknown-formatter.test.ts` (9 cases). It covers the refusal and its sentence, every whitelisted name passing, inherited keys refused, the one-formatter rule winning on a chained hole, non-word text left verbatim, `parseMailHole` / `judgeMailHoleGrammar` locating triple-braced holes, the in-app sentence unchanged, and in-app `toString` now refused. - New `packages/plugins/plugin-email/src/template-unknown-formatter.render.test.ts` (5 cases). It covers `renderTemplate` on all three faces refused with the template, locale, hole and known list, `sendTemplate` sending nothing, and the corrected name rendering. - Re-pinned `packages/plugins/plugin-email/src/template-engine.test.ts`: "falls back to the raw value for an unknown formatter (no throw)" is now "refuses an unknown formatter with TEMPLATE_INVALID, never the raw value", for both entry points. - New `packages/lint/src/validate-email-template-hole-grammar.unknown-formatter.test.ts` (8 cases). It covers all three faces, `os validate` / `build` / `lint`, and the runtime publish gate. - New `packages/metadata-protocol/src/runtime-authoring-gate.email-template-unknown-formatter.test.ts`. It checks the `422 INVALID_METADATA` envelope (`code` and `status`, plus `issues`), and that the corrected name publishes. ## Acceptance notes - Text after the `|` that is not a single word (`{{ x | two words }}`, `{{ x | }}`) still names no formatter in the mail grammar and is left as written, as `text-field-html-outlets.pin.test.ts` pins. The in-app dialect refuses the same text as an invalid hole. This PR leaves that difference alone, because the card is about formatter names. - In the ablation, the `sendTemplate` case and the re-pinned `template-engine.test.ts` case stayed green. That is the renderer's own `formatted === undefined` guard: it still throws `TEMPLATE_INVALID` when the grammar rule is removed. That is the intended second line, and the face cases, which assert the grammar's sentence, went red. ## Verification Run on head 857e696 (`origin/main` 72629f3 + 3 commits). <details><summary>Package tests and typecheck (under os-verify-lock)</summary> `pnpm --filter @objectstack/formula --filter @objectstack/plugin-email --filter @objectstack/lint --filter @objectstack/metadata-protocol --filter @objectstack/service-messaging test`, then the same filters plus `@objectstack/spec` `typecheck`, then `pnpm --filter @objectstack/spec check:generated`: VERDICT command-exit 0. - formula: Test Files 57 passed (57), Tests 1493 passed. - plugin-email: Test Files 41 passed (41), Tests 717 passed. - lint: Test Files 172 passed (172), Tests 7530 passed. - metadata-protocol: Test Files 278 passed, 4 skipped (282), Tests 21750 passed, 26 skipped. - service-messaging: Test Files 61 passed (61), Tests 734 passed. - typecheck: formula, plugin-email, lint, metadata-protocol and spec all Done, with check:test-typecheck OK. - spec check:generated: "All 14 generated artifacts are up to date". - `eslint --no-inline-config` over the changed `.ts` files: exit 0. </details> <details><summary>Ablation of the rule</summary> `node scripts/ablation-replace.mjs` made the `known-formatter` branch unreachable (anchor hit once, `--hold`), followed by `pnpm --filter @objectstack/formula build`. `ablation-dist-preflight` confirmed the marker in `dist/index.js` and `dist/index.mjs`. Results, all red as expected: - formula new file: 5 of 9 failed. - plugin-email new file plus re-pin: 3 of 36 failed (the three face cases). - lint new file: 7 of 8 failed. - metadata-protocol new file: 1 of 1 failed. The restore was `--restore` (blob equal to HEAD, `git diff HEAD` empty), followed by a rebuild. `ablation-dist-preflight --absent` confirmed the marker was gone from all 6 built files and the tree was clean. </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands`: 105 commands, run serially. 103 exit 0, including `check-adr-0087-registration --base origin/main`, `check-changeset-no-major --base origin/main`, `check:doc-authoring` and `check:docs`. 2 exit 3, PREREQUISITE NOT MET (they need a full repo build, so nothing was measured): `spec check:skill-examples` and `check:dual-build-cjs-loads`. CI runs both. `--ran` reconciliation: 105 derived, 103 run, 2 NOT-MEASURED, 0 UNRUN. </details> <details><summary>Dogfood (owed by the narrowing arm)</summary> `pnpm --workspace-concurrency=2 --filter '@objectstack/dogfood^...' build && pnpm --filter @objectstack/dogfood test` on head 857e696 (no `OS_TEST_SHARD`, no path): VERDICT command-exit 0. Test Files 348 passed, 3 skipped (351); Tests 2804 passed, 20 skipped (2824). No failures. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01WGufzgBzVZJqh8CQJLrZu8)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 295699d commit 40320f1

18 files changed

Lines changed: 423 additions & 22 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/formula': minor
3+
---
4+
5+
feat(formula)!: the mail hole grammar refuses a hole whose formatter the whitelist does not name, by name, listing the known formatters
6+
7+
Clause-②: no (narrowing) — `judgeHoleFormatter` gains a third rule, `known-formatter`: a hole that names a formatter `TEMPLATE_FORMATTERS` does not hold (`{{ amount | curency:EUR }}`, `{{ due | dat:long }}`) is refused, so `parseMailHole` carries the refusal and `judgeMailHoleGrammar` reports the hole, where both accepted it before. The in-app dialect, which already refused an unknown formatter in its own parse, now also refuses an inherited object key it used to call as a formatter (`{{ x | toString }}`, which printed `[object Object]`). No accept set is widened and no export is added or removed; `TemplateHoleGrammarRule` gains the member `'known-formatter'`.
8+
Metadata-protocol: narrowed
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped and no export is removed: a hole whose formatter never formatted anything is now refused where the template is judged. A conversion cannot know which formatter the author meant, and the refusal names the hole and lists the known formatters, so there is nothing for a migration to rewrite. -->
11+
12+
**BREAKING** accept-set narrowing in the shared hole grammar.
13+
14+
- **Before.** `parseMailHole(' amount | curency:EUR ')` answered a hole with no refusal, and the mail renderer printed the raw value for it (`1234.5`, an ISO timestamp), because `formatValue` knows no such formatter.
15+
- **Now.** The hole carries a `known-formatter` refusal whose sentence names the hole and lists `TEMPLATE_FORMATTERS`. Text after the `|` that is not a single word (`{{ x | two words }}`) still names no formatter and is left as written. A chained hole is still refused by the one-formatter rule first.
16+
- **In-app.** `templateEngine.compile` keeps its own sentence for an unknown formatter. It now also refuses `toString`, `constructor` and the other inherited object keys, which it used to accept and run as formatters.
17+
- **Who sees it.** A template whose hole misspells a formatter or names one that does not exist. No template shipped in this repository does.
18+
19+
**The remedy.** Correct the formatter's name to one the refusal lists, or format the value in the caller that supplies the data and pass the finished text as its own variable.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/lint': minor
3+
---
4+
5+
feat(lint)!: `email-template-hole-grammar` refuses an email template hole whose formatter the whitelist does not name, naming the hole and listing the known formatters
6+
7+
Clause-②: no (narrowing) — `os validate`, `os build`, `os lint` and the metadata publish door (`422 INVALID_METADATA`) refuse an `email_template` whose `subject`, `bodyText` or `bodyHtml` writes a hole naming a formatter `TEMPLATE_FORMATTERS` does not hold (`{{ amount | curency:EUR }}`, `{{ due | dat:long }}`), which they accepted before. No accept set is widened, and no export or rule id is added or removed.
8+
Metadata-protocol: narrowed
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped: a hole whose formatter never formatted anything (the renderer printed the raw value) is now refused where it is authored. A conversion cannot know which formatter was meant, and the refusal names the hole and lists the known formatters, so there is no key for a migration to rewrite and no tombstone. -->
11+
12+
**BREAKING** accept-set narrowing at every authoring door for `email_template`.
13+
14+
- **Before.** `{{ amount | curency:EUR }}` passed every gate, and the email went out with `1234.5` where money was meant. The in-app template dialect already refused such a hole; mail did not.
15+
- **Now.** The rule refuses it at `error`, naming the hole, the face and the template, and listing the known formatters. The judgement is `@objectstack/formula`'s `judgeMailHoleGrammar`, the grammar the mail renderer parses with.
16+
- **Who sees it.** A template whose hole misspells a formatter or names one that does not exist. No template shipped in this repository does.
17+
18+
**The remedy.** Correct the formatter's name to one the finding lists, or format the value in the caller that supplies the data and pass the finished text as its own variable.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/plugin-email': minor
3+
---
4+
5+
feat(plugin-email)!: a mail template hole whose formatter the whitelist does not name fails the render with `TEMPLATE_INVALID` instead of printing the raw value
6+
7+
Clause-②: no (narrowing) — `sendTemplate` and `renderTemplate` refuse a stored template whose `subject`, `body_text` or `body_html` writes a hole naming a formatter `TEMPLATE_FORMATTERS` does not hold (`{{ amount | curency:EUR }}`), which rendered the raw value before. No accept set is widened, and the package adds no export.
8+
Metadata-protocol: narrowed
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped and no export changes: the renderer refuses a hole the authoring rule refuses, for a template that reached it without being judged. A conversion cannot know which formatter was meant, so there is nothing for a migration to rewrite. -->
11+
12+
**BREAKING** a render that printed a raw value where a formatted one was meant now fails.
13+
14+
- **Before.** `{{ amount | curency:EUR }}` with `amount = 1234.5` rendered `1234.5` in every face, and `{{ due | dat:long }}` rendered the stored timestamp as written: the renderer's fallback for a formatter it did not know was the raw value.
15+
- **Now.** The render throws `TEMPLATE_INVALID: email template NAME (locale=LOCALE): the hole … names the formatter 'curency', which is not a template formatter …`, listing the known formatters; `sendTemplate` sends nothing, and the messaging channels grade the failure permanent as they grade every `TEMPLATE_INVALID`. The authoring doors refuse the same hole first (`email-template-hole-grammar`), so this is reached only by a template stored before that rule, until its next publish.
16+
- **The raw fallback is gone.** `renderTemplate` and `renderPlainTextTemplate` throw `TEMPLATE_INVALID` for such a hole too, when handed a body directly.
17+
18+
**The remedy.** Republish the template with the formatter's name corrected to one the refusal lists.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
docs(spec): the `email_template` face descriptions and the `IEmailService` `TEMPLATE_INVALID` TSDoc name a formatter the template engine does not know among the refused holes
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- **The face descriptions.** `subject`, `bodyHtml` and `bodyText` now say that a hole's formatter is one the template engine knows, and that an unknown name (a misspelled `curency`) is refused, naming the known formatters. The `currency` sentence is unchanged. The refusal itself ships in `@objectstack/formula`, `@objectstack/lint` and `@objectstack/plugin-email`; the spec only states the grammar, so no accept set of any schema moves.
11+
- **The TSDoc.** The `TEMPLATE_INVALID` error of `sendTemplate` and `renderTemplate` now names all three holes `email-template-hole-grammar` refuses: a second formatter, a formatter the whitelist does not name, and a `currency` argument that is neither a literal ISO 4217 code nor a marked `@path`.
12+
13+
No type, schema shape or export changes.

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,14 @@ never needs triple braces.
8282
same way they do in-app. `datetime` honours the reference timezone the caller
8383
passes; calendar-day `date` values are timezone-naive.
8484

85+
**The formatter is one the whitelist names.** `{{ amount | curency:EUR }}` is
86+
refused by `os validate`, `os build`, `os lint` and the metadata publish door
87+
(`email-template-hole-grammar`, a `422 INVALID_METADATA` at the publish door),
88+
naming the hole and listing the known formatters (`TEMPLATE_FORMATTERS`,
89+
exported from `@objectstack/formula`). A template stored before this rule fails its render with
90+
`TEMPLATE_INVALID` and sends nothing, rather than print the raw value (`1234.5`)
91+
where a formatted one was meant; republish it with the name corrected.
92+
8593
**One formatter per hole.** `{{ amount | default:0 | number:2 }}` is refused by
8694
`os validate`, `os build`, `os lint` and the metadata publish door
8795
(`email-template-hole-grammar`): a hole takes one formatter, and there is no

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

Lines changed: 3 additions & 3 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. 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`). |
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), and that formatter is one the template engine knows (an unknown name, such as a misspelled `curency`, is refused, naming the known formatters). 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), and that formatter is one the template engine knows (an unknown name, such as a misspelled `curency`, is refused, naming the known formatters). 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), and that formatter is one the template engine knows (an unknown name, such as a misspelled `curency`, is refused, naming the known formatters). 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 | |

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@
2020
* What follows the first `|` is judged by `judgeHoleFormatter`
2121
* (`template-engine.ts`), the function the in-app template dialect judges its
2222
* holes with too, so the two dialects refuse the same formatter parts with the
23-
* same sentence: one formatter per hole, and a `currency` argument that is a
24-
* literal ISO 4217 code. A refused hole is a hole — it is found, located and
23+
* same sentence: one formatter per hole, a formatter the whitelist names
24+
* (`TEMPLATE_FORMATTERS`), and a `currency` argument that is a literal ISO
25+
* 4217 code. A refused hole is a hole — it is found, located and
2526
* named — and the renderer refuses to render it rather than print a wrong
2627
* value or formatter syntax.
2728
*

‎packages/formula/src/template-engine.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ export const TEMPLATE_FORMATTERS: string[] = Object.keys(FORMATTERS);
166166
/** The rule a refused hole breaks. */
167167
export type TemplateHoleGrammarRule =
168168
| 'one-formatter'
169+
| 'known-formatter'
169170
| 'currency-code-literal'
170171
| 'currency-code-path-mail-only'
171172
| 'currency-code-path-undeclared';
@@ -187,6 +188,9 @@ export interface TemplateHoleGrammarRefusal {
187188
message: string;
188189
}
189190

191+
/** A formatter name as a hole writes one; any other text after the `|` names no formatter. */
192+
const WORD_RE = /^\w+$/;
193+
190194
/** An ISO 4217 alphabetic code: three capital letters, the form `Intl` formats with. */
191195
const CURRENCY_CODE_RE = /^[A-Z]{3}$/;
192196

@@ -284,6 +288,12 @@ function chainsASecondFormatter(formatterPart: string): boolean {
284288
/**
285289
* Judge one hole's formatter against the grammar both template dialects share.
286290
*
291+
* A formatter the hole names must be one {@link TEMPLATE_FORMATTERS} holds
292+
* (`known-formatter`). The mail renderer used to print the raw value for any
293+
* other name, so `{{ amount | curency:EUR }}` sent `1234.5` where money was
294+
* meant; the in-app dialect refuses such a hole in its own parse, before this
295+
* rule's sentence is read, and keeps its own wording.
296+
*
287297
* @param hole the hole as written, braces included — the refusal names it
288298
* @param formatterPart the text after the hole's first `|`, trimmed
289299
* @param formatter the formatter name the dialect parsed
@@ -309,6 +319,15 @@ export function judgeHoleFormatter(
309319
+ 'passing the finished value as its own variable.',
310320
};
311321
}
322+
if (WORD_RE.test(formatter) && !TEMPLATE_FORMATTERS.includes(formatter)) {
323+
return {
324+
rule: 'known-formatter',
325+
message:
326+
`the hole ${hole} names the formatter '${formatter}', which is not a template formatter, so it would `
327+
+ `never format the value. Known formatters: ${TEMPLATE_FORMATTERS.join(', ')}. Correct the name, or `
328+
+ 'format the value in the caller that supplies the data and pass the finished text as its own variable.',
329+
};
330+
}
312331
if (formatter === 'currency' && arg !== undefined && !isCurrencyCode(arg)) {
313332
if (currencyCodePath(formatterPart) === undefined) {
314333
return { rule: 'currency-code-literal', message: currencyArgumentRefusal(hole, arg, dialect) };

0 commit comments

Comments
 (0)