feat(abi): add keyed page and render response contracts

This commit is contained in:
Alex Dunmow 2026-08-19 21:50:28 +08:00
parent 301f286c2f
commit fb58679b6f
22 changed files with 890 additions and 233 deletions

View File

@ -770,6 +770,7 @@ message ProvisionerEnsureSettingResponse {}
// PageSeed mirrors plugin.PageConfig.
message PageSeed {
string slug = 1;
// Legacy slug-based parent reference. Prefer parent_key for new page seeds.
string parent_slug = 2;
string title = 3;
string template_key = 4;
@ -779,6 +780,16 @@ message PageSeed {
string detail_slug_field = 8;
bool reconcile_blocks = 9;
bool reconcile_template = 10;
// Stable identity scoped to the declaring plugin.
string key = 11;
// Stable identity of a parent page declared by the same plugin.
string parent_key = 12;
// Applied only when the page is created; later reconciliation does not move
// an existing page when this value changes.
string mount_path = 13;
// Shallowly add absent top-level defaults to matching plugin-owned block
// assignments while preserving all present values, including empty values.
bool merge_missing_block_content = 14;
}
message ProvisionerEnsurePageRequest {
@ -802,6 +813,10 @@ message ProvisionerEnsureMenuItemRequest {
string url = 3;
string page_slug = 4;
int32 sort_order = 5;
// Stable plugin/page-key target. When set, these identify the provisioned
// page independently of its current slug.
string page_plugin = 6;
string page_key = 7;
}
message ProvisionerEnsureMenuItemResponse {}

View File

@ -44,6 +44,23 @@ message PoweredBlock {
string template = 1;
// JSON encoding of the template data map (map[string]any).
bytes data_json = 2;
// Requested HTTP response status. Before writing headers, the host accepts
// only 200599, uses the highest status class, and keeps the first result
// encountered within that class.
int32 status_code = 3;
// Structured document metadata merged by the host into the page head. The
// first non-empty value for each field wins in deterministic render order.
DocumentMetadata metadata = 4;
}
// DocumentMetadata is the safe, shared subset of document head data a plugin
// may provide. The host validates and escapes every value before rendering.
message DocumentMetadata {
string title = 1;
string description = 2;
string canonical_url = 3;
string image_url = 4;
string robots = 5;
}
// RenderTemplateRequest renders one template (templates.TemplateFunc).
@ -76,6 +93,11 @@ message TemplateDocument {
// Extra <html>-element attributes (e.g. data-theme). A "lang" key here is
// ignored the lang field wins.
map<string, string> html_attrs = 6;
// Requested HTTP response status. The host applies the same validation and
// precedence rules as PoweredBlock.status_code.
int32 status_code = 7;
// Structured document metadata merged by the host into the page head.
DocumentMetadata metadata = 8;
}
// RenderTagRequest invokes a plugin-declared template tag (HOOK_RENDER_TAG).
@ -181,6 +203,10 @@ message PageContext {
string title = 3;
string post_type = 4; // "page", "post", "master", "system"
string status = 5; // "published", "draft", "scheduled"
// Slug derived from the resolved page hierarchy.
string derived_slug = 6;
// Effective mount path after host inheritance and normalization.
string mount_path = 7;
}
// PostContext mirrors blocks.PostContext.
@ -276,4 +302,8 @@ message BlockContext {
bytes detail_row_data_json = 36;
string blog_index_url = 37;
string category_page_url = 38;
// Slug derived from the resolved page hierarchy.
string derived_slug = 39;
// Effective mount path after host inheritance and normalization.
string mount_path = 40;
}

View File

@ -6668,19 +6668,30 @@ func (*ProvisionerEnsureSettingResponse) Descriptor() ([]byte, []int) {
// PageSeed mirrors plugin.PageConfig.
type PageSeed struct {
state protoimpl.MessageState `protogen:"open.v1"`
Slug string `protobuf:"bytes,1,opt,name=slug,proto3" json:"slug,omitempty"`
ParentSlug string `protobuf:"bytes,2,opt,name=parent_slug,json=parentSlug,proto3" json:"parent_slug,omitempty"`
Title string `protobuf:"bytes,3,opt,name=title,proto3" json:"title,omitempty"`
TemplateKey string `protobuf:"bytes,4,opt,name=template_key,json=templateKey,proto3" json:"template_key,omitempty"`
Blocks []*PageBlock `protobuf:"bytes,5,rep,name=blocks,proto3" json:"blocks,omitempty"`
DetailSourceType string `protobuf:"bytes,6,opt,name=detail_source_type,json=detailSourceType,proto3" json:"detail_source_type,omitempty"`
DetailSourceKey string `protobuf:"bytes,7,opt,name=detail_source_key,json=detailSourceKey,proto3" json:"detail_source_key,omitempty"`
DetailSlugField string `protobuf:"bytes,8,opt,name=detail_slug_field,json=detailSlugField,proto3" json:"detail_slug_field,omitempty"`
ReconcileBlocks bool `protobuf:"varint,9,opt,name=reconcile_blocks,json=reconcileBlocks,proto3" json:"reconcile_blocks,omitempty"`
ReconcileTemplate bool `protobuf:"varint,10,opt,name=reconcile_template,json=reconcileTemplate,proto3" json:"reconcile_template,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
state protoimpl.MessageState `protogen:"open.v1"`
Slug string `protobuf:"bytes,1,opt,name=slug,proto3" json:"slug,omitempty"`
// Legacy slug-based parent reference. Prefer parent_key for new page seeds.
ParentSlug string `protobuf:"bytes,2,opt,name=parent_slug,json=parentSlug,proto3" json:"parent_slug,omitempty"`
Title string `protobuf:"bytes,3,opt,name=title,proto3" json:"title,omitempty"`
TemplateKey string `protobuf:"bytes,4,opt,name=template_key,json=templateKey,proto3" json:"template_key,omitempty"`
Blocks []*PageBlock `protobuf:"bytes,5,rep,name=blocks,proto3" json:"blocks,omitempty"`
DetailSourceType string `protobuf:"bytes,6,opt,name=detail_source_type,json=detailSourceType,proto3" json:"detail_source_type,omitempty"`
DetailSourceKey string `protobuf:"bytes,7,opt,name=detail_source_key,json=detailSourceKey,proto3" json:"detail_source_key,omitempty"`
DetailSlugField string `protobuf:"bytes,8,opt,name=detail_slug_field,json=detailSlugField,proto3" json:"detail_slug_field,omitempty"`
ReconcileBlocks bool `protobuf:"varint,9,opt,name=reconcile_blocks,json=reconcileBlocks,proto3" json:"reconcile_blocks,omitempty"`
ReconcileTemplate bool `protobuf:"varint,10,opt,name=reconcile_template,json=reconcileTemplate,proto3" json:"reconcile_template,omitempty"`
// Stable identity scoped to the declaring plugin.
Key string `protobuf:"bytes,11,opt,name=key,proto3" json:"key,omitempty"`
// Stable identity of a parent page declared by the same plugin.
ParentKey string `protobuf:"bytes,12,opt,name=parent_key,json=parentKey,proto3" json:"parent_key,omitempty"`
// Applied only when the page is created; later reconciliation does not move
// an existing page when this value changes.
MountPath string `protobuf:"bytes,13,opt,name=mount_path,json=mountPath,proto3" json:"mount_path,omitempty"`
// Shallowly add absent top-level defaults to matching plugin-owned block
// assignments while preserving all present values, including empty values.
MergeMissingBlockContent bool `protobuf:"varint,14,opt,name=merge_missing_block_content,json=mergeMissingBlockContent,proto3" json:"merge_missing_block_content,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *PageSeed) Reset() {
@ -6783,6 +6794,34 @@ func (x *PageSeed) GetReconcileTemplate() bool {
return false
}
func (x *PageSeed) GetKey() string {
if x != nil {
return x.Key
}
return ""
}
func (x *PageSeed) GetParentKey() string {
if x != nil {
return x.ParentKey
}
return ""
}
func (x *PageSeed) GetMountPath() string {
if x != nil {
return x.MountPath
}
return ""
}
func (x *PageSeed) GetMergeMissingBlockContent() bool {
if x != nil {
return x.MergeMissingBlockContent
}
return false
}
type ProvisionerEnsurePageRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Page *PageSeed `protobuf:"bytes,1,opt,name=page,proto3" json:"page,omitempty"`
@ -6947,12 +6986,16 @@ func (*ProvisionerOverrideSiteSettingsResponse) Descriptor() ([]byte, []int) {
// ProvisionerEnsureMenuItemRequest mirrors plugin.MenuItemConfig under a
// named menu.
type ProvisionerEnsureMenuItemRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
MenuName string `protobuf:"bytes,1,opt,name=menu_name,json=menuName,proto3" json:"menu_name,omitempty"`
Label string `protobuf:"bytes,2,opt,name=label,proto3" json:"label,omitempty"`
Url string `protobuf:"bytes,3,opt,name=url,proto3" json:"url,omitempty"`
PageSlug string `protobuf:"bytes,4,opt,name=page_slug,json=pageSlug,proto3" json:"page_slug,omitempty"`
SortOrder int32 `protobuf:"varint,5,opt,name=sort_order,json=sortOrder,proto3" json:"sort_order,omitempty"`
state protoimpl.MessageState `protogen:"open.v1"`
MenuName string `protobuf:"bytes,1,opt,name=menu_name,json=menuName,proto3" json:"menu_name,omitempty"`
Label string `protobuf:"bytes,2,opt,name=label,proto3" json:"label,omitempty"`
Url string `protobuf:"bytes,3,opt,name=url,proto3" json:"url,omitempty"`
PageSlug string `protobuf:"bytes,4,opt,name=page_slug,json=pageSlug,proto3" json:"page_slug,omitempty"`
SortOrder int32 `protobuf:"varint,5,opt,name=sort_order,json=sortOrder,proto3" json:"sort_order,omitempty"`
// Stable plugin/page-key target. When set, these identify the provisioned
// page independently of its current slug.
PagePlugin string `protobuf:"bytes,6,opt,name=page_plugin,json=pagePlugin,proto3" json:"page_plugin,omitempty"`
PageKey string `protobuf:"bytes,7,opt,name=page_key,json=pageKey,proto3" json:"page_key,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -7022,6 +7065,20 @@ func (x *ProvisionerEnsureMenuItemRequest) GetSortOrder() int32 {
return 0
}
func (x *ProvisionerEnsureMenuItemRequest) GetPagePlugin() string {
if x != nil {
return x.PagePlugin
}
return ""
}
func (x *ProvisionerEnsureMenuItemRequest) GetPageKey() string {
if x != nil {
return x.PageKey
}
return ""
}
type ProvisionerEnsureMenuItemResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
@ -8786,7 +8843,7 @@ const file_v1_capability_proto_rawDesc = "" +
"\x1fProvisionerEnsureSettingRequest\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12,\n" +
"\x12default_value_json\x18\x02 \x01(\fR\x10defaultValueJson\"\"\n" +
" ProvisionerEnsureSettingResponse\"\x83\x03\n" +
" ProvisionerEnsureSettingResponse\"\x92\x04\n" +
"\bPageSeed\x12\x12\n" +
"\x04slug\x18\x01 \x01(\tR\x04slug\x12\x1f\n" +
"\vparent_slug\x18\x02 \x01(\tR\n" +
@ -8799,20 +8856,29 @@ const file_v1_capability_proto_rawDesc = "" +
"\x11detail_slug_field\x18\b \x01(\tR\x0fdetailSlugField\x12)\n" +
"\x10reconcile_blocks\x18\t \x01(\bR\x0freconcileBlocks\x12-\n" +
"\x12reconcile_template\x18\n" +
" \x01(\bR\x11reconcileTemplate\"D\n" +
" \x01(\bR\x11reconcileTemplate\x12\x10\n" +
"\x03key\x18\v \x01(\tR\x03key\x12\x1d\n" +
"\n" +
"parent_key\x18\f \x01(\tR\tparentKey\x12\x1d\n" +
"\n" +
"mount_path\x18\r \x01(\tR\tmountPath\x12=\n" +
"\x1bmerge_missing_block_content\x18\x0e \x01(\bR\x18mergeMissingBlockContent\"D\n" +
"\x1cProvisionerEnsurePageRequest\x12$\n" +
"\x04page\x18\x01 \x01(\v2\x10.abi.v1.PageSeedR\x04page\"\x1f\n" +
"\x1dProvisionerEnsurePageResponse\"O\n" +
"&ProvisionerOverrideSiteSettingsRequest\x12%\n" +
"\x0eoverrides_json\x18\x01 \x01(\fR\roverridesJson\")\n" +
"'ProvisionerOverrideSiteSettingsResponse\"\xa3\x01\n" +
"'ProvisionerOverrideSiteSettingsResponse\"\xdf\x01\n" +
" ProvisionerEnsureMenuItemRequest\x12\x1b\n" +
"\tmenu_name\x18\x01 \x01(\tR\bmenuName\x12\x14\n" +
"\x05label\x18\x02 \x01(\tR\x05label\x12\x10\n" +
"\x03url\x18\x03 \x01(\tR\x03url\x12\x1b\n" +
"\tpage_slug\x18\x04 \x01(\tR\bpageSlug\x12\x1d\n" +
"\n" +
"sort_order\x18\x05 \x01(\x05R\tsortOrder\"#\n" +
"sort_order\x18\x05 \x01(\x05R\tsortOrder\x12\x1f\n" +
"\vpage_plugin\x18\x06 \x01(\tR\n" +
"pagePlugin\x12\x19\n" +
"\bpage_key\x18\a \x01(\tR\apageKey\"#\n" +
"!ProvisionerEnsureMenuItemResponse\"\x87\x01\n" +
")ProvisionerRegisterEmbeddingConfigRequest\x12\x1b\n" +
"\ttable_key\x18\x01 \x01(\tR\btableKey\x12#\n" +

View File

@ -161,7 +161,14 @@ type PoweredBlock struct {
// 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"`
DataJson []byte `protobuf:"bytes,2,opt,name=data_json,json=dataJson,proto3" json:"data_json,omitempty"`
// Requested HTTP response status. Before writing headers, the host accepts
// only 200599, uses the highest status class, and keeps the first result
// encountered within that class.
StatusCode int32 `protobuf:"varint,3,opt,name=status_code,json=statusCode,proto3" json:"status_code,omitempty"`
// Structured document metadata merged by the host into the page head. The
// first non-empty value for each field wins in deterministic render order.
Metadata *DocumentMetadata `protobuf:"bytes,4,opt,name=metadata,proto3" json:"metadata,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -210,6 +217,98 @@ func (x *PoweredBlock) GetDataJson() []byte {
return nil
}
func (x *PoweredBlock) GetStatusCode() int32 {
if x != nil {
return x.StatusCode
}
return 0
}
func (x *PoweredBlock) GetMetadata() *DocumentMetadata {
if x != nil {
return x.Metadata
}
return nil
}
// DocumentMetadata is the safe, shared subset of document head data a plugin
// may provide. The host validates and escapes every value before rendering.
type DocumentMetadata struct {
state protoimpl.MessageState `protogen:"open.v1"`
Title string `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"`
Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"`
CanonicalUrl string `protobuf:"bytes,3,opt,name=canonical_url,json=canonicalUrl,proto3" json:"canonical_url,omitempty"`
ImageUrl string `protobuf:"bytes,4,opt,name=image_url,json=imageUrl,proto3" json:"image_url,omitempty"`
Robots string `protobuf:"bytes,5,opt,name=robots,proto3" json:"robots,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DocumentMetadata) Reset() {
*x = DocumentMetadata{}
mi := &file_v1_render_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DocumentMetadata) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DocumentMetadata) ProtoMessage() {}
func (x *DocumentMetadata) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[3]
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 DocumentMetadata.ProtoReflect.Descriptor instead.
func (*DocumentMetadata) Descriptor() ([]byte, []int) {
return file_v1_render_proto_rawDescGZIP(), []int{3}
}
func (x *DocumentMetadata) GetTitle() string {
if x != nil {
return x.Title
}
return ""
}
func (x *DocumentMetadata) GetDescription() string {
if x != nil {
return x.Description
}
return ""
}
func (x *DocumentMetadata) GetCanonicalUrl() string {
if x != nil {
return x.CanonicalUrl
}
return ""
}
func (x *DocumentMetadata) GetImageUrl() string {
if x != nil {
return x.ImageUrl
}
return ""
}
func (x *DocumentMetadata) GetRobots() string {
if x != nil {
return x.Robots
}
return ""
}
// RenderTemplateRequest renders one template (templates.TemplateFunc).
type RenderTemplateRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
@ -223,7 +322,7 @@ type RenderTemplateRequest struct {
func (x *RenderTemplateRequest) Reset() {
*x = RenderTemplateRequest{}
mi := &file_v1_render_proto_msgTypes[3]
mi := &file_v1_render_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -235,7 +334,7 @@ func (x *RenderTemplateRequest) String() string {
func (*RenderTemplateRequest) ProtoMessage() {}
func (x *RenderTemplateRequest) 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 {
@ -248,7 +347,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{3}
return file_v1_render_proto_rawDescGZIP(), []int{4}
}
func (x *RenderTemplateRequest) GetTemplateKey() string {
@ -281,7 +380,7 @@ type RenderTemplateResponse struct {
func (x *RenderTemplateResponse) Reset() {
*x = RenderTemplateResponse{}
mi := &file_v1_render_proto_msgTypes[4]
mi := &file_v1_render_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -293,7 +392,7 @@ func (x *RenderTemplateResponse) String() string {
func (*RenderTemplateResponse) ProtoMessage() {}
func (x *RenderTemplateResponse) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[4]
mi := &file_v1_render_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -306,7 +405,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{4}
return file_v1_render_proto_rawDescGZIP(), []int{5}
}
func (x *RenderTemplateResponse) GetDocument() *TemplateDocument {
@ -329,14 +428,19 @@ type TemplateDocument struct {
BodyEndExtraHtml []byte `protobuf:"bytes,5,opt,name=body_end_extra_html,json=bodyEndExtraHtml,proto3" json:"body_end_extra_html,omitempty"` // appended before </body>, after bn.BodyEnd
// Extra <html>-element attributes (e.g. data-theme). A "lang" key here is
// ignored — the lang field wins.
HtmlAttrs map[string]string `protobuf:"bytes,6,rep,name=html_attrs,json=htmlAttrs,proto3" json:"html_attrs,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
HtmlAttrs map[string]string `protobuf:"bytes,6,rep,name=html_attrs,json=htmlAttrs,proto3" json:"html_attrs,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
// Requested HTTP response status. The host applies the same validation and
// precedence rules as PoweredBlock.status_code.
StatusCode int32 `protobuf:"varint,7,opt,name=status_code,json=statusCode,proto3" json:"status_code,omitempty"`
// Structured document metadata merged by the host into the page head.
Metadata *DocumentMetadata `protobuf:"bytes,8,opt,name=metadata,proto3" json:"metadata,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *TemplateDocument) Reset() {
*x = TemplateDocument{}
mi := &file_v1_render_proto_msgTypes[5]
mi := &file_v1_render_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -348,7 +452,7 @@ func (x *TemplateDocument) String() string {
func (*TemplateDocument) ProtoMessage() {}
func (x *TemplateDocument) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[5]
mi := &file_v1_render_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -361,7 +465,7 @@ func (x *TemplateDocument) ProtoReflect() protoreflect.Message {
// Deprecated: Use TemplateDocument.ProtoReflect.Descriptor instead.
func (*TemplateDocument) Descriptor() ([]byte, []int) {
return file_v1_render_proto_rawDescGZIP(), []int{5}
return file_v1_render_proto_rawDescGZIP(), []int{6}
}
func (x *TemplateDocument) GetBodyHtml() []byte {
@ -406,6 +510,20 @@ func (x *TemplateDocument) GetHtmlAttrs() map[string]string {
return nil
}
func (x *TemplateDocument) GetStatusCode() int32 {
if x != nil {
return x.StatusCode
}
return 0
}
func (x *TemplateDocument) GetMetadata() *DocumentMetadata {
if x != nil {
return x.Metadata
}
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.
@ -424,7 +542,7 @@ type RenderTagRequest struct {
func (x *RenderTagRequest) Reset() {
*x = RenderTagRequest{}
mi := &file_v1_render_proto_msgTypes[6]
mi := &file_v1_render_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -436,7 +554,7 @@ func (x *RenderTagRequest) String() string {
func (*RenderTagRequest) ProtoMessage() {}
func (x *RenderTagRequest) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[6]
mi := &file_v1_render_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -449,7 +567,7 @@ func (x *RenderTagRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use RenderTagRequest.ProtoReflect.Descriptor instead.
func (*RenderTagRequest) Descriptor() ([]byte, []int) {
return file_v1_render_proto_rawDescGZIP(), []int{6}
return file_v1_render_proto_rawDescGZIP(), []int{7}
}
func (x *RenderTagRequest) GetTagName() string {
@ -486,7 +604,7 @@ type RenderTagResponse struct {
func (x *RenderTagResponse) Reset() {
*x = RenderTagResponse{}
mi := &file_v1_render_proto_msgTypes[7]
mi := &file_v1_render_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -498,7 +616,7 @@ func (x *RenderTagResponse) String() string {
func (*RenderTagResponse) ProtoMessage() {}
func (x *RenderTagResponse) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[7]
mi := &file_v1_render_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -511,7 +629,7 @@ func (x *RenderTagResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use RenderTagResponse.ProtoReflect.Descriptor instead.
func (*RenderTagResponse) Descriptor() ([]byte, []int) {
return file_v1_render_proto_rawDescGZIP(), []int{7}
return file_v1_render_proto_rawDescGZIP(), []int{8}
}
func (x *RenderTagResponse) GetHtml() string {
@ -544,7 +662,7 @@ type ApplyFilterRequest struct {
func (x *ApplyFilterRequest) Reset() {
*x = ApplyFilterRequest{}
mi := &file_v1_render_proto_msgTypes[8]
mi := &file_v1_render_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -556,7 +674,7 @@ func (x *ApplyFilterRequest) String() string {
func (*ApplyFilterRequest) ProtoMessage() {}
func (x *ApplyFilterRequest) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[8]
mi := &file_v1_render_proto_msgTypes[9]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -569,7 +687,7 @@ func (x *ApplyFilterRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use ApplyFilterRequest.ProtoReflect.Descriptor instead.
func (*ApplyFilterRequest) Descriptor() ([]byte, []int) {
return file_v1_render_proto_rawDescGZIP(), []int{8}
return file_v1_render_proto_rawDescGZIP(), []int{9}
}
func (x *ApplyFilterRequest) GetFilterName() string {
@ -605,7 +723,7 @@ type ApplyFilterResponse struct {
func (x *ApplyFilterResponse) Reset() {
*x = ApplyFilterResponse{}
mi := &file_v1_render_proto_msgTypes[9]
mi := &file_v1_render_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -617,7 +735,7 @@ func (x *ApplyFilterResponse) String() string {
func (*ApplyFilterResponse) ProtoMessage() {}
func (x *ApplyFilterResponse) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[9]
mi := &file_v1_render_proto_msgTypes[10]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -630,7 +748,7 @@ func (x *ApplyFilterResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use ApplyFilterResponse.ProtoReflect.Descriptor instead.
func (*ApplyFilterResponse) Descriptor() ([]byte, []int) {
return file_v1_render_proto_rawDescGZIP(), []int{9}
return file_v1_render_proto_rawDescGZIP(), []int{10}
}
func (x *ApplyFilterResponse) GetOutput() string {
@ -694,7 +812,7 @@ type RenderContext struct {
func (x *RenderContext) Reset() {
*x = RenderContext{}
mi := &file_v1_render_proto_msgTypes[10]
mi := &file_v1_render_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -706,7 +824,7 @@ func (x *RenderContext) String() string {
func (*RenderContext) ProtoMessage() {}
func (x *RenderContext) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[10]
mi := &file_v1_render_proto_msgTypes[11]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -719,7 +837,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{10}
return file_v1_render_proto_rawDescGZIP(), []int{11}
}
func (x *RenderContext) GetRequest() *RequestInfo {
@ -868,7 +986,7 @@ type RequestInfo struct {
func (x *RequestInfo) Reset() {
*x = RequestInfo{}
mi := &file_v1_render_proto_msgTypes[11]
mi := &file_v1_render_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -880,7 +998,7 @@ func (x *RequestInfo) String() string {
func (*RequestInfo) ProtoMessage() {}
func (x *RequestInfo) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[11]
mi := &file_v1_render_proto_msgTypes[12]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -893,7 +1011,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{11}
return file_v1_render_proto_rawDescGZIP(), []int{12}
}
func (x *RequestInfo) GetMethod() string {
@ -968,19 +1086,23 @@ func (x *RequestInfo) GetUserAgent() string {
// PageContext mirrors blocks.PageContext.
type PageContext struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // UUID
Slug string `protobuf:"bytes,2,opt,name=slug,proto3" json:"slug,omitempty"`
Title string `protobuf:"bytes,3,opt,name=title,proto3" json:"title,omitempty"`
PostType string `protobuf:"bytes,4,opt,name=post_type,json=postType,proto3" json:"post_type,omitempty"` // "page", "post", "master", "system"
Status string `protobuf:"bytes,5,opt,name=status,proto3" json:"status,omitempty"` // "published", "draft", "scheduled"
state protoimpl.MessageState `protogen:"open.v1"`
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // UUID
Slug string `protobuf:"bytes,2,opt,name=slug,proto3" json:"slug,omitempty"`
Title string `protobuf:"bytes,3,opt,name=title,proto3" json:"title,omitempty"`
PostType string `protobuf:"bytes,4,opt,name=post_type,json=postType,proto3" json:"post_type,omitempty"` // "page", "post", "master", "system"
Status string `protobuf:"bytes,5,opt,name=status,proto3" json:"status,omitempty"` // "published", "draft", "scheduled"
// Slug derived from the resolved page hierarchy.
DerivedSlug string `protobuf:"bytes,6,opt,name=derived_slug,json=derivedSlug,proto3" json:"derived_slug,omitempty"`
// Effective mount path after host inheritance and normalization.
MountPath string `protobuf:"bytes,7,opt,name=mount_path,json=mountPath,proto3" json:"mount_path,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *PageContext) Reset() {
*x = PageContext{}
mi := &file_v1_render_proto_msgTypes[12]
mi := &file_v1_render_proto_msgTypes[13]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -992,7 +1114,7 @@ func (x *PageContext) String() string {
func (*PageContext) ProtoMessage() {}
func (x *PageContext) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[12]
mi := &file_v1_render_proto_msgTypes[13]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1005,7 +1127,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{12}
return file_v1_render_proto_rawDescGZIP(), []int{13}
}
func (x *PageContext) GetId() string {
@ -1043,6 +1165,20 @@ func (x *PageContext) GetStatus() string {
return ""
}
func (x *PageContext) GetDerivedSlug() string {
if x != nil {
return x.DerivedSlug
}
return ""
}
func (x *PageContext) GetMountPath() string {
if x != nil {
return x.MountPath
}
return ""
}
// PostContext mirrors blocks.PostContext.
type PostContext struct {
state protoimpl.MessageState `protogen:"open.v1"`
@ -1061,7 +1197,7 @@ type PostContext struct {
func (x *PostContext) Reset() {
*x = PostContext{}
mi := &file_v1_render_proto_msgTypes[13]
mi := &file_v1_render_proto_msgTypes[14]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1073,7 +1209,7 @@ func (x *PostContext) String() string {
func (*PostContext) ProtoMessage() {}
func (x *PostContext) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[13]
mi := &file_v1_render_proto_msgTypes[14]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1086,7 +1222,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{13}
return file_v1_render_proto_rawDescGZIP(), []int{14}
}
func (x *PostContext) GetId() string {
@ -1166,7 +1302,7 @@ type AuthorContext struct {
func (x *AuthorContext) Reset() {
*x = AuthorContext{}
mi := &file_v1_render_proto_msgTypes[14]
mi := &file_v1_render_proto_msgTypes[15]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1178,7 +1314,7 @@ func (x *AuthorContext) String() string {
func (*AuthorContext) ProtoMessage() {}
func (x *AuthorContext) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[14]
mi := &file_v1_render_proto_msgTypes[15]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1191,7 +1327,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{14}
return file_v1_render_proto_rawDescGZIP(), []int{15}
}
func (x *AuthorContext) GetId() string {
@ -1241,7 +1377,7 @@ type CategoryContext struct {
func (x *CategoryContext) Reset() {
*x = CategoryContext{}
mi := &file_v1_render_proto_msgTypes[15]
mi := &file_v1_render_proto_msgTypes[16]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1253,7 +1389,7 @@ func (x *CategoryContext) String() string {
func (*CategoryContext) ProtoMessage() {}
func (x *CategoryContext) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[15]
mi := &file_v1_render_proto_msgTypes[16]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1266,7 +1402,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{15}
return file_v1_render_proto_rawDescGZIP(), []int{16}
}
func (x *CategoryContext) GetId() string {
@ -1302,7 +1438,7 @@ type MasterPageContext struct {
func (x *MasterPageContext) Reset() {
*x = MasterPageContext{}
mi := &file_v1_render_proto_msgTypes[16]
mi := &file_v1_render_proto_msgTypes[17]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1314,7 +1450,7 @@ func (x *MasterPageContext) String() string {
func (*MasterPageContext) ProtoMessage() {}
func (x *MasterPageContext) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[16]
mi := &file_v1_render_proto_msgTypes[17]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1327,7 +1463,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{16}
return file_v1_render_proto_rawDescGZIP(), []int{17}
}
func (x *MasterPageContext) GetId() string {
@ -1364,7 +1500,7 @@ type HumanProofBanner struct {
func (x *HumanProofBanner) Reset() {
*x = HumanProofBanner{}
mi := &file_v1_render_proto_msgTypes[17]
mi := &file_v1_render_proto_msgTypes[18]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1376,7 +1512,7 @@ func (x *HumanProofBanner) String() string {
func (*HumanProofBanner) ProtoMessage() {}
func (x *HumanProofBanner) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[17]
mi := &file_v1_render_proto_msgTypes[18]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1389,7 +1525,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{17}
return file_v1_render_proto_rawDescGZIP(), []int{18}
}
func (x *HumanProofBanner) GetActiveTimeMinutes() int32 {
@ -1433,7 +1569,7 @@ type DetailRow struct {
func (x *DetailRow) Reset() {
*x = DetailRow{}
mi := &file_v1_render_proto_msgTypes[18]
mi := &file_v1_render_proto_msgTypes[19]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1445,7 +1581,7 @@ func (x *DetailRow) String() string {
func (*DetailRow) ProtoMessage() {}
func (x *DetailRow) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[18]
mi := &file_v1_render_proto_msgTypes[19]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1458,7 +1594,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{18}
return file_v1_render_proto_rawDescGZIP(), []int{19}
}
func (x *DetailRow) GetTableId() string {
@ -1524,13 +1660,17 @@ type BlockContext struct {
DetailRowDataJson []byte `protobuf:"bytes,36,opt,name=detail_row_data_json,json=detailRowDataJson,proto3" json:"detail_row_data_json,omitempty"`
BlogIndexUrl string `protobuf:"bytes,37,opt,name=blog_index_url,json=blogIndexUrl,proto3" json:"blog_index_url,omitempty"`
CategoryPageUrl string `protobuf:"bytes,38,opt,name=category_page_url,json=categoryPageUrl,proto3" json:"category_page_url,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
// Slug derived from the resolved page hierarchy.
DerivedSlug string `protobuf:"bytes,39,opt,name=derived_slug,json=derivedSlug,proto3" json:"derived_slug,omitempty"`
// Effective mount path after host inheritance and normalization.
MountPath string `protobuf:"bytes,40,opt,name=mount_path,json=mountPath,proto3" json:"mount_path,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *BlockContext) Reset() {
*x = BlockContext{}
mi := &file_v1_render_proto_msgTypes[19]
mi := &file_v1_render_proto_msgTypes[20]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1542,7 +1682,7 @@ func (x *BlockContext) String() string {
func (*BlockContext) ProtoMessage() {}
func (x *BlockContext) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[19]
mi := &file_v1_render_proto_msgTypes[20]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1555,7 +1695,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{19}
return file_v1_render_proto_rawDescGZIP(), []int{20}
}
func (x *BlockContext) GetUrl() string {
@ -1824,6 +1964,20 @@ func (x *BlockContext) GetCategoryPageUrl() string {
return ""
}
func (x *BlockContext) GetDerivedSlug() string {
if x != nil {
return x.DerivedSlug
}
return ""
}
func (x *BlockContext) GetMountPath() string {
if x != nil {
return x.MountPath
}
return ""
}
var File_v1_render_proto protoreflect.FileDescriptor
const file_v1_render_proto_rawDesc = "" +
@ -1835,16 +1989,25 @@ const file_v1_render_proto_rawDesc = "" +
"\x0erender_context\x18\x03 \x01(\v2\x15.abi.v1.RenderContextR\rrenderContext\"Y\n" +
"\x13RenderBlockResponse\x12\x12\n" +
"\x04html\x18\x01 \x01(\tR\x04html\x12.\n" +
"\apowered\x18\x02 \x01(\v2\x14.abi.v1.PoweredBlockR\apowered\"G\n" +
"\apowered\x18\x02 \x01(\v2\x14.abi.v1.PoweredBlockR\apowered\"\x9e\x01\n" +
"\fPoweredBlock\x12\x1a\n" +
"\btemplate\x18\x01 \x01(\tR\btemplate\x12\x1b\n" +
"\tdata_json\x18\x02 \x01(\fR\bdataJson\"\x93\x01\n" +
"\tdata_json\x18\x02 \x01(\fR\bdataJson\x12\x1f\n" +
"\vstatus_code\x18\x03 \x01(\x05R\n" +
"statusCode\x124\n" +
"\bmetadata\x18\x04 \x01(\v2\x18.abi.v1.DocumentMetadataR\bmetadata\"\xa4\x01\n" +
"\x10DocumentMetadata\x12\x14\n" +
"\x05title\x18\x01 \x01(\tR\x05title\x12 \n" +
"\vdescription\x18\x02 \x01(\tR\vdescription\x12#\n" +
"\rcanonical_url\x18\x03 \x01(\tR\fcanonicalUrl\x12\x1b\n" +
"\timage_url\x18\x04 \x01(\tR\bimageUrl\x12\x16\n" +
"\x06robots\x18\x05 \x01(\tR\x06robots\"\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\"T\n" +
"\x16RenderTemplateResponse\x124\n" +
"\bdocument\x18\x02 \x01(\v2\x18.abi.v1.TemplateDocumentR\bdocumentJ\x04\b\x01\x10\x02\"\xbf\x02\n" +
"\bdocument\x18\x02 \x01(\v2\x18.abi.v1.TemplateDocumentR\bdocumentJ\x04\b\x01\x10\x02\"\x96\x03\n" +
"\x10TemplateDocument\x12\x1b\n" +
"\tbody_html\x18\x01 \x01(\fR\bbodyHtml\x12\x1d\n" +
"\n" +
@ -1853,7 +2016,10 @@ const file_v1_render_proto_rawDesc = "" +
"\x0fhead_extra_html\x18\x04 \x01(\fR\rheadExtraHtml\x12-\n" +
"\x13body_end_extra_html\x18\x05 \x01(\fR\x10bodyEndExtraHtml\x12F\n" +
"\n" +
"html_attrs\x18\x06 \x03(\v2'.abi.v1.TemplateDocument.HtmlAttrsEntryR\thtmlAttrs\x1a<\n" +
"html_attrs\x18\x06 \x03(\v2'.abi.v1.TemplateDocument.HtmlAttrsEntryR\thtmlAttrs\x12\x1f\n" +
"\vstatus_code\x18\a \x01(\x05R\n" +
"statusCode\x124\n" +
"\bmetadata\x18\b \x01(\v2\x18.abi.v1.DocumentMetadataR\bmetadata\x1a<\n" +
"\x0eHtmlAttrsEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x88\x01\n" +
@ -1916,13 +2082,16 @@ const file_v1_render_proto_rawDesc = "" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1a:\n" +
"\fCookiesEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"|\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xbe\x01\n" +
"\vPageContext\x12\x0e\n" +
"\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" +
"\x04slug\x18\x02 \x01(\tR\x04slug\x12\x14\n" +
"\x05title\x18\x03 \x01(\tR\x05title\x12\x1b\n" +
"\tpost_type\x18\x04 \x01(\tR\bpostType\x12\x16\n" +
"\x06status\x18\x05 \x01(\tR\x06status\"\xaf\x02\n" +
"\x06status\x18\x05 \x01(\tR\x06status\x12!\n" +
"\fderived_slug\x18\x06 \x01(\tR\vderivedSlug\x12\x1d\n" +
"\n" +
"mount_path\x18\a \x01(\tR\tmountPath\"\xaf\x02\n" +
"\vPostContext\x12\x0e\n" +
"\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" +
"\x04slug\x18\x02 \x01(\tR\x04slug\x12\x14\n" +
@ -1957,7 +2126,7 @@ const file_v1_render_proto_rawDesc = "" +
"\tDetailRow\x12\x19\n" +
"\btable_id\x18\x01 \x01(\tR\atableId\x12\x15\n" +
"\x06row_id\x18\x02 \x01(\tR\x05rowId\x12\x1b\n" +
"\tdata_json\x18\x03 \x01(\fR\bdataJson\"\x85\f\n" +
"\tdata_json\x18\x03 \x01(\fR\bdataJson\"\xc7\f\n" +
"\fBlockContext\x12\x10\n" +
"\x03url\x18\x01 \x01(\tR\x03url\x12\x12\n" +
"\x04path\x18\x02 \x01(\tR\x04path\x12\x12\n" +
@ -2001,7 +2170,10 @@ const file_v1_render_proto_rawDesc = "" +
"\x0fdetail_table_id\x18# \x01(\tR\rdetailTableId\x12/\n" +
"\x14detail_row_data_json\x18$ \x01(\fR\x11detailRowDataJson\x12$\n" +
"\x0eblog_index_url\x18% \x01(\tR\fblogIndexUrl\x12*\n" +
"\x11category_page_url\x18& \x01(\tR\x0fcategoryPageUrl\x1a8\n" +
"\x11category_page_url\x18& \x01(\tR\x0fcategoryPageUrl\x12!\n" +
"\fderived_slug\x18' \x01(\tR\vderivedSlug\x12\x1d\n" +
"\n" +
"mount_path\x18( \x01(\tR\tmountPath\x1a8\n" +
"\n" +
"QueryEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
@ -2025,66 +2197,69 @@ func file_v1_render_proto_rawDescGZIP() []byte {
return file_v1_render_proto_rawDescData
}
var file_v1_render_proto_msgTypes = make([]protoimpl.MessageInfo, 27)
var file_v1_render_proto_msgTypes = make([]protoimpl.MessageInfo, 28)
var file_v1_render_proto_goTypes = []any{
(*RenderBlockRequest)(nil), // 0: abi.v1.RenderBlockRequest
(*RenderBlockResponse)(nil), // 1: abi.v1.RenderBlockResponse
(*PoweredBlock)(nil), // 2: abi.v1.PoweredBlock
(*RenderTemplateRequest)(nil), // 3: abi.v1.RenderTemplateRequest
(*RenderTemplateResponse)(nil), // 4: abi.v1.RenderTemplateResponse
(*TemplateDocument)(nil), // 5: abi.v1.TemplateDocument
(*RenderTagRequest)(nil), // 6: abi.v1.RenderTagRequest
(*RenderTagResponse)(nil), // 7: abi.v1.RenderTagResponse
(*ApplyFilterRequest)(nil), // 8: abi.v1.ApplyFilterRequest
(*ApplyFilterResponse)(nil), // 9: abi.v1.ApplyFilterResponse
(*RenderContext)(nil), // 10: abi.v1.RenderContext
(*RequestInfo)(nil), // 11: abi.v1.RequestInfo
(*PageContext)(nil), // 12: abi.v1.PageContext
(*PostContext)(nil), // 13: abi.v1.PostContext
(*AuthorContext)(nil), // 14: abi.v1.AuthorContext
(*CategoryContext)(nil), // 15: abi.v1.CategoryContext
(*MasterPageContext)(nil), // 16: abi.v1.MasterPageContext
(*HumanProofBanner)(nil), // 17: abi.v1.HumanProofBanner
(*DetailRow)(nil), // 18: abi.v1.DetailRow
(*BlockContext)(nil), // 19: abi.v1.BlockContext
nil, // 20: abi.v1.TemplateDocument.HtmlAttrsEntry
nil, // 21: abi.v1.RenderContext.InjectedSlotsEntry
nil, // 22: abi.v1.RequestInfo.HeadersEntry
nil, // 23: abi.v1.RequestInfo.CookiesEntry
nil, // 24: abi.v1.BlockContext.QueryEntry
nil, // 25: abi.v1.BlockContext.CookiesEntry
nil, // 26: abi.v1.BlockContext.HeadersEntry
(*timestamppb.Timestamp)(nil), // 27: google.protobuf.Timestamp
(*DocumentMetadata)(nil), // 3: abi.v1.DocumentMetadata
(*RenderTemplateRequest)(nil), // 4: abi.v1.RenderTemplateRequest
(*RenderTemplateResponse)(nil), // 5: abi.v1.RenderTemplateResponse
(*TemplateDocument)(nil), // 6: abi.v1.TemplateDocument
(*RenderTagRequest)(nil), // 7: abi.v1.RenderTagRequest
(*RenderTagResponse)(nil), // 8: abi.v1.RenderTagResponse
(*ApplyFilterRequest)(nil), // 9: abi.v1.ApplyFilterRequest
(*ApplyFilterResponse)(nil), // 10: abi.v1.ApplyFilterResponse
(*RenderContext)(nil), // 11: abi.v1.RenderContext
(*RequestInfo)(nil), // 12: abi.v1.RequestInfo
(*PageContext)(nil), // 13: abi.v1.PageContext
(*PostContext)(nil), // 14: abi.v1.PostContext
(*AuthorContext)(nil), // 15: abi.v1.AuthorContext
(*CategoryContext)(nil), // 16: abi.v1.CategoryContext
(*MasterPageContext)(nil), // 17: abi.v1.MasterPageContext
(*HumanProofBanner)(nil), // 18: abi.v1.HumanProofBanner
(*DetailRow)(nil), // 19: abi.v1.DetailRow
(*BlockContext)(nil), // 20: abi.v1.BlockContext
nil, // 21: abi.v1.TemplateDocument.HtmlAttrsEntry
nil, // 22: abi.v1.RenderContext.InjectedSlotsEntry
nil, // 23: abi.v1.RequestInfo.HeadersEntry
nil, // 24: abi.v1.RequestInfo.CookiesEntry
nil, // 25: abi.v1.BlockContext.QueryEntry
nil, // 26: abi.v1.BlockContext.CookiesEntry
nil, // 27: abi.v1.BlockContext.HeadersEntry
(*timestamppb.Timestamp)(nil), // 28: google.protobuf.Timestamp
}
var file_v1_render_proto_depIdxs = []int32{
10, // 0: abi.v1.RenderBlockRequest.render_context:type_name -> abi.v1.RenderContext
11, // 0: abi.v1.RenderBlockRequest.render_context:type_name -> abi.v1.RenderContext
2, // 1: abi.v1.RenderBlockResponse.powered:type_name -> abi.v1.PoweredBlock
10, // 2: abi.v1.RenderTemplateRequest.render_context:type_name -> abi.v1.RenderContext
5, // 3: abi.v1.RenderTemplateResponse.document:type_name -> abi.v1.TemplateDocument
20, // 4: abi.v1.TemplateDocument.html_attrs:type_name -> abi.v1.TemplateDocument.HtmlAttrsEntry
10, // 5: abi.v1.RenderTagRequest.render_context:type_name -> abi.v1.RenderContext
11, // 6: abi.v1.RenderContext.request:type_name -> abi.v1.RequestInfo
19, // 7: abi.v1.RenderContext.block_context:type_name -> abi.v1.BlockContext
12, // 8: abi.v1.RenderContext.page:type_name -> abi.v1.PageContext
13, // 9: abi.v1.RenderContext.post:type_name -> abi.v1.PostContext
14, // 10: abi.v1.RenderContext.author:type_name -> abi.v1.AuthorContext
15, // 11: abi.v1.RenderContext.category:type_name -> abi.v1.CategoryContext
16, // 12: abi.v1.RenderContext.master_page:type_name -> abi.v1.MasterPageContext
21, // 13: abi.v1.RenderContext.injected_slots:type_name -> abi.v1.RenderContext.InjectedSlotsEntry
17, // 14: abi.v1.RenderContext.human_proof_banner:type_name -> abi.v1.HumanProofBanner
18, // 15: abi.v1.RenderContext.detail_row:type_name -> abi.v1.DetailRow
22, // 16: abi.v1.RequestInfo.headers:type_name -> abi.v1.RequestInfo.HeadersEntry
23, // 17: abi.v1.RequestInfo.cookies:type_name -> abi.v1.RequestInfo.CookiesEntry
27, // 18: abi.v1.PostContext.published_at:type_name -> google.protobuf.Timestamp
27, // 19: abi.v1.BlockContext.now:type_name -> google.protobuf.Timestamp
24, // 20: abi.v1.BlockContext.query:type_name -> abi.v1.BlockContext.QueryEntry
25, // 21: abi.v1.BlockContext.cookies:type_name -> abi.v1.BlockContext.CookiesEntry
26, // 22: abi.v1.BlockContext.headers:type_name -> abi.v1.BlockContext.HeadersEntry
23, // [23:23] is the sub-list for method output_type
23, // [23:23] is the sub-list for method input_type
23, // [23:23] is the sub-list for extension type_name
23, // [23:23] is the sub-list for extension extendee
0, // [0:23] is the sub-list for field type_name
3, // 2: abi.v1.PoweredBlock.metadata:type_name -> abi.v1.DocumentMetadata
11, // 3: abi.v1.RenderTemplateRequest.render_context:type_name -> abi.v1.RenderContext
6, // 4: abi.v1.RenderTemplateResponse.document:type_name -> abi.v1.TemplateDocument
21, // 5: abi.v1.TemplateDocument.html_attrs:type_name -> abi.v1.TemplateDocument.HtmlAttrsEntry
3, // 6: abi.v1.TemplateDocument.metadata:type_name -> abi.v1.DocumentMetadata
11, // 7: abi.v1.RenderTagRequest.render_context:type_name -> abi.v1.RenderContext
12, // 8: abi.v1.RenderContext.request:type_name -> abi.v1.RequestInfo
20, // 9: abi.v1.RenderContext.block_context:type_name -> abi.v1.BlockContext
13, // 10: abi.v1.RenderContext.page:type_name -> abi.v1.PageContext
14, // 11: abi.v1.RenderContext.post:type_name -> abi.v1.PostContext
15, // 12: abi.v1.RenderContext.author:type_name -> abi.v1.AuthorContext
16, // 13: abi.v1.RenderContext.category:type_name -> abi.v1.CategoryContext
17, // 14: abi.v1.RenderContext.master_page:type_name -> abi.v1.MasterPageContext
22, // 15: abi.v1.RenderContext.injected_slots:type_name -> abi.v1.RenderContext.InjectedSlotsEntry
18, // 16: abi.v1.RenderContext.human_proof_banner:type_name -> abi.v1.HumanProofBanner
19, // 17: abi.v1.RenderContext.detail_row:type_name -> abi.v1.DetailRow
23, // 18: abi.v1.RequestInfo.headers:type_name -> abi.v1.RequestInfo.HeadersEntry
24, // 19: abi.v1.RequestInfo.cookies:type_name -> abi.v1.RequestInfo.CookiesEntry
28, // 20: abi.v1.PostContext.published_at:type_name -> google.protobuf.Timestamp
28, // 21: abi.v1.BlockContext.now:type_name -> google.protobuf.Timestamp
25, // 22: abi.v1.BlockContext.query:type_name -> abi.v1.BlockContext.QueryEntry
26, // 23: abi.v1.BlockContext.cookies:type_name -> abi.v1.BlockContext.CookiesEntry
27, // 24: abi.v1.BlockContext.headers:type_name -> abi.v1.BlockContext.HeadersEntry
25, // [25:25] is the sub-list for method output_type
25, // [25:25] is the sub-list for method input_type
25, // [25:25] is the sub-list for extension type_name
25, // [25:25] is the sub-list for extension extendee
0, // [0:25] is the sub-list for field type_name
}
func init() { file_v1_render_proto_init() }
@ -2098,7 +2273,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: 27,
NumMessages: 28,
NumExtensions: 0,
NumServices: 0,
},

View File

@ -13,11 +13,13 @@ import (
// PageContext provides page information to blocks via context.
// The CMS populates this from its internal db.Page type.
type PageContext struct {
ID uuid.UUID
Slug string
Title string
PostType string // "page", "post", "master", "system"
Status string // "published", "draft", "scheduled"
ID uuid.UUID
Slug string
DerivedSlug string // resolved route from the page hierarchy
MountPath string // effective host-resolved mount prefix
Title string
PostType string // "page", "post", "master", "system"
Status string // "published", "draft", "scheduled"
}
// PostContext provides blog post information to blocks via context.
@ -368,44 +370,46 @@ func RenderSlot(ctx context.Context) string {
// BlockContext provides contextual information available to all block templates.
type BlockContext struct {
URL string `pongo2:"url"`
Path string `pongo2:"path"`
Slug string `pongo2:"slug"`
PageID string `pongo2:"pageId"`
PageTitle string `pongo2:"pageTitle"`
TemplateKey string `pongo2:"templateKey"`
IsEditor bool `pongo2:"isEditor"`
Timestamp int64 `pongo2:"timestamp"`
IsLoggedIn bool `pongo2:"isLoggedIn"`
UserID string `pongo2:"userId"`
UserEmail string `pongo2:"userEmail"`
UserRole string `pongo2:"userRole"`
Method string `pongo2:"method"`
Host string `pongo2:"host"`
Query map[string]string `pongo2:"query"`
Referrer string `pongo2:"referrer"`
UserAgent string `pongo2:"userAgent"`
IP string `pongo2:"ip"`
Cookies map[string]string `pongo2:"cookies"`
Headers map[string]string `pongo2:"headers"`
Country string `pongo2:"country"`
City string `pongo2:"city"`
Timezone string `pongo2:"timezone"`
Now time.Time `pongo2:"now"`
CurrentAuthor map[string]any `pongo2:"currentAuthor"`
CurrentPost map[string]any `pongo2:"currentPost"`
CurrentCategory map[string]any `pongo2:"currentCategory"`
Site map[string]any `pongo2:"site"`
IsPublicLoggedIn bool `pongo2:"isPublicLoggedIn"`
PublicUserID string `pongo2:"publicUserId"`
PublicUsername string `pongo2:"publicUsername"`
PublicDisplayName string `pongo2:"publicDisplayName"`
PublicEmailVerified bool `pongo2:"publicEmailVerified"`
DetailRowID string `pongo2:"detailRowId"`
DetailTableID string `pongo2:"detailTableId"`
DetailRowData map[string]any `pongo2:"detailRowData"`
BlogIndexURL string `pongo2:"blogIndexUrl"`
CategoryPageURL string `pongo2:"categoryPageUrl"`
URL string `pongo2:"url"`
Path string `pongo2:"path"`
Slug string `pongo2:"slug"`
DerivedSlug string `pongo2:"derivedSlug"`
MountPath string `pongo2:"mountPath"`
PageID string `pongo2:"pageId"`
PageTitle string `pongo2:"pageTitle"`
TemplateKey string `pongo2:"templateKey"`
IsEditor bool `pongo2:"isEditor"`
Timestamp int64 `pongo2:"timestamp"`
IsLoggedIn bool `pongo2:"isLoggedIn"`
UserID string `pongo2:"userId"`
UserEmail string `pongo2:"userEmail"`
UserRole string `pongo2:"userRole"`
Method string `pongo2:"method"`
Host string `pongo2:"host"`
Query map[string]string `pongo2:"query"`
Referrer string `pongo2:"referrer"`
UserAgent string `pongo2:"userAgent"`
IP string `pongo2:"ip"`
Cookies map[string]string `pongo2:"cookies"`
Headers map[string]string `pongo2:"headers"`
Country string `pongo2:"country"`
City string `pongo2:"city"`
Timezone string `pongo2:"timezone"`
Now time.Time `pongo2:"now"`
CurrentAuthor map[string]any `pongo2:"currentAuthor"`
CurrentPost map[string]any `pongo2:"currentPost"`
CurrentCategory map[string]any `pongo2:"currentCategory"`
Site map[string]any `pongo2:"site"`
IsPublicLoggedIn bool `pongo2:"isPublicLoggedIn"`
PublicUserID string `pongo2:"publicUserId"`
PublicUsername string `pongo2:"publicUsername"`
PublicDisplayName string `pongo2:"publicDisplayName"`
PublicEmailVerified bool `pongo2:"publicEmailVerified"`
DetailRowID string `pongo2:"detailRowId"`
DetailTableID string `pongo2:"detailTableId"`
DetailRowData map[string]any `pongo2:"detailRowData"`
BlogIndexURL string `pongo2:"blogIndexUrl"`
CategoryPageURL string `pongo2:"categoryPageUrl"`
}
type blockContextKey struct{}
@ -425,6 +429,7 @@ func GetBlockContext(ctx context.Context) *BlockContext {
func (bc *BlockContext) ToMap() map[string]any {
return map[string]any{
"url": bc.URL, "path": bc.Path, "slug": bc.Slug,
"derivedSlug": bc.DerivedSlug, "mountPath": bc.MountPath,
"pageId": bc.PageID, "pageTitle": bc.PageTitle,
"templateKey": bc.TemplateKey, "isEditor": bc.IsEditor,
"timestamp": bc.Timestamp, "now": bc.Now,
@ -437,12 +442,12 @@ func (bc *BlockContext) ToMap() map[string]any {
"currentAuthor": bc.CurrentAuthor, "currentPost": bc.CurrentPost,
"currentCategory": bc.CurrentCategory, "site": bc.Site,
"isPublicLoggedIn": bc.IsPublicLoggedIn,
"publicUserId": bc.PublicUserID, "publicUsername": bc.PublicUsername,
"publicDisplayName": bc.PublicDisplayName,
"publicUserId": bc.PublicUserID, "publicUsername": bc.PublicUsername,
"publicDisplayName": bc.PublicDisplayName,
"publicEmailVerified": bc.PublicEmailVerified,
"detailRowId": bc.DetailRowID, "detailTableId": bc.DetailTableID,
"detailRowId": bc.DetailRowID, "detailTableId": bc.DetailTableID,
"detailRowData": bc.DetailRowData,
"blogIndexUrl": bc.BlogIndexURL, "categoryPageUrl": bc.CategoryPageURL,
"blogIndexUrl": bc.BlogIndexURL, "categoryPageUrl": bc.CategoryPageURL,
}
}

View File

@ -3,6 +3,8 @@ package blocks
import (
"encoding/json"
"strings"
"git.dev.alexdunmow.com/block/pluginsdk/document"
)
// poweredBlockSentinel prefixes a PoweredBlock marker string. It uses NUL
@ -16,8 +18,17 @@ const poweredBlockSentinel = "\x00bn:powered\x00"
// 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"`
Template string `json:"template"`
Data map[string]any `json:"data"`
StatusCode int32 `json:"statusCode,omitempty"`
Metadata *document.Metadata `json:"metadata,omitempty"`
}
// PoweredOptions controls host-owned response data for a powered block.
// StatusCode is transported as declared; the host accepts only 200599.
type PoweredOptions struct {
StatusCode int32
Metadata *document.Metadata
}
// PoweredBlock marks a block's return value as "powered": instead of final
@ -37,11 +48,28 @@ type PoweredResult struct {
// 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})
return poweredBlockMarker(PoweredResult{Template: template, Data: data})
}
// PoweredBlockWithOptions is PoweredBlock with structured document metadata
// and an optional HTTP response status for the host to accumulate before it
// writes response headers.
func PoweredBlockWithOptions(template string, data map[string]any, options PoweredOptions) string {
return poweredBlockMarker(PoweredResult{
Template: template,
Data: data,
StatusCode: options.StatusCode,
Metadata: options.Metadata,
})
}
func poweredBlockMarker(result PoweredResult) string {
payload, err := json.Marshal(result)
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})
// empty-data result while preserving response options.
result.Data = nil
payload, _ = json.Marshal(result)
}
return poweredBlockSentinel + string(payload)
}

View File

@ -1,6 +1,10 @@
package blocks
import "testing"
import (
"testing"
"git.dev.alexdunmow.com/block/pluginsdk/document"
)
func TestPoweredBlockRoundTrip(t *testing.T) {
marker := PoweredBlock("<p>{{ x }}</p>", map[string]any{"x": "y"})
@ -16,6 +20,47 @@ func TestPoweredBlockRoundTrip(t *testing.T) {
}
}
func TestPoweredBlockWithOptionsRoundTrip(t *testing.T) {
metadata := &document.Metadata{
Title: "Missing article",
Description: "The requested article was not found.",
CanonicalURL: "https://example.com/docs/missing",
ImageURL: "https://example.com/media/missing.png",
Robots: "noindex,nofollow",
}
marker := PoweredBlockWithOptions("<h1>{{ title }}</h1>", map[string]any{"title": "Missing"}, PoweredOptions{
StatusCode: 404,
Metadata: metadata,
})
got, ok := DecodePoweredBlock(marker)
if !ok {
t.Fatal("DecodePoweredBlock did not recognize its own marker")
}
if got.StatusCode != 404 {
t.Errorf("status code = %d, want 404", got.StatusCode)
}
if got.Metadata == nil || *got.Metadata != *metadata {
t.Errorf("metadata = %+v, want %+v", got.Metadata, metadata)
}
}
func TestPoweredBlockWithOptionsPreservesOptionsWhenDataCannotMarshal(t *testing.T) {
marker := PoweredBlockWithOptions("<h1>fallback</h1>", map[string]any{"bad": make(chan int)}, PoweredOptions{
StatusCode: 503,
Metadata: &document.Metadata{Title: "Unavailable"},
})
got, ok := DecodePoweredBlock(marker)
if !ok {
t.Fatal("DecodePoweredBlock did not recognize fallback marker")
}
if got.Data != nil {
t.Errorf("data = %#v, want nil fallback", got.Data)
}
if got.StatusCode != 503 || got.Metadata == nil || got.Metadata.Title != "Unavailable" {
t.Errorf("options were not preserved: %+v", got)
}
}
func TestDecodePoweredBlockRejectsPlainHTML(t *testing.T) {
if _, ok := DecodePoweredBlock("<p>hello</p>"); ok {
t.Error("plain HTML must not decode as a powered block")

View File

@ -0,0 +1,58 @@
# Provisioned pages use stable keys and render results carry safe response data
Provisioned pages were identified only by slug. Slugs are customer-facing
content and can change when an administrator renames a page, changes its
hierarchy, or mounts a plugin below a customer-specific route. A later plugin
load could therefore fail to find its page, recreate it, or attach children
and menu items to a stale location.
Decision: a page declaration may carry a plugin-scoped `Key`. `ParentKey`
references another declaration from the same plugin, while menu items use the
pair `PagePlugin` and `PageKey` so they can target a provisioned page across
plugin boundaries. The existing `ParentSlug` field remains an explicit legacy
input, but keyed references are the stable identity for new declarations.
`MountPath` is create-only. The host applies it while creating the page but
does not relocate an existing customer page during later reconciliation.
`MergeMissingBlockContent` enables a shallow default merge for matching
plugin-owned block assignments: only absent top-level keys are added, and
present values such as an empty string, zero, false, or null are preserved.
The render ABI exposes both `DerivedSlug` and `MountPath` on page and block
contexts. `DerivedSlug` is computed from the resolved hierarchy. `MountPath`
is the effective, normalized value after host inheritance, rather than the raw
stored declaration. Guests consume these values and do not reimplement host
routing rules.
Powered blocks and template documents may also request an HTTP status and a
shared `DocumentMetadata` value. Metadata is deliberately limited to title,
description, canonical URL, image URL, and robots directives. The host
validates status codes and metadata, escapes values, and accumulates status
before writing headers. Only 200599 is accepted; the highest status class
wins, with the first result retained within a class. For metadata, the first
non-empty value for each field wins in deterministic render order, after which
page/document defaults fill gaps. Arbitrary meta/link maps were rejected
because they would create a second unvalidated head-injection path; advanced
trusted templates already have the explicit `HeadExtra` surface.
Appending fields to the v1 protobuf messages was chosen over renumbering or a
Go-only helper because `abi/proto/v1` is the language-neutral contract. Slug
fallbacks remain data in that contract; the SDK does not silently synthesize
keys from mutable slugs.
Consequences:
- Plugin updates can find the same provisioned page after customer route
changes without taking ownership of the customer's chosen slug.
- Parent and menu references survive page moves and renames.
- Changing a declaration's mount path does not unexpectedly move a live page.
- Plugins can add newly introduced block defaults without overwriting customer
configuration.
- Native and WASM render code receive the same host-resolved routing values.
- Wiki-like powered blocks can return correct not-found status and structured
metadata without rendering or injecting the document envelope themselves.
Keywords: PageConfig.Key, ParentKey, ParentSlug, MountPath,
MergeMissingBlockContent, MenuItemConfig.PagePlugin, PageKey, DerivedSlug,
PageSeed, RenderContext, PoweredBlockWithOptions, DocumentMetadata,
TemplateDocument, stable identity, page provisioning

14
document/metadata.go Normal file
View File

@ -0,0 +1,14 @@
// Package document defines structured page-response metadata shared by block
// and template render results.
package document
// Metadata is the safe document-head subset a plugin may ask the host to
// render. The host remains responsible for validating URLs and robots values
// and for escaping every value before it is written into HTML.
type Metadata struct {
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
CanonicalURL string `json:"canonicalUrl,omitempty"`
ImageURL string `json:"imageUrl,omitempty"`
Robots string `json:"robots,omitempty"`
}

View File

@ -41,8 +41,24 @@ type DataTableConfig struct {
// PageConfig defines a page to provision.
type PageConfig struct {
Slug string
ParentSlug string
// Key is the stable, plugin-scoped identity used to find the page across
// slug and hierarchy changes.
Key string
Slug string
// ParentKey identifies a parent provisioned by the same plugin.
ParentKey string
// ParentSlug is the legacy slug-based parent reference. New declarations
// should use ParentKey so moving or renaming the parent does not break the
// relationship.
ParentSlug string
// MountPath is applied when the page is first created. Reconciliation does
// not move an existing page when this value later changes.
MountPath string
Title string
TemplateKey string
Blocks []PageBlockConfig
@ -51,6 +67,10 @@ type PageConfig struct {
DetailSlugField string
ReconcileBlocks bool
ReconcileTemplate bool
// MergeMissingBlockContent shallowly adds absent top-level default keys to
// matching plugin-owned block assignments. Present values, including empty
// strings, zero, false, and null, are preserved.
MergeMissingBlockContent bool
}
// PageBlockConfig defines a block within a provisioned page.
@ -72,10 +92,15 @@ type EmbeddingConfigDef struct {
// MenuItemConfig defines a menu item to provision.
type MenuItemConfig struct {
Label string
URL string
PageSlug string
SortOrder int32
Label string
URL string
PageSlug string
// PagePlugin and PageKey identify a provisioned page independently of its
// current slug. PagePlugin permits a menu declared by one plugin to target
// a page owned by another.
PagePlugin string
PageKey string
SortOrder int32
}
// EmbedConfig defines an embeddable component template.

View File

@ -790,7 +790,9 @@ func TestCapabilityRoundTrip(t *testing.T) {
resp: &abiv1.ProvisionerEnsurePageResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
err := cs.Provisioner.EnsurePage(plugin.PageConfig{
Slug: "/", Title: "Home", TemplateKey: "homepage", ReconcileBlocks: true,
Key: "home", Slug: "/", ParentKey: "site", ParentSlug: "/legacy-site",
MountPath: "/knowledge", Title: "Home", TemplateKey: "homepage",
ReconcileBlocks: true, MergeMissingBlockContent: true,
Blocks: []plugin.PageBlockConfig{
{BlockKey: "html", Title: "Hero", Content: map[string]any{"x": float64(1)}, Slot: "main", SortOrder: 1},
},
@ -813,7 +815,9 @@ func TestCapabilityRoundTrip(t *testing.T) {
name: "provisioner_ensure_menu_item", wantMethod: "provisioner.ensure_menu_item",
resp: &abiv1.ProvisionerEnsureMenuItemResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
err := cs.Provisioner.EnsureMenuItem("main", plugin.MenuItemConfig{Label: "Docs", PageSlug: "/docs", SortOrder: 3})
err := cs.Provisioner.EnsureMenuItem("main", plugin.MenuItemConfig{
Label: "Docs", PageSlug: "/docs", PagePlugin: "wiki", PageKey: "index", SortOrder: 3,
})
if err != nil {
t.Fatal(err)
}
@ -1008,6 +1012,41 @@ func TestCapabilityRoundTrip(t *testing.T) {
}
}
func TestProvisionerPageContractGolden(t *testing.T) {
raw, err := os.ReadFile(goldenPath("provisioner_ensure_page_req"))
if err != nil {
t.Fatal(err)
}
req := &abiv1.ProvisionerEnsurePageRequest{}
if err := proto.Unmarshal(raw, req); err != nil {
t.Fatal(err)
}
page := req.GetPage()
if page == nil {
t.Fatal("page is nil")
}
if page.GetKey() != "home" || page.GetSlug() != "/" ||
page.GetParentKey() != "site" || page.GetParentSlug() != "/legacy-site" ||
page.GetMountPath() != "/knowledge" || !page.GetMergeMissingBlockContent() {
t.Errorf("page seed = %+v", page)
}
}
func TestProvisionerMenuPageReferenceContractGolden(t *testing.T) {
raw, err := os.ReadFile(goldenPath("provisioner_ensure_menu_item_req"))
if err != nil {
t.Fatal(err)
}
req := &abiv1.ProvisionerEnsureMenuItemRequest{}
if err := proto.Unmarshal(raw, req); err != nil {
t.Fatal(err)
}
if req.GetMenuName() != "main" || req.GetPageSlug() != "/docs" ||
req.GetPagePlugin() != "wiki" || req.GetPageKey() != "index" {
t.Errorf("menu request = %+v", req)
}
}
// fakeAbiErr is a transport error carrying an ABI code, like wasmguest.HostError.
type fakeAbiErr struct {
code abiv1.AbiErrorCode

View File

@ -53,15 +53,19 @@ func (s *provisionerStub) EnsureSetting(key string, defaultValue any) error {
func (s *provisionerStub) EnsurePage(config plugin.PageConfig) error {
seed := &abiv1.PageSeed{
Slug: config.Slug,
ParentSlug: config.ParentSlug,
Title: config.Title,
TemplateKey: config.TemplateKey,
DetailSourceType: config.DetailSourceType,
DetailSourceKey: config.DetailSourceKey,
DetailSlugField: config.DetailSlugField,
ReconcileBlocks: config.ReconcileBlocks,
ReconcileTemplate: config.ReconcileTemplate,
Key: config.Key,
Slug: config.Slug,
ParentKey: config.ParentKey,
ParentSlug: config.ParentSlug,
MountPath: config.MountPath,
Title: config.Title,
TemplateKey: config.TemplateKey,
DetailSourceType: config.DetailSourceType,
DetailSourceKey: config.DetailSourceKey,
DetailSlugField: config.DetailSlugField,
ReconcileBlocks: config.ReconcileBlocks,
ReconcileTemplate: config.ReconcileTemplate,
MergeMissingBlockContent: config.MergeMissingBlockContent,
}
for _, b := range config.Blocks {
contentJSON, err := json.Marshal(b.Content)
@ -92,11 +96,13 @@ func (s *provisionerStub) OverrideSiteSettings(overrides map[string]any) error {
func (s *provisionerStub) EnsureMenuItem(menuName string, config plugin.MenuItemConfig) error {
req := &abiv1.ProvisionerEnsureMenuItemRequest{
MenuName: menuName,
Label: config.Label,
Url: config.URL,
PageSlug: config.PageSlug,
SortOrder: config.SortOrder,
MenuName: menuName,
Label: config.Label,
Url: config.URL,
PageSlug: config.PageSlug,
PagePlugin: config.PagePlugin,
PageKey: config.PageKey,
SortOrder: config.SortOrder,
}
return s.invoke(context.Background(), "ensure_menu_item", req, &abiv1.ProvisionerEnsureMenuItemResponse{})
}

View File

@ -1,2 +1,2 @@
mainDocs"/docs(
mainDocs"/docs(2wiki:index

View File

@ -1,4 +1,5 @@
4
/Home"homepage*
htmlHero{"x":1}*main0H
\
/ /legacy-siteHome"homepage*
htmlHero{"x":1}*main0HZhomebsitej
/knowledgep

View File

@ -37,11 +37,13 @@ func contextFromRenderContext(ctx context.Context, rc *abiv1.RenderContext) cont
}
if p := rc.GetPage(); p != nil {
ctx = blocks.WithCurrentPage(ctx, &blocks.PageContext{
ID: parseUUID(p.GetId()),
Slug: p.GetSlug(),
Title: p.GetTitle(),
PostType: p.GetPostType(),
Status: p.GetStatus(),
ID: parseUUID(p.GetId()),
Slug: p.GetSlug(),
DerivedSlug: p.GetDerivedSlug(),
MountPath: p.GetMountPath(),
Title: p.GetTitle(),
PostType: p.GetPostType(),
Status: p.GetStatus(),
})
}
if p := rc.GetPost(); p != nil {
@ -159,6 +161,8 @@ func blockContextFromProto(pb *abiv1.BlockContext) *blocks.BlockContext {
URL: pb.GetUrl(),
Path: pb.GetPath(),
Slug: pb.GetSlug(),
DerivedSlug: pb.GetDerivedSlug(),
MountPath: pb.GetMountPath(),
PageID: pb.GetPageId(),
PageTitle: pb.GetPageTitle(),
TemplateKey: pb.GetTemplateKey(),

View File

@ -0,0 +1,51 @@
package wasmguest
import (
"context"
"testing"
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
"git.dev.alexdunmow.com/block/pluginsdk/blocks"
"github.com/google/uuid"
)
func TestContextFromRenderContextCarriesResolvedPagePaths(t *testing.T) {
pageID := uuid.MustParse("5f0c5f3f-3f1e-4a3a-9b6e-2f4f6a6d7e8f")
ctx := contextFromRenderContext(context.Background(), &abiv1.RenderContext{
Page: &abiv1.PageContext{
Id: pageID.String(),
Slug: "/article",
DerivedSlug: "/knowledge/article",
MountPath: "/knowledge",
Title: "Article",
PostType: "page",
Status: "published",
},
BlockContext: &abiv1.BlockContext{
Slug: "/article",
DerivedSlug: "/knowledge/article",
MountPath: "/knowledge",
},
})
page := blocks.GetCurrentPage(ctx)
if page == nil {
t.Fatal("current page is nil")
}
if page.ID != pageID || page.Slug != "/article" ||
page.DerivedSlug != "/knowledge/article" || page.MountPath != "/knowledge" {
t.Errorf("page context = %+v", page)
}
block := blocks.GetBlockContext(ctx)
if block == nil {
t.Fatal("block context is nil")
}
if block.Slug != "/article" || block.DerivedSlug != "/knowledge/article" || block.MountPath != "/knowledge" {
t.Errorf("block context = %+v", block)
}
data := block.ToMap()
if data["derivedSlug"] != "/knowledge/article" || data["mountPath"] != "/knowledge" {
t.Errorf("block template map = %#v", data)
}
}

View File

@ -14,6 +14,7 @@ import (
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
"git.dev.alexdunmow.com/block/pluginsdk/ai"
"git.dev.alexdunmow.com/block/pluginsdk/blocks"
"git.dev.alexdunmow.com/block/pluginsdk/document"
"git.dev.alexdunmow.com/block/pluginsdk/plugin"
"git.dev.alexdunmow.com/block/pluginsdk/plugin/wasmguest/bnwasm"
"git.dev.alexdunmow.com/block/pluginsdk/plugin/wasmguest/caps"
@ -253,7 +254,12 @@ func (g *guest) renderBlock(ctx context.Context, payload []byte) (proto.Message,
return nil, internalError("marshal powered block data: " + err.Error())
}
return &abiv1.RenderBlockResponse{
Powered: &abiv1.PoweredBlock{Template: pr.Template, DataJson: dataJSON},
Powered: &abiv1.PoweredBlock{
Template: pr.Template,
DataJson: dataJSON,
StatusCode: pr.StatusCode,
Metadata: documentMetadataToProto(pr.Metadata),
},
}, nil
}
return &abiv1.RenderBlockResponse{Html: out}, nil
@ -364,6 +370,8 @@ func (g *guest) renderTemplate(ctx context.Context, payload []byte) (proto.Messa
tdoc.HtmlAttrs = pd.HTMLAttrs
tdoc.HeadExtraHtml = head
tdoc.BodyEndExtraHtml = end
tdoc.StatusCode = pd.StatusCode
tdoc.Metadata = documentMetadataToProto(pd.Metadata)
} else {
body, err := renderPart(component)
if err != nil {
@ -374,6 +382,19 @@ func (g *guest) renderTemplate(ctx context.Context, payload []byte) (proto.Messa
return &abiv1.RenderTemplateResponse{Document: tdoc}, nil
}
func documentMetadataToProto(metadata *document.Metadata) *abiv1.DocumentMetadata {
if metadata == nil {
return nil
}
return &abiv1.DocumentMetadata{
Title: metadata.Title,
Description: metadata.Description,
CanonicalUrl: metadata.CanonicalURL,
ImageUrl: metadata.ImageURL,
Robots: metadata.Robots,
}
}
func (g *guest) handleHTTP(ctx context.Context, payload []byte) (proto.Message, *abiv1.AbiError) {
if g.reg.HTTPHandler == nil {
return nil, &abiv1.AbiError{

View File

@ -10,6 +10,7 @@ import (
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
"git.dev.alexdunmow.com/block/pluginsdk/blocks"
"git.dev.alexdunmow.com/block/pluginsdk/document"
"git.dev.alexdunmow.com/block/pluginsdk/plugin"
"git.dev.alexdunmow.com/block/pluginsdk/templates"
"google.golang.org/protobuf/proto"
@ -383,6 +384,14 @@ func TestRenderTemplatePageDocumentFillsEnvelope(t *testing.T) {
HTMLAttrs: map[string]string{"data-theme": "x"},
HeadExtra: textComponent(`<meta name="e">`),
BodyEndExtra: textComponent("<script>1</script>"),
StatusCode: 201,
Metadata: &document.Metadata{
Title: "Page title",
Description: "Page description",
CanonicalURL: "https://example.com/page",
ImageURL: "https://example.com/page.png",
Robots: "index,follow",
},
}
})
},
@ -419,6 +428,17 @@ func TestRenderTemplatePageDocumentFillsEnvelope(t *testing.T) {
if string(doc.GetBodyEndExtraHtml()) != "<script>1</script>" {
t.Errorf("body_end_extra_html = %q", doc.GetBodyEndExtraHtml())
}
if doc.GetStatusCode() != 201 {
t.Errorf("status_code = %d, want 201", doc.GetStatusCode())
}
metadata := doc.GetMetadata()
if metadata == nil || metadata.GetTitle() != "Page title" ||
metadata.GetDescription() != "Page description" ||
metadata.GetCanonicalUrl() != "https://example.com/page" ||
metadata.GetImageUrl() != "https://example.com/page.png" ||
metadata.GetRobots() != "index,follow" {
t.Errorf("metadata = %+v", metadata)
}
}
func TestJobHook(t *testing.T) {

View File

@ -8,6 +8,7 @@ import (
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
"git.dev.alexdunmow.com/block/pluginsdk/blocks"
"git.dev.alexdunmow.com/block/pluginsdk/document"
"git.dev.alexdunmow.com/block/pluginsdk/plugin"
"git.dev.alexdunmow.com/block/pluginsdk/templates"
"google.golang.org/protobuf/proto"
@ -25,7 +26,20 @@ func tagFilterRegistration() plugin.PluginRegistration {
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})
return blocks.PoweredBlockWithOptions(
"<h1>{{ title }}</h1>",
map[string]any{"title": title},
blocks.PoweredOptions{
StatusCode: 404,
Metadata: &document.Metadata{
Title: "Missing",
Description: "Missing article",
CanonicalURL: "https://example.com/missing",
ImageURL: "https://example.com/missing.png",
Robots: "noindex,nofollow",
},
},
)
})
blocks.RegisterTag("greet", func(args map[string]any, rctx blocks.RenderContext) (string, error) {
who, _ := args["who"].(string)
@ -111,6 +125,17 @@ func TestPoweredBlockRoundTrip(t *testing.T) {
if !strings.Contains(string(p.GetDataJson()), `"title":"Welcome"`) {
t.Errorf("data_json = %s", p.GetDataJson())
}
if p.GetStatusCode() != 404 {
t.Errorf("status_code = %d, want 404", p.GetStatusCode())
}
metadata := p.GetMetadata()
if metadata == nil || metadata.GetTitle() != "Missing" ||
metadata.GetDescription() != "Missing article" ||
metadata.GetCanonicalUrl() != "https://example.com/missing" ||
metadata.GetImageUrl() != "https://example.com/missing.png" ||
metadata.GetRobots() != "noindex,nofollow" {
t.Errorf("metadata = %+v", metadata)
}
}
func TestRenderTagRoundTrip(t *testing.T) {

View File

@ -10,6 +10,7 @@ import (
"strings"
"git.dev.alexdunmow.com/block/pluginsdk/blocks"
"git.dev.alexdunmow.com/block/pluginsdk/document"
"git.dev.alexdunmow.com/block/pluginsdk/plugin"
"git.dev.alexdunmow.com/block/pluginsdk/templates"
)
@ -45,7 +46,20 @@ var Registration = plugin.PluginRegistration{
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 blocks.PoweredBlockWithOptions(
"<p>hello {{ name }}</p>",
map[string]any{"name": name},
blocks.PoweredOptions{
StatusCode: 404,
Metadata: &document.Metadata{
Title: "Missing greeting",
Description: "The greeting was not found.",
CanonicalURL: "https://example.com/greetings/missing",
ImageURL: "https://example.com/greetings/missing.png",
Robots: "noindex,nofollow",
},
},
)
}
return fmt.Sprintf("<p>hello %s</p>", name)
})

View File

@ -241,6 +241,17 @@ func TestWasmFixtureTagFilterPoweredRoundTrip(t *testing.T) {
if !strings.Contains(string(p.GetDataJson()), `"name":"wazero"`) {
t.Errorf("powered data_json = %s", p.GetDataJson())
}
if p.GetStatusCode() != 404 {
t.Errorf("powered status_code = %d, want 404", p.GetStatusCode())
}
metadata := p.GetMetadata()
if metadata == nil || metadata.GetTitle() != "Missing greeting" ||
metadata.GetDescription() != "The greeting was not found." ||
metadata.GetCanonicalUrl() != "https://example.com/greetings/missing" ||
metadata.GetImageUrl() != "https://example.com/greetings/missing.png" ||
metadata.GetRobots() != "noindex,nofollow" {
t.Errorf("powered metadata = %+v", metadata)
}
// HOOK_RENDER_TAG dispatches to the registered tag fn (fresh invoke —
// no re-entrancy; RENDER_BLOCK already returned).

View File

@ -3,6 +3,8 @@ package templates
import (
"context"
"io"
"git.dev.alexdunmow.com/block/pluginsdk/document"
)
// PageDocument is a full-page template's contribution to the document. The
@ -17,6 +19,8 @@ type PageDocument struct {
HTMLAttrs map[string]string // extra <html> attributes (e.g. data-theme)
HeadExtra HTMLComponent // appended inside <head> after bn.Head
BodyEndExtra HTMLComponent // appended before </body> after bn.BodyEnd
StatusCode int32 // requested HTTP status; host accepts 200599
Metadata *document.Metadata
}
func (p *PageDocument) Render(ctx context.Context, w io.Writer) error {