core/blocks/powered.go
Alex Dunmow 04991295cf feat(abi,blocks): ninjatpl rendering as a host capability (powered blocks + tag/filter callbacks)
pongo2 stays host-side and is never compiled into a guest; plugins render
templates by handing the host a {template, data} pair, and the host calls
back into the free guest instance for plugin-declared tags/filters.

ABI (additive, buf-breaking clean):
- RenderBlockResponse gains a `powered` PoweredBlock{template, data_json};
  a block returns EITHER html OR powered.
- New hooks HOOK_RENDER_TAG (10) / HOOK_APPLY_FILTER (11) with
  RenderTag{Request,Response} and ApplyFilter{Request,Response}.
- PluginManifest gains repeated declared_tags / declared_filters (31/32).

Guest SDK (core/blocks, core/plugin/wasmguest):
- blocks.PoweredBlock(template, data) / DecodePoweredBlock: NUL-sentinel
  marker so BlockFunc's string signature is unchanged (smallest additive
  change — no ripple to existing blocks or the host guest-side).
- blocks.RegisterTag / RegisterFilter (+ RenderContext = context.Context)
  write a package-level registry; runRegister resets it per registration
  for deterministic DESCRIBE + dispatch.
- DESCRIBE emits declared_tags/filters; dispatch handles RENDER_TAG /
  APPLY_FILTER (fn errors → response.error; panics → AbiError INTERNAL,
  instance stays callable).

Docs: core/docs/wasm-abi.md gains the render-as-a-host-capability model,
the powered-block flow (re-entrancy-free), the plugin API, and the
HOST-SIDE CONTRACT the cms phase implements.

Tests: unit round-trips for powered/RENDER_TAG/APPLY_FILTER (dispatch +
error + unknown + panic + per-guest registry isolation) plus a real
wazero round-trip through the compiled fixture module. Verified no
guest-reachable package imports pongo2 (go list -deps on the wasip1
fixture build is clean).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 02:36:39 +08:00

63 lines
2.6 KiB
Go

package blocks
import (
"encoding/json"
"strings"
)
// poweredBlockSentinel prefixes a PoweredBlock marker string. It uses NUL
// bytes so it can never collide with real block HTML: a BlockFunc returns a
// plain HTML string today, and this marker is a distinct, out-of-band signal
// that the block is "powered" (template + data, rendered host-side).
const poweredBlockSentinel = "\x00bn:powered\x00"
// PoweredResult is the decoded payload of a PoweredBlock marker: the template
// source and its data map. The wasm guest's RENDER_BLOCK handler decodes it
// and forwards it as abiv1.PoweredBlock so the HOST renders the template with
// pongo2 — after the block-invoke has returned, keeping the guest free.
type PoweredResult struct {
Template string `json:"template"`
Data map[string]any `json:"data"`
}
// PoweredBlock marks a block's return value as "powered": instead of final
// HTML, the block hands back a template string plus a data map, and the host
// renders it (pongo2/ninjatpl) host-side. Return its result directly from a
// BlockFunc:
//
// func MyBlock(ctx context.Context, content map[string]any) string {
// posts := loadPosts(ctx) // build data via capabilities
// return blocks.PoweredBlock(tmpl, map[string]any{"posts": posts})
// }
//
// This is the guest-safe replacement for calling blocks.RenderTemplate inside
// a block: pongo2 never crosses the wasm boundary, so the guest cannot render
// itself — it defers rendering to the host. Because the host renders only
// after RENDER_BLOCK returns, any plugin-declared tag/filter the template
// hits ({% mytag %} / |myfilter) is a fresh RENDER_TAG / APPLY_FILTER invoke,
// never a re-entrant one.
func PoweredBlock(template string, data map[string]any) string {
payload, err := json.Marshal(PoweredResult{Template: template, Data: data})
if err != nil {
// A non-serializable data map is a programming error; fall back to an
// empty-data powered result so the template still renders.
payload, _ = json.Marshal(PoweredResult{Template: template})
}
return poweredBlockSentinel + string(payload)
}
// DecodePoweredBlock reports whether s is a PoweredBlock marker and, if so,
// returns the decoded template + data. The wasm guest uses it to distinguish a
// powered result from plain HTML. A non-marker (ordinary HTML) returns ok=false.
func DecodePoweredBlock(s string) (PoweredResult, bool) {
rest, ok := strings.CutPrefix(s, poweredBlockSentinel)
if !ok {
return PoweredResult{}, false
}
var pr PoweredResult
if err := json.Unmarshal([]byte(rest), &pr); err != nil {
return PoweredResult{}, false
}
return pr, true
}