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>
This commit is contained in:
Alex Dunmow 2026-07-04 02:36:39 +08:00
parent e663b77b47
commit 04991295cf
15 changed files with 1302 additions and 106 deletions

View File

@ -42,6 +42,13 @@ enum Hook {
// Capture the static manifest at publish time:
// payload = DescribeRequest / DescribeResponse.
HOOK_DESCRIBE = 9;
// Invoke a plugin-declared template tag (manifest.declared_tags) that the
// host engine hit while rendering a powered block:
// payload = RenderTagRequest / RenderTagResponse.
HOOK_RENDER_TAG = 10;
// Apply a plugin-declared template filter (manifest.declared_filters):
// payload = ApplyFilterRequest / ApplyFilterResponse.
HOOK_APPLY_FILTER = 11;
}
// InvokeRequest is the hostguest call envelope.

View File

@ -104,6 +104,15 @@ message PluginManifest {
// (`ninja plugin build`) stamps it into the manifest from plugin.mod so
// the loader reads one source. OFF by default.
bool data_dir = 30;
// Template tags the plugin provides (blocks.RegisterTag). For each name the
// host registers a pongo2 tag that invokes the guest via HOOK_RENDER_TAG.
// Captured by DESCRIBE from the blocks tag registry (Register-time).
repeated string declared_tags = 31;
// Template filters the plugin provides (blocks.RegisterFilter). For each
// name the host registers a pongo2 filter that invokes the guest via
// HOOK_APPLY_FILTER. Captured by DESCRIBE from the blocks filter registry.
repeated string declared_filters = 32;
}
// Dependency mirrors plugin.Dependency.

View File

@ -23,7 +23,27 @@ message RenderBlockRequest {
}
message RenderBlockResponse {
// Final rendered HTML for a plain (non-template) block. Ignored when
// `powered` is set.
string html = 1;
// Set when the block is "powered": instead of final HTML, the block hands
// back a template string + data map (blocks.PoweredBlock) and the HOST
// renders it (pongo2/ninjatpl, host-side) AFTER this RENDER_BLOCK call has
// returned. Because the block-invoke has already returned, the guest
// instance is free, so any plugin tag/filter the template hits (RENDER_TAG /
// APPLY_FILTER) is a fresh invoke no re-entrancy. A singular message field
// has explicit presence: nil (GetPowered() == nil) means a plain HTML block.
PoweredBlock powered = 2;
}
// PoweredBlock is a template-backed block result. The host renders `template`
// with `data_json` as the pongo2 data map. See core/docs/wasm-abi.md
// §"Powered blocks (render as a host capability)".
message PoweredBlock {
// ninjatpl/pongo2 template source the host renders host-side.
string template = 1;
// JSON encoding of the template data map (map[string]any).
bytes data_json = 2;
}
// RenderTemplateRequest renders one template (templates.TemplateFunc).
@ -38,6 +58,45 @@ message RenderTemplateResponse {
bytes html = 1;
}
// RenderTagRequest invokes a plugin-declared template tag (HOOK_RENDER_TAG).
// The host wires one pongo2 tag per manifest.declared_tags name; when its
// engine hits that tag while rendering a powered block, it calls back here.
// Re-entrancy-free: the originating RENDER_BLOCK has already returned.
message RenderTagRequest {
string tag_name = 1;
// JSON encoding of the tag's parsed arguments (map[string]any).
bytes args_json = 2;
// The active render context (same envelope RENDER_BLOCK carries), so the
// tag fn can read page/post/request state via blocks.Get*.
RenderContext render_context = 3;
}
// RenderTagResponse returns the tag's rendered HTML. A non-empty `error`
// carries the tag fn's returned error (distinct from an AbiError, which is
// reserved for transport/dispatch/panic failures).
message RenderTagResponse {
string html = 1;
string error = 2;
}
// ApplyFilterRequest invokes a plugin-declared template filter
// (HOOK_APPLY_FILTER). The host wires one pongo2 filter per
// manifest.declared_filters name.
message ApplyFilterRequest {
string filter_name = 1;
// The value the filter is applied to (pongo2 passes strings).
string input = 2;
// JSON encoding of the filter's arguments (map[string]any).
bytes args_json = 3;
}
// ApplyFilterResponse returns the filtered output. A non-empty `error`
// carries the filter fn's returned error.
message ApplyFilterResponse {
string output = 1;
string error = 2;
}
// RenderContext is the explicit envelope of the context values enumerated
// from core/blocks/context.go. Absent sub-messages mean the corresponding
// ctx value was not set (the getters return nil / zero values).

View File

@ -59,6 +59,13 @@ const (
// Capture the static manifest at publish time:
// payload = DescribeRequest / DescribeResponse.
Hook_HOOK_DESCRIBE Hook = 9
// Invoke a plugin-declared template tag (manifest.declared_tags) that the
// host engine hit while rendering a powered block:
// payload = RenderTagRequest / RenderTagResponse.
Hook_HOOK_RENDER_TAG Hook = 10
// Apply a plugin-declared template filter (manifest.declared_filters):
// payload = ApplyFilterRequest / ApplyFilterResponse.
Hook_HOOK_APPLY_FILTER Hook = 11
)
// Enum value maps for Hook.
@ -74,6 +81,8 @@ var (
7: "HOOK_RAG_FETCH",
8: "HOOK_MEDIA_HOOK",
9: "HOOK_DESCRIBE",
10: "HOOK_RENDER_TAG",
11: "HOOK_APPLY_FILTER",
}
Hook_value = map[string]int32{
"HOOK_UNSPECIFIED": 0,
@ -86,6 +95,8 @@ var (
"HOOK_RAG_FETCH": 7,
"HOOK_MEDIA_HOOK": 8,
"HOOK_DESCRIBE": 9,
"HOOK_RENDER_TAG": 10,
"HOOK_APPLY_FILTER": 11,
}
)
@ -1290,7 +1301,7 @@ const file_v1_invoke_proto_rawDesc = "" +
"sourceType\x12\"\n" +
"\rsource_ref_id\x18\a \x01(\tR\vsourceRefId\x12!\n" +
"\fmoderated_by\x18\b \x01(\tR\vmoderatedBy\x12\x12\n" +
"\x04note\x18\t \x01(\tR\x04note*\xcd\x01\n" +
"\x04note\x18\t \x01(\tR\x04note*\xf9\x01\n" +
"\x04Hook\x12\x14\n" +
"\x10HOOK_UNSPECIFIED\x10\x00\x12\x15\n" +
"\x11HOOK_RENDER_BLOCK\x10\x01\x12\x18\n" +
@ -1301,7 +1312,10 @@ const file_v1_invoke_proto_rawDesc = "" +
"\vHOOK_UNLOAD\x10\x06\x12\x12\n" +
"\x0eHOOK_RAG_FETCH\x10\a\x12\x13\n" +
"\x0fHOOK_MEDIA_HOOK\x10\b\x12\x11\n" +
"\rHOOK_DESCRIBE\x10\t*\xf3\x01\n" +
"\rHOOK_DESCRIBE\x10\t\x12\x13\n" +
"\x0fHOOK_RENDER_TAG\x10\n" +
"\x12\x15\n" +
"\x11HOOK_APPLY_FILTER\x10\v*\xf3\x01\n" +
"\fAbiErrorCode\x12\x1e\n" +
"\x1aABI_ERROR_CODE_UNSPECIFIED\x10\x00\x12\x1b\n" +
"\x17ABI_ERROR_CODE_INTERNAL\x10\x01\x12\x19\n" +

View File

