feat(wasmguest): PageDocument envelope; renderTemplate returns TemplateDocument

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Alex Dunmow 2026-07-07 13:27:05 +08:00
parent a43ef7c242
commit 80328eddb1
4 changed files with 151 additions and 8 deletions

View File

@ -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) {

View File

@ -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()) != "<html>My Page</html>" {
t.Errorf("html = %q", rr.GetHtml())
if string(rr.GetDocument().GetBodyHtml()) != "<html>My Page</html>" {
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("<h1>hi</h1>")
})
},
})
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()) != "<h1>hi</h1>" {
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("<main>body</main>"),
BodyClass: "cls",
Lang: "fr",
HTMLAttrs: map[string]string{"data-theme": "x"},
HeadExtra: textComponent(`<meta name="e">`),
BodyEndExtra: textComponent("<script>1</script>"),
}
})
},
})
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()) != "<main>body</main>" {
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()) != `<meta name="e">` {
t.Errorf("head_extra_html = %q", doc.GetHeadExtraHtml())
}
if string(doc.GetBodyEndExtraHtml()) != "<script>1</script>" {
t.Errorf("body_end_extra_html = %q", doc.GetBodyEndExtraHtml())
}
}

View File

@ -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()) != "<!doctype html><title>Wazero</title>" {
t.Errorf("template html = %q", rt.GetHtml())
if string(rt.GetDocument().GetBodyHtml()) != "<!doctype html><title>Wazero</title>" {
t.Errorf("template body_html = %q", rt.GetDocument().GetBodyHtml())
}
}

27
templates/pagedoc.go Normal file
View File

@ -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/<html>/<head>/<body> 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 // <html lang>; empty means host default "en"
HTMLAttrs map[string]string // extra <html> attributes (e.g. data-theme)
HeadExtra HTMLComponent // appended inside <head> after bn.Head
BodyEndExtra HTMLComponent // appended before </body> after bn.BodyEnd
}
func (p *PageDocument) Render(ctx context.Context, w io.Writer) error {
if p.Body == nil {
return nil
}
return p.Body.Render(ctx, w)
}