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>
This commit is contained in:
Alex Dunmow 2026-07-04 01:00:14 +08:00
parent 314a25353d
commit e663b77b47
7 changed files with 643 additions and 354 deletions

View File

@ -98,6 +98,23 @@ message PostInfo {
string excerpt = 4;
string featured_image_url = 5;
string author_id = 6; // UUID
string body = 7; // rendered HTML; set by get_post / list_posts(include_body)
string author_name = 8;
string author_slug = 9;
google.protobuf.Timestamp published_at = 10;
}
message ContentListPostsRequest {
int32 limit = 1;
int32 offset = 2;
string category = 3; // optional category slug filter ("" = all)
bool published_only = 4;
bool include_body = 5;
bool include_excerpt = 6;
}
message ContentListPostsResponse {
repeated PostInfo posts = 1;
}
message ContentSlugifyRequest {

File diff suppressed because it is too large Load Diff

View File

@ -2,6 +2,7 @@ package content
import (
"context"
"time"
"github.com/google/uuid"
)
@ -24,14 +25,33 @@ type PageInfo struct {
Title string
}
// PostInfo is a simplified post representation for plugins.
// PostInfo is a simplified post representation for plugins. It carries enough
// for both a blog index (title/slug/excerpt/author/date/featured image) and a
// post detail view (Body — the rendered HTML). Body is populated by GetPost and
// by ListPosts when ListPostsParams.IncludeBody is set; it is empty otherwise.
type PostInfo struct {
ID uuid.UUID
Slug string
Title string
Excerpt string
Body string // rendered HTML; populated by GetPost / ListPosts(IncludeBody)
FeaturedImageURL string
AuthorID uuid.UUID
AuthorName string
AuthorSlug string
PublishedAt time.Time
}
// ListPostsParams filters and shapes a ListPosts query. The zero value lists
// published posts from the start with no category filter, excerpts included and
// bodies omitted (the cheap blog-index shape).
type ListPostsParams struct {
Limit int // 0 = host default
Offset int // pagination offset
Category string // optional category slug filter ("" = all)
PublishedOnly bool // reserved: plugins only ever see published posts
IncludeBody bool // render each post's body to HTML (expensive)
IncludeExcerpt bool // include the post excerpt
}
// Content provides content access for plugins.
@ -40,6 +60,7 @@ type Content interface {
GetAuthorProfile(ctx context.Context, id uuid.UUID) (*AuthorProfile, error)
GetPage(ctx context.Context, slug string) (*PageInfo, error)
GetPost(ctx context.Context, slug string) (*PostInfo, error)
ListPosts(ctx context.Context, params ListPostsParams) ([]PostInfo, error)
Slugify(text string) string
BlockNoteToHTML(ctx context.Context, doc map[string]any) string
GenerateExcerpt(html string, maxLen int) string

View File

@ -12,6 +12,7 @@ import (
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
"git.dev.alexdunmow.com/block/core/ai"
"git.dev.alexdunmow.com/block/core/content"
"git.dev.alexdunmow.com/block/core/gating"
"git.dev.alexdunmow.com/block/core/plugin"
"github.com/google/uuid"
@ -188,6 +189,33 @@ func TestCapabilityRoundTrip(t *testing.T) {
}
},
},
{
name: "content_list_posts", wantMethod: "content.list_posts",
resp: &abiv1.ContentListPostsResponse{Posts: []*abiv1.PostInfo{
{
Id: idAuthor.String(), Slug: "first", Title: "First", Excerpt: "one",
FeaturedImageUrl: "media:1", AuthorId: idAuthor.String(),
AuthorName: "Ada", AuthorSlug: "ada", PublishedAt: timestamppb.New(planTime),
},
{
Id: idPlan.String(), Slug: "second", Title: "Second", Excerpt: "two",
AuthorId: idAuthor.String(), AuthorName: "Ada", AuthorSlug: "ada",
PublishedAt: timestamppb.New(planTime),
},
}},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.Content.ListPosts(ctx, content.ListPostsParams{Limit: 10, IncludeExcerpt: true})
if err != nil {
t.Fatal(err)
}
if len(got) != 2 || got[0].Slug != "first" || got[0].AuthorName != "Ada" {
t.Fatalf("posts = %+v", got)
}
if got[1].Slug != "second" || !got[1].PublishedAt.Equal(planTime) {
t.Errorf("post[1] = %+v", got[1])
}
},
},
{
name: "content_slugify", wantMethod: "content.slugify",
resp: &abiv1.ContentSlugifyResponse{Slug: "hello-world"},

View File

@ -3,6 +3,7 @@ package caps
import (
"context"
"encoding/json"
"time"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
"git.dev.alexdunmow.com/block/core/content"
@ -55,14 +56,49 @@ func (s *contentStub) GetPost(ctx context.Context, slug string) (*content.PostIn
if p == nil {
return nil, nil
}
return &content.PostInfo{
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()),
}, nil
AuthorName: p.GetAuthorName(),
AuthorSlug: p.GetAuthorSlug(),
PublishedAt: publishedAt,
}
}
// Slugify has no error channel in the interface; a transport failure degrades

View File

@ -0,0 +1,2 @@

0

View File

@ -0,0 +1,5 @@
z
$22222222-2222-2222-2222-222222222222firstFirst"one*media:12$22222222-2222-2222-2222-222222222222BAdaJadaRÀÈžÒ
s
$88888888-8888-8888-8888-888888888888secondSecond"two2$22222222-2222-2222-2222-222222222222BAdaJadaRÀÈžÒ