You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 7f4258f
Browse filesBrowse the repository at this point in the historyBrowse files
docs(spec): the email template locale docs describe the ladder #28880 landed (#29014)
<!-- claude-projects-attribution: {"github_login":"os-steve"} -->
_Requested by **Steve** · [project
thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9T4EekKNS3mPkuvkK2fCJrT)_Fixes#28896
Clause-②: no
Metadata-protocol: none
## 维护者速读
- 改了什么:spec 里讲邮件模板 locale 解析的说明文字(`SendTemplateInput`
两段注释、`email-template.zod.ts` 的常量与 `locale` 注释及对外 JSON schema 的
describe、`defineStack` 缺 `en-US` 行时的提醒文字)改成 #28880 落地后的真实规则;只改文字,不改
schema、不改提醒何时触发。
- 为什么改:#28880 之后内置模板加载器会按 BCP-47 折叠(`en-GB`→`en`、`zh-TW`→`zh-CN`),再试栈声明的
fallback,再试 `en-US`;旧文字还说“精确匹配、只重试 `en-US`、不折叠”,作者照着写会做错判断。
- 风险与回滚:只有注释、describe 和一条 `console.warn` 文案,运行行为不变;回滚即 revert 本 PR。
- 先看:`packages/spec/src/system/email-template.zod.ts`(describe
是对外发布的文字)、`packages/spec/src/contracts/email-service.ts`(`SendTemplateInput.locale`
是唯一完整写出两条阶梯的地方)、`packages/spec/src/stack.zod.ts`(提醒文案)。
- 两分钟验证:`pnpm --filter @objectstack/spec exec vitest run
src/stack-email-template-locale-floor.test.ts`,预期全绿(现有钉住这段文字的断言都还成立)。
- 需要你做的:无,席位落地(落地前需一次契约复审档复核)。
## What changes
Text only. No schema shape, accept set, export or warning trigger
changes. `en` and `en-US` stay two rows of one bundle; only how the
resolver picks the row is described anew, as landed in #28917
(`e0dc0771ab`).
- **`packages/spec/src/contracts/email-service.ts`**:
`SendTemplateInput.template` now says the name is matched exactly and
every rung reads one row at one tag exactly. `SendTemplateInput.locale`
carries both ladders as the code has them
(`EmailService.resolveAndRenderTemplate`): a named locale over the
built-in registry loader folds by `resolveBundleLocale` (exact,
case-insensitive, base language, variant expansion, first match in
ascending tag order), then the declared fallback
(`II18nService.getFallbackLocale()`: `i18n.fallbackLocale`, else
`defaultLocale`) folded the same way, then `en-US`, then
`TEMPLATE_NOT_FOUND`; a rung resolving to a tag already tried is
skipped; a replacement `TemplateLoader` keeps the exact ladder; a
no-locale call keeps `en-US` then the lowest tag. The two
`TEMPLATE_NOT_FOUND` error lines (`sendTemplate`, `renderTemplate`) no
longer say `(name, locale|en-US)`.
- **`packages/spec/src/system/email-template.zod.ts`**: the
`EMAIL_TEMPLATE_FLOOR_LOCALE` docblock (the floor is now the rung that
depends on neither the fold nor the declared fallback), the `locale`
docblock, and the `locale` `.describe()` served in the JSON schema.
- **`packages/spec/src/stack.zod.ts`** (bounded in-place fix, same
stale-ladder class, not in the card's list):
`warnEmailTemplateLocaleFloor`'s docblock and its `console.warn` text
said "matches exactly and retries only `en-US` — there is no
language-subtag folding". The text now describes the fold, the declared
fallback and the exact ladder a replacement loader keeps. Which bundles
it fires on is unchanged (see Acceptance notes).
- **`packages/spec/src/stack-email-template-locale-floor.test.ts`**: the
file's header comment only (it stated the old ladder in the present
tense). No assertion changed.
- Regenerated `content/docs/references/system/email-template.mdx` (the
`locale` row) and a spec `patch` changeset.
## Tests
Forced test edits: none. Every existing pin on this wording still holds
(`'en-US'`, `TEMPLATE_NOT_FOUND`, `/NAMES a locale/`, `/lowest locale
tag/` in the describe; `'no fallback floor'`, `"none tagged 'en-US'"`,
`"carries rows for 'en-GB'"` in the warning).
<details><summary>Readings at 862c7bd</summary>
- `pnpm --filter @objectstack/spec test` (under `os-verify-lock`): Test
Files 789 passed (789), Tests 23325 passed | 1 todo.
- Consumer: `pnpm --filter @objectstack/plugin-email test` after `pnpm
--filter '@objectstack/plugin-email^...' build`: Test Files 47 passed
(47), Tests 764 passed (764), `VERDICT command-exit 0`. This includes
`template-locale-fold.test.ts` and `template-locale-resolution.test.ts`,
which pin the ladder this text describes.
- `pnpm --filter @objectstack/spec check:generated`: 1 of 14 stale
(`check:docs`) before regeneration; `--fix` re-check `✓ check:docs`.
- The `repo` vitest project
(`email-template-floor-locale-parity.pin.test.ts`) is CI's (`Test
Core`); not run locally, the file is unchanged.
</details>
## Acceptance notes
- **The floor warning now also fires on bundles the built-in loader
serves.** `THE_TRAP` in the floor test (`defaultLocale: 'en'`,
`fallbackLocale: 'en'`, an `en` row, no `en-US` row) reaches every
recipient over the built-in loader on a host whose i18n service answers
that fallback, yet still warns. The warning still names a real gap (a
replacement `TemplateLoader`, or a host that declares no fallback), and
its text now says so. Whether its trigger should change is a behaviour
question on an authoring diagnostic, left to the seat.
- **Text outside `packages/spec` that still states the exact ladder**
(not touched here): `content/docs/automation/email-templates.mdx`
L217–233 (hand-written docs);
`packages/metadata-core/src/item-key-discriminators.ts` L13–19 (a code
docblock citing the old ladder);
`docs/qa/platform-checklist/areas/integration-system.json` L957 (a
checklist clause asserting "EXACT match with no language-subtag
folding").
- Card vs code: they agree. One detail the card did not state: a
replacement `TemplateLoader` gets neither the fold nor the declared
fallback rung (pinned in `template-locale-fold.test.ts`), and the text
says so.
## Verification
<details><summary>Gate list</summary>
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` at 862c7bd derived 106 commands; each was
run with stdin closed and its exit captured before any pipe. 103 exited
0 on the first sweep; three exited 3 (prerequisite not met, nothing
measured) and exited 0 after their prerequisite:
`check-plugin-teardown-shape.mjs --self-test` after `git fetch --depth=1
origin 621a487` (its pinned fixture), `check:skill-examples` and
`check:dual-build-cjs-loads` after `turbo run build
--filter='./packages/**'`. `--ran` reconciles 106 derived, 106 run, 0
NOT-MEASURED, 0 UNRUN. `pnpm --filter @objectstack/spec typecheck`:
`VERDICT command-exit 0`. `origin/main` moved 15 commits since the
branch point (`135054eb`); none touches a file this PR changes, and `git
merge-tree` merges clean, so main was not merged in before opening.
</details>
---
_Generated by [Claude
Code](https://claude.ai/code/session_01ADHkmRewLso9227jPeGYag)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
docs(spec): the email template locale docs describe the ladder #28880 landed (#28896)
6
+
7
+
Clause-②: no
8
+
Metadata-protocol: none
9
+
10
+
The `SendTemplateInput.template` / `.locale` docs, the `EMAIL_TEMPLATE_FLOOR_LOCALE` docblock, the `EmailTemplateDefinitionSchema.locale` docblock and its served `describe`, and the `defineStack` warning for a bundle with no `en-US` row said the resolver matches the named locale exactly and retries only `en-US`, with no language-subtag folding. Since #28880, over the built-in template loader, a call that names a locale folds it against the template's own locales (exact, case-insensitive, base language, variant expansion), then tries the stack's declared fallback locale (`i18n.fallbackLocale`, else `defaultLocale`) folded the same way, then `en-US`, then fails with `TEMPLATE_NOT_FOUND`. The texts now say so, and say that a replacement `TemplateLoader` keeps the exact ladder and that a call naming no locale keeps its own ladder (`en-US`, then the bundle's lowest tag).
11
+
12
+
Text only: no schema shape, accept set or warning trigger changes. `en` and `en-US` are still two rows of one bundle.
|**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. |
51
-
|**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`. |
51
+
| **locale** | `string` | optional (default: `"en-US"`) | BCP-47 locale (e.g. en-US, zh-CN) — the bundle key. Each rung of the resolver reads one row at one tag exactly, so `en` and `en-US` are different ROWS of one bundle. A call that NAMES a locale, over the built-in template loader, folds it against the bundle's tags (exact, case-insensitive, base language, then variant expansion: `en-GB` reaches `en`, `zh-TW` reaches `zh-CN`), then folds the stack's declared fallback locale (`i18n.fallbackLocale`, else `defaultLocale`) the same way, then tries the literal `en-US`; a replacement template loader matches the tag exactly and retries only `en-US`. `en-US` is the floor every loader honours: when no rung lands on a row the call raises TEMPLATE_NOT_FOUND, which is permanent — the delivery dead-letters with no retry. A call naming NO locale folds nothing and does not dead-letter: it starts at `en-US` and otherwise drops to the bundle's lowest locale tag and renders that silently. Tag the English row `en-US` so every loader has the floor. |
52
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
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
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`). |
0 commit comments