@xexr/blog-platform 1.0.7 → 1.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +110 -29
  3. package/dist/app/blog/[slug]/page.d.ts +68 -0
  4. package/dist/app/blog/[slug]/page.tsx +175 -116
  5. package/dist/app/blog/authors/[slug]/page.test.ts +108 -0
  6. package/dist/app/blog/authors/[slug]/page.tsx +387 -0
  7. package/dist/app/blog/page.tsx +138 -113
  8. package/dist/components/ArticleReadingProgress.tsx +208 -0
  9. package/dist/components/ArticleTopActions.tsx +179 -0
  10. package/dist/components/AuthorByline.tsx +569 -0
  11. package/dist/components/AuthorPages.test.ts +87 -0
  12. package/dist/components/AuthorPages.tsx +129 -0
  13. package/dist/components/ExpandableArticleImage.tsx +79 -0
  14. package/dist/components/Pagination.tsx +236 -0
  15. package/dist/components/TopicArchivePages.tsx +204 -0
  16. package/dist/components/ui/badge.tsx +36 -0
  17. package/dist/components/ui/button.tsx +59 -0
  18. package/dist/components/ui/card.tsx +92 -0
  19. package/dist/components/ui/pagination.tsx +127 -0
  20. package/dist/components/ui/separator.tsx +29 -0
  21. package/dist/index.d.mts +174 -112
  22. package/dist/index.d.ts +174 -112
  23. package/dist/index.js +83 -8
  24. package/dist/index.js.map +1 -1
  25. package/dist/index.mjs +83 -8
  26. package/dist/index.mjs.map +1 -1
  27. package/dist/lib/article-ui.ts +158 -0
  28. package/dist/lib/auth.ts +354 -0
  29. package/dist/lib/authors.ts +100 -0
  30. package/dist/lib/cors.ts +243 -0
  31. package/dist/lib/database.ts +277 -0
  32. package/dist/lib/init.ts +186 -0
  33. package/dist/lib/mdx.ts +73 -0
  34. package/dist/lib/metadata.ts +558 -0
  35. package/dist/lib/ratelimit.ts +224 -0
  36. package/dist/lib/security-headers.ts +322 -0
  37. package/dist/lib/seo.ts +295 -0
  38. package/dist/lib/sitemap.ts +738 -0
  39. package/dist/lib/storage/drizzle.ts +400 -0
  40. package/dist/lib/structured-data.ts +609 -0
  41. package/dist/lib/topics.ts +56 -0
  42. package/dist/lib/urls.ts +36 -0
  43. package/dist/lib/utils.ts +6 -0
  44. package/dist/lib/version.ts +154 -0
  45. package/dist/types/contracts.ts +259 -0
  46. package/dist/types/index.d.mts +20 -1
  47. package/dist/types/index.d.ts +20 -1
  48. package/dist/types/index.ts +16 -0
  49. package/package.json +14 -9
