feat(bnp): codeless system/page templates — layouts without code (WO-WZ-020)

manifest.yaml gains system_templates + page_templates declarations; each page
template's .ninjatpl source lives at templates/<system>/<key>.ninjatpl and is
validated at pack time. Verify no longer rejects declared system/page
templates on codeless manifests (guest template_keys stay rejected) — the cms
host registers them source-tracked and renders the files through the same
host pongo pipeline powered blocks use. Blog/system/normal page layouts are
now fully expressible with zero code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Alex Dunmow 2026-07-04 11:50:38 +08:00
parent 4a1d1883cb
commit 52d7413aa0
4 changed files with 48 additions and 4 deletions

View File

@ -39,6 +39,18 @@ type manifestYAML struct {
SettingsSchema string `yaml:"settings_schema"` // JSON file SettingsSchema string `yaml:"settings_schema"` // JSON file
MasterPages string `yaml:"master_pages"` // JSON file ([]masterPageJSON) MasterPages string `yaml:"master_pages"` // JSON file ([]masterPageJSON)
RequiredIconPacks []string `yaml:"required_icon_packs"` RequiredIconPacks []string `yaml:"required_icon_packs"`
SystemTemplates []struct {
Key string `yaml:"key"`
Title string `yaml:"title"`
Description string `yaml:"description"`
} `yaml:"system_templates"`
PageTemplates []struct {
System string `yaml:"system"`
Key string `yaml:"key"`
Title string `yaml:"title"`
Description string `yaml:"description"`
Slots []string `yaml:"slots"`
} `yaml:"page_templates"`
CSS *struct { CSS *struct {
NpmPackages map[string]string `yaml:"npm_packages"` NpmPackages map[string]string `yaml:"npm_packages"`
CSSDirectives []string `yaml:"css_directives"` CSSDirectives []string `yaml:"css_directives"`
@ -382,6 +394,30 @@ func applyManifestYAML(dir string, m *abiv1.PluginManifest) error {
}) })
} }
for _, st := range my.SystemTemplates {
if st.Key == "" || st.Title == "" {
return fmt.Errorf("%s: system_templates entries need key and title", manifestYAMLName)
}
m.SystemTemplates = append(m.SystemTemplates, &abiv1.SystemTemplateMeta{
Key: st.Key, Title: st.Title, Description: st.Description,
})
}
for _, pt := range my.PageTemplates {
if pt.System == "" || pt.Key == "" || pt.Title == "" {
return fmt.Errorf("%s: page_templates entries need system, key, and title", manifestYAMLName)
}
// Layout source convention: templates/<system>/<key>.ninjatpl,
// rendered host-side (core/docs/codeless-bnp.md).
rel := filepath.Join(dirTemplates, pt.System, pt.Key+".ninjatpl")
if err := fileWithin(dir, rel); err != nil {
return fmt.Errorf("%s: page template %s/%s: %w", manifestYAMLName, pt.System, pt.Key, err)
}
m.PageTemplates = append(m.PageTemplates, &abiv1.PageTemplateMeta{
SystemKey: pt.System, Key: pt.Key, Title: pt.Title,
Description: pt.Description, Slots: pt.Slots,
})
}
if my.MasterPages != "" { if my.MasterPages != "" {
mpRaw, err := readJSONFile(my.MasterPages, "master_pages") mpRaw, err := readJSONFile(my.MasterPages, "master_pages")
if err != nil { if err != nil {

View File

@ -29,7 +29,10 @@ func codelessFixture(t *testing.T) string {
writeFixture(t, dir, map[string]string{ writeFixture(t, dir, map[string]string{
"plugin.mod": "[plugin]\nname = \"fixture-theme\"\nversion = \"0.1.0\"\nkind = \"theme\"\n", "plugin.mod": "[plugin]\nname = \"fixture-theme\"\nversion = \"0.1.0\"\nkind = \"theme\"\n",
"manifest.yaml": "theme_presets: presets.json\nrequired_icon_packs: [lucide]\n" + "manifest.yaml": "theme_presets: presets.json\nrequired_icon_packs: [lucide]\n" +
"master_pages: master_pages.json\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>`,
"presets.json": `{"presets":[{"key":"default"}]}`, "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}]}]`, "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" + "blocks/blocks.yaml": "blocks:\n - key: hero\n title: Hero\n category: content\n" +
@ -73,6 +76,9 @@ func TestCodelessBuildAndVerifyRoundTrip(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(m.GetSystemTemplates()) != 1 || len(m.GetPageTemplates()) != 1 || m.GetPageTemplates()[0].GetSystemKey() != "fixture" {
t.Errorf("templates = %v / %v", m.GetSystemTemplates(), m.GetPageTemplates())
}
if !m.GetCodeless() || len(m.GetMasterPages()) != 1 || len(m.GetThemePresets()) == 0 { if !m.GetCodeless() || len(m.GetMasterPages()) != 1 || len(m.GetThemePresets()) == 0 {
t.Errorf("manifest = codeless:%v masters:%d presets:%dB", t.Errorf("manifest = codeless:%v masters:%d presets:%dB",
m.GetCodeless(), len(m.GetMasterPages()), len(m.GetThemePresets())) m.GetCodeless(), len(m.GetMasterPages()), len(m.GetThemePresets()))

View File

@ -159,8 +159,8 @@ func CodelessHookViolation(m *abiv1.PluginManifest) error {
return viol("template tags/filters (express pure snippets as template partials)") return viol("template tags/filters (express pure snippets as template partials)")
case len(m.GetRbacMethodRoles()) > 0 || len(m.GetCoreServiceBindings()) > 0: case len(m.GetRbacMethodRoles()) > 0 || len(m.GetCoreServiceBindings()) > 0:
return viol("Connect services") return viol("Connect services")
case len(m.GetBlocks()) > 0 || len(m.GetTemplateKeys()) > 0 || len(m.GetSystemTemplates()) > 0 || len(m.GetPageTemplates()) > 0: case len(m.GetBlocks()) > 0 || len(m.GetTemplateKeys()) > 0:
return viol("guest-rendered blocks/templates (codeless blocks live in blocks/blocks.yaml)") return viol("guest-rendered blocks/template keys (codeless blocks live in blocks/blocks.yaml; layouts are declared system/page templates rendered host-side)")
case m.GetDirectoryExtensions().GetPanelSectionCount() > 0 || m.GetDirectoryExtensions().GetPinDecoratorCount() > 0: case m.GetDirectoryExtensions().GetPanelSectionCount() > 0 || m.GetDirectoryExtensions().GetPinDecoratorCount() > 0:
return viol("directory extension callbacks") return viol("directory extension callbacks")
case m.GetDataDir(): case m.GetDataDir():

View File

@ -48,7 +48,9 @@ my-theme/
blocks.yaml # key/title/category/schema/template/providers per block blocks.yaml # key/title/category/schema/template/providers per block
hero.schema.json hero.schema.json
hero.ninjatpl hero.ninjatpl
templates/ # reserved for host-rendered page templates (future) templates/ # <system>/<key>.ninjatpl layout sources for the
# system/page templates declared in manifest.yaml,
# rendered host-side (blog/system/normal page layouts)
seed/ seed/
seed.json # settings {merge/override/ensure}, media, pages, menu_items seed.json # settings {merge/override/ensure}, media, pages, menu_items
hero.jpg # media bytes referenced by seed.json entries hero.jpg # media bytes referenced by seed.json entries