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
6 changes: 5 additions & 1 deletion src/components/PostRow.astro
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ const { post, heading: Heading = 'h3', showChip = true, meta = 'byline' } = Astr
<div class="blog-row-body">
<div class="blog-row-title-line">
<Heading class="blog-row-title">{post.data.title}</Heading>
{showChip && <span class="chip chip--sm">{topicName(post.data.topicId)}</span>}
{
showChip && post.data.topicId && (
<span class="chip chip--sm">{topicName(post.data.topicId)}</span>
)
}
</div>
<p class="blog-row-summary">{post.data.summary}</p>
</div>
Expand Down
4 changes: 3 additions & 1 deletion src/content/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ const posts = defineCollection({
// Display name is derived from topicId via topicName(); kept optional for
// backward compat with existing frontmatter but no longer rendered.
topic: z.string().optional(),
topicId: z.enum(TOPIC_IDS),
// Optional: a post can belong to a topic hub, or stand alone. Missing topic
// only removes it from /topics/<x> — it stays in /blog, search, tags, feeds.
topicId: z.enum(TOPIC_IDS).optional(),
// A writer-suggested topic not yet in TOPIC_IDS — surfaced for a maintainer
// (or automation) to create the topic or remap topicId. Ignored by rendering.
proposedTopic: z.string().optional(),
Expand Down
9 changes: 5 additions & 4 deletions src/lib/topics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,19 @@ export const TOPICS: Topic[] = entries

const TOPIC_BY_ID = new Map(TOPICS.map((t) => [t.id, t]));

/** Canonical display name for a topic ID; falls back to the ID if unknown. */
export function topicName(id: string): string {
/** Canonical display name for a topic ID; '' when a post has no topic, or the ID if unknown. */
export function topicName(id: string | undefined): string {
if (!id) return '';
return TOPIC_BY_ID.get(id)?.name ?? id;
}

export function countPostsByTopic<T extends { data: { topicId: string } }>(
export function countPostsByTopic<T extends { data: { topicId?: string } }>(
posts: T[],
): Record<string, number> {
const counts: Record<string, number> = {};
for (const t of TOPICS) counts[t.id] = 0;
for (const p of posts) {
if (counts[p.data.topicId] !== undefined) counts[p.data.topicId]++;
if (p.data.topicId && counts[p.data.topicId] !== undefined) counts[p.data.topicId]++;
}
return counts;
}
1 change: 1 addition & 0 deletions src/pages/authors/[handle]/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const posts = (await resolvePostAuthors(matching)).sort(sortPostsByDate);
const recent = posts.slice(0, RECENT_LIMIT);

const topicCounts = posts.reduce<Record<string, number>>((acc, p) => {
if (!p.data.topicId) return acc;
const name = topicName(p.data.topicId);
acc[name] = (acc[name] ?? 0) + 1;
return acc;
Expand Down
31 changes: 24 additions & 7 deletions src/pages/blog/[slug].astro
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,17 @@ const authors = await resolveAuthors(post.data.authors);
const authorNames = authors.map((a) => a.data.name).join(', ');

const all = await getCollection('posts', ({ data }) => !data.draft);
const postTags = new Set(post.data.tags ?? []);
const related = await resolvePostAuthors(
all
.filter((a) => a.data.topicId === post.data.topicId && a.id !== post.id)
.filter((a) => {
if (a.id === post.id) return false;
// With a topic, relate by topic; without one, fall back to shared tags so
// topicless posts still get a meaningful "related" strip (never each other).
return post.data.topicId
? a.data.topicId === post.data.topicId
: (a.data.tags ?? []).some((t) => postTags.has(t));
})
.sort(sortPostsByDate)
.slice(0, 6),
);
Expand Down Expand Up @@ -89,13 +97,22 @@ const breadcrumbJsonLd = {
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: SITE.url },
...(post.data.topicId
? [
{
'@type': 'ListItem',
position: 2,
name: topicLabel,
item: `${SITE.url}/topics/${post.data.topicId}`,
},
]
: []),
{
'@type': 'ListItem',
position: 2,
name: topicLabel,
item: `${SITE.url}/topics/${post.data.topicId}`,
position: post.data.topicId ? 3 : 2,
name: post.data.title,
item: canonical,
},
{ '@type': 'ListItem', position: 3, name: post.data.title, item: canonical },
],
};
---
Expand All @@ -117,13 +134,13 @@ const breadcrumbJsonLd = {
<article
class="article-shell"
data-pagefind-body
data-pagefind-meta={`topic:${topicName(post.data.topicId)}, date:${formatDate(dateISO)}, read:${post.data.readMin} min, authors:${authorNames}`}
data-pagefind-meta={`${topicLabel ? `topic:${topicLabel}, ` : ''}date:${formatDate(dateISO)}, read:${post.data.readMin} min, authors:${authorNames}`}
>
<div class="article-head">
<a href="/blog" class="article-back">← Back to archive</a>

<div class="article-byline" style="margin-bottom: 4px;">
<span class="chip chip--sm">{topicName(post.data.topicId)}</span>
{post.data.topicId && <span class="chip chip--sm">{topicLabel}</span>}
{
post.data.tags?.map((t) => (
<a href={`/tags/${tagSlug(t)}`} class="hashtag">
Expand Down
4 changes: 2 additions & 2 deletions src/pages/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ const featuredTools = pickCoreTools(allTools);
{featured.map((p) => (
<a class="article-card" href={`/blog/${p.id}`}>
<div class="article-meta">
<span class="chip chip--sm">{topicName(p.data.topicId)}</span>
{p.data.topicId && <span class="chip chip--sm">{topicName(p.data.topicId)}</span>}
</div>
<h3 class="article-title">{p.data.title}</h3>
<p class="article-summary">{p.data.summary}</p>
Expand Down Expand Up @@ -132,7 +132,7 @@ const featuredTools = pickCoreTools(allTools);
{recent.map((p) => (
<a class="article-card" href={`/blog/${p.id}`}>
<div class="article-meta">
<span class="chip chip--sm">{topicName(p.data.topicId)}</span>
{p.data.topicId && <span class="chip chip--sm">{topicName(p.data.topicId)}</span>}
</div>
<h3 class="article-title">{p.data.title}</h3>
<p class="article-summary">{p.data.summary}</p>
Expand Down
2 changes: 1 addition & 1 deletion src/pages/rss.xml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export async function GET(context: APIContext) {
pubDate: p.data.date,
link: `/blog/${p.id}`,
author: authorNames(p),
categories: [topicName(p.data.topicId), ...(p.data.tags ?? [])],
categories: [topicName(p.data.topicId), ...(p.data.tags ?? [])].filter(Boolean),
customData: `<author>${escapeXml(authorNames(p))}</author>`,
})),
customData: `<language>en-us</language>`,
Expand Down
1 change: 1 addition & 0 deletions src/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -2374,6 +2374,7 @@ a.hashtag:hover {
margin-top: 4px;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
Expand Down
6 changes: 6 additions & 0 deletions src/write/editor/editor-theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@
letter-spacing: 0.06em;
color: var(--ink-3);
}
.write-optional {
text-transform: none;
letter-spacing: 0;
color: var(--ink-4);
font-style: italic;
}

.write-meta-row input,
.write-meta-row select {
Expand Down
6 changes: 4 additions & 2 deletions src/write/meta/MetaForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,9 @@ export function MetaForm({ authors, topics, meta, images, onChange }: Props) {
</label>
)}
<label>
Topic
<span>
Topic <span className="write-optional">(optional)</span>
</span>
<select
value={proposing ? '__propose__' : meta.topicId}
onChange={(e) => {
Expand All @@ -144,7 +146,7 @@ export function MetaForm({ authors, topics, meta, images, onChange }: Props) {
set({ topicId: topic?.id ?? '', topicName: topic?.name ?? '', proposedTopic: '' });
}}
>
<option value="">Pick a topic</option>
<option value="">No specific topic</option>
{topics.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
Expand Down
11 changes: 11 additions & 0 deletions src/write/serialize/toMdx.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,17 @@ describe('frontmatter', () => {
expect(fm).toContain("cover: './hero.png'");
});

it('omits topic fields when no topic is chosen', () => {
const { mdx } = serializePost(
{ ...meta, topicId: '', topicName: '' },
[block('paragraph', {}, [t('body')])],
{ tableVariants: {}, today },
);
const fm = mdx.split('---')[1];
expect(fm).not.toContain('topicId:');
expect(fm).not.toContain('topic:');
});

it('preserves an existing write date and always stamps updated as today', () => {
const { mdx } = serializePost(
{ ...meta, date: '2026-01-05' },
Expand Down
7 changes: 5 additions & 2 deletions src/write/serialize/toMdx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,9 +417,12 @@ function buildFrontmatter(meta: PostMeta, blocks: SBlock[], opts: SerializeOptio
`date: ${yaml(meta.date || todayStr)}`,
`updated: ${yaml(todayStr)}`,
`readMin: ${readMin}`,
`topic: ${yaml(meta.topicName)}`,
`topicId: ${yaml(meta.topicId)}`,
];
// Topic is optional; only emit it when the writer chose one.
if (meta.topicId) {
lines.push(`topic: ${yaml(meta.topicName)}`);
lines.push(`topicId: ${yaml(meta.topicId)}`);
}
if (meta.tags.length > 0) lines.push(`tags: [${meta.tags.map(yaml).join(', ')}]`);
if (meta.proposedTopic?.trim()) lines.push(`proposedTopic: ${yaml(meta.proposedTopic.trim())}`);
if (meta.coverFileName) lines.push(`cover: ${yaml(`./${meta.coverFileName}`)}`);
Expand Down
1 change: 0 additions & 1 deletion src/write/serialize/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ export function validate(meta: PostMeta, blocks: SBlock[]): string[] {
const issues: string[] = [];
if (!meta.title.trim()) issues.push('Add a title.');
if (!meta.summary.trim()) issues.push('Add a one-sentence summary below the title.');
if (!meta.topicId) issues.push('Pick a topic.');
if (!SLUG_RE.test(meta.slug)) {
issues.push('The URL slug must be lowercase words separated by hyphens, like my-article.');
}
Expand Down
Loading