feat(bnp): codeless template overrides + email wrappers (WO-WZ-021)

manifest.yaml gains template_overrides [{template, block}] and
email_wrappers [system keys]; sources by convention at
templates/overrides/<template>/<block>.ninjatpl and
templates/email/<system>.ninjatpl (validated at build). Populates the
existing manifest fields BlockTemplateOverrides / EmailWrapperSystemKeys —
no proto change. Unblocks full theme-catalog codeless conversion: every
theme in the fleet registers overrides + an email wrapper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Alex Dunmow 2026-07-04 13:52:32 +08:00
parent 8bd92ea5c4
commit 87bbf9fe46
3 changed files with 82 additions and 4 deletions

View File

@ -51,7 +51,19 @@ type manifestYAML struct {
Description string `yaml:"description"`
Slots []string `yaml:"slots"`
} `yaml:"page_templates"`
CSS *struct {
// TemplateOverrides declare theme-scoped overrides of builtin block
// rendering. Source convention: templates/overrides/<template>/<block>.ninjatpl,
// rendered host-side with the block's content map as the template context.
TemplateOverrides []struct {
Template string `yaml:"template"` // template/system key the override binds to
Block string `yaml:"block"` // builtin block key being overridden
} `yaml:"template_overrides"`
// EmailWrappers declare branded email wrappers per system key. Source
// convention: templates/email/<system>.ninjatpl, rendered host-side with
// body/colors/site/unsubscribe_url/preview_text in the context (emit the
// pre-rendered body with |safe).
EmailWrappers []string `yaml:"email_wrappers"`
CSS *struct {
NpmPackages map[string]string `yaml:"npm_packages"`
CSSDirectives []string `yaml:"css_directives"`
InputCSSAppend string `yaml:"input_css_append"`
@ -418,6 +430,29 @@ func applyManifestYAML(dir string, m *abiv1.PluginManifest) error {
})
}
for _, to := range my.TemplateOverrides {
if to.Template == "" || to.Block == "" {
return fmt.Errorf("%s: template_overrides entries need template and block", manifestYAMLName)
}
rel := filepath.Join(dirTemplates, "overrides", to.Template, to.Block+".ninjatpl")
if err := fileWithin(dir, rel); err != nil {
return fmt.Errorf("%s: template override %s/%s: %w", manifestYAMLName, to.Template, to.Block, err)
}
m.BlockTemplateOverrides = append(m.BlockTemplateOverrides, &abiv1.BlockTemplateOverride{
TemplateKey: to.Template, BlockKey: to.Block,
})
}
for _, ek := range my.EmailWrappers {
if ek == "" {
return fmt.Errorf("%s: email_wrappers entries must be non-empty system keys", manifestYAMLName)
}
rel := filepath.Join(dirTemplates, "email", ek+".ninjatpl")
if err := fileWithin(dir, rel); err != nil {
return fmt.Errorf("%s: email wrapper %s: %w", manifestYAMLName, ek, err)
}
m.EmailWrapperSystemKeys = append(m.EmailWrapperSystemKeys, ek)
}
if my.MasterPages != "" {
mpRaw, err := readJSONFile(my.MasterPages, "master_pages")
if err != nil {

View File

@ -31,8 +31,12 @@ func codelessFixture(t *testing.T) string {
"manifest.yaml": "theme_presets: presets.json\nrequired_icon_packs: [lucide]\n" +
"master_pages: master_pages.json\n" +
"system_templates:\n - key: fixture\n title: Fixture Theme\n" +
"page_templates:\n - system: fixture\n key: landing\n title: Landing\n slots: [main]\n",
"templates/fixture/landing.ninjatpl": `<main>{{ content }}</main>`,
"page_templates:\n - system: fixture\n key: landing\n title: Landing\n slots: [main]\n" +
"template_overrides:\n - template: fixture\n block: heading\n" +
"email_wrappers: [fixture]\n",
"templates/fixture/landing.ninjatpl": `<main>{{ content }}</main>`,
"templates/overrides/fixture/heading.ninjatpl": `<h2 class="deco">{{ text }}</h2>`,
"templates/email/fixture.ninjatpl": `<table><tr><td>{{ body|safe }}</td></tr></table>`,
"presets.json": `{"presets":[{"key":"default"}]}`,
"master_pages.json": `[{"key":"landing","title":"Landing","blocks":[{"block_key":"html","title":"Hero","content":{"x":1},"slot":"main","sort_order":1}]}]`,
"blocks/blocks.yaml": "blocks:\n - key: hero\n title: Hero\n category: content\n" +
@ -86,6 +90,14 @@ func TestCodelessBuildAndVerifyRoundTrip(t *testing.T) {
if len(m.GetRequiredIconPacks()) != 1 || m.GetRequiredIconPacks()[0] != "lucide" {
t.Errorf("icon packs = %v", m.GetRequiredIconPacks())
}
if len(m.GetBlockTemplateOverrides()) != 1 ||
m.GetBlockTemplateOverrides()[0].GetTemplateKey() != "fixture" ||
m.GetBlockTemplateOverrides()[0].GetBlockKey() != "heading" {
t.Errorf("template overrides = %v", m.GetBlockTemplateOverrides())
}
if len(m.GetEmailWrapperSystemKeys()) != 1 || m.GetEmailWrapperSystemKeys()[0] != "fixture" {
t.Errorf("email wrappers = %v", m.GetEmailWrapperSystemKeys())
}
}
func TestIsCodelessRepoFalseWithGoSource(t *testing.T) {
@ -123,6 +135,19 @@ func TestCodelessBuildRejectsBadDeclarations(t *testing.T) {
{"data_dir grant", map[string]string{
"plugin.mod": "[plugin]\nname = \"x\"\nversion = \"0.1.0\"\ndata_dir = true\n",
}},
{"override missing template file", map[string]string{
"plugin.mod": "[plugin]\nname = \"x\"\nversion = \"0.1.0\"\n",
"manifest.yaml": "template_overrides:\n - template: x\n block: heading\n",
}},
{"override missing block key", map[string]string{
"plugin.mod": "[plugin]\nname = \"x\"\nversion = \"0.1.0\"\n",
"manifest.yaml": "template_overrides:\n - template: x\n",
"templates/overrides/x/heading.ninjatpl": `<h1>{{ text }}</h1>`,
}},
{"email wrapper missing template file", map[string]string{
"plugin.mod": "[plugin]\nname = \"x\"\nversion = \"0.1.0\"\n",
"manifest.yaml": "email_wrappers: [x]\n",
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {

View File

@ -28,6 +28,19 @@ plugin is *data the host runs*, not a program that links a library. No Go, no
- Master pages, theme presets, fonts, CSS manifest, icon packs, settings
schema (`manifest.yaml`), assets (host-served), migrations (host-run Goose),
seed data (`seed/seed.json`, applied via the WO-WZ-019 provisioner).
- **Template overrides** (WO-WZ-021): theme-scoped re-renders of builtin
blocks. Declared in `manifest.yaml` as
`template_overrides: [{template: <system-key>, block: <block-key>}]`; the
source lives at `templates/overrides/<template>/<block>.ninjatpl` and is
rendered host-side with the block's content map as the template context.
- **Email wrappers** (WO-WZ-021): `email_wrappers: [<system-key>, …]` in
`manifest.yaml`, source at `templates/email/<system>.ninjatpl`. The context
carries `body` (pre-rendered HTML — emit it with `{{ body|safe }}`),
`colors.*` (snake_case hex tokens: `primary`, `primary_foreground`,
`secondary`, `secondary_foreground`, `background`, `foreground`, `muted`,
`muted_foreground`, `border`, `card`, `card_foreground`), `site.*` (`name`,
`logo_url`, `url`, `support_email`), `unsubscribe_url`, `preview_text`.
Render failure falls back to the unwrapped body.
**Requires code (a wasm plugin):**
- A block whose data no declared provider can produce; a *computing* tag or
@ -43,7 +56,10 @@ my-theme/
plugin.mod # name/version/kind (data_dir forbidden)
manifest.yaml # optional: theme_presets/bundled_fonts/settings_schema
# (JSON file refs), master_pages (JSON file),
# required_icon_packs, css {...}, dependencies [...]
# required_icon_packs, css {...}, dependencies [...],
# system_templates/page_templates,
# template_overrides [{template, block}],
# email_wrappers [system keys]
blocks/
blocks.yaml # key/title/category/schema/template/providers per block
hero.schema.json
@ -51,6 +67,8 @@ my-theme/
templates/ # <system>/<key>.ninjatpl layout sources for the
# system/page templates declared in manifest.yaml,
# rendered host-side (blog/system/normal page layouts)
overrides/<system>/<block>.ninjatpl # template_overrides sources
email/<system>.ninjatpl # email_wrappers sources
seed/
seed.json # settings {merge/override/ensure}, media, pages, menu_items
hero.jpg # media bytes referenced by seed.json entries