From d0ab542fcdde787fb350981bbc70662f3eefc471 Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Sat, 27 Jun 2026 05:30:19 -0400 Subject: [PATCH] feat(blog): excerpt fallback + tag chips (#113) Two blog UI gaps from the spec-drift audit: - BlogIndex (blog-index.md): when `summary` is null, fall back to the first paragraph of `bodyHtml` truncated to ~280 chars (plain-text, derived client-side from the already-sanitized HTML). - BlogDetail (blog-detail.md): render the post's tags as chips in the footer linking to `/blog?tag=`. The blog response didn't carry tags, so the serializer now resolves them (tag-assignments where taggableType=blog_post) and includes a `tags` field (spec api/blog.md updated). Web type + UI added. Tests: BlogIndex excerpt fallback; BlogDetail chips present/absent; API blog suite still green (tags in response). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/src/services/blog-post.ts | 13 +++- .../api/src/services/serializers/blog-post.ts | 14 +++- apps/web/src/lib/api.ts | 1 + apps/web/src/screens/BlogDetail.tsx | 13 ++++ apps/web/src/screens/BlogIndex.tsx | 23 +++++- apps/web/tests/BlogDetail.test.tsx | 70 +++++++++++++++++++ apps/web/tests/BlogIndex.test.tsx | 29 ++++++++ specs/api/blog.md | 1 + 8 files changed, 156 insertions(+), 8 deletions(-) create mode 100644 apps/web/tests/BlogDetail.test.tsx diff --git a/apps/api/src/services/blog-post.ts b/apps/api/src/services/blog-post.ts index aedceed..62f032c 100644 --- a/apps/api/src/services/blog-post.ts +++ b/apps/api/src/services/blog-post.ts @@ -4,7 +4,7 @@ * Per specs/api/blog.md. Writes happen via PR to the data repo, not the * runtime — no mutation methods here. */ -import type { BlogPost } from '@cfp/shared/schemas'; +import type { BlogPost, Tag } from '@cfp/shared/schemas'; import type { InMemoryState } from '../store/memory/state.js'; import { serializeBlogPost, type BlogPostResponse } from './serializers/blog-post.js'; @@ -66,6 +66,15 @@ export class BlogPostService { #serialize(post: BlogPost): BlogPostResponse { const author = post.authorId ? (this.#state.people.get(post.authorId) ?? null) : null; - return serializeBlogPost(post, { author }); + return serializeBlogPost(post, { author, tags: this.#tagsFor(post.id) }); + } + + #tagsFor(postId: string): Tag[] { + const taIds = this.#state.tagAssignmentsByTaggable.get(postId) ?? new Set(); + return [...taIds] + .map((taId) => this.#state.tagAssignments.get(taId)) + .filter((ta): ta is NonNullable => ta?.taggableType === 'blog_post') + .map((ta) => this.#state.tags.get(ta.tagId)) + .filter((t): t is Tag => t !== undefined); } } diff --git a/apps/api/src/services/serializers/blog-post.ts b/apps/api/src/services/serializers/blog-post.ts index 5b5747e..e4de21d 100644 --- a/apps/api/src/services/serializers/blog-post.ts +++ b/apps/api/src/services/serializers/blog-post.ts @@ -1,8 +1,14 @@ /** * BlogPost serializer. */ -import type { BlogPost, Person } from '@cfp/shared/schemas'; -import { renderMarkdown, serializePersonAvatar, type PersonAvatar } from './common.js'; +import type { BlogPost, Person, Tag } from '@cfp/shared/schemas'; +import { + renderMarkdown, + serializePersonAvatar, + serializeTagItem, + type PersonAvatar, + type TagItem, +} from './common.js'; export interface BlogPostResponse { readonly id: string; @@ -16,13 +22,14 @@ export interface BlogPostResponse { readonly featuredImageUrl: string | null; readonly body: string; readonly bodyHtml: string; + readonly tags: TagItem[]; readonly createdAt: string; readonly updatedAt: string; } export function serializeBlogPost( post: BlogPost, - opts: { author: Person | null }, + opts: { author: Person | null; tags?: Tag[] }, ): BlogPostResponse { return { id: post.id, @@ -38,6 +45,7 @@ export function serializeBlogPost( : null, body: post.body, bodyHtml: renderMarkdown(post.body).html, + tags: (opts.tags ?? []).map(serializeTagItem), createdAt: post.createdAt, updatedAt: post.updatedAt, }; diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 4a9c1d7..94130a3 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -297,6 +297,7 @@ export interface BlogPostResponse { readonly featuredImageUrl: string | null; readonly body: string; readonly bodyHtml: string; + readonly tags: TagItem[]; readonly createdAt: string; readonly updatedAt: string; } diff --git a/apps/web/src/screens/BlogDetail.tsx b/apps/web/src/screens/BlogDetail.tsx index e7e2a12..8fcbb1c 100644 --- a/apps/web/src/screens/BlogDetail.tsx +++ b/apps/web/src/screens/BlogDetail.tsx @@ -68,6 +68,19 @@ export function BlogDetail() {