core/plugin/wasmguest/caps/content.go
Alex Dunmow e663b77b47 content: add Content.ListPosts capability for blog listing
Adds ListPosts(ctx, ListPostsParams) to the content.Content capability so
wasm plugins can list posts with bodies — the per-plugin Postgres role
denies direct public.blog_posts reads (42501) and GetPost only fetches a
single post's metadata. Extends PostInfo with Body, AuthorName, AuthorSlug
and PublishedAt (additive; existing get_post golden unchanged). Wires all
layers: interface, ABI PostInfo/ContentListPosts{Request,Response} messages
(buf breaking clean), guest stub, and a deterministic content_list_posts
golden replayed by the cms host parity test.

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

142 lines
4.4 KiB
Go

package caps
import (
"context"
"encoding/json"
"time"
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
}
info := postInfoFromProto(p)
return &info, nil
}
func (s *contentStub) ListPosts(ctx context.Context, params content.ListPostsParams) ([]content.PostInfo, error) {
req := &abiv1.ContentListPostsRequest{
Limit: int32(params.Limit),
Offset: int32(params.Offset),
Category: params.Category,
PublishedOnly: params.PublishedOnly,
IncludeBody: params.IncludeBody,
IncludeExcerpt: params.IncludeExcerpt,
}
resp := &abiv1.ContentListPostsResponse{}
if err := s.invoke(ctx, "list_posts", req, resp); err != nil {
return nil, err
}
out := make([]content.PostInfo, 0, len(resp.GetPosts()))
for _, p := range resp.GetPosts() {
out = append(out, postInfoFromProto(p))
}
return out, nil
}
// postInfoFromProto decodes an abiv1.PostInfo into the SDK content.PostInfo,
// shared by GetPost and ListPosts.
func postInfoFromProto(p *abiv1.PostInfo) content.PostInfo {
var publishedAt time.Time
if ts := p.GetPublishedAt(); ts != nil {
publishedAt = ts.AsTime()
}
return content.PostInfo{
ID: parseUUID(p.GetId()),
Slug: p.GetSlug(),
Title: p.GetTitle(),
Excerpt: p.GetExcerpt(),
Body: p.GetBody(),
FeaturedImageURL: p.GetFeaturedImageUrl(),
AuthorID: parseUUID(p.GetAuthorId()),
AuthorName: p.GetAuthorName(),
AuthorSlug: p.GetAuthorSlug(),
PublishedAt: publishedAt,
}
}
// 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()
}