Skip to content

Commit 2f269ca

Browse files
authored
Resolve post/tool authors defensively: missing profile falls back to Guest instead of failing the build (#28)
1 parent d5ab4d8 commit 2f269ca

8 files changed

Lines changed: 47 additions & 15 deletions

File tree

src/content/config.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { readdirSync } from 'node:fs';
2-
import { defineCollection, reference, z } from 'astro:content';
2+
import { defineCollection, z } from 'astro:content';
33
import { glob } from 'astro/loaders';
44

55
// Topic ids come from the data files themselves, so post frontmatter is
@@ -31,7 +31,9 @@ const posts = defineCollection({
3131
z.object({
3232
title: z.string(),
3333
summary: z.string(),
34-
authors: z.array(reference('authors')).min(1),
34+
// Plain author handles, resolved defensively at render (see lib/posts.ts):
35+
// a missing profile falls back to Guest rather than failing the build.
36+
authors: z.array(z.string()).min(1),
3537
date: z.coerce.date(),
3638
readMin: z.number().int().positive(),
3739
// Display name is derived from topicId via topicName(); kept optional for
@@ -62,7 +64,7 @@ const tools = defineCollection({
6264
tag: z.enum(['Live', 'Beta', 'Experimental', 'Soon']),
6365
icon: z.string().optional(),
6466
thumbnail: z.union([image(), z.string().url()]).optional(),
65-
authors: z.array(reference('authors')).optional(),
67+
authors: z.array(z.string()).optional(),
6668
topics: z.array(z.string()).optional(),
6769
tags: z.array(z.string()).optional(),
6870
repo: z.string().url().optional(),

src/lib/posts.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,42 @@
11
// Server-only post helpers (astro:content cannot be bundled into client scripts —
22
// keep this module out of anything imported by <script> code; taxonomy lives in data.ts).
3-
import { getEntries, type CollectionEntry } from 'astro:content';
3+
import { getEntry, type CollectionEntry } from 'astro:content';
4+
5+
type AuthorData = CollectionEntry<'authors'>['data'];
6+
7+
// A resolved author is shaped like an authors-collection entry, but is produced
8+
// defensively: a handle with no profile never throws, it degrades to Guest.
9+
export type ResolvedAuthor = { id: string; data: AuthorData };
410

511
export type PostWithAuthors = CollectionEntry<'posts'> & {
6-
authors: CollectionEntry<'authors'>[];
12+
authors: ResolvedAuthor[];
13+
};
14+
15+
const SYNTHETIC_GUEST: ResolvedAuthor = {
16+
id: 'guest',
17+
data: { name: 'Guest Contributor', role: 'contributor' } as AuthorData,
718
};
819

20+
// Resolve one handle to an author. Missing profile → Guest, with a build-time
21+
// warning so a broken reference is visible in logs without breaking the build.
22+
async function resolveAuthor(handle: string): Promise<ResolvedAuthor> {
23+
const entry = await getEntry('authors', handle);
24+
if (entry) return { id: entry.id, data: entry.data };
25+
console.warn(`[authors] no profile for "${handle}" — falling back to Guest.`);
26+
const guest = await getEntry('authors', 'guest');
27+
return guest ? { id: guest.id, data: guest.data } : SYNTHETIC_GUEST;
28+
}
29+
30+
export async function resolveAuthors(handles: string[] = []): Promise<ResolvedAuthor[]> {
31+
return Promise.all(handles.map(resolveAuthor));
32+
}
33+
934
export async function resolvePostAuthors(
1035
posts: CollectionEntry<'posts'>[],
1136
): Promise<PostWithAuthors[]> {
12-
return Promise.all(posts.map(async (p) => ({ ...p, authors: await getEntries(p.data.authors) })));
37+
return Promise.all(
38+
posts.map(async (p) => ({ ...p, authors: await resolveAuthors(p.data.authors) })),
39+
);
1340
}
1441

1542
export function authorNames(post: PostWithAuthors): string {

src/pages/authors/[handle]/articles/[...page].astro

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export const getStaticPaths = (async ({ paginate }) => {
1414
const all = await getCollection('posts', ({ data }) => !data.draft);
1515
const paths = [];
1616
for (const author of authors) {
17-
const matching = all.filter((p) => p.data.authors.some((ref) => ref.id === author.id));
17+
const matching = all.filter((p) => p.data.authors.some((h) => h === author.id));
1818
const posts = (await resolvePostAuthors(matching)).sort(sortPostsByDate);
1919
paths.push(
2020
...paginate(posts, { params: { handle: author.id }, pageSize: 20, props: { author } }),

src/pages/authors/[handle]/index.astro

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ const { author } = Astro.props;
2525
const RECENT_LIMIT = 5;
2626
2727
const allRaw = await getCollection('posts', ({ data }) => !data.draft);
28-
const matching = allRaw.filter((p) => p.data.authors.some((ref) => ref.id === author.id));
28+
const matching = allRaw.filter((p) => p.data.authors.some((h) => h === author.id));
2929
const posts = (await resolvePostAuthors(matching)).sort(sortPostsByDate);
3030
const recent = posts.slice(0, RECENT_LIMIT);
3131

src/pages/authors/index.astro

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ const posts = await getCollection('posts', ({ data }) => !data.draft);
99
1010
const contributors = authors
1111
.map((author) => {
12-
const authorPosts = posts.filter((p) => p.data.authors.some((ref) => ref.id === author.id));
12+
const authorPosts = posts.filter((p) => p.data.authors.some((h) => h === author.id));
1313
const lastDate = authorPosts.reduce<Date | null>((acc, p) => {
1414
const d = p.data.date;
1515
return !acc || d > acc ? d : acc;

src/pages/blog/[slug].astro

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
---
2-
import { getCollection, getEntries, render } from 'astro:content';
2+
import { getCollection, render } from 'astro:content';
3+
import { resolveAuthors } from '@/lib/posts';
34
import type { CollectionEntry } from 'astro:content';
45
import { Image } from 'astro:assets';
56
import BaseLayout from '@/layouts/BaseLayout.astro';
@@ -31,7 +32,7 @@ const { Content } = await render(post);
3132
// Match remark-math's triggers ($$…$$ or $…$) so a stray '$' (prices, shell) doesn't pull in KaTeX CSS.
3233
const hasMath = /\$\$[\s\S]+?\$\$|\$[^\n$]+?\$/.test(post.body ?? '');
3334
34-
const authors = await getEntries(post.data.authors);
35+
const authors = await resolveAuthors(post.data.authors);
3536
const authorNames = authors.map((a) => a.data.name).join(', ');
3637
3738
const all = await getCollection('posts', ({ data }) => !data.draft);

src/pages/og/post/[slug].png.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { APIRoute } from 'astro';
2-
import { getCollection, getEntries } from 'astro:content';
2+
import { getCollection } from 'astro:content';
3+
import { resolveAuthors } from '@/lib/posts';
34
import type { CollectionEntry } from 'astro:content';
45
import { readFileSync } from 'fs';
56
import { join } from 'path';
@@ -76,7 +77,7 @@ async function coverDataUrl(post: CollectionEntry<'posts'>): Promise<string | un
7677
export const GET: APIRoute = async ({ props }) => {
7778
const { post } = props as { post: CollectionEntry<'posts'> };
7879
return pngResponseWithFallback(async () => {
79-
const authors = await getEntries(post.data.authors);
80+
const authors = await resolveAuthors(post.data.authors);
8081
const cover = await coverDataUrl(post);
8182
return generateOgPng(
8283
{

src/pages/playground/[tool].astro

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
---
2-
import { getCollection, getEntries, render } from 'astro:content';
2+
import { getCollection, render } from 'astro:content';
33
import type { CollectionEntry } from 'astro:content';
44
import BaseLayout from '@/layouts/BaseLayout.astro';
55
import { mdxComponents } from '@/components/MDXComponents.tsx';
6+
import { resolveAuthors } from '@/lib/posts';
67
import { SITE } from '@/lib/site';
78
89
export async function getStaticPaths() {
@@ -20,7 +21,7 @@ interface Props {
2021
const { tool } = Astro.props;
2122
const { Content } = await render(tool);
2223
23-
const authors = tool.data.authors ? await getEntries(tool.data.authors) : [];
24+
const authors = await resolveAuthors(tool.data.authors);
2425
const canonical = `${SITE.url}/playground/${tool.id}`;
2526
const ogImage = `/og/tool/${tool.id}.png`;
2627

0 commit comments

Comments
 (0)