diff --git a/plugin/wasmguest/dispatch.go b/plugin/wasmguest/dispatch.go
index 3cd419b..d6a3875 100644
--- a/plugin/wasmguest/dispatch.go
+++ b/plugin/wasmguest/dispatch.go
@@ -17,6 +17,7 @@ import (
"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"
+ "git.dev.alexdunmow.com/block/pluginsdk/templates"
"github.com/google/uuid"
"google.golang.org/protobuf/proto"
)
@@ -331,11 +332,44 @@ func (g *guest) renderTemplate(ctx context.Context, payload []byte) (proto.Messa
if component == nil {
return nil, internalError("template " + req.GetTemplateKey() + " returned nil component")
}
- var buf bytes.Buffer
- if err := component.Render(ctx, &buf); err != nil {
- return nil, internalError("render template " + req.GetTemplateKey() + ": " + err.Error())
+ tdoc := &abiv1.TemplateDocument{}
+ renderPart := func(c templates.HTMLComponent) ([]byte, error) {
+ if c == nil {
+ return nil, nil
+ }
+ var buf bytes.Buffer
+ if err := c.Render(ctx, &buf); err != nil {
+ return nil, err
+ }
+ return buf.Bytes(), nil
}
- return &abiv1.RenderTemplateResponse{Html: buf.Bytes()}, nil
+ if pd, ok := component.(*templates.PageDocument); ok {
+ body, err := renderPart(pd.Body)
+ if err != nil {
+ return nil, internalError("render template " + req.GetTemplateKey() + ": " + err.Error())
+ }
+ head, err := renderPart(pd.HeadExtra)
+ if err != nil {
+ return nil, internalError("render template " + req.GetTemplateKey() + " head extra: " + err.Error())
+ }
+ end, err := renderPart(pd.BodyEndExtra)
+ if err != nil {
+ return nil, internalError("render template " + req.GetTemplateKey() + " body-end extra: " + err.Error())
+ }
+ tdoc.BodyHtml = body
+ tdoc.BodyClass = pd.BodyClass
+ tdoc.Lang = pd.Lang
+ tdoc.HtmlAttrs = pd.HTMLAttrs
+ tdoc.HeadExtraHtml = head
+ tdoc.BodyEndExtraHtml = end
+ } else {
+ body, err := renderPart(component)
+ if err != nil {
+ return nil, internalError("render template " + req.GetTemplateKey() + ": " + err.Error())
+ }
+ tdoc.BodyHtml = body
+ }
+ return &abiv1.RenderTemplateResponse{Document: tdoc}, nil
}
func (g *guest) handleHTTP(ctx context.Context, payload []byte) (proto.Message, *abiv1.AbiError) {
diff --git a/plugin/wasmguest/dispatch_test.go b/plugin/wasmguest/dispatch_test.go
index 3e053a4..a252fae 100644
--- a/plugin/wasmguest/dispatch_test.go
+++ b/plugin/wasmguest/dispatch_test.go
@@ -334,8 +334,90 @@ func TestRenderTemplate(t *testing.T) {
if err := proto.Unmarshal(resp.GetPayload(), rr); err != nil {
t.Fatalf("unmarshal: %v", err)
}
- if string(rr.GetHtml()) != "My Page" {
- t.Errorf("html = %q", rr.GetHtml())
+ if string(rr.GetDocument().GetBodyHtml()) != "My Page" {
+ t.Errorf("body_html = %q", rr.GetDocument().GetBodyHtml())
+ }
+}
+
+func TestRenderTemplatePlainComponentBecomesBodyOnly(t *testing.T) {
+ g := newGuest(plugin.PluginRegistration{
+ Name: "tpltest",
+ Register: func(tr templates.TemplateRegistry, _ blocks.BlockRegistry) error {
+ return tr.Register("plain", func(_ context.Context, _ map[string]any) templates.HTMLComponent {
+ return textComponent("
hi
")
+ })
+ },
+ })
+ resp := invokeHook(t, g, abiv1.Hook_HOOK_RENDER_TEMPLATE, &abiv1.RenderTemplateRequest{
+ TemplateKey: "plain",
+ })
+ if resp.GetError() != nil {
+ t.Fatalf("render error: %v", resp.GetError())
+ }
+ rr := &abiv1.RenderTemplateResponse{}
+ if err := proto.Unmarshal(resp.GetPayload(), rr); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ doc := rr.GetDocument()
+ if doc == nil {
+ t.Fatal("nil document")
+ }
+ if string(doc.GetBodyHtml()) != "hi
" {
+ t.Errorf("body_html = %q", doc.GetBodyHtml())
+ }
+ if doc.GetBodyClass() != "" || doc.GetLang() != "" || len(doc.GetHtmlAttrs()) != 0 ||
+ len(doc.GetHeadExtraHtml()) != 0 || len(doc.GetBodyEndExtraHtml()) != 0 {
+ t.Errorf("plain component should set only body_html, got %+v", doc)
+ }
+}
+
+func TestRenderTemplatePageDocumentFillsEnvelope(t *testing.T) {
+ g := newGuest(plugin.PluginRegistration{
+ Name: "tpltest",
+ Register: func(tr templates.TemplateRegistry, _ blocks.BlockRegistry) error {
+ return tr.Register("page", func(_ context.Context, _ map[string]any) templates.HTMLComponent {
+ return &templates.PageDocument{
+ Body: textComponent("body"),
+ BodyClass: "cls",
+ Lang: "fr",
+ HTMLAttrs: map[string]string{"data-theme": "x"},
+ HeadExtra: textComponent(``),
+ BodyEndExtra: textComponent(""),
+ }
+ })
+ },
+ })
+ resp := invokeHook(t, g, abiv1.Hook_HOOK_RENDER_TEMPLATE, &abiv1.RenderTemplateRequest{
+ TemplateKey: "page",
+ })
+ if resp.GetError() != nil {
+ t.Fatalf("render error: %v", resp.GetError())
+ }
+ rr := &abiv1.RenderTemplateResponse{}
+ if err := proto.Unmarshal(resp.GetPayload(), rr); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ doc := rr.GetDocument()
+ if doc == nil {
+ t.Fatal("nil document")
+ }
+ if string(doc.GetBodyHtml()) != "body" {
+ t.Errorf("body_html = %q", doc.GetBodyHtml())
+ }
+ if doc.GetBodyClass() != "cls" {
+ t.Errorf("body_class = %q", doc.GetBodyClass())
+ }
+ if doc.GetLang() != "fr" {
+ t.Errorf("lang = %q", doc.GetLang())
+ }
+ if doc.GetHtmlAttrs()["data-theme"] != "x" {
+ t.Errorf("html_attrs = %v", doc.GetHtmlAttrs())
+ }
+ if string(doc.GetHeadExtraHtml()) != `` {
+ t.Errorf("head_extra_html = %q", doc.GetHeadExtraHtml())
+ }
+ if string(doc.GetBodyEndExtraHtml()) != "" {
+ t.Errorf("body_end_extra_html = %q", doc.GetBodyEndExtraHtml())
}
}
diff --git a/plugin/wasmguest/wasmhost_test.go b/plugin/wasmguest/wasmhost_test.go
index 9df7815..a21542b 100644
--- a/plugin/wasmguest/wasmhost_test.go
+++ b/plugin/wasmguest/wasmhost_test.go
@@ -183,8 +183,8 @@ func TestWasmFixtureDescribeRoundTrip(t *testing.T) {
if err := proto.Unmarshal(resp.GetPayload(), rt); err != nil {
t.Fatalf("unmarshal RenderTemplateResponse: %v", err)
}
- if string(rt.GetHtml()) != "Wazero" {
- t.Errorf("template html = %q", rt.GetHtml())
+ if string(rt.GetDocument().GetBodyHtml()) != "Wazero" {
+ t.Errorf("template body_html = %q", rt.GetDocument().GetBodyHtml())
}
}
diff --git a/templates/pagedoc.go b/templates/pagedoc.go
new file mode 100644
index 0000000..a57b189
--- /dev/null
+++ b/templates/pagedoc.go
@@ -0,0 +1,27 @@
+package templates
+
+import (
+ "context"
+ "io"
+)
+
+// PageDocument is a full-page template's contribution to the document. The
+// HOST owns the envelope (doctype/// and the bn chrome);
+// the guest supplies the body interior and envelope attributes. PageDocument
+// is itself an HTMLComponent (renders Body), so TemplateFunc's signature is
+// unchanged and non-page callers need no adaptation.
+type PageDocument struct {
+ Body HTMLComponent
+ BodyClass string
+ Lang string // ; empty means host default "en"
+ HTMLAttrs map[string]string // extra attributes (e.g. data-theme)
+ HeadExtra HTMLComponent // appended inside after bn.Head
+ BodyEndExtra HTMLComponent // appended before after bn.BodyEnd
+}
+
+func (p *PageDocument) Render(ctx context.Context, w io.Writer) error {
+ if p.Body == nil {
+ return nil
+ }
+ return p.Body.Render(ctx, w)
+}