diff --git a/abi/proto/v1/capability.proto b/abi/proto/v1/capability.proto index 6b522f0..bab24d7 100644 --- a/abi/proto/v1/capability.proto +++ b/abi/proto/v1/capability.proto @@ -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 {} diff --git a/abi/proto/v1/render.proto b/abi/proto/v1/render.proto index dda13fa..0d20a29 100644 --- a/abi/proto/v1/render.proto +++ b/abi/proto/v1/render.proto @@ -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 200–599, 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 -element attributes (e.g. data-theme). A "lang" key here is // ignored — the lang field wins. map 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; } diff --git a/abi/v1/capability.pb.go b/abi/v1/capability.pb.go index d033754..143773d 100644 --- a/abi/v1/capability.pb.go +++ b/abi/v1/capability.pb.go @@ -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" + diff --git a/abi/v1/render.pb.go b/abi/v1/render.pb.go index 2f2841e..1ab9aef 100644 --- a/abi/v1/render.pb.go +++ b/abi/v1/render.pb.go @@ -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 200–599, 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 , after bn.BodyEnd // Extra -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, }, diff --git a/blocks/context.go b/blocks/context.go index 1922ea5..7e50f86 100644 --- a/blocks/context.go +++ b/blocks/context.go @@ -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, } } diff --git a/blocks/powered.go b/blocks/powered.go index ff3bd4f..77e07f2 100644 --- a/blocks/powered.go +++ b/blocks/powered.go @@ -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 200–599. +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) } diff --git a/blocks/powered_test.go b/blocks/powered_test.go index 2095c02..2ee0776 100644 --- a/blocks/powered_test.go +++ b/blocks/powered_test.go @@ -1,6 +1,10 @@ package blocks -import "testing" +import ( + "testing" + + "git.dev.alexdunmow.com/block/pluginsdk/document" +) func TestPoweredBlockRoundTrip(t *testing.T) { marker := PoweredBlock("

{{ x }}

", 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("

{{ title }}

", 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("

fallback

", 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("

hello

"); ok { t.Error("plain HTML must not decode as a powered block") diff --git a/docs/adr/0003-keyed-page-provisioning-and-render-response-data.md b/docs/adr/0003-keyed-page-provisioning-and-render-response-data.md new file mode 100644 index 0000000..2b5e63e --- /dev/null +++ b/docs/adr/0003-keyed-page-provisioning-and-render-response-data.md @@ -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 200–599 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 diff --git a/document/metadata.go b/document/metadata.go new file mode 100644 index 0000000..299497e --- /dev/null +++ b/document/metadata.go @@ -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"` +} diff --git a/plugin/provisioner.go b/plugin/provisioner.go index 91f5eb0..c2ed441 100644 --- a/plugin/provisioner.go +++ b/plugin/provisioner.go @@ -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. diff --git a/plugin/wasmguest/caps/caps_roundtrip_test.go b/plugin/wasmguest/caps/caps_roundtrip_test.go index 1a8867d..623a941 100644 --- a/plugin/wasmguest/caps/caps_roundtrip_test.go +++ b/plugin/wasmguest/caps/caps_roundtrip_test.go @@ -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 diff --git a/plugin/wasmguest/caps/provisioner.go b/plugin/wasmguest/caps/provisioner.go index d583bb4..9f7dd6a 100644 --- a/plugin/wasmguest/caps/provisioner.go +++ b/plugin/wasmguest/caps/provisioner.go @@ -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{}) } diff --git a/plugin/wasmguest/caps/testdata/golden/provisioner_ensure_menu_item_req.pb b/plugin/wasmguest/caps/testdata/golden/provisioner_ensure_menu_item_req.pb index c486d4f..c832393 100644 --- a/plugin/wasmguest/caps/testdata/golden/provisioner_ensure_menu_item_req.pb +++ b/plugin/wasmguest/caps/testdata/golden/provisioner_ensure_menu_item_req.pb @@ -1,2 +1,2 @@ -mainDocs"/docs( \ No newline at end of file +mainDocs"/docs(2wiki:index \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/provisioner_ensure_page_req.pb b/plugin/wasmguest/caps/testdata/golden/provisioner_ensure_page_req.pb index 1f5bb6f..0582949 100644 --- a/plugin/wasmguest/caps/testdata/golden/provisioner_ensure_page_req.pb +++ b/plugin/wasmguest/caps/testdata/golden/provisioner_ensure_page_req.pb @@ -1,4 +1,5 @@ -4 -/Home"homepage* -htmlHero{"x":1}*main0H \ No newline at end of file +\ +/ /legacy-siteHome"homepage* +htmlHero{"x":1}*main0HZhomebsitej +/knowledgep \ No newline at end of file diff --git a/plugin/wasmguest/context.go b/plugin/wasmguest/context.go index 6a1041d..27fdf79 100644 --- a/plugin/wasmguest/context.go +++ b/plugin/wasmguest/context.go @@ -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(), diff --git a/plugin/wasmguest/context_test.go b/plugin/wasmguest/context_test.go new file mode 100644 index 0000000..69a9a34 --- /dev/null +++ b/plugin/wasmguest/context_test.go @@ -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) + } +} diff --git a/plugin/wasmguest/dispatch.go b/plugin/wasmguest/dispatch.go index 89bfae5..1d96d53 100644 --- a/plugin/wasmguest/dispatch.go +++ b/plugin/wasmguest/dispatch.go @@ -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{ diff --git a/plugin/wasmguest/dispatch_test.go b/plugin/wasmguest/dispatch_test.go index 73a4fa5..472688f 100644 --- a/plugin/wasmguest/dispatch_test.go +++ b/plugin/wasmguest/dispatch_test.go @@ -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(``), BodyEndExtra: textComponent(""), + 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()) != "" { 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) { diff --git a/plugin/wasmguest/render_capability_test.go b/plugin/wasmguest/render_capability_test.go index e8e28af..af26dca 100644 --- a/plugin/wasmguest/render_capability_test.go +++ b/plugin/wasmguest/render_capability_test.go @@ -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("

{{ title }}

", map[string]any{"title": title}) + return blocks.PoweredBlockWithOptions( + "

{{ title }}

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

hello {{ name }}

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

hello {{ name }}

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

hello %s

", name) }) diff --git a/plugin/wasmguest/wasmhost_test.go b/plugin/wasmguest/wasmhost_test.go index d9fc11f..3b407c3 100644 --- a/plugin/wasmguest/wasmhost_test.go +++ b/plugin/wasmguest/wasmhost_test.go @@ -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). diff --git a/templates/pagedoc.go b/templates/pagedoc.go index a57b189..e6bf8df 100644 --- a/templates/pagedoc.go +++ b/templates/pagedoc.go @@ -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 attributes (e.g. data-theme) HeadExtra HTMLComponent // appended inside after bn.Head BodyEndExtra HTMLComponent // appended before after bn.BodyEnd + StatusCode int32 // requested HTTP status; host accepts 200–599 + Metadata *document.Metadata } func (p *PageDocument) Render(ctx context.Context, w io.Writer) error {