Alex Dunmow b6d40ed8ac feat: bootstrap block/pluginsdk — the proto-first plugin SDK (P1)
Bootstrap the plugin-facing SDK module so the fleet migration (P2) becomes a
mechanical block/core/X -> block/pluginsdk/X import rewrite.

- abi/proto/v1: the single source-of-truth ABI proto tree, copied from the cms
  authoring source (cms/backend/abi/proto/v1) with go_package retargeted to
  git.dev.alexdunmow.com/block/pluginsdk/abi/v1;abiv1. Kills the hand-kept
  cms/core proto duplication (audit gap 4).
- abi/v1: generated Go bindings (buf generate); byte-identical to core's abi/v1
  save the embedded go_package path.
- plugin/ (registration + DI surface), plugin/wasmguest/** (transport shim,
  caps stubs, bnwasm db driver, testdata fixtures + golden .pb), and the
  guest-facing type packages: blocks (+builtin/shared/tags), templates
  (+pongo/bn), auth, settings, content, gating, crypto, rbac, video, ai,
  subscriptions, menus, datasources. Internal imports rewritten core ->
  pluginsdk; zero block/core references remain.
- README/AGENTS(+CLAUDE symlink)/Makefile: proto is the contract, Go is one
  binding; no replace directives; templates/bn is a synced copy authored in cms.

Spec: cms docs/superpowers/specs/2026-07-07-proto-first-plugin-sdk-design.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:51:00 +08:00

226 lines
6.9 KiB
Go

package wasmguest
import (
"context"
"encoding/json"
"net/http"
"net/url"
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
"git.dev.alexdunmow.com/block/pluginsdk/blocks"
"github.com/google/uuid"
)
// contextFromRenderContext rebuilds the context.Context that block and
// template code expects, applying each RenderContext field through the same
// context keys core/blocks/context.go defines. Existing render code reading
// via blocks.Get* works unchanged inside the guest.
//
// Function-valued context entries (SlotRenderer, MediaResolver,
// EmbedResolver, Queries) do not cross the wire; their v1 mappings are
// documented in core/docs/wasm-abi.md. RenderContext.theme_json and .locale
// have no core/blocks context key yet and are ignored here until a consumer
// key exists.
func contextFromRenderContext(ctx context.Context, rc *abiv1.RenderContext) context.Context {
if rc == nil {
return ctx
}
if req := requestFromInfo(rc.GetRequest()); req != nil {
ctx = blocks.WithRequest(ctx, req)
}
if bc := blockContextFromProto(rc.GetBlockContext()); bc != nil {
ctx = blocks.WithBlockContext(ctx, bc)
}
if rc.GetTemplateKey() != "" {
ctx = blocks.WithTemplateKey(ctx, rc.GetTemplateKey())
}
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(),
})
}
if p := rc.GetPost(); p != nil {
post := &blocks.PostContext{
ID: parseUUID(p.GetId()),
Slug: p.GetSlug(),
Title: p.GetTitle(),
Excerpt: p.GetExcerpt(),
FeaturedImageURL: p.GetFeaturedImageUrl(),
AuthorID: parseUUID(p.GetAuthorId()),
ReadingTime: int(p.GetReadingTime()),
IsFeatured: p.GetIsFeatured(),
}
if ts := p.GetPublishedAt(); ts != nil {
post.PublishedAt = ts.AsTime()
}
ctx = blocks.WithCurrentBlogPost(ctx, post)
}
if a := rc.GetAuthor(); a != nil {
ctx = blocks.WithCurrentAuthor(ctx, &blocks.AuthorContext{
ID: parseUUID(a.GetId()),
Name: a.GetName(),
Slug: a.GetSlug(),
Bio: a.GetBio(),
AvatarURL: a.GetAvatarUrl(),
})
}
if c := rc.GetCategory(); c != nil {
ctx = blocks.WithCurrentCategory(ctx, &blocks.CategoryContext{
ID: parseUUID(c.GetId()),
Name: c.GetName(),
Slug: c.GetSlug(),
})
}
if mp := rc.GetMasterPage(); mp != nil {
ctx = blocks.WithMasterPage(ctx, &blocks.MasterPageContext{
ID: parseUUID(mp.GetId()),
Slug: mp.GetSlug(),
Title: mp.GetTitle(),
})
}
if rc.GetRequestedPath() != "" {
ctx = blocks.WithRequestedPath(ctx, rc.GetRequestedPath())
}
if slots := rc.GetInjectedSlots(); len(slots) > 0 {
ctx = blocks.WithInjectedSlots(ctx, slots)
}
if rc.GetIsEditor() {
ctx = blocks.WithIsEditor(ctx, true)
}
if slots := rc.GetExpectedSlots(); len(slots) > 0 {
ctx = blocks.WithExpectedSlots(ctx, slots)
}
if id := parseUUID(rc.GetBlockId()); id != uuid.Nil {
ctx = blocks.WithBlockID(ctx, id)
}
if id := parseUUID(rc.GetCurrentPageId()); id != uuid.Nil {
ctx = blocks.WithCurrentPageID(ctx, id)
}
if hp := rc.GetHumanProofBanner(); hp != nil {
ctx = blocks.WithHumanProofBanner(ctx, &blocks.HumanProofBannerData{
ActiveTimeMinutes: int(hp.GetActiveTimeMinutes()),
KeystrokeCount: int(hp.GetKeystrokeCount()),
SessionCount: int(hp.GetSessionCount()),
PostSlug: hp.GetPostSlug(),
})
}
if dr := rc.GetDetailRow(); dr != nil {
ctx = blocks.WithDetailRow(ctx, dr.GetTableId(), dr.GetRowId(), unmarshalMap(dr.GetDataJson()))
}
return ctx
}
// requestFromInfo rebuilds a *http.Request carrying the subset of fields
// render code reads via blocks.GetRequest.
func requestFromInfo(ri *abiv1.RequestInfo) *http.Request {
if ri == nil {
return nil
}
u, err := url.Parse(ri.GetUrl())
if err != nil || ri.GetUrl() == "" {
u = &url.URL{Path: ri.GetPath(), RawQuery: ri.GetRawQuery()}
}
req := &http.Request{
Method: ri.GetMethod(),
URL: u,
Proto: "HTTP/1.1",
ProtoMajor: 1,
ProtoMinor: 1,
Header: make(http.Header, len(ri.GetHeaders())+2),
Host: ri.GetHost(),
RemoteAddr: ri.GetRemoteIp(),
RequestURI: u.RequestURI(),
}
for k, v := range ri.GetHeaders() {
req.Header.Set(k, v)
}
if ri.GetReferrer() != "" && req.Header.Get("Referer") == "" {
req.Header.Set("Referer", ri.GetReferrer())
}
if ri.GetUserAgent() != "" && req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", ri.GetUserAgent())
}
for name, value := range ri.GetCookies() {
req.AddCookie(&http.Cookie{Name: name, Value: value})
}
return req
}
func blockContextFromProto(pb *abiv1.BlockContext) *blocks.BlockContext {
if pb == nil {
return nil
}
bc := &blocks.BlockContext{
URL: pb.GetUrl(),
Path: pb.GetPath(),
Slug: pb.GetSlug(),
PageID: pb.GetPageId(),
PageTitle: pb.GetPageTitle(),
TemplateKey: pb.GetTemplateKey(),
IsEditor: pb.GetIsEditor(),
Timestamp: pb.GetTimestamp(),
IsLoggedIn: pb.GetIsLoggedIn(),
UserID: pb.GetUserId(),
UserEmail: pb.GetUserEmail(),
UserRole: pb.GetUserRole(),
Method: pb.GetMethod(),
Host: pb.GetHost(),
Query: pb.GetQuery(),
Referrer: pb.GetReferrer(),
UserAgent: pb.GetUserAgent(),
IP: pb.GetIp(),
Cookies: pb.GetCookies(),
Headers: pb.GetHeaders(),
Country: pb.GetCountry(),
City: pb.GetCity(),
Timezone: pb.GetTimezone(),
CurrentAuthor: unmarshalMap(pb.GetCurrentAuthorJson()),
CurrentPost: unmarshalMap(pb.GetCurrentPostJson()),
CurrentCategory: unmarshalMap(pb.GetCurrentCategoryJson()),
Site: unmarshalMap(pb.GetSiteJson()),
IsPublicLoggedIn: pb.GetIsPublicLoggedIn(),
PublicUserID: pb.GetPublicUserId(),
PublicUsername: pb.GetPublicUsername(),
PublicDisplayName: pb.GetPublicDisplayName(),
PublicEmailVerified: pb.GetPublicEmailVerified(),
DetailRowID: pb.GetDetailRowId(),
DetailTableID: pb.GetDetailTableId(),
DetailRowData: unmarshalMap(pb.GetDetailRowDataJson()),
BlogIndexURL: pb.GetBlogIndexUrl(),
CategoryPageURL: pb.GetCategoryPageUrl(),
}
if ts := pb.GetNow(); ts != nil {
bc.Now = ts.AsTime()
}
return bc
}
// unmarshalMap decodes a JSON object into map[string]any, returning nil for
// empty or undecodable input (render code treats absent maps as nil).
func unmarshalMap(raw []byte) map[string]any {
if len(raw) == 0 {
return nil
}
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
return nil
}
return m
}
func parseUUID(s string) uuid.UUID {
if s == "" {
return uuid.Nil
}
id, err := uuid.Parse(s)
if err != nil {
return uuid.Nil
}
return id
}