core/plugin/wasmguest/caps/content.go
Alex Dunmow bec3a43f55 feat(wasmguest): guest capability stubs over host calls (WO-WZ-003)
Every CoreServices interface (core/plugin/deps.go) now has a guest-side stub
that marshals to the WO-WZ-001 capability messages and dispatches through the
generic host_call transport, so plugin service code compiles and runs
unchanged against content.Content, settings.Settings, plugin.PluginBridge, etc.

- core/plugin/wasmguest/caps/: one file per family (17 families, 38 methods),
  a var _ <iface> = (*stub)(nil) compile proof each, and NewCoreServices(call)
  assembling them. The transport is injected (CallFunc) so marshaling is
  natively testable; the wasm shim binds it to CallHost, DESCRIBE probes pass
  nil (capability calls fail cleanly instead of nil-panicking).
- Error mapping wraps AbiError with <family>.<method> context and maps
  DEADLINE_EXCEEDED onto context.DeadlineExceeded.
- RAGService.RegisterContentFetcher stays guest-side (RAGStub) for
  HOOK_RAG_FETCH dispatch; Query/OnContentChanged marshal out. dispatch.go and
  describe.go now source fetchers from the caps RAG stub.
- caps_roundtrip_test.go: fake transport + 76 deterministic golden payloads
  (family_method_{req,resp}.pb) covering 100% of families, plus error-mapping,
  deadline, nil-transport, and guest-side-fetcher tests. WO-WZ-006 replays the
  same goldens to prevent host/guest drift.
- Acceptance: testdata/fixture Load hook calls deps.Content/Settings/Bridge
  unchanged (compiles for wasip1); caps_wasmhost_test.go drives it end-to-end
  through a real wazero module + fake host_call table.
- Disposition table in docs/wasm-abi.md: every member stub | host-side
  (Pool, Interceptors, AppURL/MediaPath, CoreServiceBindings host-side).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:23:58 +08:00

106 lines
3.2 KiB
Go

package caps
import (
"context"
"encoding/json"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
"git.dev.alexdunmow.com/block/core/content"
"github.com/google/uuid"
)
// contentStub implements content.Content over content.* capability calls.
type contentStub struct{ base }
var _ content.Content = (*contentStub)(nil)
func (s *contentStub) GetAuthorProfile(ctx context.Context, id uuid.UUID) (*content.AuthorProfile, error) {
resp := &abiv1.ContentGetAuthorProfileResponse{}
if err := s.invoke(ctx, "get_author_profile", &abiv1.ContentGetAuthorProfileRequest{Id: id.String()}, resp); err != nil {
return nil, err
}
a := resp.GetAuthor()
if a == nil {
return nil, nil
}
return &content.AuthorProfile{
ID: parseUUID(a.GetId()),
Name: a.GetName(),
Slug: a.GetSlug(),
Bio: a.GetBio(),
AvatarURL: a.GetAvatarUrl(),
Website: a.GetWebsite(),
SocialLinks: a.GetSocialLinks(),
}, nil
}
func (s *contentStub) GetPage(ctx context.Context, slug string) (*content.PageInfo, error) {
resp := &abiv1.ContentGetPageResponse{}
if err := s.invoke(ctx, "get_page", &abiv1.ContentGetPageRequest{Slug: slug}, resp); err != nil {
return nil, err
}
p := resp.GetPage()
if p == nil {
return nil, nil
}
return &content.PageInfo{ID: parseUUID(p.GetId()), Slug: p.GetSlug(), Title: p.GetTitle()}, nil
}
func (s *contentStub) GetPost(ctx context.Context, slug string) (*content.PostInfo, error) {
resp := &abiv1.ContentGetPostResponse{}
if err := s.invoke(ctx, "get_post", &abiv1.ContentGetPostRequest{Slug: slug}, resp); err != nil {
return nil, err
}
p := resp.GetPost()
if p == nil {
return nil, nil
}
return &content.PostInfo{
ID: parseUUID(p.GetId()),
Slug: p.GetSlug(),
Title: p.GetTitle(),
Excerpt: p.GetExcerpt(),
FeaturedImageURL: p.GetFeaturedImageUrl(),
AuthorID: parseUUID(p.GetAuthorId()),
}, nil
}
// Slugify has no error channel in the interface; a transport failure degrades
// to the empty string (the caller treats an empty slug as "unavailable").
func (s *contentStub) Slugify(text string) string {
resp := &abiv1.ContentSlugifyResponse{}
if err := s.invoke(context.Background(), "slugify", &abiv1.ContentSlugifyRequest{Text: text}, resp); err != nil {
return ""
}
return resp.GetSlug()
}
func (s *contentStub) BlockNoteToHTML(ctx context.Context, doc map[string]any) string {
docJSON, err := json.Marshal(doc)
if err != nil {
return ""
}
resp := &abiv1.ContentBlockNoteToHtmlResponse{}
if err := s.invoke(ctx, "block_note_to_html", &abiv1.ContentBlockNoteToHtmlRequest{DocJson: docJSON}, resp); err != nil {
return ""
}
return resp.GetHtml()
}
func (s *contentStub) GenerateExcerpt(html string, maxLen int) string {
resp := &abiv1.ContentGenerateExcerptResponse{}
req := &abiv1.ContentGenerateExcerptRequest{Html: html, MaxLen: int32(maxLen)}
if err := s.invoke(context.Background(), "generate_excerpt", req, resp); err != nil {
return ""
}
return resp.GetExcerpt()
}
func (s *contentStub) StripHTML(str string) string {
resp := &abiv1.ContentStripHtmlResponse{}
if err := s.invoke(context.Background(), "strip_html", &abiv1.ContentStripHtmlRequest{Html: str}, resp); err != nil {
return ""
}
return resp.GetText()
}