Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/26660-currency-code-path-formula.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/formula': minor
---

feat(formula): the mail hole grammar admits `currency:@path`, which reads the ISO 4217 code from the send's data; the in-app template dialect refuses that form by name

Clause-②: yes
Metadata-protocol: added

A mail template can now format each record's amount in that record's own currency: `{{ planned_amount | currency:@currency_code }}`. The rule, exported as `CURRENCY_ARGUMENT_RULE`: a `currency` argument is either a literal ISO 4217 code in three capital letters (`currency:EUR`) or, in a mail template only, `@` followed by a path into the send's data whose value is such a code (`currency:@currency_code`).

- **Widened:** `parseMailHole` and `judgeMailHoleGrammar` admit `currency:@path` when the path is a dotted path of word characters with no empty segment, written right after the `@` and not in quotes. The parsed hole carries the path as `codePath`.
- **Unchanged refusals:** a bare argument is still never read as a path, so `currency:currency_code`, `currency:record.currency_code`, a lowercase or empty code and a double-quoted code stay refused (`currency-code-literal`); so do `currency:@`, `currency:@ x`, `currency:'@x'` and `currency:@a..b`. In the mail dialect that refusal now names the marked form as the way to read a code from the data.
- **Declared variables:** `judgeMailHoleGrammar(face, { variables })` refuses a marked path that none of the template's declared variables names, itself or as a root it lies under (`currency-code-path-undeclared`), when the template declares any.
- **In-app dialect:** `templateEngine` (flow text slots, action and bulk-action copy, `titleFormat`, `promptTemplate`) refuses `currency:@path` at compile and evaluate with its own rule, `currency-code-path-mail-only`: the doors that judge that text check a hole's path against the roots a slot binds and never read an argument. It was refused there before too, as `currency-code-literal`.
- **The value:** `currencyCodeValueRefusal(hole, path, value)` names a resolved value that is not an ISO 4217 code in three capital letters exactly as stored (missing, `null`, `eur`, `' EUR'`, a number). `formatValue('currency', …)` still never reads a path and still throws for a non-code argument; no argument still formats in US dollars.

New exports: `CURRENCY_ARGUMENT_RULE`, `currencyCodePath`, `currencyCodeValueRefusal`, and the types `TemplateHoleDialect` and `MailHoleGrammarOptions`. `judgeHoleFormatter` takes an optional fifth argument, the dialect (`'in-app'` by default, `'mail'`), and `TemplateHoleGrammarRule` gains `currency-code-path-mail-only` and `currency-code-path-undeclared`.
12 changes: 12 additions & 0 deletions .changeset/26660-currency-code-path-lint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/lint': minor
---

feat(lint): `email-template-hole-grammar` accepts `currency:@path` and refuses a marked path the template's declared variables do not declare

Clause-②: yes
Metadata-protocol: added

`os validate`, `os build`, `os lint` and the `email_template` publish door accept a mail template hole such as `{{ planned_amount | currency:@currency_code }}`, which reads the ISO 4217 code from the send's data, when its path is well-formed. When the template declares `variables`, a marked path that none of them declares, itself or as a root it lies under (`contract` declares `contract.currency_code`), is refused at `error` (a `422 INVALID_METADATA` at the publish door), naming the hole and the path. A template that declares no variables has the path judged at render.

Everything the rule refused before stays refused: a bare path (`currency:currency_code`), a lowercase or empty code, a quoted or empty marked path, and a second formatter. The finding's hint now quotes the argument rule, `CURRENCY_ARGUMENT_RULE` from `@objectstack/formula`.
14 changes: 14 additions & 0 deletions .changeset/26660-currency-code-path-plugin-email.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/plugin-email': minor
---

feat(plugin-email): a mail template formats an amount in the record's own currency with `currency:@path`

Clause-②: yes
Metadata-protocol: added

`{{ planned_amount | currency:@currency_code }}` formats the amount in the currency whose ISO 4217 code the send's `data` holds at `currency_code`, so one template, in every locale row of its bundle, serves records in many currencies. With `currency_code: 'EUR'` it renders `€1,234.50` in `en-US`; with `'CNY'` it renders `CN¥1,234.50` in `en-US` and `¥1,234.50` in `zh-CN`. The path is resolved like a hole's own path, in all three faces, double- or triple-braced.