@@ -0,0 +1,387 @@
1
+ import React from "react";
2
+ import Image from "next/image";
3
+ import Link from "next/link";
4
+ import { ArrowLeft, Globe, Mail } from "lucide-react";
5
+ import { formatDistanceToNow } from "date-fns";
6
+
7
+ import Pagination from "../../../../components/Pagination";
8
+ import { Badge } from "../../../../components/ui/badge";
9
+ import { Separator } from "../../../../components/ui/separator";
10
+ import {
11
+ createStructuredDataScript,
12
+ generateAuthorStructuredData,
13
+ } from "../../../../lib/structured-data";
14
+ import { cn } from "../../../../lib/utils";
15
+ import {
16
+ buildBlogPath,
17
+ buildAuthorPath,
18
+ normalizeBlogBasePath,
19
+ } from "../../../../lib/urls";
20
+ import { type BlogTheme, getBlogThemeStyle } from "../../../../lib/article-ui";
21
+ import type {
22
+ PublishedArticle,
23
+ PublishedAuthor,
24
+ PublishedSite,
25
+ } from "../../../../types/contracts";
26
+
27
+ interface AuthorPageProps {
28
+ author: PublishedAuthor;
29
+ articles: PublishedArticle[];
30
+ site: PublishedSite;
31
+ className?: string;
32
+ basePath?: string;
33
+ theme?: BlogTheme;
34
+ pagination?: {
35
+ currentPage: number;
36
+ totalPages: number;
37
+ baseUrl?: string;
38
+ totalItems?: number;
39
+ };
40
+ navigation?: {
41
+ backToHome?: {
42
+ url: string;
43
+ label?: string;
44
+ };
45
+ };
46
+ }
47
+
48
+ export default function AuthorPage({
49
+ author,
50
+ articles,
51
+ site,
52
+ className,
53
+ basePath = "/blog",
54
+ theme,
55
+ pagination,
56
+ navigation,
57
+ }: AuthorPageProps) {
58
+ const normalizedBasePath = normalizeBlogBasePath(basePath) || "/";
59
+ const backUrl = navigation?.backToHome?.url ?? normalizedBasePath;
60
+ const authorUrl = buildAuthorPath(normalizedBasePath, author.slug);
61
+ const totalArticleCount = pagination?.totalItems ?? articles.length;
62
+ const structuredDataProps = createStructuredDataScript(
63
+ generateAuthorStructuredData(author, {
64
+ pathPrefix: normalizedBasePath,
65
+ }),
66
+ );
67
+
68
+ return (
69
+ <main
70
+ style={getBlogThemeStyle(theme)}
71
+ className={cn(
72
+ "author-page mx-auto max-w-5xl px-4 py-8 sm:px-6 sm:py-12 lg:px-8 lg:py-16",
73
+ className,
74
+ )}
75
+ >
76
+ <header className="mb-10 space-y-8 sm:mb-14">
77
+ <Link
78
+ href={backUrl}
79
+ className="text-muted-foreground group focus-visible:ring-ring inline-flex items-center gap-2 rounded-md px-2 py-1 text-sm font-medium transition-colors hover:text-[var(--blog-accent)] focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
80
+ >
81
+ <ArrowLeft
82
+ className="size-4 transition-transform group-hover:-translate-x-1"
83
+ aria-hidden="true"
84
+ />
85
+ {navigation?.backToHome?.label ?? "Back to all articles"}
86
+ </Link>
87
+
88
+ <div className="grid gap-6 sm:grid-cols-[auto_minmax(0,1fr)] sm:items-center">
89
+ <AuthorAvatar author={author} />
90
+ <div className="min-w-0 space-y-4">
91
+ <div className="space-y-2">
92
+ <div className="flex flex-wrap items-center gap-2">
93
+ {author.isGuest && (
94
+ <Badge variant="outline" className="text-xs">
95
+ Guest Author
96
+ </Badge>
97
+ )}
98
+ </div>
99
+ <h1 className="text-foreground text-4xl leading-tight font-semibold text-balance sm:text-5xl">
100
+ {author.name}
101
+ </h1>
102
+ </div>
103
+
104
+ {author.bio && (
105
+ <p className="text-muted-foreground max-w-3xl text-lg leading-relaxed text-pretty">
106
+ {author.bio}
107
+ </p>
108
+ )}
109
+
110
+ <AuthorSocialLinks author={author} />
111
+ </div>
112
+ </div>
113
+ </header>
114
+
115
+ <Separator className="mb-8" />
116
+
117
+ <section aria-labelledby="author-articles-title" className="space-y-6">
118
+ <div className="flex flex-wrap items-end justify-between gap-3">
119
+ <div>
120
+ <h2
121
+ id="author-articles-title"
122
+ className="text-foreground text-2xl font-semibold"
123
+ >
124
+ Articles by {author.name}
125
+ </h2>
126
+ <p className="text-muted-foreground mt-1 text-sm">
127
+ {totalArticleCount}{" "}
128
+ {totalArticleCount === 1 ? "article" : "articles"} on {site.name}
129
+ </p>
130
+ </div>
131
+ </div>
132
+
133
+ {articles.length === 0 ? (
134
+ <p className="text-muted-foreground border-border rounded-2xl border p-6">
135
+ This author does not have any published articles yet.
136
+ </p>
137
+ ) : (
138
+ <div className="divide-border divide-y">
139
+ {articles.map((article) => (
140
+ <AuthorArticlePreview
141
+ key={article.id}
142
+ article={article}
143
+ basePath={normalizedBasePath}
144
+ />
145
+ ))}
146
+ </div>
147
+ )}
148
+
149
+ {pagination && pagination.totalPages > 1 && (
150
+ <div className="pt-4">
151
+ <Pagination
152
+ currentPage={pagination.currentPage}
153
+ totalPages={pagination.totalPages}
154
+ baseUrl={pagination.baseUrl ?? authorUrl}
155
+ className="justify-center"
156
+ />
157
+ </div>
158
+ )}
159
+ </section>
160
+
161
+ <script
162
+ type={structuredDataProps.type}
163
+ dangerouslySetInnerHTML={structuredDataProps.dangerouslySetInnerHTML}
164
+ />
165
+ </main>
166
+ );
167
+ }
168
+
169
+ function AuthorSocialLinks({ author }: { author: PublishedAuthor }) {
170
+ const links = getAuthorSocialLinks(author);
171
+
172
+ if (links.length === 0) {
173
+ return null;
174
+ }
175
+
176
+ return (
177
+ <div
178
+ className="flex flex-wrap items-center gap-2"
179
+ aria-label="Author links"
180
+ >
181
+ {links.map((link) => (
182
+ <a
183
+ key={link.label}
184
+ href={link.href}
185
+ target={link.external ? "_blank" : undefined}
186
+ rel={link.external ? "noopener noreferrer" : undefined}
187
+ aria-label={link.ariaLabel}
188
+ className="text-muted-foreground hover:text-foreground border-border inline-flex size-9 items-center justify-center rounded-full border grayscale transition-colors hover:border-[var(--blog-accent-border)] hover:bg-[var(--blog-accent-bg)]"
189
+ >
190
+ {link.icon}
191
+ </a>
192
+ ))}
193
+ </div>
194
+ );
195
+ }
196
+
197
+ function getAuthorSocialLinks(author: PublishedAuthor) {
198
+ const links = author.socialLinks;
199
+
200
+ if (!links) {
201
+ return [];
202
+ }
203
+
204
+ return [
205
+ links.website
206
+ ? {
207
+ href: links.website,
208
+ label: "website",
209
+ ariaLabel: `${author.name}'s website`,
210
+ external: true,
211
+ icon: <Globe className="size-4" aria-hidden="true" />,
212
+ }
213
+ : null,
214
+ (links.x ?? links.twitter)
215
+ ? {
216
+ href: links.x ?? links.twitter ?? "",
217
+ label: "x",
218
+ ariaLabel: `${author.name} on X`,
219
+ external: true,
220
+ icon: (
221
+ <span className="text-sm font-semibold" aria-hidden="true">
222
+ X
223
+ </span>
224
+ ),
225
+ }
226
+ : null,
227
+ links.linkedin
228
+ ? {
229
+ href: links.linkedin,
230
+ label: "linkedin",
231
+ ariaLabel: `${author.name} on LinkedIn`,
232
+ external: true,
233
+ icon: (
234
+ <span className="text-sm font-bold" aria-hidden="true">
235
+ in
236
+ </span>
237
+ ),
238
+ }
239
+ : null,
240
+ links.github
241
+ ? {
242
+ href: links.github,
243
+ label: "github",
244
+ ariaLabel: `${author.name} on GitHub`,
245
+ external: true,
246
+ icon: (
247
+ <span className="text-xs font-bold" aria-hidden="true">
248
+ GH
249
+ </span>
250
+ ),
251
+ }
252
+ : null,
253
+ links.email
254
+ ? {
255
+ href: `mailto:${links.email}`,
256
+ label: "email",
257
+ ariaLabel: `Email ${author.name}`,
258
+ external: false,
259
+ icon: <Mail className="size-4" aria-hidden="true" />,
260
+ }
261
+ : null,
262
+ ].filter((link): link is NonNullable<typeof link> => link !== null);
263
+ }
264
+
265
+ function AuthorArticlePreview({
266
+ article,
267
+ basePath,
268
+ }: {
269
+ article: PublishedArticle;
270
+ basePath: string;
271
+ }) {
272
+ const publishedRelativeLabel = formatDistanceToNow(
273
+ new Date(article.publishedAt),
274
+ { addSuffix: true },
275
+ );
276
+
277
+ return (
278
+ <article className="py-6 first:pt-0 last:pb-0">
279
+ <Link
280
+ href={buildBlogPath(basePath, article.slug)}
281
+ className="group grid gap-4 hover:no-underline sm:grid-cols-[minmax(0,1fr)_12rem] sm:items-start"
282
+ >
283
+ <div className="min-w-0 space-y-3">
284
+ <h3 className="text-xl leading-tight font-semibold text-balance transition-colors group-hover:text-[var(--blog-text-accent)]">
285
+ {article.title}
286
+ </h3>
287
+ {article.excerpt && (
288
+ <p className="text-muted-foreground line-clamp-3 leading-relaxed">
289
+ {article.excerpt}
290
+ </p>
291
+ )}
292
+ <div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
293
+ <time dateTime={article.publishedAt}>{publishedRelativeLabel}</time>
294
+ <span aria-hidden="true">•</span>
295
+ <span>{Math.ceil(article.readingSeconds / 60)} min read</span>
296
+ </div>
297
+ </div>
298
+
299
+ {article.featuredImage && (
300
+ <div className="border-border relative aspect-[16/10] overflow-hidden rounded-xl border bg-[var(--blog-accent-bg)]">
301
+ <Image
302
+ src={article.featuredImage}
303
+ alt={
304
+ article.featuredImageAlt ??
305
+ `Featured image for ${article.title}`
306
+ }
307
+ width={320}
308
+ height={200}
309
+ className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
310
+ />
311
+ </div>
312
+ )}
313
+ </Link>
314
+ </article>
315
+ );
316
+ }
317
+
318
+ function AuthorAvatar({ author }: { author: PublishedAuthor }) {
319
+ if (author.avatarUrl) {
320
+ return (
321
+ <Image
322
+ src={author.avatarUrl}
323
+ alt={author.name}
324
+ width={128}
325
+ height={128}
326
+ className="size-28 rounded-full border border-[var(--blog-accent-border)] object-cover sm:size-32"
327
+ priority
328
+ />
329
+ );
330
+ }
331
+
332
+ return (
333
+ <span className="bg-muted text-muted-foreground inline-flex size-28 items-center justify-center rounded-full text-3xl font-semibold sm:size-32">
334
+ {getAuthorInitials(author.name)}
335
+ </span>
336
+ );
337
+ }
338
+
339
+ function getAuthorInitials(name: string): string {
340
+ const initials = name
341
+ .trim()
342
+ .split(/\s+/)
343
+ .filter(Boolean)
344
+ .slice(0, 2)
345
+ .map((part) => part[0]?.toUpperCase())
346
+ .join("");
347
+
348
+ return initials || "?";
349
+ }
350
+
351
+ export function generateAuthorPageMetadata(
352
+ author: PublishedAuthor,
353
+ site: PublishedSite,
354
+ basePath = "/blog",
355
+ ) {
356
+ const canonicalUrl = `https://${site.domain
357
+ .replace(/^https?:\/\//, "")
358
+ .replace(/\/+$/, "")}${buildAuthorPath(basePath, author.slug)}`;
359
+ const title = `${author.name} - ${site.name}`;
360
+ const description =
361
+ author.bio ?? `Read articles by ${author.name} on ${site.name}.`;
362
+
363
+ return {
364
+ title,
365
+ description,
366
+ authors: [{ name: author.name }],
367
+ openGraph: {
368
+ title,
369
+ description,
370
+ type: "profile",
371
+ url: canonicalUrl,
372
+ images: author.avatarUrl
373
+ ? [{ url: author.avatarUrl, alt: author.name }]
374
+ : undefined,
375
+ siteName: site.name,
376
+ },
377
+ twitter: {
378
+ card: author.avatarUrl ? "summary_large_image" : "summary",
379
+ title,
380
+ description,
381
+ images: author.avatarUrl ? [author.avatarUrl] : undefined,
382
+ },
383
+ alternates: {
384
+ canonical: canonicalUrl,
385
+ },
386
+ };
387
+ }
@@ -12,12 +12,13 @@ import {
12
12
  generateBlogHomeStructuredData,
13
13
  createStructuredDataScript,
14
14
  } from "../../lib/structured-data";
