diff --git a/abi/proto/v1/invoke.proto b/abi/proto/v1/invoke.proto index 69109b6..2bd6895 100644 --- a/abi/proto/v1/invoke.proto +++ b/abi/proto/v1/invoke.proto @@ -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 host→guest call envelope. diff --git a/abi/proto/v1/manifest.proto b/abi/proto/v1/manifest.proto index 8e75993..63a6736 100644 --- a/abi/proto/v1/manifest.proto +++ b/abi/proto/v1/manifest.proto @@ -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. diff --git a/abi/proto/v1/render.proto b/abi/proto/v1/render.proto index d466c67..62c86db 100644 --- a/abi/proto/v1/render.proto +++ b/abi/proto/v1/render.proto @@ -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). diff --git a/abi/v1/invoke.pb.go b/abi/v1/invoke.pb.go index dfcc2a1..d85eb57 100644 --- a/abi/v1/invoke.pb.go +++ b/abi/v1/invoke.pb.go @@ -59,21 +59,30 @@ 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. var ( Hook_name = map[int32]string{ - 0: "HOOK_UNSPECIFIED", - 1: "HOOK_RENDER_BLOCK", - 2: "HOOK_RENDER_TEMPLATE", - 3: "HOOK_HANDLE_HTTP", - 4: "HOOK_JOB", - 5: "HOOK_LOAD", - 6: "HOOK_UNLOAD", - 7: "HOOK_RAG_FETCH", - 8: "HOOK_MEDIA_HOOK", - 9: "HOOK_DESCRIBE", + 0: "HOOK_UNSPECIFIED", + 1: "HOOK_RENDER_BLOCK", + 2: "HOOK_RENDER_TEMPLATE", + 3: "HOOK_HANDLE_HTTP", + 4: "HOOK_JOB", + 5: "HOOK_LOAD", + 6: "HOOK_UNLOAD", + 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" + diff --git a/abi/v1/manifest.pb.go b/abi/v1/manifest.pb.go index c4f2bca..984b4ee 100644 --- a/abi/v1/manifest.pb.go +++ b/abi/v1/manifest.pb.go @@ -103,9 +103,17 @@ type PluginManifest struct { // outside the guest code, so DESCRIBE cannot populate it: the packer // (`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"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + 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 } func (x *PluginManifest) Reset() { @@ -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" + diff --git a/abi/v1/render.pb.go b/abi/v1/render.pb.go index 33f2b18..4e391ad 100644 --- a/abi/v1/render.pb.go +++ b/abi/v1/render.pb.go @@ -93,8 +93,18 @@ func (x *RenderBlockRequest) GetRenderContext() *RenderContext { } type RenderBlockResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Html string `protobuf:"bytes,1,opt,name=html,proto3" json:"html,omitempty"` + 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, }, diff --git a/blocks/powered.go b/blocks/powered.go new file mode 100644 index 0000000..ff3bd4f --- /dev/null +++ b/blocks/powered.go @@ -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 +} diff --git a/blocks/powered_test.go b/blocks/powered_test.go new file mode 100644 index 0000000..2095c02 --- /dev/null +++ b/blocks/powered_test.go @@ -0,0 +1,59 @@ +package blocks + +import "testing" + +func TestPoweredBlockRoundTrip(t *testing.T) { + marker := PoweredBlock("

{{ x }}

", map[string]any{"x": "y"}) + pr, ok := DecodePoweredBlock(marker) + if !ok { + t.Fatal("DecodePoweredBlock did not recognize its own marker") + } + if pr.Template != "

{{ x }}

" { + 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("

hello

"); 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") + } +} diff --git a/blocks/tagfilter.go b/blocks/tagfilter.go new file mode 100644 index 0000000..c3948ae --- /dev/null +++ b/blocks/tagfilter.go @@ -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 +} diff --git a/docs/wasm-abi.md b/docs/wasm-abi.md index 7e79e47..885a232 100644 --- a/docs/wasm-abi.md +++ b/docs/wasm-abi.md @@ -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 %}
  • {{ p.title }}
  • {% 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 "", 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 diff --git a/plugin/wasmguest/describe.go b/plugin/wasmguest/describe.go index bcfed56..30450ab 100644 --- a/plugin/wasmguest/describe.go +++ b/plugin/wasmguest/describe.go @@ -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{ diff --git a/plugin/wasmguest/dispatch.go b/plugin/wasmguest/dispatch.go index 43f4276..b8c6ea4 100644 --- a/plugin/wasmguest/dispatch.go +++ b/plugin/wasmguest/dispatch.go @@ -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) { diff --git a/plugin/wasmguest/render_capability_test.go b/plugin/wasmguest/render_capability_test.go new file mode 100644 index 0000000..d41f049 --- /dev/null +++ b/plugin/wasmguest/render_capability_test.go @@ -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("

    {{ title }}

    ", 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() != "

    {{ title }}

    " { + 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()) + } +} diff --git a/plugin/wasmguest/testdata/fixture/registration.go b/plugin/wasmguest/testdata/fixture/registration.go index a1d2917..a4561d8 100644 --- a/plugin/wasmguest/testdata/fixture/registration.go +++ b/plugin/wasmguest/testdata/fixture/registration.go @@ -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("

    hello {{ name }}

    ", map[string]any{"name": name}) + } return fmt.Sprintf("

    hello %s

    ", 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 "" + strings.ToUpper(msg) + "", 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("" + title + "") diff --git a/plugin/wasmguest/wasmhost_test.go b/plugin/wasmguest/wasmhost_test.go index 3badc10..9bae060 100644 --- a/plugin/wasmguest/wasmhost_test.go +++ b/plugin/wasmguest/wasmhost_test.go @@ -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() != "

    hello {{ name }}

    " { + 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() != "HI" || 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")