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:
parent
e663b77b47
commit
04991295cf
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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" +
|
||||
|
||||
@ -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" +
|
||||
|
||||
@ -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
62
blocks/powered.go
Normal 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
59
blocks/powered_test.go
Normal 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
99
blocks/tagfilter.go
Normal 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
|
||||
}
|
||||
108
docs/wasm-abi.md
108
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 %}<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
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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) {
|
||||
|
||||
220
plugin/wasmguest/render_capability_test.go
Normal file
220
plugin/wasmguest/render_capability_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
@ -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>")
|
||||
|
||||
@ -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")
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user