From 80328eddb12bd147e987db7b60838b49f72d6293 Mon Sep 17 00:00:00 2001 From: Alex Dunmow Date: Tue, 7 Jul 2026 13:27:05 +0800 Subject: [PATCH] feat(wasmguest): PageDocument envelope; renderTemplate returns TemplateDocument Co-Authored-By: Claude Fable 5 --- plugin/wasmguest/dispatch.go | 42 +++++++++++++-- plugin/wasmguest/dispatch_test.go | 86 ++++++++++++++++++++++++++++++- plugin/wasmguest/wasmhost_test.go | 4 +- templates/pagedoc.go | 27 ++++++++++ 4 files changed, 151 insertions(+), 8 deletions(-) create mode 100644 templates/pagedoc.go 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) +}