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:
parent
314a25353d
commit
e663b77b47
@ -98,6 +98,23 @@ message PostInfo {
|
|||||||
string excerpt = 4;
|
string excerpt = 4;
|
||||||
string featured_image_url = 5;
|
string featured_image_url = 5;
|
||||||
string author_id = 6; // UUID
|
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 {
|
message ContentSlugifyRequest {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -2,6 +2,7 @@ package content
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
@ -24,14 +25,33 @@ type PageInfo struct {
|
|||||||
Title string
|
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 {
|
type PostInfo struct {
|
||||||
ID uuid.UUID
|
ID uuid.UUID
|
||||||
Slug string
|
Slug string
|
||||||
Title string
|
Title string
|
||||||
Excerpt string
|
Excerpt string
|
||||||
|
Body string // rendered HTML; populated by GetPost / ListPosts(IncludeBody)
|
||||||
FeaturedImageURL string
|
FeaturedImageURL string
|
||||||
AuthorID uuid.UUID
|
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.
|
// Content provides content access for plugins.
|
||||||
@ -40,6 +60,7 @@ type Content interface {
|
|||||||
GetAuthorProfile(ctx context.Context, id uuid.UUID) (*AuthorProfile, error)
|
GetAuthorProfile(ctx context.Context, id uuid.UUID) (*AuthorProfile, error)
|
||||||
GetPage(ctx context.Context, slug string) (*PageInfo, error)
|
GetPage(ctx context.Context, slug string) (*PageInfo, error)
|
||||||
GetPost(ctx context.Context, slug string) (*PostInfo, error)
|
GetPost(ctx context.Context, slug string) (*PostInfo, error)
|
||||||
|
ListPosts(ctx context.Context, params ListPostsParams) ([]PostInfo, error)
|
||||||
Slugify(text string) string
|
Slugify(text string) string
|
||||||
BlockNoteToHTML(ctx context.Context, doc map[string]any) string
|
BlockNoteToHTML(ctx context.Context, doc map[string]any) string
|
||||||
GenerateExcerpt(html string, maxLen int) string
|
GenerateExcerpt(html string, maxLen int) string
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
|
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
|
||||||
"git.dev.alexdunmow.com/block/core/ai"
|
"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/gating"
|
||||||
"git.dev.alexdunmow.com/block/core/plugin"
|
"git.dev.alexdunmow.com/block/core/plugin"
|
||||||
"github.com/google/uuid"
|
"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",
|
name: "content_slugify", wantMethod: "content.slugify",
|
||||||
resp: &abiv1.ContentSlugifyResponse{Slug: "hello-world"},
|
resp: &abiv1.ContentSlugifyResponse{Slug: "hello-world"},
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package caps
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
|
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
|
||||||
"git.dev.alexdunmow.com/block/core/content"
|
"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 {
|
if p == nil {
|
||||||
return nil, 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()),
|
ID: parseUUID(p.GetId()),
|
||||||
Slug: p.GetSlug(),
|
Slug: p.GetSlug(),
|
||||||
Title: p.GetTitle(),
|
Title: p.GetTitle(),
|
||||||
Excerpt: p.GetExcerpt(),
|
Excerpt: p.GetExcerpt(),
|
||||||
|
Body: p.GetBody(),
|
||||||
FeaturedImageURL: p.GetFeaturedImageUrl(),
|
FeaturedImageURL: p.GetFeaturedImageUrl(),
|
||||||
AuthorID: parseUUID(p.GetAuthorId()),
|
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
|
// Slugify has no error channel in the interface; a transport failure degrades
|
||||||
|
|||||||
2
plugin/wasmguest/caps/testdata/golden/content_list_posts_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/content_list_posts_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
0
|
||||||
5
plugin/wasmguest/caps/testdata/golden/content_list_posts_resp.pb
vendored
Normal file
5
plugin/wasmguest/caps/testdata/golden/content_list_posts_resp.pb
vendored
Normal 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ÀÈžÒ
|
||||||
Loading…
x
Reference in New Issue
Block a user