15
- import { buildBlogPath, normalizeBlogBasePath } from "../../lib/urls";
16
- import { formatDistanceToNow } from "date-fns";
17
15
  import {
18
- type BlogTheme,
19
- getBlogThemeStyle,
20
- } from "../../lib/article-ui";
16
+ buildAuthorPath,
17
+ buildBlogPath,
18
+ normalizeBlogBasePath,
19
+ } from "../../lib/urls";
20
+ import { formatDistanceToNow } from "date-fns";
21
+ import { type BlogTheme, getBlogThemeStyle } from "../../lib/article-ui";
21
22
 
22
23
  interface BlogHomePageProps {
23
24
  articles: PublishedArticle[];
@@ -169,56 +170,110 @@ function ArticlePreview({
169
170
 
170
171
  return (
171
172
  <article className="group border-border mb-6 block border-b pb-6 last:mb-0 last:border-b-0 last:pb-0 sm:mb-8 sm:pb-8">
172
- <Link
173
- href={articleUrl}
174
- className="focus-visible:ring-ring block rounded-2xl hover:no-underline focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
175
- aria-label={`Read article: ${article.title}`}
176
- >
177
- {/* Mobile/Small Tablet: Vertical layout */}
178
- <div className="space-y-4 lg:hidden">
179
- {imageUrl && (
180
- <div className="relative aspect-[16/10] w-full overflow-hidden rounded-2xl">
181
- <Image
182
- src={imageUrl}
183
- alt={
184
- article.featuredImageAlt ??
185
- `Featured image for ${article.title}`
186
- }
187
- width={400}
188
- height={250}
189
- className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
190
- />
191
- </div>
192
- )}
173
+ {/* Mobile/Small Tablet: Vertical layout */}
174
+ <div className="space-y-4 lg:hidden">
175
+ {imageUrl && (
176
+ <Link
177
+ href={articleUrl}
178
+ className="focus-visible:ring-ring relative block aspect-[16/10] w-full overflow-hidden rounded-2xl focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
179
+ aria-label={`Read article: ${article.title}`}
180
+ >
181
+ <Image
182
+ src={imageUrl}
183
+ alt={
184
+ article.featuredImageAlt ??
185
+ `Featured image for ${article.title}`
186
+ }
187
+ width={400}
188
+ height={250}
189
+ className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
190
+ />
191
+ </Link>
192
+ )}
193
193
 
194
- <div className="space-y-3">
195
- <h2 className="group-hover:text-[var(--blog-accent)] text-lg leading-tight font-bold text-balance transition-colors sm:text-xl md:text-2xl">
194
+ <div className="space-y-3">
195
+ <Link
196
+ href={articleUrl}
197
+ className="focus-visible:ring-ring block rounded-md hover:no-underline focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
198
+ >
199
+ <h2 className="text-lg leading-tight font-bold text-balance transition-colors group-hover:text-[var(--blog-accent)] sm:text-xl md:text-2xl">
196
200
  {article.title}
197
201
  </h2>
202
+ </Link>
198
203
 
199
- <div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
200
- {article.author && (
201
- <>
202
- <span className="inline-flex items-center gap-2 font-medium">
203
- <AuthorAvatar author={article.author} size="sm" />
204
- {article.author.name}
205
- </span>
206
- <span className="text-border">•</span>
207
- </>
208
- )}
209
- <time
210
- dateTime={article.publishedAt}
211
- className="font-medium"
212
- aria-label={`Published ${formattedDate}`}
213
- >
214
- {formattedDate}
215
- </time>
216
- <span className="text-border">•</span>
217
- <span className="font-medium">{readingTime}</span>
204
+ <div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
205
+ {article.author && (
206
+ <>
207
+ <AuthorLink author={article.author} basePath={basePath} />
208
+ <span className="text-border">•</span>
209
+ </>
210
+ )}
211
+ <time
212
+ dateTime={article.publishedAt}
213
+ className="font-medium"
214
+ aria-label={`Published ${formattedDate}`}
215
+ >
216
+ {formattedDate}
217
+ </time>
218
+ <span className="text-border">•</span>
219
+ <span className="font-medium">{readingTime}</span>
220
+ </div>
221
+
222
+ {article.excerpt && (
223
+ <p className="text-muted-foreground line-clamp-3 text-sm leading-relaxed sm:text-base">
224
+ {article.excerpt}
225
+ </p>
226
+ )}
227
+
228
+ {(article.topics ?? []).length > 0 && (
229
+ <div className="flex flex-wrap gap-2">
230
+ {(article.topics ?? []).map((topic) => (
231
+ <span
232
+ key={topic.id}
233
+ className="rounded-full border border-[var(--blog-accent-border)] px-2.5 py-1 text-xs font-medium text-[var(--blog-text-accent)]"
234
+ >
235
+ {topic.name}
236
+ </span>
237
+ ))}
218
238
  </div>
239
+ )}
240
+ </div>
241
+ </div>
242
+
243
+ {/* Large Tablet/Desktop: Horizontal layout */}
244
+ <div className="hidden items-start gap-6 lg:flex xl:gap-8">
245
+ {imageUrl && (
246
+ <Link
247
+ href={articleUrl}
248
+ className="focus-visible:ring-ring relative block aspect-[16/10] w-64 shrink-0 overflow-hidden rounded-2xl focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none xl:w-72"
249
+ aria-label={`Read article: ${article.title}`}
250
+ >
251
+ <Image
252
+ src={imageUrl}
253
+ alt={
254
+ article.featuredImageAlt ??
255
+ `Featured image for ${article.title}`
256
+ }
257
+ width={288}
258
+ height={180}
259
+ className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
260
+ />
261
+ </Link>
262
+ )}
263
+
264
+ <div className="flex min-h-[180px] flex-1 flex-col justify-between space-y-4 xl:min-h-[200px]">
265
+ <div className="space-y-4">
266
+ <Link
267
+ href={articleUrl}
268
+ className="focus-visible:ring-ring block rounded-md hover:no-underline focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
269
+ >
270
+ <h2 className="text-2xl leading-tight font-bold text-balance transition-colors group-hover:text-[var(--blog-accent)] xl:text-3xl">
271
+ {article.title}
272
+ </h2>
273
+ </Link>
219
274
 
220
275
  {article.excerpt && (
221
- <p className="text-muted-foreground line-clamp-3 text-sm leading-relaxed sm:text-base">
276
+ <p className="text-muted-foreground line-clamp-3 text-base leading-relaxed xl:text-lg">
222
277
  {article.excerpt}
223
278
  </p>
224
279
  )}
@@ -236,78 +291,48 @@ function ArticlePreview({
236
291
  </div>
237
292
  )}
238
293
  </div>
239
- </div>
240
294
 
241
- {/* Large Tablet/Desktop: Horizontal layout */}
242
- <div className="hidden items-start gap-6 lg:flex xl:gap-8">
243
- {imageUrl && (
244
- <div className="relative aspect-[16/10] w-64 shrink-0 overflow-hidden rounded-2xl xl:w-72">
245
- <Image
246
- src={imageUrl}
247
- alt={
248
- article.featuredImageAlt ??
249
- `Featured image for ${article.title}`
250
- }
251
- width={288}
252
- height={180}
253
- className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
254
- />
255
- </div>
256
- )}
257
-
258
- <div className="flex min-h-[180px] flex-1 flex-col justify-between space-y-4 xl:min-h-[200px]">
259
- <div className="space-y-4">
260
- <h2 className="group-hover:text-[var(--blog-accent)] text-2xl leading-tight font-bold text-balance transition-colors xl:text-3xl">
261
- {article.title}
262
- </h2>
263
-
264
- {article.excerpt && (
265
- <p className="text-muted-foreground line-clamp-3 text-base leading-relaxed xl:text-lg">
266
- {article.excerpt}
267
- </p>
268
- )}
269
-
270
- {(article.topics ?? []).length > 0 && (
271
- <div className="flex flex-wrap gap-2">
272
- {(article.topics ?? []).map((topic) => (
273
- <span
274
- key={topic.id}
275
- className="rounded-full border border-[var(--blog-accent-border)] px-2.5 py-1 text-xs font-medium text-[var(--blog-text-accent)]"
276
- >
277
- {topic.name}
278
- </span>
279
- ))}
280
- </div>
281
- )}
282
- </div>
283
-
284
- <div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
285
- {article.author && (
286
- <>
287
- <span className="inline-flex items-center gap-2 font-medium">
288
- <AuthorAvatar author={article.author} size="sm" />
289
- {article.author.name}
290
- </span>
291
- <span className="text-border">•</span>
292
- </>
293
- )}
294
- <time
295
- dateTime={article.publishedAt}
296
- className="font-medium"
297
- aria-label={`Published ${formattedDate}`}
298
- >
299
- {formattedDate}
300
- </time>
301
- <span className="text-border">•</span>
302
- <span className="font-medium">{readingTime}</span>
303
- </div>
295
+ <div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
296
+ {article.author && (
297
+ <>
298
+ <AuthorLink author={article.author} basePath={basePath} />
299
+ <span className="text-border">•</span>
300
+ </>
301
+ )}
302
+ <time
303
+ dateTime={article.publishedAt}
304
+ className="font-medium"
305
+ aria-label={`Published ${formattedDate}`}
306
+ >
307
+ {formattedDate}
308
+ </time>
309
+ <span className="text-border">•</span>
310
+ <span className="font-medium">{readingTime}</span>
304
311
  </div>
305
312
  </div>
306
- </Link>
313
+ </div>
307
314
  </article>
308
315
  );
309
316
  }
310
317
 
318
+ function AuthorLink({
319
+ author,
320
+ basePath,
321
+ }: {
322
+ author: PublishedAuthor;
323
+ basePath: string;
324
+ }) {
325
+ return (
326
+ <Link
327
+ href={buildAuthorPath(basePath, author.slug)}
328
+ className="inline-flex items-center gap-2 font-medium transition-colors hover:text-[var(--blog-accent)] hover:no-underline"
329
+ >
330
+ <AuthorAvatar author={author} size="sm" />
331
+ {author.name}
332
+ </Link>
333
+ );
334
+ }
335
+
311
336
  function AuthorAvatar({
312
337
  author,
313
338
  size,