Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions apps/api/src/services/blog-post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<string>();
return [...taIds]
.map((taId) => this.#state.tagAssignments.get(taId))
.filter((ta): ta is NonNullable<typeof ta> => ta?.taggableType === 'blog_post')
.map((ta) => this.#state.tags.get(ta.tagId))
.filter((t): t is Tag => t !== undefined);
}
}
14 changes: 11 additions & 3 deletions apps/api/src/services/serializers/blog-post.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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,
Expand All @@ -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,
};
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
13 changes: 13 additions & 0 deletions apps/web/src/screens/BlogDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,19 @@ export function BlogDetail() {
</div>

<footer className="mt-10 pt-6 border-t border-border">
{post.tags.length > 0 && (
<div className="mb-4 flex flex-wrap gap-2">
{post.tags.map((t) => (
<Link
key={`${t.namespace}.${t.slug}`}
to={`/blog?tag=${encodeURIComponent(`${t.namespace}.${t.slug}`)}`}
className="inline-flex items-center rounded-full border border-border bg-muted px-2.5 py-0.5 text-xs text-muted-foreground hover:text-foreground"
>
{t.title}
</Link>
))}
</div>
)}
<Link to="/blog" className="text-primary underline">
← Back to all posts
</Link>
Expand Down
23 changes: 20 additions & 3 deletions apps/web/src/screens/BlogIndex.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,15 +147,32 @@ function BlogIndexCard({ post }: { post: BlogPostResponse }) {
) : null}
<time dateTime={post.postedAt}>{formatPostedAt(post.postedAt)}</time>
</div>
{post.summary && (
<p className="text-muted-foreground mt-2 leading-relaxed">{post.summary}</p>
)}
{(() => {
// blog-index.md Display Rules: show `summary`; if absent, fall back
// to the first paragraph of bodyHtml truncated to ~280 chars.
const text = post.summary ?? excerptFromHtml(post.bodyHtml);
return text ? (
<p className="text-muted-foreground mt-2 leading-relaxed">{text}</p>
) : null;
})()}
</div>
</article>
</li>
);
}

/**
* Plain-text excerpt from already-sanitized post HTML: the first paragraph's
* text, truncated to ~280 chars at a word boundary. Only strips tags for the
* preview — the full post renders via the server-sanitized HTML elsewhere.
*/
function excerptFromHtml(html: string): string {
const firstParagraph = /<p[^>]*>(.*?)<\/p>/is.exec(html)?.[1] ?? html;
const text = firstParagraph.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
if (text.length <= 280) return text;
return text.slice(0, 280).replace(/\s+\S*$/, '') + '…';
}

function formatPostedAt(iso: string): string {
const d = new Date(iso);
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
Expand Down
70 changes: 70 additions & 0 deletions apps/web/tests/BlogDetail.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { Routes, Route } from 'react-router';
import { renderScreen, mockOk } from './test-utils.js';
import { BlogDetail } from '../src/screens/BlogDetail.js';
import { AuthProvider } from '../src/hooks/useAuth.js';

const POST = {
id: '01951a3c-0000-7000-8000-bbbbbbbbbbbb',
slug: 'roundup',
title: 'Civic Tech Roundup',
summary: null,
author: null,
postedAt: '2026-05-10T12:00:00Z',
editedAt: null,
featuredImageKey: null,
featuredImageUrl: null,
body: '# x',
bodyHtml: '<p>Body</p>',
tags: [{ namespace: 'topic', slug: 'transit', title: 'Transit' }],
createdAt: '2026-05-10T12:00:00Z',
updatedAt: '2026-05-10T12:00:00Z',
};

describe('BlogDetail tag chips', () => {
afterEach(() => {
vi.restoreAllMocks();
});

function mock(post: typeof POST): void {
vi.spyOn(globalThis, 'fetch').mockImplementation(((input: string) => {
if (input.startsWith('/api/auth/me')) return Promise.resolve(new Response(null, { status: 404 }));
if (input.startsWith('/api/blog-posts/roundup')) {
return Promise.resolve(
new Response(JSON.stringify(mockOk(post)), { status: 200, headers: { 'content-type': 'application/json' } }),
);
}
return Promise.resolve(new Response(null, { status: 404 }));
}) as typeof fetch);
}

function render(): void {
renderScreen(
<AuthProvider>
<Routes>
<Route path="/blog/:slug" element={<BlogDetail />} />
</Routes>
</AuthProvider>,
{ initialEntries: ['/blog/roundup'] },
);
}

it('renders tag chips linking to /blog?tag=<handle>', async () => {
mock(POST);
render();
await waitFor(() => {
expect(screen.getByRole('link', { name: 'Transit' })).toBeInTheDocument();
});
expect(screen.getByRole('link', { name: 'Transit' })).toHaveAttribute('href', '/blog?tag=topic.transit');
});

it('renders no tag chips when the post has no tags', async () => {
mock({ ...POST, tags: [] } as unknown as typeof POST);
render();
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Civic Tech Roundup' })).toBeInTheDocument();
});
expect(screen.queryByRole('link', { name: 'Transit' })).not.toBeInTheDocument();
});
});
29 changes: 29 additions & 0 deletions apps/web/tests/BlogIndex.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,35 @@ describe('BlogIndex', () => {
expect(screen.getByText('A short blurb.')).toBeInTheDocument();
});

it('falls back to a bodyHtml first-paragraph excerpt when summary is null', async () => {
const noSummary = {
...SAMPLE_POST,
summary: null,
bodyHtml: '<h1>Heading</h1><p>First paragraph of the body.</p><p>Second.</p>',
};
vi.spyOn(globalThis, 'fetch').mockImplementation(((input: string) => {
if (input.startsWith('/api/auth/me')) return Promise.resolve(new Response(null, { status: 404 }));
if (input.startsWith('/api/blog-posts')) {
return Promise.resolve(
new Response(JSON.stringify(mockPaginated([noSummary], { totalItems: 1 })), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
}
return Promise.resolve(new Response(null, { status: 404 }));
}) as typeof fetch);
renderScreen(
<AuthProvider>
<BlogIndex />
</AuthProvider>,
{ initialEntries: ['/blog'] },
);
await waitFor(() => {
expect(screen.getByText('First paragraph of the body.')).toBeInTheDocument();
});
});

it('renders the empty state when no posts are returned', async () => {
vi.spyOn(globalThis, 'fetch').mockImplementation(((input: string) => {
if (input.startsWith('/api/auth/me')) {
Expand Down
1 change: 1 addition & 0 deletions specs/api/blog.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ Standard 404 envelope (per [conventions.md](conventions.md)). Slug-history redir
"featuredImageUrl": "/api/attachments/blog-posts/civic-tech-roundup-2026/cover.jpg", // or null — derived from featuredImageKey
"body": "Markdown source",
"bodyHtml": "<p>...</p>", // sanitized HTML, server-rendered
"tags": [{ "namespace": "topic", "slug": "transit", "title": "Transit" }, ...], // tags assigned to the post; [] when none
"createdAt": "...",
"updatedAt": "..."
}
Expand Down