Skip to content

Commit b445807

Browse files
feat(formula, plugin-email, lint)!: a mail template hole whose text after the | is not a formatter name is refused by name, and mail reads every formatter part as the in-app dialect does (#27270)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBJMi61dBZ6D1yX5agd3kpSa)_ Fixes #27225 Claim: 6469619404 Clause-②: no (narrowing) Metadata-protocol: narrowed Skills-docs: none — `skills/objectstack-formula/SKILL.md:444` teaches a template hole as `{{ path | formatter[:arg] }}` with the `TEMPLATE_FORMATTERS` whitelist, which is exactly the shape this PR enforces, and no `skills/**` file teaches or shows a mail hole whose formatter part is not that shape (grep over `skills/**` for `formatter`, `emailTemplates` and `email_template`: the hits are that row, the automation skill's "a variable path, optional formatter, no logic" sentence, and two unrelated uses of the word). The docs page `content/docs/automation/email-templates.mdx` gains the rule here. ## 维护者速读 - **改了什么:** 邮件模板里 `|` 后面写的不是一个格式化器名(如 `{{ due | date-long }}`、`{{ x | two words }}`、`{{ x | }}`)时,现在在 `os validate` / `os build` / `os lint` 和元数据发布门(`422 INVALID_METADATA`)被按名拒绝,说明正确写法(一个词,可带 `:参数`)并列出全部格式化器;已存的旧模板渲染时报 `TEMPLATE_INVALID`,不再把原文发给收件人。同时邮件与应用内模板改用同一个切分函数,`{{ amount | currency :EUR }}`、`default:it's` 这类应用内本来就接受的写法,邮件里也照应用内的读法渲染。 - **为什么改:** 原来这种写法通过所有门禁,邮件正文里直接出现 `{{ due | date-long }}` 原文;应用内模板早就拒绝,两种方言不一致(#27217 合同复核 S1)。 - **风险与回滚:** 收窄(BREAKING),只影响格式化器写成非单词的邮件模板;仓内出厂模板、示例、种子、技能零命中。另有一类读法变化:邮件里每个双引号参数现在都去掉首尾引号,与应用内一致(`default:"n/a"`、`date:"long"`、`number:"2"`、`truncate:"80"`、`default:""`);其中 `currency:"EUR"` 以前被 `currency-code-literal` 拒收,现在可发布、按 EUR 渲染(一处放宽,级别仍由收窄决定)。回滚即 revert 本 PR。 - **先看:** `packages/formula/src/template-engine.ts`(新增 `splitHoleFormatter` 与 `formatter-shape` 规则)、`packages/formula/src/mail-hole-grammar.ts`(去掉 `FILTER_RE`,改读共享切分)、`packages/plugins/plugin-email/src/text-field-html-outlets.pin.test.ts`(原样输出的边界重钉)。 - **两分钟验证:** `pnpm --filter @objectstack/formula exec vitest run src/template-formatter-shape.test.ts`,预期 10 个用例全绿,其中「两种方言接受完全相同的格式化器写法」逐条比对 21 种写法。 - **需要你做的:** 无,席位落地(与 #27249 串行,后落地者合入 main)。 ## What changes - **`@objectstack/formula`, one tokenizer for both dialects** (`template-engine.ts`). New `splitHoleFormatter(formatterPart)` splits the text after a hole's first `|` at its first `:`, trims both sides and removes one enclosing quote mark at each end of the argument. This is the in-app `parseHole`'s own code, moved out unchanged. `parseHole` and the mail grammar's `parseMailHole` both read it, so the two dialects now accept exactly the same formatter parts (the seat's ruling on the claim). The function is exported from the module for the mail grammar; the package index exports nothing new. - **`@objectstack/formula`, a fourth rule, `formatter-shape`** (`judgeHoleFormatter`). It is judged after `one-formatter` and before `known-formatter`: a formatter name that is not one word is refused. The sentence names the hole, says what it wrote where the formatter name goes (or that it wrote none), states the shape `{{ path | formatter[:arg] }}` and lists `TEMPLATE_FORMATTERS`. `known-formatter` keeps refusing a one-word unknown name, so `{{ x | curency :EUR }}` still reads `known-formatter`. `TemplateHoleGrammarRule` gains `'formatter-shape'`. - **`@objectstack/formula`, the mail grammar** (`mail-hole-grammar.ts`). Its own `FILTER_RE` is gone. A path followed by `|` now always parses as a hole; a formatter part the grammar refuses rides as `refusal`, so `judgeMailHoleGrammar` reports it. `parseMailHole` still answers `null` only when the part before any `|` is not a path. - **The in-app dialect: no accept set moves.** Its compile refused every formatter part this PR refuses, before and after, and keeps its own "invalid template hole" sentence (pinned). - **`@objectstack/plugin-email`**: no code change in `render()`. It already throws `TEMPLATE_INVALID` for a parsed hole that carries a refusal, so these holes are refused at render. Only the header comment names the new case. - **`@objectstack/lint` `email-template-hole-grammar` and the publish door**: no code change. They read `judgeMailHoleGrammar`. The rule's header and the `authoring-rules.ts` roster comment name the new case. - **`@objectstack/spec`**: `MAIL_TEMPLATE_HOLES` (the `subject` / `bodyHtml` / `bodyText` descriptions) states the shape, and the `IEmailService` `TEMPLATE_INVALID` TSDoc names it among the refused holes. `content/docs/references/system/email-template.mdx` is regenerated. - **Docs**: `content/docs/automation/email-templates.mdx` gains a paragraph on the rule, the shared reading and the verbatim boundary. The stale sentence "An unknown formatter falls back to the raw value rather than failing the render." (false since #27217) is deleted from the same page. - **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` (describe and TSDoc only, `none`). The formula entry carries a "Supersedes, in the same release" paragraph, because #26948's unreleased entry says non-word formatter text "is left as written", which is no longer true. ### The verbatim boundary (`text-field-html-outlets.pin.test.ts:284`) Decided and pinned: **brace text whose part before any `|` is not a path is the template's own text and stays verbatim** (`{{ not a path }}`, `{{}}`, `{{ | upper }}`, `{{ a b | upper }}`, `{{ | date-long }}`). **A path followed by `|` is a formatter use and is judged.** The corpus line at :284 drops `{{ x | two words }}` and gains `{{ a b | upper }}`. A new case in the same pin renders the literal set unchanged through the real path and refuses `{{ x | two words }}`, `{{ due | date-long }}`, `{{ x | }}` and the triple-braced form in both entry points. ### Census (on `origin/main` `9ff8154c`, which contains #27217) Every git-tracked file was scanned for a mail-grammar hole whose path parses, followed by `|` and a formatter part the old `FILTER_RE` failed. Shipped mail templates (plugin-email `auth-templates.ts`, examples, apps, seeds, dogfood and downstream fixtures, `skills/**`) have **0** hits. The 79 raw hits are grammar prose in TSDoc, docs, CHANGELOGs and changesets; chained holes the one-formatter rule already refuses; GitHub Actions `||` expressions in `.github/` and `scripts/`; `ui/cliff.toml` (a Tera template, not mail); and a source-escaped `currency: \'USD\'` that parses at runtime. Two test lines pinned the old verbatim print: the outlets pin :284 and `template-unknown-formatter.test.ts:45`. Both are re-pinned here. No mail template in the repo writes a double-quoted argument, so the double-quoted-argument reading change (below) touches none. ### Merge order with #27249 **Serial with #27249 (#26939).** That PR edits the plugin-email `render()` hunk, formula `template-engine.ts`, `template-hole-grammar.test.ts`, `email-templates.mdx` and the `IEmailService` TSDoc. This branch leaves `render()` alone (its only plugin-email source edit is three lines in the header comment, clear of #27249's hunks). Whichever lands second merges `main` (no rebase). ## Tests - New `packages/formula/src/template-formatter-shape.test.ts` (10 cases): the tokenizer; the refusal and its sentence (named hole, shape, known list, and the empty case); `one-formatter` still winning on a chained hole; `parseMailHole` carrying the refusal; the in-app readings (`currency :EUR`, `default:it's`, `default:"n/a"`, `currency :@currency_code`); the literal set staying `null`; `judgeMailHoleGrammar` locating refused holes, triple-braced included. **⭐ Dialect parity:** 21 formatter parts, each compiled in-app and parsed in mail, with the same verdict asserted on both sides (12 accepted, 9 refused), `currency:"EUR"` and `date:"long"` among the accepted. The in-app sentence is unchanged. - Re-pinned `packages/formula/src/template-unknown-formatter.test.ts`: "text after the `|` that is not a word names no formatter, and the mail grammar leaves it verbatim" is now "…is refused by the formatter-shape rule, never left verbatim". - New `packages/plugins/plugin-email/src/template-formatter-shape.render.test.ts` (5 cases): `renderTemplate` refuses on all three faces with the template, locale, hole and shape; `sendTemplate` sends nothing; the in-app readings render (`€1,234.50`, `it&#39;s`, `2026-10-28`, `n/a`). - `packages/plugins/plugin-email/src/text-field-html-outlets.pin.test.ts`: the boundary case above, plus the corpus edit. - New `packages/lint/src/validate-email-template-hole-grammar.formatter-shape.test.ts` (9 cases): all three faces; a template using `currency :EUR`, `date :long` and `default:it's` draws nothing; literal brace text draws nothing; `os validate` / `build` / `lint`; the runtime publish gate. - New `packages/metadata-protocol/src/runtime-authoring-gate.email-template-formatter-shape.test.ts`: the `422 INVALID_METADATA` envelope (`code` and `status`, plus `issues`), and the corrected template (with `currency :EUR`) publishing. ## Acceptance notes - **The two dialects still differ in the PATH part, not here.** The in-app path admits `$` and `[i]` (`PATH_ONLY_RE`), the mail path does not (`PATH_RE`), so `{{ $error.message | upper }}` is a hole in-app and literal text in mail. That is the path grammar, outside this card, and it is unchanged. - **Every double-quoted argument changes reading, and one spelling widens.** The mail grammar kept double quotes as part of the argument; the shared tokenizer removes one enclosing quote mark at each end, as in-app always did. So `default:"n/a"` supplied `"n/a"` and supplies `n/a`; `date:"long"` fell back to the short style and is long; `number:"2"` had no fixed digits and has two; `truncate:"80"` did not truncate and cuts at 80; `default:""` supplied `""` and supplies nothing. `currency:"EUR"` was refused by `currency-code-literal` at every door and at render, and now publishes and formats in EUR: that one spelling is a widening. The narrowing is the BREAKING arm, so the declarations and the levels do not move. The formula and lint changesets say so (their earlier "nothing refused is accepted" / "no accept set is widened" sentences were false and are corrected). The census found no mail template with a double-quoted argument. - **`known-formatter` and `formatter-shape` overlap by design.** Without the `WORD_RE` guard, `known-formatter` would also catch `date-long`, but its sentence ("names the formatter 'date-long'") reads as a misspelling. `formatter-shape` states the shape instead. ## Verification All readings below are on head `b0fe9278`, except where a later line names `4bc8e16d` (branch off `origin/main` `9ff8154c`, `origin/main` `4d3a077f` merged in, which touched only `driver-sqlite-wasm/vitest.config.ts`). <details><summary>Before and after, the mail grammar and the in-app compile</summary> Before (`origin/main` `9ff8154c`): `parseMailHole(' due | date-long ')`, `' x | two words '`, `' x | '`, `' amount | currency :EUR '` and `" note | default:it's "` all answered `null`. `judgeMailHoleGrammar` reported nothing, and the renderer printed each hole as written. In-app: `date-long`, `two words` and the empty part were refused at compile; `currency :EUR` and `default:it's` compiled. After (`b0fe9278`): the first three carry `refusal.rule === 'formatter-shape'` and the renderer throws `TEMPLATE_INVALID`. `currency :EUR` parses as `{ formatter: 'currency', arg: 'EUR' }` and renders `€1,234.50`; `default:it's` parses with `arg: "it's"`. In-app verdicts are unchanged on all 19 parts of the parity table. </details> <details><summary>Tests and typecheck</summary> - `pnpm --filter @objectstack/formula exec vitest run --maxWorkers=2`: 59 files, 1541 tests. One failure on the first run was the new test's own offset (expected 62, actual 60), corrected; then the three grammar files 43/43. - `pnpm --filter @objectstack/plugin-email exec vitest run --maxWorkers=2` (formula dist rebuilt first): 43 files, 735 tests, all passed. - `pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2`: 174 files, 7552 tests, all passed. - `pnpm --filter @objectstack/metadata-protocol exec vitest run src/runtime-authoring-gate.email-template` (closure rebuilt first): 3 files, 8 tests, all passed. - `pnpm --filter @objectstack/formula --filter @objectstack/plugin-email --filter @objectstack/lint --filter @objectstack/metadata-protocol typecheck`: all four ran (`tsc --noEmit`, plus `check:test-typecheck` for the first three), VERDICT command-exit 0. - Round 1 (`4bc8e16d`; changesets, one docs line and `template-formatter-shape.test.ts` only): `pnpm --filter @objectstack/formula exec vitest run src/template-formatter-shape.test.ts` 10/10 passed, with the parity table at 21 parts. The dogfood battery on `b0fe9278` carries over (no `src/**` moved): 348 of 351 files and 2804 of 2824 tests passed, the rest skipped. - `pnpm --filter @objectstack/spec build && check:generated`: `check:docs` was stale (`content/docs/references/**`), `--fix` regenerated `references/system/email-template.mdx`, and the rerun reports all 14 artifacts up to date. </details> <details><summary>Ablation</summary> With the fix committed, `node scripts/ablation-replace.mjs` added `if (formatter === formatter) return null;` at the top of the `formatter-shape` branch of `judgeHoleFormatter` (anchor 1 → 0, blob `6014dfef` → `7f1c5707`). This simulates the old answer, where a non-word name passes the judge. Then it ran `template-formatter-shape.test.ts` and `template-unknown-formatter.test.ts`: **6 failed | 13 passed**, the refusal, sentence, empty-part, mail-parse, face-location and dialect-parity cases. The restore confirmed the blob equals HEAD, and `git diff HEAD` was empty. The formula tests import from `src`, so that leg needed no rebuild. The plugin-email, lint and metadata-protocol legs read formula's `dist` and were not ablated. </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands` on `b0fe9278` derived **106** families. All were run serially, each line recorded as `CMD :: exit N`, and `--ran` reconciled: **106 derived, 104 run with exit 0, 2 NOT MEASURED, 0 unrun.** - NOT MEASURED (exit 3, PREREQUISITE NOT MET, no full `pnpm build` in this tree): `pnpm --filter @objectstack/spec run check:skill-examples` and `pnpm check:dual-build-cjs-loads`. Both read built output of packages this PR does not touch; CI's full build carries them. - Green families that read this diff include `check-adr-0087-registration --base origin/main`, `check-changeset-no-major --base origin/main`, `check-empty-changeset --base origin/main`, `check:type-check-debt` and `check:undeclared-dep-imports`. - `test:repo` was not run locally (CI's Test Core runs it). </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01WGufzhX8CnnYtA7h6jieDA)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufzhX8CnnYtA7h6jieDA --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bf01226 commit b445807

19 files changed

Lines changed: 544 additions & 52 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/formula': minor
3+
---
4+
5+
feat(formula)!: the mail hole grammar refuses text after a hole's `|` that is not a formatter name, and reads every formatter part exactly as the in-app dialect does
6+
7+
Clause-②: no (narrowing) — `parseMailHole` answered `null` for a path followed by `|` whose formatter part failed its own pattern (`{{ due | date-long }}`, `{{ x | two words }}`, `{{ x | }}`), so `judgeMailHoleGrammar` never judged the hole. It now parses the hole and carries a refusal under the new rule `formatter-shape`, which `judgeHoleFormatter` judges for both dialects. One mail spelling is accepted that was refused before: a double-quoted `currency` argument (`currency:"EUR"`) kept its quotes, so `currency-code-literal` refused it, and it now reads `EUR`, publishes and renders, as in-app. The narrowing is the BREAKING arm and decides the level. The formatter parts the in-app dialect accepts and the mail pattern did not (`currency :EUR`, `default:it's`) were accepted unjudged before and are accepted now. `TemplateHoleGrammarRule` gains the member `'formatter-shape'`; the package index exports nothing new.
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 part never formatted anything (the renderer printed the hole as written) is now refused where the template is judged. A conversion cannot know which formatter the author meant, and the refusal names the hole and states the shape, so there is nothing for a migration to rewrite. -->
11+
12+
**BREAKING** accept-set narrowing in the shared hole grammar.
13+
14+
- **One tokenizer.** `splitHoleFormatter` (`template-engine.ts`) splits a formatter part at its first `:`, trims both sides and removes one enclosing quote mark at each end of the argument. The in-app `parseHole` and the mail `parseMailHole` both read it, so the two dialects accept exactly the same formatter parts. The mail grammar's own pattern is gone.
15+
- **Before.** `parseMailHole(' due | date-long ')` answered `null`: the text was not a hole to any reader, and the mail renderer printed `{{ due | date-long }}` to the recipient. `{{ amount | currency :EUR }}` and `{{ note | default:it's }}` were printed as written too, though the in-app dialect formats them. Every double-quoted argument kept its quotes: `default:"n/a"` supplied `"n/a"`, `date:"long"` and `number:"2"` reached their formatter as `"long"` and `"2"` (so `date` fell back to its short style and `number` to no fixed digits), `truncate:"80"` was not a number, `default:""` supplied `""`, and `currency:"EUR"` was refused by `currency-code-literal`.
16+
- **Now.** A name that is not one word is refused by `formatter-shape`, whose sentence names the hole, states the shape (`{{ path | formatter[:arg] }}`, one word optionally followed by `:` and its argument) and lists `TEMPLATE_FORMATTERS`. A chained hole is still refused by `one-formatter` first, and a one-word unknown name by `known-formatter`. `currency :EUR` is `currency` with `EUR`, `default:it's` supplies `it's`, and one enclosing quote mark is removed at each end of every argument, as in-app: `default:"n/a"` supplies `n/a`, `date:"long"` is `long`, `number:"2"` is `2`, `truncate:"80"` is `80`, `default:""` supplies nothing, and `currency:"EUR"` formats in EUR. Brace text whose part before any `|` is not a path (`{{ not a path }}`, `{{ | upper }}`) is still not a hole.
17+
- **In-app.** No accept set moves: its compile already refused these holes, and keeps its own sentence.
18+
- **Who sees it.** A mail template whose hole writes a formatter that is not one word. No template shipped in this repository does.
19+
- **Supersedes, in the same release.** The `known-formatter` entry of this release says text after the `|` that is not a single word "still names no formatter and is left as written". That is no longer so: it is refused, as above.
20+
21+
**The remedy.** Write the formatter as one of the names the refusal lists, optionally followed by `:` and its argument (`{{ due | date:long }}`), 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 text after the `|` is not a formatter name, stating the formatter shape
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 path followed by `|` and text that is not a formatter name (`{{ due | date-long }}`, `{{ x | two words }}`, `{{ x | }}`), which they accepted before. One spelling is widened: a double-quoted `currency` argument (`currency:"EUR"`), refused before by `currency-code-literal` because the mail grammar kept its quotes, is now read as `EUR` and accepted, as in-app. The narrowing is the BREAKING arm and decides the level. 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 part never formatted anything (the renderer printed it as written) is now refused where it is authored. A conversion cannot know which formatter was meant, and the refusal names the hole and states the shape, 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.** `{{ due | date-long }}` passed every gate, and the email went out with that text in its body. 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, stating the shape (one word, optionally followed by `:` and its argument) and listing the known formatters. The judgement is `@objectstack/formula`'s `judgeMailHoleGrammar`, which reads formatter parts with the in-app dialect's own tokenizer, so `{{ amount | currency :EUR }}` is accepted here as it is in-app.
16+
- **Who sees it.** A template whose hole writes a formatter that is not one word. No template shipped in this repository does.
17+
18+
**The remedy.** Write the formatter as one of the names the finding lists, optionally followed by `:` and its argument, or format the value in the caller that supplies the data and pass the finished text as its own variable.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/plugin-email': minor
3+
---
4+
5+
feat(plugin-email)!: a mail template hole whose text after the `|` is not a formatter name fails the render with `TEMPLATE_INVALID` instead of being printed as written
6+
7+
Clause-②: no (narrowing) — `sendTemplate` and `renderTemplate` refuse a stored template whose `subject`, `body_text` or `body_html` writes a path followed by `|` and text that is not a formatter name (`{{ due | date-long }}`, `{{ x | two words }}`), which they printed to the recipient as written before. 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 hole as written now fails.
13+
14+
- **Before.** `{{ due | date-long }}` rendered the text `{{ due | date-long }}` in every face. So did `{{ amount | currency :EUR }}` and `{{ note | default:it's }}`, though the in-app dialect formats both.
15+
- **Now.** A formatter part that is not a formatter name makes the render throw `TEMPLATE_INVALID: email template NAME (locale=LOCALE): the hole … writes 'date-long' where the formatter name goes …`, stating the shape and listing the known formatters; `sendTemplate` sends nothing. 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. `renderTemplate` and `renderPlainTextTemplate` throw it too when handed a body directly.
16+
- **Read as in-app.** `{{ amount | currency :EUR }}` now renders `€1,234.50`, `{{ note | default:it's }}` renders `it's` for an empty value, and every double-quoted argument loses one enclosing quote mark at each end: `default:"n/a"` supplies `n/a`, `date:"long"` formats in the long style, `number:"2"` with two digits, `truncate:"80"` at 80, `default:""` supplies nothing, and `currency:"EUR"`, refused before, formats in EUR.
17+
- **Still verbatim.** Brace text whose part before any `|` is not a path (`{{ not a path }}`, `{{}}`, `{{ | upper }}`) is the template's own text and renders as written.
18+
19+
**The remedy.** Republish the template with the formatter written as one of the names the refusal lists, optionally followed by `:` and its argument.
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 state the formatter shape a mail hole is held to
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- **The face descriptions.** `subject`, `bodyHtml` and `bodyText` now say that a formatter is one word, optionally followed by `:` and its argument, and that text which is not (such as `date-long`) is refused. 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 text after the `|` that is not a formatter name among the holes `email-template-hole-grammar` refuses.
12+
13+
No type, schema shape or export changes.

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,17 @@ 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 word, optionally followed by `:` and its argument.**
86+
`{{ due | date-long }}` and `{{ x | two words }}` are refused by the same doors
87+
and rule, naming the hole and the shape, where the renderer used to print the
88+
hole to the reader as written. Mail reads a formatter part exactly as in-app
89+
templates do: `{{ amount | currency :EUR }}` is `currency` with `EUR`, and
90+
`default:'n/a'` and `default:"n/a"` both supply `n/a`. Brace text with no path
91+
before the `|` (`{{ not a path }}`, `{{ | upper }}`) is not a hole and stays as
92+
written. A template stored before this rule fails its render with
93+
`TEMPLATE_INVALID` and sends nothing; republish it with the formatter written
94+
in that shape.
95+
8596
**The formatter is one the whitelist names.** `{{ amount | curency:EUR }}` is
8697
refused by `os validate`, `os build`, `os lint` and the metadata publish door
8798
(`email-template-hole-grammar`, a `422 INVALID_METADATA` at the publish door),
@@ -152,8 +163,6 @@ Three properties of the renderer to author around:
152163
rendering by design; branching belongs in the caller, which passes in the
153164
already-decided values.
154165

155-
An unknown formatter falls back to the raw value rather than failing the render.
156-
157166
## Declared variables
158167

159168
`variables` documents the holes: each entry has a `name` (the path as written in

‎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), 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`). |
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 formatter is one word, optionally followed by `:` and its argument (text that is not, such as `date-long`, is refused), and it 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). A formatter is one word, optionally followed by `:` and its argument (text that is not, such as `date-long`, is refused), and it 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). A formatter is one word, optionally followed by `:` and its argument (text that is not, such as `date-long`, is refused), and it 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 | |

0 commit comments

Comments
 (0)