From 2b6cc0d55d755e1e6eb3db9cd8a377f4e41f3328 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 13:03:54 +0000 Subject: [PATCH 1/4] wip(formula): one formatter tokenizer for both template dialects; formatter-shape refusal Claude-Session: https://claude.ai/code/session_01WGufzhX8CnnYtA7h6jieDA Co-authored-by: Claude --- packages/formula/src/mail-hole-grammar.ts | 52 +++---- packages/formula/src/template-engine.ts | 60 ++++++-- .../src/template-formatter-shape.test.ts | 128 ++++++++++++++++++ .../src/template-unknown-formatter.test.ts | 6 +- .../plugin-email/src/template-engine.ts | 3 +- .../template-formatter-shape.render.test.ts | 86 ++++++++++++ .../src/text-field-html-outlets.pin.test.ts | 21 ++- 7 files changed, 316 insertions(+), 40 deletions(-) create mode 100644 packages/formula/src/template-formatter-shape.test.ts create mode 100644 packages/plugins/plugin-email/src/template-formatter-shape.render.test.ts diff --git a/packages/formula/src/mail-hole-grammar.ts b/packages/formula/src/mail-hole-grammar.ts index 3621c9c0cba..32a6ae7f4bb 100644 --- a/packages/formula/src/mail-hole-grammar.ts +++ b/packages/formula/src/mail-hole-grammar.ts @@ -14,15 +14,19 @@ * * A hole is `{{path}}`, `{{{path}}}` or either with one formatter from the * shared whitelist, `{{ path | formatter[:arg] }}`. The path is a dotted path - * of word characters. Text between braces that is not a path is not a hole: - * the renderer leaves it as written. + * of word characters. Text between braces whose part before any `|` is not + * a path (`{{ not a path }}`, `{{}}`, `{{ | upper }}`) is not a hole: the + * renderer leaves it as written. A path followed by `|` is a formatter use, + * and is judged. * - * What follows the first `|` is judged by `judgeHoleFormatter` - * (`template-engine.ts`), the function the in-app template dialect judges its - * holes with too, so the two dialects refuse the same formatter parts with the - * same sentence: one formatter per hole, a formatter the whitelist names - * (`TEMPLATE_FORMATTERS`), and a `currency` argument that is a literal ISO - * 4217 code. A refused hole is a hole — it is found, located and + * What follows the first `|` is split by `splitHoleFormatter` and judged by + * `judgeHoleFormatter` (`template-engine.ts`), the two functions the in-app + * template dialect reads its holes with too, so the two dialects accept the + * same formatter parts (`currency :EUR` is `currency` with `EUR` in both) and + * refuse the rest with the same sentence: a formatter name of one word + * (`{{ due | date-long }}` is refused, never printed as written), one + * formatter per hole, a formatter the whitelist names (`TEMPLATE_FORMATTERS`), + * and a `currency` argument that is a literal ISO 4217 code. A refused hole is a hole — it is found, located and * named — and the renderer refuses to render it rather than print a wrong * value or formatter syntax. * @@ -37,7 +41,13 @@ * currency in its place. */ -import { CURRENCY_ARGUMENT_RULE, currencyCodePath, judgeHoleFormatter, type TemplateHoleGrammarRefusal } from './template-engine'; +import { + CURRENCY_ARGUMENT_RULE, + currencyCodePath, + judgeHoleFormatter, + splitHoleFormatter, + type TemplateHoleGrammarRefusal, +} from './template-engine'; /** 1=open (`{{` or `{{{`), 2=brace-free inner body, 3=close (`}}` or `}}}`). */ const MAIL_HOLE_SOURCE = String.raw`(\{\{\{?)([^{}]*)(\}\}\}?)`; @@ -55,7 +65,6 @@ export function mailHolePattern(): RegExp { // Validated against small, already-extracted strings (bounded input → no ReDoS). const PATH_RE = /^[\w.]+$/; -const FILTER_RE = /^(\w+)(?::\s*'?([^']*?)'?)?$/; /** A mail hole's inner body, parsed. */ export interface ParsedMailHole { @@ -75,8 +84,9 @@ export interface ParsedMailHole { /** * Parse a hole's inner body (the text between the braces). `null` when the - * body is not a path with an optional formatter — text the renderer leaves - * verbatim, and that no reader treats as a hole. + * part before any `|` is not a path — text the renderer leaves verbatim, and + * that no reader treats as a hole. A path followed by `|` always parses: a + * formatter part the grammar refuses is carried as the hole's `refusal`. */ export function parseMailHole(inner: string, hole = `{{${inner}}}`): ParsedMailHole | null { const pipe = inner.indexOf('|'); @@ -87,18 +97,14 @@ export function parseMailHole(inner: string, hole = `{{${inner}}}`): ParsedMailH const path = inner.slice(0, pipe).trim(); if (!PATH_RE.test(path)) return null; const formatterPart = inner.slice(pipe + 1).trim(); - const m = FILTER_RE.exec(formatterPart); - if (!m) { - // `{{ x | upper | lower }}` matches no single formatter shape, and the - // renderer used to leave it on the page as written: a chained hole is - // still a hole, refused by the one-formatter rule. - const name = formatterPart.split(/[:|]/, 1)[0].trim(); - const refusal = judgeHoleFormatter(hole, formatterPart, name, undefined, 'mail'); - return refusal ? { path, formatter: name, refusal } : null; + const { name, arg } = splitHoleFormatter(formatterPart); + const refusal = judgeHoleFormatter(hole, formatterPart, name, arg, 'mail'); + if (refusal?.rule === 'one-formatter') { + // `{{ x | upper | lower }}`: the renderer used to leave it on the page as + // written. Its formatter is the name before the second `|`. + return { path, formatter: formatterPart.split(/[:|]/, 1)[0].trim(), refusal }; } - const formatter = m[1]; - const arg = m[2]; - const refusal = judgeHoleFormatter(hole, formatterPart, formatter, arg, 'mail'); + const formatter = name; const codePath = refusal ? undefined : currencyCodePath(formatterPart); return { path, diff --git a/packages/formula/src/template-engine.ts b/packages/formula/src/template-engine.ts index c38c74eded6..6014dfef880 100644 --- a/packages/formula/src/template-engine.ts +++ b/packages/formula/src/template-engine.ts @@ -145,8 +145,14 @@ export const TEMPLATE_FORMATTERS: string[] = Object.keys(FORMATTERS); // both template dialects: this engine's (in-app slots, ADR-0032 §3) and the // mail renderer's (`mail-hole-grammar.ts`, read by `@objectstack/plugin-email` // and by `@objectstack/lint`'s email template rule). Each dialect parses its -// own path; what follows the first `|` is judged here, once. +// own path; what follows the first `|` is split into a name and an argument +// by `splitHoleFormatter` and judged here, once, so the two dialects accept +// exactly the same formatter parts. // +// - A formatter is a name of one word, optionally followed by `:` and its +// argument. The mail renderer used to print any other text after the `|` +// (`{{ due | date-long }}`, `{{ x | two words }}`) to the reader as +// written, while this engine refused it. // - One formatter per hole. A second `|` used to be read as part of the first // formatter's argument, so `{{ x | default:0 | number:2 }}` rendered the // text `0 | number:2` — formatter syntax printed to the reader — and @@ -166,6 +172,7 @@ export const TEMPLATE_FORMATTERS: string[] = Object.keys(FORMATTERS); /** The rule a refused hole breaks. */ export type TemplateHoleGrammarRule = | 'one-formatter' + | 'formatter-shape' | 'known-formatter' | 'currency-code-literal' | 'currency-code-path-mail-only' @@ -191,6 +198,31 @@ export interface TemplateHoleGrammarRefusal { /** A formatter name as a hole writes one; any other text after the `|` names no formatter. */ const WORD_RE = /^\w+$/; +/** A hole's formatter part, split into the formatter name and its argument. */ +export interface HoleFormatterParts { + /** The text before the first `:`, trimmed: the formatter name when the hole is well formed. */ + name: string; + /** The text after the first `:`, trimmed, one enclosing quote mark removed at each end; absent when no `:` was written. */ + arg?: string; +} + +/** + * Split a hole's formatter part (the text after its first `|`, trimmed) into + * a name and an argument — the one tokenizer both template dialects read, so + * a formatter part one of them accepts is never one the other refuses or + * prints. `currency :EUR` is `currency` with `EUR`, and `default:'n/a'` and + * `default:"n/a"` both give `n/a`. The name is judged by + * {@link judgeHoleFormatter}. + */ +export function splitHoleFormatter(formatterPart: string): HoleFormatterParts { + const colon = formatterPart.indexOf(':'); + if (colon === -1) return { name: formatterPart }; + return { + name: formatterPart.slice(0, colon).trim(), + arg: formatterPart.slice(colon + 1).trim().replace(/^['"]|['"]$/g, ''), + }; +} + /** An ISO 4217 alphabetic code: three capital letters, the form `Intl` formats with. */ const CURRENCY_CODE_RE = /^[A-Z]{3}$/; @@ -288,7 +320,10 @@ function chainsASecondFormatter(formatterPart: string): boolean { /** * Judge one hole's formatter against the grammar both template dialects share. * - * A formatter the hole names must be one {@link TEMPLATE_FORMATTERS} holds + * The name ({@link splitHoleFormatter}) must be one word (`formatter-shape`): + * the mail renderer used to print `{{ due | date-long }}` to the reader as + * written; the in-app dialect refuses such a hole in its own parse and keeps + * its own wording. A formatter the hole names must be one {@link TEMPLATE_FORMATTERS} holds * (`known-formatter`). The mail renderer used to print the raw value for any * other name, so `{{ amount | curency:EUR }}` sent `1234.5` where money was * meant; the in-app dialect refuses such a hole in its own parse, before this @@ -319,7 +354,18 @@ export function judgeHoleFormatter( + 'passing the finished value as its own variable.', }; } - if (WORD_RE.test(formatter) && !TEMPLATE_FORMATTERS.includes(formatter)) { + if (!WORD_RE.test(formatter)) { + return { + rule: 'formatter-shape', + message: + `the hole ${hole} ${formatter === '' ? 'writes no formatter name after the `|`' : `writes '${formatter}' where the formatter name goes`}, ` + + 'so it would never format the value: a formatter is one word, optionally followed by `:` and its ' + + `argument, \`{{ path | formatter[:arg] }}\`. Known formatters: ${TEMPLATE_FORMATTERS.join(', ')}. Write one ` + + 'of them, or format the value in the caller that supplies the data and pass the finished text as its own ' + + 'variable.', + }; + } + if (!TEMPLATE_FORMATTERS.includes(formatter)) { return { rule: 'known-formatter', message: @@ -427,13 +473,7 @@ function parseHole(inner: string): ParsedHole | null { if (!PATH_ONLY_RE.test(path)) return null; const filterPart = inner.slice(pipe + 1).trim(); // `name` or `name:arg` or `name:'arg'` - const colon = filterPart.indexOf(':'); - let name = filterPart; - let arg: string | undefined; - if (colon !== -1) { - name = filterPart.slice(0, colon).trim(); - arg = filterPart.slice(colon + 1).trim().replace(/^['"]|['"]$/g, ''); - } + const { name, arg } = splitHoleFormatter(filterPart); const refusal = judgeHoleFormatter(`{{${inner}}}`, filterPart, name, arg); // A chained hole is refused by the one-formatter rule before its first // formatter's name is looked up (`upper | lower` names no formatter). diff --git a/packages/formula/src/template-formatter-shape.test.ts b/packages/formula/src/template-formatter-shape.test.ts new file mode 100644 index 00000000000..f8196562ee7 --- /dev/null +++ b/packages/formula/src/template-formatter-shape.test.ts @@ -0,0 +1,128 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The text after a hole's `|` is split into a formatter name and an argument by + * one tokenizer (`splitHoleFormatter`) and judged by one function + * (`judgeHoleFormatter`) in both template dialects, so a formatter part one + * dialect accepts is never one the other refuses or prints. A name that is not + * one word (`{{ due | date-long }}`, `{{ x | two words }}`) is refused by the + * `formatter-shape` rule: the mail grammar used to answer `null` for it, and + * the mail renderer printed the hole to the reader as written. + */ +import { describe, expect, it } from 'vitest'; + +import { judgeMailHoleGrammar, parseMailHole } from './mail-hole-grammar'; +import { judgeHoleFormatter, splitHoleFormatter, TEMPLATE_FORMATTERS, templateEngine } from './template-engine'; + +/** Formatter parts on both sides of the grammar, each with its verdict in both dialects. */ +const PARTS: ReadonlyArray = [ + ['upper', true], + ['currency:EUR', true], + ['currency :EUR', true], + ["currency : 'USD'", true], + ['date :long', true], + ["default:it's", true], + ["default:'n/a'", true], + ['default:"n/a"', true], + ['number: 2', true], + ['upper:', true], + ['date-long', false], + ['two words', false], + ['', false], + [': EUR', false], + ['date-long:x', false], + ['curency:EUR', false], + ['curency :EUR', false], + ['upper | lower', false], + ['currency:eur', false], +]; + +describe('splitHoleFormatter', () => { + it('splits at the first colon, trims both sides and removes one enclosing quote mark at each end', () => { + expect(splitHoleFormatter('upper')).toEqual({ name: 'upper' }); + expect(splitHoleFormatter('currency :EUR')).toEqual({ name: 'currency', arg: 'EUR' }); + expect(splitHoleFormatter("default: 'n/a' ")).toEqual({ name: 'default', arg: 'n/a' }); + expect(splitHoleFormatter('default:"n/a"')).toEqual({ name: 'default', arg: 'n/a' }); + expect(splitHoleFormatter("default:it's")).toEqual({ name: 'default', arg: "it's" }); + expect(splitHoleFormatter('date-long')).toEqual({ name: 'date-long' }); + expect(splitHoleFormatter('')).toEqual({ name: '' }); + }); +}); + +describe('the formatter-shape rule', () => { + it('⭐ refuses a name that is not one word, naming the hole and the shape and listing every known formatter', () => { + const refusal = judgeHoleFormatter('{{ due | date-long }}', 'date-long', 'date-long', undefined); + expect(refusal?.rule).toBe('formatter-shape'); + expect(refusal?.message).toContain('{{ due | date-long }}'); + expect(refusal?.message).toContain("writes 'date-long' where the formatter name goes"); + expect(refusal?.message).toContain('`{{ path | formatter[:arg] }}`'); + expect(refusal?.message).toContain(`Known formatters: ${TEMPLATE_FORMATTERS.join(', ')}.`); + }); + + it('says so when nothing follows the `|`', () => { + expect(judgeHoleFormatter('{{ x | }}', '', '', undefined)?.message).toContain('writes no formatter name after the `|`'); + }); + + it('leaves a chained hole to the one-formatter rule', () => { + expect(judgeHoleFormatter('{{ x | date-long | upper }}', 'date-long | upper', 'date-long | upper', undefined)?.rule) + .toBe('one-formatter'); + }); +}); + +describe('the mail grammar', () => { + it('⭐ parses a path followed by `|` as a hole, carrying the refusal of a formatter part that is not a formatter', () => { + expect(parseMailHole(' due | date-long ')).toMatchObject({ + path: 'due', + formatter: 'date-long', + refusal: { rule: 'formatter-shape' }, + }); + expect(parseMailHole(' x | two words ')?.refusal?.rule).toBe('formatter-shape'); + expect(parseMailHole(' x | ')?.refusal?.rule).toBe('formatter-shape'); + expect(parseMailHole(' x | date-long:x ')?.refusal?.rule).toBe('formatter-shape'); + }); + + it('reads the parts the in-app dialect accepts the way it reads them', () => { + expect(parseMailHole(' amount | currency :EUR ')).toEqual({ path: 'amount', formatter: 'currency', arg: 'EUR' }); + expect(parseMailHole(" note | default:it's ")).toEqual({ path: 'note', formatter: 'default', arg: "it's" }); + expect(parseMailHole(' note | default:"n/a" ')).toEqual({ path: 'note', formatter: 'default', arg: 'n/a' }); + expect(parseMailHole(' amount | currency :@currency_code ')).toEqual({ + path: 'amount', + formatter: 'currency', + arg: '@currency_code', + codePath: 'currency_code', + }); + }); + + it('leaves text whose part before any `|` is not a path verbatim: it is not a hole', () => { + for (const inner of [' not a path ', '', ' | upper ', ' a b | upper ', ' | date-long ', ' { + const face = 'Due {{ due | date-long }}, {{ amount | currency :EUR }} for {{{ who | two words }}}'; + expect(judgeMailHoleGrammar(face).map((f) => [f.text, f.start, f.refusal.rule])).toEqual([ + ['{{ due | date-long }}', 4, 'formatter-shape'], + ['{{{ who | two words }}}', 62, 'formatter-shape'], + ]); + }); +}); + +describe('the two dialects', () => { + it('⭐ accept exactly the same formatter parts', () => { + for (const [part, accepted] of PARTS) { + const inApp = templateEngine.compile(`{{ record.x | ${part} }}`).ok; + const mail = parseMailHole(` x | ${part} `); + expect(inApp, `in-app: ${part}`).toBe(accepted); + expect(mail !== null && mail.refusal === undefined, `mail: ${part}`).toBe(accepted); + } + }); + + it('the in-app dialect keeps its own sentence for a name that is not one word', () => { + const r = templateEngine.compile('{{ record.due | date-long }}'); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.error.message).toContain('invalid template hole'); + expect(r.error.message).not.toContain('formatter name goes'); + }); +}); diff --git a/packages/formula/src/template-unknown-formatter.test.ts b/packages/formula/src/template-unknown-formatter.test.ts index a9828c885e3..ed4a53cba75 100644 --- a/packages/formula/src/template-unknown-formatter.test.ts +++ b/packages/formula/src/template-unknown-formatter.test.ts @@ -41,9 +41,9 @@ describe('a formatter the whitelist does not name', () => { .toBe('one-formatter'); }); - it('text after the `|` that is not a word names no formatter, and the mail grammar leaves it verbatim', () => { - expect(judgeHoleFormatter('{{ x | two words }}', 'two words', 'two words', undefined)).toBeNull(); - expect(parseMailHole(' x | two words ')).toBeNull(); + it('text after the `|` that is not a word is refused by the formatter-shape rule, never left verbatim', () => { + expect(judgeHoleFormatter('{{ x | two words }}', 'two words', 'two words', undefined)?.rule).toBe('formatter-shape'); + expect(parseMailHole(' x | two words ')?.refusal?.rule).toBe('formatter-shape'); }); }); diff --git a/packages/plugins/plugin-email/src/template-engine.ts b/packages/plugins/plugin-email/src/template-engine.ts index 1c917a9740f..eb62dd1804c 100644 --- a/packages/plugins/plugin-email/src/template-engine.ts +++ b/packages/plugins/plugin-email/src/template-engine.ts @@ -28,7 +28,8 @@ * * The hole grammar is `@objectstack/formula`'s (`mailHolePattern`, * `parseMailHole`), the one copy the authoring rule and the placement judge - * read too. A hole that grammar REFUSES — a second formatter + * read too. A hole that grammar REFUSES — text after the `|` that is not a + * formatter name (`{{ due | date-long }}`), a second formatter * (`{{ x | default:0 | number:2 }}`), a formatter the whitelist does not name * (`{{ amount | curency:EUR }}`), or a `currency` argument that is not a * literal ISO 4217 code — is refused at authoring and publish; one that still diff --git a/packages/plugins/plugin-email/src/template-formatter-shape.render.test.ts b/packages/plugins/plugin-email/src/template-formatter-shape.render.test.ts new file mode 100644 index 00000000000..ce4a49c9924 --- /dev/null +++ b/packages/plugins/plugin-email/src/template-formatter-shape.render.test.ts @@ -0,0 +1,86 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A mail hole whose text after the `|` is not a formatter name + * (`{{ due | date-long }}`, `{{ x | two words }}`) is refused at render with + * `TEMPLATE_INVALID`. The renderer used to print the hole to the reader as + * written. Authoring refuses such a hole first (`email-template-hole-grammar`, + * `@objectstack/lint`); this is the guard for a template stored before that + * rule. A formatter part the in-app dialect accepts renders here the way that + * dialect reads it (`currency :EUR`, `default:it's`). + * + * Measured through the by-name doors, `EmailService.renderTemplate` and + * `sendTemplate`, over all three faces. + */ + +import { describe, expect, it } from 'vitest'; +import type { IEmailTransport, NormalizedEmailMessage, TransportSendResult } from '@objectstack/spec/contracts'; +import type { EmailTemplateRow, TemplateLoader } from './index.js'; +import { EmailService } from './email-service.js'; + +function makeService(rows: EmailTemplateRow[]) { + const sent: NormalizedEmailMessage[] = []; + const transport: IEmailTransport = { + async send(message: NormalizedEmailMessage): Promise { + sent.push(message); + return { messageId: `msg-${sent.length}` }; + }, + }; + const templateLoader: TemplateLoader = { + async load(name) { + return rows.find((r) => r.name === name) ?? null; + }, + }; + const svc = new EmailService({ + transport, + defaultFrom: { address: 'no-reply@x.com' }, + templateLoader, + persistence: { + async insert(r) { return { id: r.id }; }, + async update() { /* noop */ }, + }, + }); + return { svc, sent }; +} + +const row = (name: string, over: Partial): EmailTemplateRow => ({ + name, + locale: 'en-US', + subject: 'Notice', + body_html: '