The value at the path must be the code in three capital letters, as stored. A missing or `null` value, a lowercase code (`eur`) or any other value makes `renderTemplate` and `sendTemplate` throw `TEMPLATE_INVALID: email template NAME (locale=L): …`, naming the hole, the path and the value, and send nothing. The amount is never formatted in a default currency in its place.

Unchanged: a literal code (`currency:EUR`) formats in that code, no argument formats in US dollars, and a bare path (`currency:currency_code`) is still refused with `TEMPLATE_INVALID`.
12 changes: 12 additions & 0 deletions .changeset/26660-currency-code-path-spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/spec': minor
---

docs(spec): the `email_template` faces state the mail hole grammar, including `currency:@path`, which reads the ISO 4217 code from the send's data

Clause-②: yes
Metadata-protocol: added

`EmailTemplateDefinitionSchema`'s `subject`, `bodyHtml` and `bodyText` descriptions now state the hole grammar a mail template is rendered and judged with: holes are `{{ path }}` or `{{ path | formatter[:arg] }}` over the send's `data`, with one formatter per hole, and a `currency` argument is either a literal ISO 4217 code in three capital letters (`currency:EUR`) or, in a mail template only, `@` followed by a path into the send's data whose value is such a code (`currency:@currency_code`). The `variables[].name` description adds that, when a template declares any variable, the path of a `currency:@path` argument must be one of them or lie under one.

Only descriptions change; no key, type or default does. The form itself is judged by `@objectstack/formula`, refused or accepted at authoring by `@objectstack/lint` and rendered by `@objectstack/plugin-email`.
39 changes: 31 additions & 8 deletions content/docs/automation/email-templates.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -88,14 +88,37 @@ never needs triple braces.
chaining. Decide the value in the caller that supplies the data — a default, a
second format — and pass the finished value as its own variable.