@ -104,6 +104,14 @@ type PluginManifest struct {
// (`ninja plugin build`) stamps it into the manifest from plugin.mod so
// the loader reads one source. OFF by default.
DataDir bool `protobuf:"varint,30,opt,name=data_dir,json=dataDir,proto3" json:"data_dir,omitempty"`
// Template tags the plugin provides (blocks.RegisterTag). For each name the
// host registers a pongo2 tag that invokes the guest via HOOK_RENDER_TAG.
// Captured by DESCRIBE from the blocks tag registry (Register-time).
DeclaredTags []string `protobuf:"bytes,31,rep,name=declared_tags,json=declaredTags,proto3" json:"declared_tags,omitempty"`
// Template filters the plugin provides (blocks.RegisterFilter). For each
// name the host registers a pongo2 filter that invokes the guest via
// HOOK_APPLY_FILTER. Captured by DESCRIBE from the blocks filter registry.
DeclaredFilters []string `protobuf:"bytes,32,rep,name=declared_filters,json=declaredFilters,proto3" json:"declared_filters,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -348,6 +356,20 @@ func (x *PluginManifest) GetDataDir() bool {
return false
}
func (x *PluginManifest) GetDeclaredTags() []string {
if x != nil {
return x.DeclaredTags
}
return nil
}
func (x *PluginManifest) GetDeclaredFilters() []string {
if x != nil {
return x.DeclaredFilters
}
return nil
}
// Dependency mirrors plugin.Dependency.
type Dependency struct {
state protoimpl.MessageState `protogen:"open.v1"`
@ -1260,7 +1282,7 @@ var File_v1_manifest_proto protoreflect.FileDescriptor
const file_v1_manifest_proto_rawDesc = "" +
"\n" +
"\x11v1/manifest.proto\x12\x06abi.v1\"\xa2\f\n" +
"\x11v1/manifest.proto\x12\x06abi.v1\"\xf2\f\n" +
"\x0ePluginManifest\x12\x1f\n" +
"\vabi_version\x18\x01 \x01(\rR\n" +
"abiVersion\x12\x12\n" +
@ -1295,7 +1317,9 @@ const file_v1_manifest_proto_rawDesc = "" +
"\x0fhas_media_hooks\x18\x1b \x01(\bR\rhasMediaHooks\x12'\n" +
"\x0fhas_provisioner\x18\x1c \x01(\bR\x0ehasProvisioner\x12N\n" +
"\x15core_service_bindings\x18\x1d \x03(\v2\x1a.abi.v1.CoreServiceBindingR\x13coreServiceBindings\x12\x19\n" +
"\bdata_dir\x18\x1e \x01(\bR\adataDir\x1aB\n" +
"\bdata_dir\x18\x1e \x01(\bR\adataDir\x12#\n" +
"\rdeclared_tags\x18\x1f \x03(\tR\fdeclaredTags\x12)\n" +
"\x10declared_filters\x18 \x03(\tR\x0fdeclaredFilters\x1aB\n" +
"\x14RbacMethodRolesEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"a\n" +

View File

@ -94,7 +94,17 @@ func (x *RenderBlockRequest) GetRenderContext() *RenderContext {
type RenderBlockResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Final rendered HTML for a plain (non-template) block. Ignored when
// `powered` is set.
Html string `protobuf:"bytes,1,opt,name=html,proto3" json:"html,omitempty"`
// Set when the block is "powered": instead of final HTML, the block hands
// back a template string + data map (blocks.PoweredBlock) and the HOST
// renders it (pongo2/ninjatpl, host-side) AFTER this RENDER_BLOCK call has
// returned. Because the block-invoke has already returned, the guest
// instance is free, so any plugin tag/filter the template hits (RENDER_TAG /
// APPLY_FILTER) is a fresh invoke — no re-entrancy. A singular message field
// has explicit presence: nil (GetPowered() == nil) means a plain HTML block.
Powered *PoweredBlock `protobuf:"bytes,2,opt,name=powered,proto3" json:"powered,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -136,6 +146,70 @@ func (x *RenderBlockResponse) GetHtml() string {
return ""
}
func (x *RenderBlockResponse) GetPowered() *PoweredBlock {
if x != nil {
return x.Powered
}
return nil
}
// PoweredBlock is a template-backed block result. The host renders `template`
// with `data_json` as the pongo2 data map. See core/docs/wasm-abi.md
// §"Powered blocks (render as a host capability)".
type PoweredBlock struct {
state protoimpl.MessageState `protogen:"open.v1"`
// ninjatpl/pongo2 template source the host renders host-side.
Template string `protobuf:"bytes,1,opt,name=template,proto3" json:"template,omitempty"`
// JSON encoding of the template data map (map[string]any).
DataJson []byte `protobuf:"bytes,2,opt,name=data_json,json=dataJson,proto3" json:"data_json,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *PoweredBlock) Reset() {
*x = PoweredBlock{}
mi := &file_v1_render_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *PoweredBlock) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*PoweredBlock) ProtoMessage() {}
func (x *PoweredBlock) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use PoweredBlock.ProtoReflect.Descriptor instead.
func (*PoweredBlock) Descriptor() ([]byte, []int) {
return file_v1_render_proto_rawDescGZIP(), []int{2}
}
func (x *PoweredBlock) GetTemplate() string {
if x != nil {
return x.Template
}
return ""
}
func (x *PoweredBlock) GetDataJson() []byte {
if x != nil {
return x.DataJson
}
return nil
}
// RenderTemplateRequest renders one template (templates.TemplateFunc).
type RenderTemplateRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
@ -149,7 +223,7 @@ type RenderTemplateRequest struct {
func (x *RenderTemplateRequest) Reset() {
*x = RenderTemplateRequest{}
mi := &file_v1_render_proto_msgTypes[2]
mi := &file_v1_render_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -161,7 +235,7 @@ func (x *RenderTemplateRequest) String() string {
func (*RenderTemplateRequest) ProtoMessage() {}
func (x *RenderTemplateRequest) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[2]
mi := &file_v1_render_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -174,7 +248,7 @@ func (x *RenderTemplateRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use RenderTemplateRequest.ProtoReflect.Descriptor instead.
func (*RenderTemplateRequest) Descriptor() ([]byte, []int) {
return file_v1_render_proto_rawDescGZIP(), []int{2}
return file_v1_render_proto_rawDescGZIP(), []int{3}
}
func (x *RenderTemplateRequest) GetTemplateKey() string {
@ -207,7 +281,7 @@ type RenderTemplateResponse struct {
func (x *RenderTemplateResponse) Reset() {
*x = RenderTemplateResponse{}
mi := &file_v1_render_proto_msgTypes[3]
mi := &file_v1_render_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -219,7 +293,7 @@ func (x *RenderTemplateResponse) String() string {
func (*RenderTemplateResponse) ProtoMessage() {}
func (x *RenderTemplateResponse) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[3]
mi := &file_v1_render_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -232,7 +306,7 @@ func (x *RenderTemplateResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use RenderTemplateResponse.ProtoReflect.Descriptor instead.
func (*RenderTemplateResponse) Descriptor() ([]byte, []int) {
return file_v1_render_proto_rawDescGZIP(), []int{3}
return file_v1_render_proto_rawDescGZIP(), []int{4}
}
func (x *RenderTemplateResponse) GetHtml() []byte {
@ -242,6 +316,247 @@ func (x *RenderTemplateResponse) GetHtml() []byte {
return nil
}
// RenderTagRequest invokes a plugin-declared template tag (HOOK_RENDER_TAG).
// The host wires one pongo2 tag per manifest.declared_tags name; when its
// engine hits that tag while rendering a powered block, it calls back here.
// Re-entrancy-free: the originating RENDER_BLOCK has already returned.
type RenderTagRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
TagName string `protobuf:"bytes,1,opt,name=tag_name,json=tagName,proto3" json:"tag_name,omitempty"`
// JSON encoding of the tag's parsed arguments (map[string]any).
ArgsJson []byte `protobuf:"bytes,2,opt,name=args_json,json=argsJson,proto3" json:"args_json,omitempty"`
// The active render context (same envelope RENDER_BLOCK carries), so the
// tag fn can read page/post/request state via blocks.Get*.
RenderContext *RenderContext `protobuf:"bytes,3,opt,name=render_context,json=renderContext,proto3" json:"render_context,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *RenderTagRequest) Reset() {
*x = RenderTagRequest{}
mi := &file_v1_render_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RenderTagRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*RenderTagRequest) ProtoMessage() {}
func (x *RenderTagRequest) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use RenderTagRequest.ProtoReflect.Descriptor instead.
func (*RenderTagRequest) Descriptor() ([]byte, []int) {
return file_v1_render_proto_rawDescGZIP(), []int{5}
}
func (x *RenderTagRequest) GetTagName() string {
if x != nil {
return x.TagName
}
return ""
}
func (x *RenderTagRequest) GetArgsJson() []byte {
if x != nil {
return x.ArgsJson
}
return nil
}
func (x *RenderTagRequest) GetRenderContext() *RenderContext {
if x != nil {
return x.RenderContext
}
return nil
}
// RenderTagResponse returns the tag's rendered HTML. A non-empty `error`
// carries the tag fn's returned error (distinct from an AbiError, which is
// reserved for transport/dispatch/panic failures).
type RenderTagResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Html string `protobuf:"bytes,1,opt,name=html,proto3" json:"html,omitempty"`
Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *RenderTagResponse) Reset() {
*x = RenderTagResponse{}
mi := &file_v1_render_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RenderTagResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*RenderTagResponse) ProtoMessage() {}
func (x *RenderTagResponse) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use RenderTagResponse.ProtoReflect.Descriptor instead.
func (*RenderTagResponse) Descriptor() ([]byte, []int) {
return file_v1_render_proto_rawDescGZIP(), []int{6}
}
func (x *RenderTagResponse) GetHtml() string {
if x != nil {
return x.Html
}
return ""
}
func (x *RenderTagResponse) GetError() string {
if x != nil {
return x.Error
}
return ""
}
// ApplyFilterRequest invokes a plugin-declared template filter
// (HOOK_APPLY_FILTER). The host wires one pongo2 filter per
// manifest.declared_filters name.
type ApplyFilterRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
FilterName string `protobuf:"bytes,1,opt,name=filter_name,json=filterName,proto3" json:"filter_name,omitempty"`
// The value the filter is applied to (pongo2 passes strings).
Input string `protobuf:"bytes,2,opt,name=input,proto3" json:"input,omitempty"`
// JSON encoding of the filter's arguments (map[string]any).
ArgsJson []byte `protobuf:"bytes,3,opt,name=args_json,json=argsJson,proto3" json:"args_json,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ApplyFilterRequest) Reset() {
*x = ApplyFilterRequest{}
mi := &file_v1_render_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ApplyFilterRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ApplyFilterRequest) ProtoMessage() {}
func (x *ApplyFilterRequest) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ApplyFilterRequest.ProtoReflect.Descriptor instead.
func (*ApplyFilterRequest) Descriptor() ([]byte, []int) {
return file_v1_render_proto_rawDescGZIP(), []int{7}
}
func (x *ApplyFilterRequest) GetFilterName() string {
if x != nil {
return x.FilterName
}
return ""
}
func (x *ApplyFilterRequest) GetInput() string {
if x != nil {
return x.Input
}
return ""
}
func (x *ApplyFilterRequest) GetArgsJson() []byte {
if x != nil {
return x.ArgsJson
}
return nil
}
// ApplyFilterResponse returns the filtered output. A non-empty `error`
// carries the filter fn's returned error.
type ApplyFilterResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Output string `protobuf:"bytes,1,opt,name=output,proto3" json:"output,omitempty"`
Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ApplyFilterResponse) Reset() {
*x = ApplyFilterResponse{}
mi := &file_v1_render_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ApplyFilterResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ApplyFilterResponse) ProtoMessage() {}
func (x *ApplyFilterResponse) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ApplyFilterResponse.ProtoReflect.Descriptor instead.
func (*ApplyFilterResponse) Descriptor() ([]byte, []int) {
return file_v1_render_proto_rawDescGZIP(), []int{8}
}
func (x *ApplyFilterResponse) GetOutput() string {
if x != nil {
return x.Output
}
return ""
}
func (x *ApplyFilterResponse) GetError() string {
if x != nil {
return x.Error
}
return ""
}
// RenderContext is the explicit envelope of the context values enumerated
// from core/blocks/context.go. Absent sub-messages mean the corresponding
// ctx value was not set (the getters return nil / zero values).
@ -289,7 +604,7 @@ type RenderContext struct {
func (x *RenderContext) Reset() {
*x = RenderContext{}
mi := &file_v1_render_proto_msgTypes[4]
mi := &file_v1_render_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -301,7 +616,7 @@ func (x *RenderContext) String() string {
func (*RenderContext) ProtoMessage() {}
func (x *RenderContext) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[4]
mi := &file_v1_render_proto_msgTypes[9]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -314,7 +629,7 @@ func (x *RenderContext) ProtoReflect() protoreflect.Message {
// Deprecated: Use RenderContext.ProtoReflect.Descriptor instead.
func (*RenderContext) Descriptor() ([]byte, []int) {
return file_v1_render_proto_rawDescGZIP(), []int{4}
return file_v1_render_proto_rawDescGZIP(), []int{9}
}
func (x *RenderContext) GetRequest() *RequestInfo {
@ -463,7 +778,7 @@ type RequestInfo struct {
func (x *RequestInfo) Reset() {
*x = RequestInfo{}
mi := &file_v1_render_proto_msgTypes[5]
mi := &file_v1_render_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -475,7 +790,7 @@ func (x *RequestInfo) String() string {
func (*RequestInfo) ProtoMessage() {}
func (x *RequestInfo) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[5]
mi := &file_v1_render_proto_msgTypes[10]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -488,7 +803,7 @@ func (x *RequestInfo) ProtoReflect() protoreflect.Message {
// Deprecated: Use RequestInfo.ProtoReflect.Descriptor instead.
func (*RequestInfo) Descriptor() ([]byte, []int) {
return file_v1_render_proto_rawDescGZIP(), []int{5}
return file_v1_render_proto_rawDescGZIP(), []int{10}
}
func (x *RequestInfo) GetMethod() string {
@ -575,7 +890,7 @@ type PageContext struct {
func (x *PageContext) Reset() {
*x = PageContext{}
mi := &file_v1_render_proto_msgTypes[6]
mi := &file_v1_render_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -587,7 +902,7 @@ func (x *PageContext) String() string {
func (*PageContext) ProtoMessage() {}
func (x *PageContext) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[6]
mi := &file_v1_render_proto_msgTypes[11]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -600,7 +915,7 @@ func (x *PageContext) ProtoReflect() protoreflect.Message {
// Deprecated: Use PageContext.ProtoReflect.Descriptor instead.
func (*PageContext) Descriptor() ([]byte, []int) {
return file_v1_render_proto_rawDescGZIP(), []int{6}
return file_v1_render_proto_rawDescGZIP(), []int{11}
}
func (x *PageContext) GetId() string {
@ -656,7 +971,7 @@ type PostContext struct {
func (x *PostContext) Reset() {
*x = PostContext{}
mi := &file_v1_render_proto_msgTypes[7]
mi := &file_v1_render_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -668,7 +983,7 @@ func (x *PostContext) String() string {
func (*PostContext) ProtoMessage() {}
func (x *PostContext) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[7]
mi := &file_v1_render_proto_msgTypes[12]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -681,7 +996,7 @@ func (x *PostContext) ProtoReflect() protoreflect.Message {
// Deprecated: Use PostContext.ProtoReflect.Descriptor instead.
func (*PostContext) Descriptor() ([]byte, []int) {
return file_v1_render_proto_rawDescGZIP(), []int{7}
return file_v1_render_proto_rawDescGZIP(), []int{12}
}
func (x *PostContext) GetId() string {
@ -761,7 +1076,7 @@ type AuthorContext struct {
func (x *AuthorContext) Reset() {
*x = AuthorContext{}
mi := &file_v1_render_proto_msgTypes[8]
mi := &file_v1_render_proto_msgTypes[13]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -773,7 +1088,7 @@ func (x *AuthorContext) String() string {
func (*AuthorContext) ProtoMessage() {}
func (x *AuthorContext) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[8]
mi := &file_v1_render_proto_msgTypes[13]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -786,7 +1101,7 @@ func (x *AuthorContext) ProtoReflect() protoreflect.Message {
// Deprecated: Use AuthorContext.ProtoReflect.Descriptor instead.
func (*AuthorContext) Descriptor() ([]byte, []int) {
return file_v1_render_proto_rawDescGZIP(), []int{8}
return file_v1_render_proto_rawDescGZIP(), []int{13}
}
func (x *AuthorContext) GetId() string {
@ -836,7 +1151,7 @@ type CategoryContext struct {
func (x *CategoryContext) Reset() {
*x = CategoryContext{}
mi := &file_v1_render_proto_msgTypes[9]
mi := &file_v1_render_proto_msgTypes[14]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -848,7 +1163,7 @@ func (x *CategoryContext) String() string {
func (*CategoryContext) ProtoMessage() {}
func (x *CategoryContext) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[9]
mi := &file_v1_render_proto_msgTypes[14]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -861,7 +1176,7 @@ func (x *CategoryContext) ProtoReflect() protoreflect.Message {
// Deprecated: Use CategoryContext.ProtoReflect.Descriptor instead.
func (*CategoryContext) Descriptor() ([]byte, []int) {
return file_v1_render_proto_rawDescGZIP(), []int{9}
return file_v1_render_proto_rawDescGZIP(), []int{14}
}
func (x *CategoryContext) GetId() string {
@ -897,7 +1212,7 @@ type MasterPageContext struct {
func (x *MasterPageContext) Reset() {
*x = MasterPageContext{}
mi := &file_v1_render_proto_msgTypes[10]
mi := &file_v1_render_proto_msgTypes[15]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -909,7 +1224,7 @@ func (x *MasterPageContext) String() string {
func (*MasterPageContext) ProtoMessage() {}
func (x *MasterPageContext) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[10]
mi := &file_v1_render_proto_msgTypes[15]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -922,7 +1237,7 @@ func (x *MasterPageContext) ProtoReflect() protoreflect.Message {
// Deprecated: Use MasterPageContext.ProtoReflect.Descriptor instead.
func (*MasterPageContext) Descriptor() ([]byte, []int) {
return file_v1_render_proto_rawDescGZIP(), []int{10}
return file_v1_render_proto_rawDescGZIP(), []int{15}
}
func (x *MasterPageContext) GetId() string {
@ -959,7 +1274,7 @@ type HumanProofBanner struct {
func (x *HumanProofBanner) Reset() {
*x = HumanProofBanner{}
mi := &file_v1_render_proto_msgTypes[11]
mi := &file_v1_render_proto_msgTypes[16]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -971,7 +1286,7 @@ func (x *HumanProofBanner) String() string {
func (*HumanProofBanner) ProtoMessage() {}
func (x *HumanProofBanner) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[11]
mi := &file_v1_render_proto_msgTypes[16]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -984,7 +1299,7 @@ func (x *HumanProofBanner) ProtoReflect() protoreflect.Message {
// Deprecated: Use HumanProofBanner.ProtoReflect.Descriptor instead.
func (*HumanProofBanner) Descriptor() ([]byte, []int) {
return file_v1_render_proto_rawDescGZIP(), []int{11}
return file_v1_render_proto_rawDescGZIP(), []int{16}
}
func (x *HumanProofBanner) GetActiveTimeMinutes() int32 {
@ -1028,7 +1343,7 @@ type DetailRow struct {
func (x *DetailRow) Reset() {
*x = DetailRow{}
mi := &file_v1_render_proto_msgTypes[12]
mi := &file_v1_render_proto_msgTypes[17]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1040,7 +1355,7 @@ func (x *DetailRow) String() string {
func (*DetailRow) ProtoMessage() {}
func (x *DetailRow) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[12]
mi := &file_v1_render_proto_msgTypes[17]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1053,7 +1368,7 @@ func (x *DetailRow) ProtoReflect() protoreflect.Message {
// Deprecated: Use DetailRow.ProtoReflect.Descriptor instead.
func (*DetailRow) Descriptor() ([]byte, []int) {
return file_v1_render_proto_rawDescGZIP(), []int{12}
return file_v1_render_proto_rawDescGZIP(), []int{17}
}
func (x *DetailRow) GetTableId() string {
@ -1125,7 +1440,7 @@ type BlockContext struct {
func (x *BlockContext) Reset() {
*x = BlockContext{}
mi := &file_v1_render_proto_msgTypes[13]
mi := &file_v1_render_proto_msgTypes[18]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1137,7 +1452,7 @@ func (x *BlockContext) String() string {
func (*BlockContext) ProtoMessage() {}
func (x *BlockContext) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[13]
mi := &file_v1_render_proto_msgTypes[18]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1150,7 +1465,7 @@ func (x *BlockContext) ProtoReflect() protoreflect.Message {
// Deprecated: Use BlockContext.ProtoReflect.Descriptor instead.
func (*BlockContext) Descriptor() ([]byte, []int) {
return file_v1_render_proto_rawDescGZIP(), []int{13}
return file_v1_render_proto_rawDescGZIP(), []int{18}
}
func (x *BlockContext) GetUrl() string {
@ -1427,15 +1742,34 @@ const file_v1_render_proto_rawDesc = "" +
"\x12RenderBlockRequest\x12\x1b\n" +
"\tblock_key\x18\x01 \x01(\tR\bblockKey\x12!\n" +
"\fcontent_json\x18\x02 \x01(\fR\vcontentJson\x12<\n" +
"\x0erender_context\x18\x03 \x01(\v2\x15.abi.v1.RenderContextR\rrenderContext\")\n" +
"\x0erender_context\x18\x03 \x01(\v2\x15.abi.v1.RenderContextR\rrenderContext\"Y\n" +
"\x13RenderBlockResponse\x12\x12\n" +
"\x04html\x18\x01 \x01(\tR\x04html\"\x93\x01\n" +
"\x04html\x18\x01 \x01(\tR\x04html\x12.\n" +
"\apowered\x18\x02 \x01(\v2\x14.abi.v1.PoweredBlockR\apowered\"G\n" +
"\fPoweredBlock\x12\x1a\n" +
"\btemplate\x18\x01 \x01(\tR\btemplate\x12\x1b\n" +
"\tdata_json\x18\x02 \x01(\fR\bdataJson\"\x93\x01\n" +
"\x15RenderTemplateRequest\x12!\n" +
"\ftemplate_key\x18\x01 \x01(\tR\vtemplateKey\x12\x19\n" +
"\bdoc_json\x18\x02 \x01(\fR\adocJson\x12<\n" +
"\x0erender_context\x18\x03 \x01(\v2\x15.abi.v1.RenderContextR\rrenderContext\",\n" +
"\x16RenderTemplateResponse\x12\x12\n" +
"\x04html\x18\x01 \x01(\fR\x04html\"\x80\a\n" +
"\x04html\x18\x01 \x01(\fR\x04html\"\x88\x01\n" +
"\x10RenderTagRequest\x12\x19\n" +
"\btag_name\x18\x01 \x01(\tR\atagName\x12\x1b\n" +
"\targs_json\x18\x02 \x01(\fR\bargsJson\x12<\n" +
"\x0erender_context\x18\x03 \x01(\v2\x15.abi.v1.RenderContextR\rrenderContext\"=\n" +
"\x11RenderTagResponse\x12\x12\n" +
"\x04html\x18\x01 \x01(\tR\x04html\x12\x14\n" +
"\x05error\x18\x02 \x01(\tR\x05error\"h\n" +
"\x12ApplyFilterRequest\x12\x1f\n" +
"\vfilter_name\x18\x01 \x01(\tR\n" +
"filterName\x12\x14\n" +
"\x05input\x18\x02 \x01(\tR\x05input\x12\x1b\n" +
"\targs_json\x18\x03 \x01(\fR\bargsJson\"C\n" +
"\x13ApplyFilterResponse\x12\x16\n" +
"\x06output\x18\x01 \x01(\tR\x06output\x12\x14\n" +
"\x05error\x18\x02 \x01(\tR\x05error\"\x80\a\n" +
"\rRenderContext\x12-\n" +
"\arequest\x18\x01 \x01(\v2\x13.abi.v1.RequestInfoR\arequest\x129\n" +
"\rblock_context\x18\x02 \x01(\v2\x14.abi.v1.BlockContextR\fblockContext\x12!\n" +
@ -1589,55 +1923,62 @@ func file_v1_render_proto_rawDescGZIP() []byte {
return file_v1_render_proto_rawDescData
}
var file_v1_render_proto_msgTypes = make([]protoimpl.MessageInfo, 20)
var file_v1_render_proto_msgTypes = make([]protoimpl.MessageInfo, 25)
var file_v1_render_proto_goTypes = []any{
(*RenderBlockRequest)(nil), // 0: abi.v1.RenderBlockRequest
(*RenderBlockResponse)(nil), // 1: abi.v1.RenderBlockResponse
(*RenderTemplateRequest)(nil), // 2: abi.v1.RenderTemplateRequest
(*RenderTemplateResponse)(nil), // 3: abi.v1.RenderTemplateResponse
(*RenderContext)(nil), // 4: abi.v1.RenderContext
(*RequestInfo)(nil), // 5: abi.v1.RequestInfo
(*PageContext)(nil), // 6: abi.v1.PageContext
(*PostContext)(nil), // 7: abi.v1.PostContext
(*AuthorContext)(nil), // 8: abi.v1.AuthorContext
(*CategoryContext)(nil), // 9: abi.v1.CategoryContext
(*MasterPageContext)(nil), // 10: abi.v1.MasterPageContext
(*HumanProofBanner)(nil), // 11: abi.v1.HumanProofBanner
(*DetailRow)(nil), // 12: abi.v1.DetailRow
(*BlockContext)(nil), // 13: abi.v1.BlockContext
nil, // 14: abi.v1.RenderContext.InjectedSlotsEntry
nil, // 15: abi.v1.RequestInfo.HeadersEntry
nil, // 16: abi.v1.RequestInfo.CookiesEntry
nil, // 17: abi.v1.BlockContext.QueryEntry
nil, // 18: abi.v1.BlockContext.CookiesEntry
nil, // 19: abi.v1.BlockContext.HeadersEntry
(*timestamppb.Timestamp)(nil), // 20: google.protobuf.Timestamp
(*PoweredBlock)(nil), // 2: abi.v1.PoweredBlock
(*RenderTemplateRequest)(nil), // 3: abi.v1.RenderTemplateRequest
(*RenderTemplateResponse)(nil), // 4: abi.v1.RenderTemplateResponse
(*RenderTagRequest)(nil), // 5: abi.v1.RenderTagRequest
(*RenderTagResponse)(nil), // 6: abi.v1.RenderTagResponse
(*ApplyFilterRequest)(nil), // 7: abi.v1.ApplyFilterRequest
(*ApplyFilterResponse)(nil), // 8: abi.v1.ApplyFilterResponse
(*RenderContext)(nil), // 9: abi.v1.RenderContext
(*RequestInfo)(nil), // 10: abi.v1.RequestInfo
(*PageContext)(nil), // 11: abi.v1.PageContext
(*PostContext)(nil), // 12: abi.v1.PostContext
(*AuthorContext)(nil), // 13: abi.v1.AuthorContext
(*CategoryContext)(nil), // 14: abi.v1.CategoryContext
(*MasterPageContext)(nil), // 15: abi.v1.MasterPageContext
(*HumanProofBanner)(nil), // 16: abi.v1.HumanProofBanner
(*DetailRow)(nil), // 17: abi.v1.DetailRow
(*BlockContext)(nil), // 18: abi.v1.BlockContext
nil, // 19: abi.v1.RenderContext.InjectedSlotsEntry
nil, // 20: abi.v1.RequestInfo.HeadersEntry
nil, // 21: abi.v1.RequestInfo.CookiesEntry
nil, // 22: abi.v1.BlockContext.QueryEntry
nil, // 23: abi.v1.BlockContext.CookiesEntry
nil, // 24: abi.v1.BlockContext.HeadersEntry
(*timestamppb.Timestamp)(nil), // 25: google.protobuf.Timestamp
}
var file_v1_render_proto_depIdxs = []int32{
4, // 0: abi.v1.RenderBlockRequest.render_context:type_name -> abi.v1.RenderContext
4, // 1: abi.v1.RenderTemplateRequest.render_context:type_name -> abi.v1.RenderContext
5, // 2: abi.v1.RenderContext.request:type_name -> abi.v1.RequestInfo
13, // 3: abi.v1.RenderContext.block_context:type_name -> abi.v1.BlockContext
6, // 4: abi.v1.RenderContext.page:type_name -> abi.v1.PageContext
7, // 5: abi.v1.RenderContext.post:type_name -> abi.v1.PostContext
8, // 6: abi.v1.RenderContext.author:type_name -> abi.v1.AuthorContext
9, // 7: abi.v1.RenderContext.category:type_name -> abi.v1.CategoryContext
10, // 8: abi.v1.RenderContext.master_page:type_name -> abi.v1.MasterPageContext
14, // 9: abi.v1.RenderContext.injected_slots:type_name -> abi.v1.RenderContext.InjectedSlotsEntry
11, // 10: abi.v1.RenderContext.human_proof_banner:type_name -> abi.v1.HumanProofBanner
12, // 11: abi.v1.RenderContext.detail_row:type_name -> abi.v1.DetailRow
15, // 12: abi.v1.RequestInfo.headers:type_name -> abi.v1.RequestInfo.HeadersEntry
16, // 13: abi.v1.RequestInfo.cookies:type_name -> abi.v1.RequestInfo.CookiesEntry
20, // 14: abi.v1.PostContext.published_at:type_name -> google.protobuf.Timestamp
20, // 15: abi.v1.BlockContext.now:type_name -> google.protobuf.Timestamp
17, // 16: abi.v1.BlockContext.query:type_name -> abi.v1.BlockContext.QueryEntry
18, // 17: abi.v1.BlockContext.cookies:type_name -> abi.v1.BlockContext.CookiesEntry
19, // 18: abi.v1.BlockContext.headers:type_name -> abi.v1.BlockContext.HeadersEntry
19, // [19:19] is the sub-list for method output_type
19, // [19:19] is the sub-list for method input_type
19, // [19:19] is the sub-list for extension type_name
19, // [19:19] is the sub-list for extension extendee
0, // [0:19] is the sub-list for field type_name
9, // 0: abi.v1.RenderBlockRequest.render_context:type_name -> abi.v1.RenderContext
2, // 1: abi.v1.RenderBlockResponse.powered:type_name -> abi.v1.PoweredBlock
9, // 2: abi.v1.RenderTemplateRequest.render_context:type_name -> abi.v1.RenderContext
9, // 3: abi.v1.RenderTagRequest.render_context:type_name -> abi.v1.RenderContext
10, // 4: abi.v1.RenderContext.request:type_name -> abi.v1.RequestInfo
18, // 5: abi.v1.RenderContext.block_context:type_name -> abi.v1.BlockContext
11, // 6: abi.v1.RenderContext.page:type_name -> abi.v1.PageContext
12, // 7: abi.v1.RenderContext.post:type_name -> abi.v1.PostContext
13, // 8: abi.v1.RenderContext.author:type_name -> abi.v1.AuthorContext
14, // 9: abi.v1.RenderContext.category:type_name -> abi.v1.CategoryContext
15, // 10: abi.v1.RenderContext.master_page:type_name -> abi.v1.MasterPageContext
19, // 11: abi.v1.RenderContext.injected_slots:type_name -> abi.v1.RenderContext.InjectedSlotsEntry
16, // 12: abi.v1.RenderContext.human_proof_banner:type_name -> abi.v1.HumanProofBanner
17, // 13: abi.v1.RenderContext.detail_row:type_name -> abi.v1.DetailRow
20, // 14: abi.v1.RequestInfo.headers:type_name -> abi.v1.RequestInfo.HeadersEntry
21, // 15: abi.v1.RequestInfo.cookies:type_name -> abi.v1.RequestInfo.CookiesEntry
25, // 16: abi.v1.PostContext.published_at:type_name -> google.protobuf.Timestamp
25, // 17: abi.v1.BlockContext.now:type_name -> google.protobuf.Timestamp
22, // 18: abi.v1.BlockContext.query:type_name -> abi.v1.BlockContext.QueryEntry
23, // 19: abi.v1.BlockContext.cookies:type_name -> abi.v1.BlockContext.CookiesEntry
24, // 20: abi.v1.BlockContext.headers:type_name -> abi.v1.BlockContext.HeadersEntry
21, // [21:21] is the sub-list for method output_type
21, // [21:21] is the sub-list for method input_type
21, // [21:21] is the sub-list for extension type_name
21, // [21:21] is the sub-list for extension extendee
0, // [0:21] is the sub-list for field type_name
}
func init() { file_v1_render_proto_init() }
@ -1651,7 +1992,7 @@ func file_v1_render_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_v1_render_proto_rawDesc), len(file_v1_render_proto_rawDesc)),
NumEnums: 0,
NumMessages: 20,
NumMessages: 25,
NumExtensions: 0,
NumServices: 0,
},

62
blocks/powered.go Normal file
View File

@ -0,0 +1,62 @@
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
}

59
blocks/powered_test.go Normal file
View File

@ -0,0 +1,59 @@
package blocks
import "testing"
func TestPoweredBlockRoundTrip(t *testing.T) {
marker := PoweredBlock("<p>{{ x }}</p>", map[string]any{"x": "y"})
pr, ok := DecodePoweredBlock(marker)
if !ok {
t.Fatal("DecodePoweredBlock did not recognize its own marker")
}
if pr.Template != "<p>{{ x }}</p>" {
t.Errorf("template = %q", pr.Template)
}
if pr.Data["x"] != "y" {
t.Errorf("data = %v", pr.Data)
}
}
func TestDecodePoweredBlockRejectsPlainHTML(t *testing.T) {
if _, ok := DecodePoweredBlock("<p>hello</p>"); ok {
t.Error("plain HTML must not decode as a powered block")
}
if _, ok := DecodePoweredBlock(""); ok {
t.Error("empty string must not decode as a powered block")
}
}
func TestTagFilterRegistry(t *testing.T) {
ResetRegisteredTagsAndFilters()
t.Cleanup(ResetRegisteredTagsAndFilters)
RegisterTag("b", func(map[string]any, RenderContext) (string, error) { return "b", nil })
RegisterTag("a", func(map[string]any, RenderContext) (string, error) { return "a", nil })
RegisterFilter("f", func(string, map[string]any) (string, error) { return "f", nil })
// Empty name / nil fn are ignored.
RegisterTag("", nil)
RegisterFilter("g", nil)
if names := RegisteredTagNames(); len(names) != 2 || names[0] != "a" || names[1] != "b" {
t.Errorf("tag names = %v, want sorted [a b]", names)
}
if names := RegisteredFilterNames(); len(names) != 1 || names[0] != "f" {
t.Errorf("filter names = %v, want [f]", names)
}
if _, ok := LookupTag("a"); !ok {
t.Error("LookupTag(a) missing")
}
if _, ok := LookupFilter("f"); !ok {
t.Error("LookupFilter(f) missing")
}
if _, ok := LookupTag("missing"); ok {
t.Error("LookupTag(missing) should be absent")
}
ResetRegisteredTagsAndFilters()
if RegisteredTagNames() != nil || RegisteredFilterNames() != nil {
t.Error("reset did not clear registries")
}
}

99
blocks/tagfilter.go Normal file
View File

@ -0,0 +1,99 @@
package blocks
import (
"context"
"sort"
)
// RenderContext is the render-time context handed to a plugin template tag.
// It is the standard context.Context that carries the active render values,
// so a tag fn reads page/post/author/request state with the same accessors
// blocks use everywhere else (blocks.GetCurrentPage(rctx),
// blocks.GetRequest(rctx), blocks.GetTemplateKey(rctx), …).
type RenderContext = context.Context
// TagFunc renders a plugin-provided template tag. args holds the tag's parsed
// arguments; rctx is the active render context. It returns the tag's HTML.
type TagFunc func(args map[string]any, rctx RenderContext) (string, error)
// FilterFunc applies a plugin-provided template filter to input. args holds
// the filter's arguments (e.g. {{ x|myfilter:"arg" }}). It returns the
// filtered output.
type FilterFunc func(input string, args map[string]any) (string, error)
// Tag/filter registries are package-level singletons, mirroring
// RegisterTemplateRenderer: in the wasm guest exactly one plugin owns the
// module, so a global registry is the natural home for its RegisterTag /
// RegisterFilter calls. The guest snapshots names into the DESCRIBE manifest
// (declared_tags / declared_filters) and dispatches HOOK_RENDER_TAG /
// HOOK_APPLY_FILTER through LookupTag / LookupFilter.
var (
registeredTags = map[string]TagFunc{}
registeredFilters = map[string]FilterFunc{}
)
// RegisterTag registers a custom template tag under name. Call it from your
// plugin's Register func (alongside block registration) so DESCRIBE captures
// it into manifest.declared_tags and the host wires a pongo2 tag that calls
// back via HOOK_RENDER_TAG. A later registration of the same name wins.
func RegisterTag(name string, fn TagFunc) {
if name == "" || fn == nil {
return
}
registeredTags[name] = fn
}
// RegisterFilter registers a custom template filter under name. Call it from
// your plugin's Register func so DESCRIBE captures it into
// manifest.declared_filters and the host wires a pongo2 filter that calls back
// via HOOK_APPLY_FILTER. A later registration of the same name wins.
func RegisterFilter(name string, fn FilterFunc) {
if name == "" || fn == nil {
return
}
registeredFilters[name] = fn
}
// RegisteredTagNames returns the registered tag names, sorted (deterministic
// manifests). Read by the wasm guest's DESCRIBE handler.
func RegisteredTagNames() []string {
return sortedRegistryKeys(registeredTags)
}
// RegisteredFilterNames returns the registered filter names, sorted.
func RegisteredFilterNames() []string {
return sortedRegistryKeys(registeredFilters)
}
// LookupTag resolves a registered tag fn for HOOK_RENDER_TAG dispatch.
func LookupTag(name string) (TagFunc, bool) {
fn, ok := registeredTags[name]
return fn, ok
}
// LookupFilter resolves a registered filter fn for HOOK_APPLY_FILTER dispatch.
func LookupFilter(name string) (FilterFunc, bool) {
fn, ok := registeredFilters[name]
return fn, ok
}
// ResetRegisteredTagsAndFilters clears the tag/filter registries. The wasm
// guest calls it before running a registration's Register pass so each
// installed plugin starts from a clean slate (and so publish-time DESCRIBE is
// deterministic). Also used by tests.
func ResetRegisteredTagsAndFilters() {
registeredTags = map[string]TagFunc{}
registeredFilters = map[string]FilterFunc{}
}
func sortedRegistryKeys[V any](m map[string]V) []string {
if len(m) == 0 {
return nil
}
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}

View File

@ -229,8 +229,10 @@ Host→guest calls. `InvokeRequest{hook, payload, deadline_ms}` →
| Hook | Request / Response | Fires |
|---|---|---|
| `HOOK_RENDER_BLOCK` | `RenderBlockRequest` / `RenderBlockResponse` | Public render of one plugin block (`blocks.BlockFunc`). |
| `HOOK_RENDER_BLOCK` | `RenderBlockRequest` / `RenderBlockResponse` | Public render of one plugin block (`blocks.BlockFunc`). The response carries **either** final `html` **or** a `powered` `{template, data_json}` result — see [Powered blocks](#powered-blocks--render-as-a-host-capability). |
| `HOOK_RENDER_TEMPLATE` | `RenderTemplateRequest` / `RenderTemplateResponse` | Render of one plugin template (`templates.TemplateFunc`). |
| `HOOK_RENDER_TAG` | `RenderTagRequest` / `RenderTagResponse` | Run a plugin-declared template tag (`manifest.declared_tags`) the host engine hit while rendering a powered block (`blocks.RegisterTag`). Re-entrancy-free: the originating RENDER_BLOCK has returned. |
| `HOOK_APPLY_FILTER` | `ApplyFilterRequest` / `ApplyFilterResponse` | Apply a plugin-declared template filter (`manifest.declared_filters`, `blocks.RegisterFilter`). |
| `HOOK_HANDLE_HTTP` | `HttpRequest` / `HttpResponse` | Buffered HTTP/ConnectRPC request forwarded to the guest's internal mux (only when `manifest.has_http_handler`). No streaming/SSE/WebSocket in v1. |
| `HOOK_JOB` | `JobRequest` / `JobResponse` | Background job dispatch for a `manifest.job_types` entry (`plugin.JobHandlerFunc`). |
| `HOOK_LOAD` | `LoadRequest` / `LoadResponse` | Plugin load (`PluginRegistration.Load`); `LoadRequest.host_config` delivers `AppURL`/`MediaPath`. |
@ -430,7 +432,8 @@ Field-by-field:
| `Name` | `PluginManifest.name` |
| `Version` | `PluginManifest.version` |
| `Dependencies` | `dependencies` (`Dependency`) |
| `Register` | Static effects captured by DESCRIBE: `blocks` (`BlockMeta`), `block_template_overrides`, `template_keys`, `system_templates`, `page_templates`, `email_wrapper_system_keys` |
| `Register` | Static effects captured by DESCRIBE: `blocks` (`BlockMeta`), `block_template_overrides`, `template_keys`, `system_templates`, `page_templates`, `email_wrapper_system_keys`, `declared_tags`, `declared_filters` |
| `blocks.RegisterTag` / `blocks.RegisterFilter` (called in `Register`) | `declared_tags` / `declared_filters` + `HOOK_RENDER_TAG` / `HOOK_APPLY_FILTER` |
| `RegisterWithProvisioner` | Same captures + `has_provisioner` (provisioning runs at load, host-side) |
| `Assets` | `.bnp` artifact `assets/` directory (host serves directly; never crosses the boundary) |
| `Schemas` | `.bnp` artifact `schemas/` directory (host loads into the block registry) |
@ -467,6 +470,107 @@ Function-valued context entries cannot serialize; their v1 mapping:
- `GetQueries` → the `db.*` driver (plugin sqlc code, per-plugin role).
- `SlotRenderer` / `MediaResolver` / `EmbedResolver`**open items** (below).
## Powered blocks — render as a host capability
pongo2/ninjatpl is a **host capability**: the template engine stays host-side
(cms) and is **never** compiled into a guest. A guest-reachable core package
(anything under `blocks/` or `plugin/wasmguest/`) MUST NOT import
`github.com/flosch/pongo2/*` — verified by `go list -deps` on the compiled
guest plugin showing zero `flosch/pongo2`. `core/templates/pongo` (the engine)
is core-resident but host-only; it is not in any guest's dependency graph.
Because the guest can't render a template itself, a template-backed block
**defers** rendering to the host. A `blocks.BlockFunc` returns EITHER:
- **plain HTML** — a non-template block returns its final string as always; or
- **a powered result**`blocks.PoweredBlock(template, data)`: the block
builds its data (via capabilities like `Content.ListPosts`) and hands the
host a `{template, data}` pair instead of final HTML.
`PoweredBlock` encodes the `{template, data}` behind a NUL-delimited sentinel
in the `BlockFunc`'s `string` return, so **`BlockFunc`'s signature is
unchanged** (the smallest additive change — no ripple to existing blocks or the
host guest-side). The guest's RENDER_BLOCK handler decodes the sentinel
(`blocks.DecodePoweredBlock`) and fills `RenderBlockResponse.powered`
(`PoweredBlock{template, data_json}`); a plain HTML block fills
`RenderBlockResponse.html` and leaves `powered` nil.
### Flow (re-entrancy-free by construction)
1. Host calls guest `HOOK_RENDER_BLOCK`. A template-backed block returns a
**powered** result `{template, data_json}`; a plain block returns `html`.
2. **After the block-invoke returns**, the host renders the powered `template`
with `data_json` using pongo2, host-side. The guest instance is now free.
3. When the host engine hits a plugin-declared tag (`{% mytag %}`) or filter
(`{{ x|myfilter }}`), it calls back into the *free* guest instance:
`HOOK_RENDER_TAG {tag_name, args_json, render_context} → {html, error}` or
`HOOK_APPLY_FILTER {filter_name, input, args_json} → {output, error}`. Each
callback is a **fresh** `bn_invoke` — never nested inside the still-running
RENDER_BLOCK — so there is no guest re-entrancy.
### Plugin API (`core/blocks`)
Registered in the plugin's `Register` func (alongside blocks), guest-safe (no
pongo2):
```go
// A block returns a powered result instead of final HTML:
func MyBlock(ctx context.Context, content map[string]any) string {
posts := loadPosts(ctx) // via deps/HostServices capabilities
return blocks.PoweredBlock("{% for p in posts %}<li>{{ p.title }}</li>{% endfor %}",
map[string]any{"posts": posts})
}
// Custom tag: args are the parsed tag arguments; rctx is the render context
// (blocks.RenderContext == context.Context — read state via blocks.Get*).
blocks.RegisterTag("mytag", func(args map[string]any, rctx blocks.RenderContext) (string, error) {
return "<span></span>", nil
})
// Custom filter: input is the piped value; args are the filter arguments.
blocks.RegisterFilter("myfilter", func(input string, args map[string]any) (string, error) {
return strings.ToUpper(input), nil
})
```
`RegisterTag`/`RegisterFilter` write a package-level registry (one plugin owns
a wasm module). The guest resets it at the start of each registration's
`Register` pass (`runRegister`), so DESCRIBE captures exactly this plugin's
names into `declared_tags`/`declared_filters` and HOOK dispatch resolves them
via `blocks.LookupTag`/`LookupFilter`. Register tags/filters **in `Register`**
(not package `init`), so DESCRIBE — which runs `Register` — sees them.
A tag/filter fn error surfaces in `RenderTagResponse.error` /
`ApplyFilterResponse.error` (a normal logic failure the host decides how to
render). `AbiError` stays reserved for transport/decode/panic failures; a panic
in a tag/filter recovers to `ABI_ERROR_CODE_INTERNAL` and the instance stays
callable, matching every other hook.
### Host-side contract (the cms phase implements)
The core half (this SDK) defines the wire + guest dispatch. The cms host must:
1. **Render powered blocks host-side.** After `HOOK_RENDER_BLOCK` returns, if
`RenderBlockResponse.powered` is set, render `powered.template` with
`powered.data_json` (JSON → `map[string]any`) through the existing pongo2
engine (`core/templates/pongo`) and use that as the block's HTML. If `html`
is set instead, use it directly. Do the pongo2 render **outside** the
guest-invoke call so the instance is free for callbacks.
2. **Wire per-plugin callback tags/filters.** At load, read
`manifest.declared_tags` / `manifest.declared_filters` and register, in the
pongo2 environment used to render that plugin's powered blocks, one tag per
declared name that invokes `HOOK_RENDER_TAG` (packing the tag args +
current `RenderContext`) and one filter per declared name that invokes
`HOOK_APPLY_FILTER`. Surface a non-empty response `error` as a render error.
3. **Rely on the re-entrancy guarantee.** Because step 1 renders only after the
block-invoke returned, the callbacks in step 2 are fresh invokes on a free
instance — no special re-entrancy handling is needed.
> Migration note: existing blocks that call `blocks.RenderTemplate` inside the
> block (host-side .so world) switch to returning `blocks.PoweredBlock` in the
> wasm world. This SDK ships the mechanism only; per-plugin migration (e.g.
> assumechaos) is a later phase.
## Open items (flagged for runtime WOs — additive, no major bump needed)
- **AI tool execution**: `AiToolRegisterRequest` registers a tool; executing

View File

@ -8,6 +8,7 @@ import (
"connectrpc.com/connect"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
"git.dev.alexdunmow.com/block/core/blocks"
"git.dev.alexdunmow.com/block/core/plugin"
"git.dev.alexdunmow.com/block/core/plugin/wasmguest/caps"
"git.dev.alexdunmow.com/block/core/rbac"
@ -146,6 +147,14 @@ func (g *guest) buildManifest() *abiv1.PluginManifest {
}
}
m.RequiredIconPacks = append(m.RequiredIconPacks, reg.RequiredIconPacks...)
// Custom template tags/filters the plugin registered via blocks.RegisterTag
// / blocks.RegisterFilter during the Register pass (runRegister reset the
// registry first, so these reflect exactly this plugin). The host wires a
// pongo2 tag/filter per name that calls back via HOOK_RENDER_TAG /
// HOOK_APPLY_FILTER.
m.DeclaredTags = blocks.RegisteredTagNames()
m.DeclaredFilters = blocks.RegisteredFilterNames()
if reg.DirectoryExtensions != nil {
if de := reg.DirectoryExtensions(); de != nil {
pde := &abiv1.DirectoryExtensions{

View File

@ -3,6 +3,7 @@ package wasmguest
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
@ -11,6 +12,7 @@ import (
"time"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
"git.dev.alexdunmow.com/block/core/blocks"
"git.dev.alexdunmow.com/block/core/plugin"
"git.dev.alexdunmow.com/block/core/plugin/wasmguest/bnwasm"
"git.dev.alexdunmow.com/block/core/plugin/wasmguest/caps"
@ -108,6 +110,12 @@ func runRegister(reg plugin.PluginRegistration, tr *captureTemplateRegistry, br
err = fmt.Errorf("panic in Register: %v", r)
}
}()
// Give this registration a clean tag/filter slate. blocks.RegisterTag /
// RegisterFilter write a package-level registry (one plugin owns the wasm
// module), so resetting here makes newGuest deterministic: after Register,
// the registry holds exactly this plugin's tags/filters for DESCRIBE and
// HOOK_RENDER_TAG / HOOK_APPLY_FILTER dispatch.
blocks.ResetRegisteredTagsAndFilters()
// Match the .so loader: block keys are plugin-prefixed and override
// sources stamped via PluginBlockRegistry.
pbr := plugin.NewPluginBlockRegistry(br, reg.Name)
@ -162,6 +170,10 @@ func (g *guest) invoke(hookID uint32, req []byte) (out []byte) {
payload, abiErr = g.renderBlock(ctx, env.GetPayload())
case abiv1.Hook_HOOK_RENDER_TEMPLATE:
payload, abiErr = g.renderTemplate(ctx, env.GetPayload())
case abiv1.Hook_HOOK_RENDER_TAG:
payload, abiErr = g.renderTag(ctx, env.GetPayload())
case abiv1.Hook_HOOK_APPLY_FILTER:
payload, abiErr = g.applyFilter(ctx, env.GetPayload())
case abiv1.Hook_HOOK_HANDLE_HTTP:
payload, abiErr = g.handleHTTP(ctx, env.GetPayload())
case abiv1.Hook_HOOK_JOB:
@ -215,7 +227,75 @@ func (g *guest) renderBlock(ctx context.Context, payload []byte) (proto.Message,
if content == nil {
content = map[string]any{}
}
return &abiv1.RenderBlockResponse{Html: fn(ctx, content)}, nil
out := fn(ctx, content)
// A block may return EITHER final HTML or a "powered" result
// (blocks.PoweredBlock): a template + data the HOST renders with pongo2
// after this call returns. Detect the powered marker and forward it as
// PoweredBlock; otherwise it is plain HTML.
if pr, ok := blocks.DecodePoweredBlock(out); ok {
dataJSON, err := json.Marshal(pr.Data)
if err != nil {
return nil, internalError("marshal powered block data: " + err.Error())
}
return &abiv1.RenderBlockResponse{
Powered: &abiv1.PoweredBlock{Template: pr.Template, DataJson: dataJSON},
}, nil
}
return &abiv1.RenderBlockResponse{Html: out}, nil
}
// renderTag handles HOOK_RENDER_TAG: the host engine hit a plugin-declared
// tag while rendering a powered block and calls back to run it. Re-entrancy-
// free — the originating RENDER_BLOCK has already returned, so this is a fresh
// invoke. A tag fn error surfaces in RenderTagResponse.error (not an AbiError,
// which stays reserved for decode/dispatch/panic failures).
func (g *guest) renderTag(ctx context.Context, payload []byte) (proto.Message, *abiv1.AbiError) {
req := &abiv1.RenderTagRequest{}
if err := proto.Unmarshal(payload, req); err != nil {
return nil, decodeError("RenderTagRequest", err)
}
fn, ok := blocks.LookupTag(req.GetTagName())
if !ok {
return nil, &abiv1.AbiError{
Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED,
Message: "unknown tag " + req.GetTagName(),
}
}
ctx = contextFromRenderContext(ctx, req.GetRenderContext())
args := unmarshalMap(req.GetArgsJson())
if args == nil {
args = map[string]any{}
}
html, err := fn(args, ctx)
if err != nil {
return &abiv1.RenderTagResponse{Error: err.Error()}, nil
}
return &abiv1.RenderTagResponse{Html: html}, nil
}
// applyFilter handles HOOK_APPLY_FILTER: run a plugin-declared filter. Same
// re-entrancy-free callback model as renderTag.
func (g *guest) applyFilter(_ context.Context, payload []byte) (proto.Message, *abiv1.AbiError) {
req := &abiv1.ApplyFilterRequest{}
if err := proto.Unmarshal(payload, req); err != nil {
return nil, decodeError("ApplyFilterRequest", err)
}
fn, ok := blocks.LookupFilter(req.GetFilterName())
if !ok {
return nil, &abiv1.AbiError{
Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED,
Message: "unknown filter " + req.GetFilterName(),
}
}
args := unmarshalMap(req.GetArgsJson())
if args == nil {
args = map[string]any{}
}
out, err := fn(req.GetInput(), args)
if err != nil {
return &abiv1.ApplyFilterResponse{Error: err.Error()}, nil
}
return &abiv1.ApplyFilterResponse{Output: out}, nil
}
func (g *guest) renderTemplate(ctx context.Context, payload []byte) (proto.Message, *abiv1.AbiError) {

View File

@ -0,0 +1,220 @@
package wasmguest
import (
"context"
"errors"
"strings"
"testing"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
"git.dev.alexdunmow.com/block/core/blocks"
"git.dev.alexdunmow.com/block/core/plugin"
"git.dev.alexdunmow.com/block/core/templates"
"google.golang.org/protobuf/proto"
)
// tagFilterRegistration registers a powered block plus a custom tag and
// filter, exercising the render-as-a-host-capability surface (RENDER_TAG,
// APPLY_FILTER, powered RENDER_BLOCK, and DESCRIBE declared_tags/filters).
func tagFilterRegistration() plugin.PluginRegistration {
return plugin.PluginRegistration{
Name: "capfx",
Version: "0.1.0",
Register: func(_ templates.TemplateRegistry, br blocks.BlockRegistry) error {
br.Register(blocks.BlockMeta{Key: "hero", Title: "Hero", Category: blocks.CategoryContent},
func(ctx context.Context, content map[string]any) string {
title, _ := content["title"].(string)
// Powered: return template + data; the host renders it.
return blocks.PoweredBlock("<h1>{{ title }}</h1>", map[string]any{"title": title})
})
blocks.RegisterTag("greet", func(args map[string]any, rctx blocks.RenderContext) (string, error) {
who, _ := args["who"].(string)
if who == "boom" {
panic("tag kaboom")
}
if who == "err" {
return "", errors.New("greet failed")
}
// Reads render context via the standard accessors.
tpl := blocks.GetTemplateKey(rctx)
return "hi " + who + " on " + tpl, nil
})
blocks.RegisterFilter("upper", func(input string, args map[string]any) (string, error) {
if input == "err" {
return "", errors.New("upper failed")
}
return strings.ToUpper(input), nil
})
return nil
},
}
}
func TestDescribeEmitsDeclaredTagsAndFilters(t *testing.T) {
g := newGuest(tagFilterRegistration())
resp := invokeHook(t, g, abiv1.Hook_HOOK_DESCRIBE, &abiv1.DescribeRequest{HostAbiVersion: 1})
if resp.GetError() != nil {
t.Fatalf("describe error: %v", resp.GetError())
}
dr := &abiv1.DescribeResponse{}
if err := proto.Unmarshal(resp.GetPayload(), dr); err != nil {
t.Fatalf("unmarshal: %v", err)
}
m := dr.GetManifest()
if got := m.GetDeclaredTags(); len(got) != 1 || got[0] != "greet" {
t.Errorf("declared_tags = %v, want [greet]", got)
}
if got := m.GetDeclaredFilters(); len(got) != 1 || got[0] != "upper" {
t.Errorf("declared_filters = %v, want [upper]", got)
}
}
// TestDescribeTagRegistryIsPerGuest proves runRegister's reset isolates the
// package-level tag/filter registry per installed registration: a tag-free
// registration must describe zero tags even after a prior tag-registering one.
func TestDescribeTagRegistryIsPerGuest(t *testing.T) {
_ = newGuest(tagFilterRegistration()) // populates the registry
g := newGuest(fixtureRegistration()) // fixtureRegistration registers no tags
resp := invokeHook(t, g, abiv1.Hook_HOOK_DESCRIBE, &abiv1.DescribeRequest{HostAbiVersion: 1})
dr := &abiv1.DescribeResponse{}
if err := proto.Unmarshal(resp.GetPayload(), dr); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got := dr.GetManifest().GetDeclaredTags(); len(got) != 0 {
t.Errorf("declared_tags = %v, want empty (registry not reset per guest)", got)
}
}
func TestPoweredBlockRoundTrip(t *testing.T) {
g := newGuest(tagFilterRegistration())
resp := invokeHook(t, g, abiv1.Hook_HOOK_RENDER_BLOCK, &abiv1.RenderBlockRequest{
BlockKey: "capfx:hero",
ContentJson: []byte(`{"title":"Welcome"}`),
})
if resp.GetError() != nil {
t.Fatalf("render error: %v", resp.GetError())
}
rb := &abiv1.RenderBlockResponse{}
if err := proto.Unmarshal(resp.GetPayload(), rb); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if rb.GetHtml() != "" {
t.Errorf("html = %q, want empty (powered)", rb.GetHtml())
}
p := rb.GetPowered()
if p == nil {
t.Fatal("nil Powered result")
}
if p.GetTemplate() != "<h1>{{ title }}</h1>" {
t.Errorf("template = %q", p.GetTemplate())
}
if !strings.Contains(string(p.GetDataJson()), `"title":"Welcome"`) {
t.Errorf("data_json = %s", p.GetDataJson())
}
}
func TestRenderTagRoundTrip(t *testing.T) {
g := newGuest(tagFilterRegistration())
resp := invokeHook(t, g, abiv1.Hook_HOOK_RENDER_TAG, &abiv1.RenderTagRequest{
TagName: "greet",
ArgsJson: []byte(`{"who":"sam"}`),
RenderContext: &abiv1.RenderContext{TemplateKey: "landing"},
})
if resp.GetError() != nil {
t.Fatalf("render tag error: %v", resp.GetError())
}
tr := &abiv1.RenderTagResponse{}
if err := proto.Unmarshal(resp.GetPayload(), tr); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if tr.GetHtml() != "hi sam on landing" || tr.GetError() != "" {
t.Errorf("html=%q error=%q", tr.GetHtml(), tr.GetError())
}
}
func TestRenderTagFnErrorSurfacesInResponse(t *testing.T) {
g := newGuest(tagFilterRegistration())
resp := invokeHook(t, g, abiv1.Hook_HOOK_RENDER_TAG, &abiv1.RenderTagRequest{
TagName: "greet", ArgsJson: []byte(`{"who":"err"}`),
})
if resp.GetError() != nil {
t.Fatalf("unexpected AbiError: %v", resp.GetError())
}
tr := &abiv1.RenderTagResponse{}
if err := proto.Unmarshal(resp.GetPayload(), tr); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if tr.GetHtml() != "" || !strings.Contains(tr.GetError(), "greet failed") {
t.Errorf("html=%q error=%q, want error carrying 'greet failed'", tr.GetHtml(), tr.GetError())
}
}
func TestRenderTagUnknownIsUnimplemented(t *testing.T) {
g := newGuest(tagFilterRegistration())
resp := invokeHook(t, g, abiv1.Hook_HOOK_RENDER_TAG, &abiv1.RenderTagRequest{TagName: "nope"})
if resp.GetError().GetCode() != abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED {
t.Fatalf("error = %v, want UNIMPLEMENTED", resp.GetError())
}
}
func TestRenderTagPanicRecoversAndStaysCallable(t *testing.T) {
g := newGuest(tagFilterRegistration())
resp := invokeHook(t, g, abiv1.Hook_HOOK_RENDER_TAG, &abiv1.RenderTagRequest{
TagName: "greet", ArgsJson: []byte(`{"who":"boom"}`),
})
if resp.GetError().GetCode() != abiv1.AbiErrorCode_ABI_ERROR_CODE_INTERNAL {
t.Fatalf("error = %v, want INTERNAL", resp.GetError())
}
if !strings.Contains(resp.GetError().GetMessage(), "tag kaboom") {
t.Errorf("message = %q, want panic value", resp.GetError().GetMessage())
}
// Guest stays callable after a recovered tag panic.
resp2 := invokeHook(t, g, abiv1.Hook_HOOK_RENDER_TAG, &abiv1.RenderTagRequest{
TagName: "greet", ArgsJson: []byte(`{"who":"again"}`),
})
if resp2.GetError() != nil {
t.Fatalf("post-panic tag failed: %v", resp2.GetError())
}
}
func TestApplyFilterRoundTrip(t *testing.T) {
g := newGuest(tagFilterRegistration())
resp := invokeHook(t, g, abiv1.Hook_HOOK_APPLY_FILTER, &abiv1.ApplyFilterRequest{
FilterName: "upper", Input: "hello",
})
if resp.GetError() != nil {
t.Fatalf("apply filter error: %v", resp.GetError())
}
fr := &abiv1.ApplyFilterResponse{}
if err := proto.Unmarshal(resp.GetPayload(), fr); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if fr.GetOutput() != "HELLO" || fr.GetError() != "" {
t.Errorf("output=%q error=%q", fr.GetOutput(), fr.GetError())
}
}
func TestApplyFilterFnErrorSurfacesInResponse(t *testing.T) {
g := newGuest(tagFilterRegistration())
resp := invokeHook(t, g, abiv1.Hook_HOOK_APPLY_FILTER, &abiv1.ApplyFilterRequest{
FilterName: "upper", Input: "err",
})
if resp.GetError() != nil {
t.Fatalf("unexpected AbiError: %v", resp.GetError())
}
fr := &abiv1.ApplyFilterResponse{}
if err := proto.Unmarshal(resp.GetPayload(), fr); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if fr.GetOutput() != "" || !strings.Contains(fr.GetError(), "upper failed") {
t.Errorf("output=%q error=%q", fr.GetOutput(), fr.GetError())
}
}
func TestApplyFilterUnknownIsUnimplemented(t *testing.T) {
g := newGuest(tagFilterRegistration())
resp := invokeHook(t, g, abiv1.Hook_HOOK_APPLY_FILTER, &abiv1.ApplyFilterRequest{FilterName: "nope"})
if resp.GetError().GetCode() != abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED {
t.Fatalf("error = %v, want UNIMPLEMENTED", resp.GetError())
}
}

View File

@ -7,6 +7,7 @@ import (
"context"
"fmt"
"io"
"strings"
"git.dev.alexdunmow.com/block/core/blocks"
"git.dev.alexdunmow.com/block/core/plugin"
@ -41,8 +42,22 @@ var Registration = plugin.PluginRegistration{
if name == "" {
name = "world"
}
if powered, _ := content["powered"].(bool); powered {
// Powered path: hand the host a template + data instead of final
// HTML, so the host renders it (pongo2) after this call returns.
return blocks.PoweredBlock("<p>hello {{ name }}</p>", map[string]any{"name": name})
}
return fmt.Sprintf("<p>hello %s</p>", name)
})
// Custom template tag + filter the host wires from manifest.declared_tags
// / declared_filters, invoked via HOOK_RENDER_TAG / HOOK_APPLY_FILTER.
blocks.RegisterTag("shout", func(args map[string]any, rctx blocks.RenderContext) (string, error) {
msg, _ := args["msg"].(string)
return "<strong>" + strings.ToUpper(msg) + "</strong>", nil
})
blocks.RegisterFilter("exclaim", func(input string, args map[string]any) (string, error) {
return input + "!", nil
})
return tr.Register("wasmfixture-page", func(ctx context.Context, doc map[string]any) templates.HTMLComponent {
title, _ := doc["title"].(string)
return htmlString("<!doctype html><title>" + title + "</title>")

View File

@ -188,6 +188,100 @@ func TestWasmFixtureDescribeRoundTrip(t *testing.T) {
}
}
// TestWasmFixtureTagFilterPoweredRoundTrip drives the render-as-a-host-
// capability surface through a REAL compiled wasm module: DESCRIBE must
// surface the plugin's declared tags/filters, a powered block must return a
// {template,data} result (not final HTML), and HOOK_RENDER_TAG /
// HOOK_APPLY_FILTER must dispatch to the plugin's registered fns.
func TestWasmFixtureTagFilterPoweredRoundTrip(t *testing.T) {
if testing.Short() {
t.Skip("compiles a wasm module; skipped in -short")
}
w := instantiateFixture(t)
// DESCRIBE surfaces declared_tags / declared_filters.
resp := w.invoke(t, abiv1.Hook_HOOK_DESCRIBE, &abiv1.DescribeRequest{HostAbiVersion: 1})
if resp.GetError() != nil {
t.Fatalf("DESCRIBE error: %v", resp.GetError())
}
dr := &abiv1.DescribeResponse{}
if err := proto.Unmarshal(resp.GetPayload(), dr); err != nil {
t.Fatalf("unmarshal DescribeResponse: %v", err)
}
m := dr.GetManifest()
if got := m.GetDeclaredTags(); len(got) != 1 || got[0] != "shout" {
t.Errorf("declared_tags = %v, want [shout]", got)
}
if got := m.GetDeclaredFilters(); len(got) != 1 || got[0] != "exclaim" {
t.Errorf("declared_filters = %v, want [exclaim]", got)
}
// A powered block returns {template,data}, not final HTML.
resp = w.invoke(t, abiv1.Hook_HOOK_RENDER_BLOCK, &abiv1.RenderBlockRequest{
BlockKey: "wasmfixture:greeting",
ContentJson: []byte(`{"name":"wazero","powered":true}`),
})
if resp.GetError() != nil {
t.Fatalf("powered RENDER_BLOCK error: %v", resp.GetError())
}
rb := &abiv1.RenderBlockResponse{}
if err := proto.Unmarshal(resp.GetPayload(), rb); err != nil {
t.Fatalf("unmarshal RenderBlockResponse: %v", err)
}
if rb.GetHtml() != "" {
t.Errorf("powered block set html = %q, want empty", rb.GetHtml())
}
p := rb.GetPowered()
if p == nil {
t.Fatal("powered block returned nil Powered result")
}
if p.GetTemplate() != "<p>hello {{ name }}</p>" {
t.Errorf("powered template = %q", p.GetTemplate())
}
if !strings.Contains(string(p.GetDataJson()), `"name":"wazero"`) {
t.Errorf("powered data_json = %s", p.GetDataJson())
}
// HOOK_RENDER_TAG dispatches to the registered tag fn (fresh invoke —
// no re-entrancy; RENDER_BLOCK already returned).
resp = w.invoke(t, abiv1.Hook_HOOK_RENDER_TAG, &abiv1.RenderTagRequest{
TagName: "shout",
ArgsJson: []byte(`{"msg":"hi"}`),
})
if resp.GetError() != nil {
t.Fatalf("RENDER_TAG error: %v", resp.GetError())
}
tr := &abiv1.RenderTagResponse{}
if err := proto.Unmarshal(resp.GetPayload(), tr); err != nil {
t.Fatalf("unmarshal RenderTagResponse: %v", err)
}
if tr.GetHtml() != "<strong>HI</strong>" || tr.GetError() != "" {
t.Errorf("tag result html=%q error=%q", tr.GetHtml(), tr.GetError())
}
// HOOK_APPLY_FILTER dispatches to the registered filter fn.
resp = w.invoke(t, abiv1.Hook_HOOK_APPLY_FILTER, &abiv1.ApplyFilterRequest{
FilterName: "exclaim",
Input: "hello",
})
if resp.GetError() != nil {
t.Fatalf("APPLY_FILTER error: %v", resp.GetError())
}
fr := &abiv1.ApplyFilterResponse{}
if err := proto.Unmarshal(resp.GetPayload(), fr); err != nil {
t.Fatalf("unmarshal ApplyFilterResponse: %v", err)
}
if fr.GetOutput() != "hello!" || fr.GetError() != "" {
t.Errorf("filter result output=%q error=%q", fr.GetOutput(), fr.GetError())
}
// Unknown tag → UNIMPLEMENTED AbiError.
resp = w.invoke(t, abiv1.Hook_HOOK_RENDER_TAG, &abiv1.RenderTagRequest{TagName: "nope"})
if resp.GetError().GetCode() != abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED {
t.Errorf("unknown tag error = %v, want UNIMPLEMENTED", resp.GetError())
}
}
func TestWasmFixturePanicReturnsAbiErrorAndInstanceSurvives(t *testing.T) {
if testing.Short() {
t.Skip("compiles a wasm module; skipped in -short")