Notice

', + active: true, + ...over, +}); + +const data = { amount: 1234.5, due: '2026-10-28', note: '' }; + +describe('a hole whose text after the `|` is not a formatter name', () => { + it.each([ + ['subject', { subject: 'Due {{ due | date-long }}' }], + ['body_html', { body_html: '

{{ amount | two words }}

' }], + ['body_text', { body_text: 'Due {{ due | }}' }], + ])('⭐ in %s: the render is refused with TEMPLATE_INVALID, naming the template and the shape — never the hole as written', async (_face, over) => { + const { svc } = makeService([row('invoice.due', over)]); + await expect(svc.renderTemplate({ template: 'invoice.due', data })) + .rejects.toThrow(/^TEMPLATE_INVALID: email template invoice\.due \(locale=en-US\): the hole \{\{ /); + await expect(svc.renderTemplate({ template: 'invoice.due', data })) + .rejects.toThrow('a formatter is one word, optionally followed by `:` and its argument'); + }); + + it('sendTemplate refuses the same template and sends nothing', async () => { + const { svc, sent } = makeService([row('invoice.due', { subject: 'Due {{ due | date-long }}' })]); + await expect(svc.sendTemplate({ template: 'invoice.due', to: 'a@x.com', data })) + .rejects.toThrow(/^TEMPLATE_INVALID: /); + expect(sent).toEqual([]); + }); + + it('a formatter part the in-app dialect accepts renders the way it reads it, in every face', async () => { + const { svc } = makeService([row('invoice.due', { + subject: 'Due {{ amount | currency :EUR }}', + body_html: "