**The `currency` argument is a literal code.** `currency:EUR` formats in euros,
and `currency` with no argument formats in US dollars. The argument is an ISO
4217 code in three capital letters, never a path: `currency:currency_code` does
not read the record's `currency_code`. `os validate`, `os build`, `os lint` and
the metadata publish door refuse any other argument (`email-template-hole-grammar`,
a `422 INVALID_METADATA` at the publish door), naming the hole and the rule. To
show an amount in each record's own currency, format it in the caller that
supplies the data and pass the formatted text as its own variable.
**The `currency` argument is a literal code or a marked path.** 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`). `currency`
with no argument formats in US dollars.

The marked path is how one template shows each record's amount in that record's
own currency:

```ts
subject: 'Instalment overdue: {{ planned_amount | currency:@currency_code }}',
```

With `data` holding `planned_amount: 1234.5` and `currency_code: 'EUR'` it renders
`€1,234.50` in `en-US`; with `currency_code: 'CNY'` it renders `CN¥1,234.50`. The
value at the path must be the code in capitals, as stored: a missing or `null`
value, a lowercase code (`eur`) or anything else makes the render fail with
`TEMPLATE_INVALID`, naming the template, its locale, the hole and the value. The
amount is never formatted in a default currency in its place.

A bare argument is never read as a path: `currency:currency_code` is refused,
and so are a lowercase or empty code, a quoted path and an `@` with no path
after it. `os validate`, `os build`, `os lint` and the metadata publish door
refuse these (`email-template-hole-grammar`, a `422 INVALID_METADATA` at the
publish door), naming the hole and the rule. When the template declares
`variables`, the same doors also refuse a marked path that none of them
declares (a variable `contract` declares `contract.currency_code`).

The `@` form belongs to mail templates. In-app template text (flow
notifications, action and bulk-action copy) takes a literal code only and
refuses `currency:@path` by name.

**Where a hole may sit in `bodyHtml`.** The renderer's escaping is right for
element text and for a quoted attribute value that holds plain text, so those
Expand Down
10 changes: 5 additions & 5 deletions content/docs/references/system/email-template.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| **bodyHtml** | `string` | ✅ | HTML body template |
| **bodyText** | `string` | optional | Plain-text body template (auto-derived from HTML when omitted) |
| **subject** | `string` | ✅ | Subject template. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over the send's `data`, with at most one formatter per hole (a second, chained formatter is refused). A `currency` argument is either a literal ISO 4217 code in three capital letters (`currency:EUR`) or, in a mail template only, `@` followed by a path into the send's data whose value is such a code (`currency:@currency_code`). |
| **bodyHtml** | `string` | ✅ | HTML body template. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over the send's `data`, with at most one formatter per hole (a second, chained formatter is refused). A `currency` argument is either a literal ISO 4217 code in three capital letters (`currency:EUR`) or, in a mail template only, `@` followed by a path into the send's data whose value is such a code (`currency:@currency_code`). |
| **bodyText** | `string` | optional | Plain-text body template (auto-derived from HTML when omitted). Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over the send's `data`, with at most one formatter per hole (a second, chained formatter is refused). A `currency` argument is either a literal ISO 4217 code in three capital letters (`currency:EUR`) or, in a mail template only, `@` followed by a path into the send's data whose value is such a code (`currency:@currency_code`). |
| **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 | |
Expand All @@ -71,7 +71,7 @@ const result = EmailTemplateDefinitionSchema.parse(data);

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

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **name** | `string` | ✅ | Variable name as referenced in placeholders (snake_case or dotted path) |
| **name** | `string` | ✅ | Variable name as referenced in placeholders (snake_case or dotted path). When the template declares any variable, the path of a `currency:@path` argument must be one of them or lie under one. |
| **type** | `Enum<'string' \| 'number' \| 'boolean' \| 'date' \| 'url' \| 'user' \| 'record'>` | optional (default: `"string"`) | |
| **required** | `boolean` | optional (default: `false`) | |
| **description** | `string` | optional | Author hint shown in Studio |
Expand Down
14 changes: 11 additions & 3 deletions packages/formula/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,20 @@ export type {
ParseCelToAstOptions,
} from './cel-engine';
export { cronEngine } from './cron-engine';
export { templateEngine, TEMPLATE_FORMATTERS, formatValue, judgeHoleFormatter } from './template-engine';
export type { TemplateHoleGrammarRefusal, TemplateHoleGrammarRule } from './template-engine';
export {
templateEngine,
TEMPLATE_FORMATTERS,
formatValue,
judgeHoleFormatter,
CURRENCY_ARGUMENT_RULE,
currencyCodePath,
currencyCodeValueRefusal,
} from './template-engine';
export type { TemplateHoleDialect, TemplateHoleGrammarRefusal, TemplateHoleGrammarRule } from './template-engine';
// The mail template hole grammar — one copy, read by `@objectstack/plugin-email`'s
// renderer, the placement judge below and `@objectstack/lint`'s grammar rule.
export { mailHolePattern, parseMailHole, judgeMailHoleGrammar } from './mail-hole-grammar';
export type { ParsedMailHole, MailHoleGrammarFinding } from './mail-hole-grammar';
export type { ParsedMailHole, MailHoleGrammarFinding, MailHoleGrammarOptions } from './mail-hole-grammar';
// #25189 — where a hole may sit in a mail template's HTML face (ruling
// 6335348115 on #25082, F1 A2 / F3 C2). Here because both readers already
// depend on this package and on nothing else they share: `@objectstack/lint`'s
Expand Down
64 changes: 59 additions & 5 deletions packages/formula/src/mail-hole-grammar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,19 @@
* 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.
*
* One argument form is the mail dialect's own: `currency:@path` reads the
* ISO 4217 code from the send's data (`CURRENCY_ARGUMENT_RULE`), so one
* template formats each record's amount in that record's currency. The path
* after the `@` is the same dotted path a hole names. Here it is parsed
* ({@link ParsedMailHole}.`codePath`), checked against the template's declared
* `variables` when it declares any ({@link judgeMailHoleGrammar}), and the
* renderer resolves it and refuses a value that is not a code
* (`currencyCodeValueRefusal`) — it never formats the amount in a default
* currency in its place.
*/

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

/** 1=open (`{{` or `{{{`), 2=brace-free inner body, 3=close (`}}` or `}}}`). */
const MAIL_HOLE_SOURCE = String.raw`(\{\{\{?)([^{}]*)(\}\}\}?)`;
Expand All @@ -52,6 +62,12 @@ export interface ParsedMailHole {
formatter?: string;
/** The formatter's argument, quotes removed; absent when none was written. */
arg?: string;
/**
* Set for `currency:@path`: the path into the send's data the currency code is
* read from (`currency_code`). The renderer resolves it; `arg` keeps the text
* as written (`@currency_code`).
*/
codePath?: string;
/** Set when the grammar refuses the hole: it is not rendered, and authoring refuses it. */
refusal?: TemplateHoleGrammarRefusal;
}
Expand All @@ -76,20 +92,42 @@ export function parseMailHole(inner: string, hole = `{{${inner}}}`): ParsedMailH
// 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);
const refusal = judgeHoleFormatter(hole, formatterPart, name, undefined, 'mail');
return refusal ? { path, formatter: name, refusal } : null;
}
const formatter = m[1];
const arg = m[2];
const refusal = judgeHoleFormatter(hole, formatterPart, formatter, arg);
const refusal = judgeHoleFormatter(hole, formatterPart, formatter, arg, 'mail');
const codePath = refusal ? undefined : currencyCodePath(formatterPart);
return {
path,
formatter,
...(arg !== undefined ? { arg } : {}),
...(codePath !== undefined ? { codePath } : {}),
...(refusal ? { refusal } : {}),
};
}

/**
* True when a `currency:@path` path is one the template declares: a declared
* variable names the path itself, or a root it lies under (`contract`
* declares `contract.currency_code`).
*/
function isDeclaredPath(path: string, variables: readonly string[]): boolean {
return variables.some((name) => name === path || path.startsWith(`${name}.`));
}

/** What {@link judgeMailHoleGrammar} judges a face against, beyond its own text. */
export interface MailHoleGrammarOptions {
/**
* The names of the template's declared `variables`. When the template
* declares any, a `currency:@path` hole whose path none of them declares is
* refused (`currency-code-path-undeclared`). Absent or empty, the path is
* judged at render, where a code that does not resolve is refused.
*/
variables?: readonly string[];
}

/** One hole of a mail template face that the grammar refuses. */
export interface MailHoleGrammarFinding {
/** Offset of the hole's first brace in the face. */
Expand All @@ -100,12 +138,28 @@ export interface MailHoleGrammarFinding {
}

/** Every hole in one mail template face (`subject`, `bodyText` or `bodyHtml`) the grammar refuses, in order. */
export function judgeMailHoleGrammar(face: string): MailHoleGrammarFinding[] {
export function judgeMailHoleGrammar(face: string, options: MailHoleGrammarOptions = {}): MailHoleGrammarFinding[] {
if (typeof face !== 'string' || face === '') return [];
const variables = options.variables ?? [];
const findings: MailHoleGrammarFinding[] = [];
for (const m of face.matchAll(mailHolePattern())) {
const parsed = parseMailHole(m[2], m[0]);
if (parsed?.refusal) findings.push({ start: m.index ?? 0, text: m[0], refusal: parsed.refusal });
if (parsed?.refusal) {
findings.push({ start: m.index ?? 0, text: m[0], refusal: parsed.refusal });
} else if (parsed?.codePath !== undefined && variables.length > 0 && !isDeclaredPath(parsed.codePath, variables)) {
findings.push({
start: m.index ?? 0,
text: m[0],
refusal: {
rule: 'currency-code-path-undeclared',
message:
`the hole ${m[0]} reads its currency code from \`${parsed.codePath}\`, which this template's `
+ `\`variables\` do not declare (it declares ${variables.map((v) => `\`${v}\``).join(', ')}). `
+ `${CURRENCY_ARGUMENT_RULE} Declare \`${parsed.codePath}\` beside them, or read the code from a path `
+ 'they declare.',
},
});
}
}
return findings;
}
Loading
Loading