{{ note | default:it's }}

", + body_text: 'Due {{ due | date :iso }}, {{ note | default:"n/a" }}', + })]); + expect(await svc.renderTemplate({ template: 'invoice.due', data })) + .toEqual({ subject: 'Due €1,234.50', html: '

it's

', text: 'Due 2026-10-28, n/a' }); + }); +}); diff --git a/packages/plugins/plugin-email/src/text-field-html-outlets.pin.test.ts b/packages/plugins/plugin-email/src/text-field-html-outlets.pin.test.ts index 1c0c1af01d0..4b77ffd3f19 100644 --- a/packages/plugins/plugin-email/src/text-field-html-outlets.pin.test.ts +++ b/packages/plugins/plugin-email/src/text-field-html-outlets.pin.test.ts @@ -269,19 +269,34 @@ function pinTemplateEngine(): void { } expect(judged, 'the corpus carries holes to compare').toBeGreaterThan(200); }); + + it('the verbatim boundary: brace text whose part before any `|` is not a path is left as written; a path then `|` is judged', () => { + // Not a hole: no path, so it is the template's own text. + const literal = '

{{ not a path }} {{}} {{ | upper }} {{ a b | upper }} {{ | date-long }}

'; + expect(judgeMailHtmlHoles(literal)).toEqual([]); + expect(renderTemplate(literal, everyPath(PERSON_MARKUP))).toBe(literal); + // A formatter use: text after the `|` that is not a formatter name is + // refused at render in both entry points, never printed as written. + for (const hole of ['{{ x | two words }}', '{{ due | date-long }}', '{{ x | }}', '{{{ x | two words }}}']) { + expect(() => renderTemplate(`

${hole}

`, { x: PERSON, due: PERSON }), hole).toThrow(/^TEMPLATE_INVALID: the hole /); + expect(() => renderPlainTextTemplate(hole, { x: PERSON, due: PERSON }), hole).toThrow(/^TEMPLATE_INVALID: the hole /); + } + }); } /** * The grammar's edges, beside the built-ins' ordinary holes: spacing, dotted * paths, formatters with and without an argument, mismatched and stacked - * braces, and brace pairs the renderer leaves verbatim. No `default` - * formatter, which renders text for a missing value. + * braces, and brace pairs the renderer leaves verbatim (their part before any + * `|` is not a path; a path then `|` whose formatter part is not a formatter + * is refused, pinned by the boundary case above). No `default` formatter, + * which renders text for a missing value. */ const GRAMMAR_CORPUS = [ '

{{a}} {{ a.b }} {{a_b.c1}}{{ spaced }}

', '

{{ x | upper }} {{ total | currency:EUR }} {{ t | currency: \'USD\' }} {{ d | date }}

', '

{{{a}}} {{{a}} {{a}}} {{{{a}}}} {{{ a }}}

', - '

{{ not a path }} {{}} {{ | upper }} {{ x | two words }} {{a{b}} {x}

', + '

{{ not a path }} {{}} {{ | upper }} {{ a b | upper }} {{a{b}} {x}

', '

{{ {{u}}', ]; From f3050846278d0a26ee0d1e30a80b30c1a92ea8ca Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 13:13:37 +0000 Subject: [PATCH 2/4] feat(formula, plugin-email, lint): pin the formatter-shape refusal at every door; docs and changesets Claude-Session: https://claude.ai/code/session_01WGufzhX8CnnYtA7h6jieDA Co-authored-by: Claude --- .../27225-mail-formatter-shape-formula.md | 21 ++++++ .changeset/27225-mail-formatter-shape-lint.md | 18 +++++ ...27225-mail-formatter-shape-plugin-email.md | 19 ++++++ .changeset/27225-mail-formatter-shape-spec.md | 13 ++++ content/docs/automation/email-templates.mdx | 11 ++++ .../src/template-formatter-shape.test.ts | 2 +- packages/lint/src/authoring-rules.ts | 7 +- ...plate-hole-grammar.formatter-shape.test.ts | 66 +++++++++++++++++++ .../validate-email-template-hole-grammar.ts | 3 + ...ate.email-template-formatter-shape.test.ts | 57 ++++++++++++++++ packages/spec/src/contracts/email-service.ts | 10 +-- .../spec/src/system/email-template.zod.ts | 3 +- 12 files changed, 222 insertions(+), 8 deletions(-) create mode 100644 .changeset/27225-mail-formatter-shape-formula.md create mode 100644 .changeset/27225-mail-formatter-shape-lint.md create mode 100644 .changeset/27225-mail-formatter-shape-plugin-email.md create mode 100644 .changeset/27225-mail-formatter-shape-spec.md create mode 100644 packages/lint/src/validate-email-template-hole-grammar.formatter-shape.test.ts create mode 100644 packages/metadata-protocol/src/runtime-authoring-gate.email-template-formatter-shape.test.ts diff --git a/.changeset/27225-mail-formatter-shape-formula.md b/.changeset/27225-mail-formatter-shape-formula.md new file mode 100644 index 00000000000..f3bb8b5dc9d --- /dev/null +++ b/.changeset/27225-mail-formatter-shape-formula.md @@ -0,0 +1,21 @@ +--- +'@objectstack/formula': minor +--- + +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 + +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. No authoring door accepts anything it refused before; 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. +Metadata-protocol: narrowed + + + +**BREAKING** accept-set narrowing in the shared hole grammar. + +- **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. +- **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. `default:"n/a"` supplied the text with its double quotes. +- **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 `default:"n/a"` supplies `n/a`, as in-app. Brace text whose part before any `|` is not a path (`{{ not a path }}`, `{{ | upper }}`) is still not a hole. +- **In-app.** No accept set moves: its compile already refused these holes, and keeps its own sentence. +- **Who sees it.** A mail template whose hole writes a formatter that is not one word. No template shipped in this repository does. +- **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. + +**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. diff --git a/.changeset/27225-mail-formatter-shape-lint.md b/.changeset/27225-mail-formatter-shape-lint.md new file mode 100644 index 00000000000..6b03747a8d5 --- /dev/null +++ b/.changeset/27225-mail-formatter-shape-lint.md @@ -0,0 +1,18 @@ +--- +'@objectstack/lint': minor +--- + +feat(lint)!: `email-template-hole-grammar` refuses an email template hole whose text after the `|` is not a formatter name, stating the formatter shape + +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. No accept set is widened, and no export or rule id is added or removed. +Metadata-protocol: narrowed + + + +**BREAKING** accept-set narrowing at every authoring door for `email_template`. + +- **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. +- **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. +- **Who sees it.** A template whose hole writes a formatter that is not one word. No template shipped in this repository does. + +**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. diff --git a/.changeset/27225-mail-formatter-shape-plugin-email.md b/.changeset/27225-mail-formatter-shape-plugin-email.md new file mode 100644 index 00000000000..194497cdae0 --- /dev/null +++ b/.changeset/27225-mail-formatter-shape-plugin-email.md @@ -0,0 +1,19 @@ +--- +'@objectstack/plugin-email': minor +--- + +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 + +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. +Metadata-protocol: narrowed + + + +**BREAKING** a render that printed a hole as written now fails. + +- **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. +- **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. +- **Read as in-app.** `{{ amount | currency :EUR }}` now renders `€1,234.50`, `{{ note | default:it's }}` renders `it's` for an empty value, and `default:"n/a"` supplies `n/a` without its quotes. +- **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. + +**The remedy.** Republish the template with the formatter written as one of the names the refusal lists, optionally followed by `:` and its argument. diff --git a/.changeset/27225-mail-formatter-shape-spec.md b/.changeset/27225-mail-formatter-shape-spec.md new file mode 100644 index 00000000000..8a294f8b15d --- /dev/null +++ b/.changeset/27225-mail-formatter-shape-spec.md @@ -0,0 +1,13 @@ +--- +'@objectstack/spec': patch +--- + +docs(spec): the `email_template` face descriptions and the `IEmailService` `TEMPLATE_INVALID` TSDoc state the formatter shape a mail hole is held to + +Clause-②: no +Metadata-protocol: none + +- **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. +- **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. + +No type, schema shape or export changes. diff --git a/content/docs/automation/email-templates.mdx b/content/docs/automation/email-templates.mdx index 99836d4d5a0..e72aa71eae7 100644 --- a/content/docs/automation/email-templates.mdx +++ b/content/docs/automation/email-templates.mdx @@ -82,6 +82,17 @@ never needs triple braces. same way they do in-app. `datetime` honours the reference timezone the caller passes; calendar-day `date` values are timezone-naive. +**The formatter is one word, optionally followed by `:` and its argument.** +`{{ due | date-long }}` and `{{ x | two words }}` are refused by the same doors +and rule, naming the hole and the shape, where the renderer used to print the +hole to the reader as written. Mail reads a formatter part exactly as in-app +templates do: `{{ amount | currency :EUR }}` is `currency` with `EUR`, and +`default:'n/a'` and `default:"n/a"` both supply `n/a`. Brace text with no path +before the `|` (`{{ not a path }}`, `{{ | upper }}`) is not a hole and stays as +written. A template stored before this rule fails its render with +`TEMPLATE_INVALID` and sends nothing; republish it with the formatter written +in that shape. + **The formatter is one the whitelist names.** `{{ amount | curency:EUR }}` is refused by `os validate`, `os build`, `os lint` and the metadata publish door (`email-template-hole-grammar`, a `422 INVALID_METADATA` at the publish door), diff --git a/packages/formula/src/template-formatter-shape.test.ts b/packages/formula/src/template-formatter-shape.test.ts index f8196562ee7..66d78f39eab 100644 --- a/packages/formula/src/template-formatter-shape.test.ts +++ b/packages/formula/src/template-formatter-shape.test.ts @@ -103,7 +103,7 @@ describe('the mail grammar', () => { const face = 'Due {{ due | date-long }}, {{ amount | currency :EUR }} for {{{ who | two words }}}'; expect(judgeMailHoleGrammar(face).map((f) => [f.text, f.start, f.refusal.rule])).toEqual([ ['{{ due | date-long }}', 4, 'formatter-shape'], - ['{{{ who | two words }}}', 62, 'formatter-shape'], + ['{{{ who | two words }}}', 60, 'formatter-shape'], ]); }); }); diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index ee5df30a1d0..1697a7c43c4 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -2472,8 +2472,11 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ }, // Every hole of an email template's three faces is within the mail hole // grammar — `@objectstack/formula`'s `judgeMailHoleGrammar`, the grammar the - // mail renderer parses with. A hole that chains a second formatter is - // refused, naming the one-formatter rule and the "branch in the caller" + // mail renderer parses with. Text after a path's `|` that is not a formatter + // name (`{{ due | date-long }}`) is refused, stating the formatter shape + // (`formatter-shape`; the renderer printed the hole as written). A hole that + // chains a second formatter is refused, naming the one-formatter rule and + // the "branch in the caller" // remedy (the renderer printed `0 | number:2`); a formatter name outside // `TEMPLATE_FORMATTERS` is refused, listing the known formatters // (`known-formatter`; the renderer printed the raw value); a `currency` argument that diff --git a/packages/lint/src/validate-email-template-hole-grammar.formatter-shape.test.ts b/packages/lint/src/validate-email-template-hole-grammar.formatter-shape.test.ts new file mode 100644 index 00000000000..d95b6e7a253 --- /dev/null +++ b/packages/lint/src/validate-email-template-hole-grammar.formatter-shape.test.ts @@ -0,0 +1,66 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `email-template-hole-grammar` refuses a hole whose text after the `|` is not + * a formatter name (`{{ due | date-long }}`, `{{ x | two words }}`), on every + * authoring door: the three CLI commands and the runtime publish gate for an + * `email_template` write. Which formatter parts are accepted is + * `@objectstack/formula`'s to decide, the same for both template dialects + * (pinned in its `template-formatter-shape.test.ts`); this file holds the + * rule's reading. + */ +import { describe, expect, it } from 'vitest'; + +import { AUTHORING_COMMANDS, runAuthoringRules } from './authoring-rules.js'; +import { runRuntimeAuthoringRules } from './runtime-gate.js'; +import { + EMAIL_TEMPLATE_HOLE_GRAMMAR, + validateEmailTemplateHoleGrammar, +} from './validate-email-template-hole-grammar.js'; + +const template = (over: Record = {}) => ({ + name: 'invoice.due', + label: 'Invoice due', + locale: 'en-US', + subject: 'Due {{ amount | currency :EUR }}', + bodyHtml: '

{{ amount | currency:EUR }} by {{ due | date:long }}

', + bodyText: "Due {{ due | date :long }}, {{ note | default:it's }}", + ...over, +}); + +const MISSHAPEN = '{{ due | date-long }}'; + +describe('the rule, on the stack', () => { + it.each(['subject', 'bodyText', 'bodyHtml'])('⭐ refuses a formatter part that is not a formatter name in %s, naming the hole and the shape', (face) => { + const findings = validateEmailTemplateHoleGrammar({ emailTemplates: [template({ [face]: `Due ${MISSHAPEN}` })] }); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ severity: 'error', rule: EMAIL_TEMPLATE_HOLE_GRAMMAR, path: `emailTemplates[0].${face}` }); + expect(findings[0].where).toContain('invoice.due'); + expect(findings[0].message).toContain(MISSHAPEN); + expect(findings[0].message).toContain('a formatter is one word, optionally followed by `:` and its argument'); + }); + + it('a template whose every formatter part the in-app dialect accepts draws nothing', () => { + expect(validateEmailTemplateHoleGrammar({ emailTemplates: [template()] })).toEqual([]); + }); + + it('brace text whose part before any `|` is not a path is the template\'s own text, and draws nothing', () => { + expect(validateEmailTemplateHoleGrammar({ emailTemplates: [template({ bodyText: '{{ not a path }} {{ | upper }} {{}}' })] })) + .toEqual([]); + }); + + it.each([...AUTHORING_COMMANDS])('os %s refuses it at error', (command) => { + const stack = { emailTemplates: [template({ bodyText: 'Due {{ x | two words }}' })] }; + const errors = runAuthoringRules(command, { normalized: stack, parsed: stack }) + .filter((f) => f.severity === 'error' && f.rule === EMAIL_TEMPLATE_HOLE_GRAMMAR); + expect(errors.map((f) => f.path)).toEqual(['emailTemplates[0].bodyText']); + }); +}); + +describe('the runtime publish gate, for an email_template write', () => { + it('⭐ refuses the written template, located on the written template itself', () => { + const result = runRuntimeAuthoringRules({ type: 'email_template', item: template({ subject: MISSHAPEN }) }); + expect(result.rulesRun).toContain('validateEmailTemplateHoleGrammar'); + expect(result.errors.map((f) => [f.rule, f.path])).toEqual([[EMAIL_TEMPLATE_HOLE_GRAMMAR, 'emailTemplates[0].subject']]); + }); +}); diff --git a/packages/lint/src/validate-email-template-hole-grammar.ts b/packages/lint/src/validate-email-template-hole-grammar.ts index 6a77922ffed..315b4a25422 100644 --- a/packages/lint/src/validate-email-template-hole-grammar.ts +++ b/packages/lint/src/validate-email-template-hole-grammar.ts @@ -10,6 +10,9 @@ * renderer can honour as written. A hole the grammar refuses never renders as * its author meant it: * + * - text after the `|` that is not a formatter name, one word optionally + * followed by `:` and its argument (`{{ due | date-long }}`) — the renderer + * used to print the hole to the reader as written; * - a formatter the whitelist (`TEMPLATE_FORMATTERS`) does not name * (`{{ amount | curency:EUR }}`) — the renderer used to print the raw value * (`1234.5`) where a formatted one was meant; diff --git a/packages/metadata-protocol/src/runtime-authoring-gate.email-template-formatter-shape.test.ts b/packages/metadata-protocol/src/runtime-authoring-gate.email-template-formatter-shape.test.ts new file mode 100644 index 00000000000..6f393c7ca28 --- /dev/null +++ b/packages/metadata-protocol/src/runtime-authoring-gate.email-template-formatter-shape.test.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The HOST half of the email template publish door, for a hole whose text + * after the `|` is not a formatter name (`{{ due | date-long }}`): the shipped + * caller threads the `email_template` write to the runtime gate, and the + * refusal arrives in the ADR-0112 envelope (`422 INVALID_METADATA`) a Studio / + * REST `/meta` / MCP author's client reads. + * + * Harness: none — `evaluateRuntimeAuthoringGate` is pure. It RETURNS the error + * and its caller throws it, so the refusal is asserted on the envelope, `code` + * AND `status` together, plus the `issues` array. + */ +import { describe, expect, it } from 'vitest'; + +import { evaluateRuntimeAuthoringGate } from './runtime-authoring-gate.js'; + +const template = (over: Record = {}) => ({ + name: 'invoice.due', + label: 'Invoice due', + locale: 'en-US', + subject: 'Due {{ amount | currency :EUR }}', + bodyHtml: '

Due {{ due | date:long }}

', + variables: [{ name: 'amount', type: 'number' }, { name: 'due', type: 'date' }], + ...over, +}); + +const publishTemplate = (body: unknown) => + evaluateRuntimeAuthoringGate({ + type: 'email_template', + name: 'invoice.due', + state: 'active', + body, + objects: [], + permissions: [], + books: [], + datasets: [], + }); + +describe('the email_template publish door, for a formatter part that is not a formatter name', () => { + it('⭐ LIT — the hole is refused in the ADR-0112 envelope, naming the hole and the shape', () => { + const verdict = publishTemplate(template({ bodyHtml: '

Due {{ due | date-long }}

' })); + + expect(verdict.error, 'the caller throws this; null here means the door judged nothing').not.toBeNull(); + expect((verdict.error as { code?: string }).code).toBe('INVALID_METADATA'); + expect((verdict.error as { status?: number }).status).toBe(422); + + const issues = (verdict.error as { issues?: Array> }).issues!; + expect(issues.map((i) => [i.rule, i.path])).toEqual([['email-template-hole-grammar', 'emailTemplates[0].bodyHtml']]); + const wire = JSON.stringify(issues); + expect(wire).toContain('{{ due | date-long }}'); + expect(wire).toContain('a formatter is one word'); + + // Non-vacuous: the corrected formatter publishes, and so does `currency :EUR`, which the in-app dialect accepts. + expect(publishTemplate(template()).error).toBeNull(); + }); +}); diff --git a/packages/spec/src/contracts/email-service.ts b/packages/spec/src/contracts/email-service.ts index 8120a39c38c..82e512d2ec8 100644 --- a/packages/spec/src/contracts/email-service.ts +++ b/packages/spec/src/contracts/email-service.ts @@ -319,8 +319,9 @@ export interface IEmailService { * - `TEMPLATE_INACTIVE` — the template exists but `active=false`. * - `MISSING_VARIABLES` — declared `required` variables absent from `data`. * - `TEMPLATE_INVALID` — the stored template has a hole the authoring rules - * refuse (rule `email-template-hole-grammar`: a second formatter, a - * formatter the whitelist does not name, or a `currency` argument that is + * refuse (rule `email-template-hole-grammar`: text after the `|` that is + * not a formatter name, a second formatter, a formatter the whitelist + * does not name, or a `currency` argument that is * neither a literal ISO 4217 code nor a marked `@path`). It is refused * at render, as `TEMPLATE_INVALID: email template (locale=): * `, so nothing is delivered. Permanent: retrying cannot @@ -345,8 +346,9 @@ export interface IEmailService { * - `TEMPLATE_INACTIVE` — the template exists but `active=false`. * - `MISSING_VARIABLES` — declared `required` variables absent from `data`. * - `TEMPLATE_INVALID` — the stored template has a hole the authoring rules - * refuse (rule `email-template-hole-grammar`: a second formatter, a - * formatter the whitelist does not name, or a `currency` argument that is + * refuse (rule `email-template-hole-grammar`: text after the `|` that is + * not a formatter name, a second formatter, a formatter the whitelist + * does not name, or a `currency` argument that is * neither a literal ISO 4217 code nor a marked `@path`). It is refused * at render, as `TEMPLATE_INVALID: email template (locale=): * `, so nothing is rendered. Permanent: retrying cannot diff --git a/packages/spec/src/system/email-template.zod.ts b/packages/spec/src/system/email-template.zod.ts index c2269ae362b..d42b4f01e41 100644 --- a/packages/spec/src/system/email-template.zod.ts +++ b/packages/spec/src/system/email-template.zod.ts @@ -107,7 +107,8 @@ const EMAIL_TEMPLATE_IS_SYSTEM_REMOVED = */ const MAIL_TEMPLATE_HOLES = "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 ' + + '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`)."; From 03176c9609a6a1293794487e3a60baf798609139 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 13:21:22 +0000 Subject: [PATCH 3/4] docs(spec): regenerate the email_template reference page Claude-Session: https://claude.ai/code/session_01WGufzhX8CnnYtA7h6jieDA Co-authored-by: Claude --- content/docs/references/system/email-template.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/content/docs/references/system/email-template.mdx b/content/docs/references/system/email-template.mdx index a42d91c745e..9ee7f1983c2 100644 --- a/content/docs/references/system/email-template.mdx +++ b/content/docs/references/system/email-template.mdx @@ -49,9 +49,9 @@ const result = EmailTemplateDefinitionSchema.parse(data); | **label** | `string` | ✅ | Display label | | **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. | | **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`. | -| **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`). | -| **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`). | -| **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`). | +| **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`). | +| **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`). | +| **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`). | | **variables** | `{ name: string; type?: Enum<'string' \| 'number' \| 'boolean' \| 'date' \| 'url' \| 'user' \| 'record'>; required?: boolean; description?: string }[]` | optional (default: `[]`) | | | **fromOverride** | `{ name?: string; address: string }` | optional | | | **replyTo** | `string` | optional | | From 4bc8e16d39df2740f685b5aa0d82036ab7d2babb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 14:29:23 +0000 Subject: [PATCH 4/4] docs(formula, plugin-email, lint): disclose the whole double-quoted-argument class in the changesets; drop the stale raw-fallback sentence Claude-Session: https://claude.ai/code/session_01WGufzhX8CnnYtA7h6jieDA Co-authored-by: Claude --- .changeset/27225-mail-formatter-shape-formula.md | 6 +++--- .changeset/27225-mail-formatter-shape-lint.md | 2 +- .changeset/27225-mail-formatter-shape-plugin-email.md | 2 +- content/docs/automation/email-templates.mdx | 2 -- packages/formula/src/template-formatter-shape.test.ts | 4 ++++ 5 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.changeset/27225-mail-formatter-shape-formula.md b/.changeset/27225-mail-formatter-shape-formula.md index f3bb8b5dc9d..968bea04927 100644 --- a/.changeset/27225-mail-formatter-shape-formula.md +++ b/.changeset/27225-mail-formatter-shape-formula.md @@ -4,7 +4,7 @@ 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 -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. No authoring door accepts anything it refused before; 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. +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. Metadata-protocol: narrowed @@ -12,8 +12,8 @@ Metadata-protocol: narrowed **BREAKING** accept-set narrowing in the shared hole grammar. - **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. -- **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. `default:"n/a"` supplied the text with its double quotes. -- **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 `default:"n/a"` supplies `n/a`, as in-app. Brace text whose part before any `|` is not a path (`{{ not a path }}`, `{{ | upper }}`) is still not a hole. +- **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`. +- **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. - **In-app.** No accept set moves: its compile already refused these holes, and keeps its own sentence. - **Who sees it.** A mail template whose hole writes a formatter that is not one word. No template shipped in this repository does. - **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. diff --git a/.changeset/27225-mail-formatter-shape-lint.md b/.changeset/27225-mail-formatter-shape-lint.md index 6b03747a8d5..e2ff851f7a6 100644 --- a/.changeset/27225-mail-formatter-shape-lint.md +++ b/.changeset/27225-mail-formatter-shape-lint.md @@ -4,7 +4,7 @@ feat(lint)!: `email-template-hole-grammar` refuses an email template hole whose text after the `|` is not a formatter name, stating the formatter shape -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. No accept set is widened, and no export or rule id is added or removed. +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. Metadata-protocol: narrowed diff --git a/.changeset/27225-mail-formatter-shape-plugin-email.md b/.changeset/27225-mail-formatter-shape-plugin-email.md index 194497cdae0..44f5e38cf4f 100644 --- a/.changeset/27225-mail-formatter-shape-plugin-email.md +++ b/.changeset/27225-mail-formatter-shape-plugin-email.md @@ -13,7 +13,7 @@ Metadata-protocol: narrowed - **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. - **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. -- **Read as in-app.** `{{ amount | currency :EUR }}` now renders `€1,234.50`, `{{ note | default:it's }}` renders `it's` for an empty value, and `default:"n/a"` supplies `n/a` without its quotes. +- **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. - **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. **The remedy.** Republish the template with the formatter written as one of the names the refusal lists, optionally followed by `:` and its argument. diff --git a/content/docs/automation/email-templates.mdx b/content/docs/automation/email-templates.mdx index e72aa71eae7..e6134a51768 100644 --- a/content/docs/automation/email-templates.mdx +++ b/content/docs/automation/email-templates.mdx @@ -163,8 +163,6 @@ Three properties of the renderer to author around: rendering by design; branching belongs in the caller, which passes in the already-decided values. -An unknown formatter falls back to the raw value rather than failing the render. - ## Declared variables `variables` documents the holes: each entry has a `name` (the path as written in diff --git a/packages/formula/src/template-formatter-shape.test.ts b/packages/formula/src/template-formatter-shape.test.ts index 66d78f39eab..64fa3533766 100644 --- a/packages/formula/src/template-formatter-shape.test.ts +++ b/packages/formula/src/template-formatter-shape.test.ts @@ -24,6 +24,8 @@ const PARTS: ReadonlyArray = [ ["default:it's", true], ["default:'n/a'", true], ['default:"n/a"', true], + ['currency:"EUR"', true], + ['date:"long"', true], ['number: 2', true], ['upper:', true], ['date-long', false], @@ -85,6 +87,8 @@ describe('the mail grammar', () => { expect(parseMailHole(' amount | currency :EUR ')).toEqual({ path: 'amount', formatter: 'currency', arg: 'EUR' }); expect(parseMailHole(" note | default:it's ")).toEqual({ path: 'note', formatter: 'default', arg: "it's" }); expect(parseMailHole(' note | default:"n/a" ')).toEqual({ path: 'note', formatter: 'default', arg: 'n/a' }); + // A double-quoted `currency` code was refused while the mail grammar kept its quotes; it reads as in-app now. + expect(parseMailHole(' amount | currency:"EUR" ')).toEqual({ path: 'amount', formatter: 'currency', arg: 'EUR' }); expect(parseMailHole(' amount | currency :@currency_code ')).toEqual({ path: 'amount', formatter: 'currency',