@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.
- package/CHANGELOG.md +7 -0
- package/README.md +110 -29
- package/dist/app/blog/[slug]/page.d.ts +68 -0
- package/dist/app/blog/[slug]/page.tsx +175 -116
- package/dist/app/blog/authors/[slug]/page.test.ts +108 -0
- package/dist/app/blog/authors/[slug]/page.tsx +387 -0
- package/dist/app/blog/page.tsx +138 -113
- package/dist/components/ArticleReadingProgress.tsx +208 -0
- package/dist/components/ArticleTopActions.tsx +179 -0
- package/dist/components/AuthorByline.tsx +569 -0
- package/dist/components/AuthorPages.test.ts +87 -0
- package/dist/components/AuthorPages.tsx +129 -0
- package/dist/components/ExpandableArticleImage.tsx +79 -0
- package/dist/components/Pagination.tsx +236 -0
- package/dist/components/TopicArchivePages.tsx +204 -0
- package/dist/components/ui/badge.tsx +36 -0
- package/dist/components/ui/button.tsx +59 -0
- package/dist/components/ui/card.tsx +92 -0
- package/dist/components/ui/pagination.tsx +127 -0
- package/dist/components/ui/separator.tsx +29 -0
- package/dist/index.d.mts +174 -112
- package/dist/index.d.ts +174 -112
- package/dist/index.js +83 -8
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +83 -8
- package/dist/index.mjs.map +1 -1
- package/dist/lib/article-ui.ts +158 -0
- package/dist/lib/auth.ts +354 -0
- package/dist/lib/authors.ts +100 -0
- package/dist/lib/cors.ts +243 -0
- package/dist/lib/database.ts +277 -0
- package/dist/lib/init.ts +186 -0
- package/dist/lib/mdx.ts +73 -0
- package/dist/lib/metadata.ts +558 -0
- package/dist/lib/ratelimit.ts +224 -0
- package/dist/lib/security-headers.ts +322 -0
- package/dist/lib/seo.ts +295 -0
- package/dist/lib/sitemap.ts +738 -0
- package/dist/lib/storage/drizzle.ts +400 -0
- package/dist/lib/structured-data.ts +609 -0
- package/dist/lib/topics.ts +56 -0
- package/dist/lib/urls.ts +36 -0
- package/dist/lib/utils.ts +6 -0
- package/dist/lib/version.ts +154 -0
- package/dist/types/contracts.ts +259 -0
- package/dist/types/index.d.mts +20 -1
- package/dist/types/index.d.ts +20 -1
- package/dist/types/index.ts +16 -0
- package/package.json +14 -9
|
@@ -7,21 +7,27 @@ import {
|
|
|
7
7
|
type PublishedAuthor,
|
|
8
8
|
} from "../../../types/contracts";
|
|
9
9
|
import { cn } from "../../../lib/utils";
|
|
10
|
-
import { ArrowLeft, ArrowUpRight,
|
|
10
|
+
import { ArrowLeft, ArrowUpRight, Globe, Mail } from "lucide-react";
|
|
11
|
+
import { FaGithub, FaLinkedinIn, FaXTwitter } from "react-icons/fa6";
|
|
11
12
|
import { Badge } from "../../../components/ui/badge";
|
|
12
13
|
import { Card, CardContent } from "../../../components/ui/card";
|
|
14
|
+
import ArticleTopActions from "../../../components/ArticleTopActions";
|
|
13
15
|
import { ExpandableArticleImage } from "../../../components/ExpandableArticleImage";
|
|
16
|
+
import { ArticleReadingProgress } from "../../../components/ArticleReadingProgress";
|
|
14
17
|
import { Separator } from "../../../components/ui/separator";
|
|
15
18
|
import {
|
|
16
19
|
generateCompleteArticleStructuredData,
|
|
17
20
|
createStructuredDataScript,
|
|
18
21
|
} from "../../../lib/structured-data";
|
|
19
22
|
import { articleMdxOptions } from "../../../lib/mdx";
|
|
20
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
buildAuthorPath,
|
|
25
|
+
buildBlogPath,
|
|
26
|
+
normalizeBlogBasePath,
|
|
27
|
+
} from "../../../lib/urls";
|
|
21
28
|
import {
|
|
22
29
|
type BlogTheme,
|
|
23
30
|
extractTableOfContents,
|
|
24
|
-
formatTableOfContentsSummary,
|
|
25
31
|
formatArticleDateMeta,
|
|
26
32
|
getBlogThemeStyle,
|
|
27
33
|
} from "../../../lib/article-ui";
|
|
@@ -91,11 +97,15 @@ export default function ArticlePage({
|
|
|
91
97
|
showShareLinks = true,
|
|
92
98
|
}: ArticlePageProps) {
|
|
93
99
|
const normalizedBasePath = normalizeBlogBasePath(basePath) || "/";
|
|
94
|
-
const dateMeta = formatArticleDateMeta(
|
|
100
|
+
const dateMeta = formatArticleDateMeta(
|
|
101
|
+
article.publishedAt,
|
|
102
|
+
article.updatedAt,
|
|
103
|
+
);
|
|
95
104
|
const readingTime = formatReadingTime(article.readingSeconds);
|
|
96
105
|
const tocItems = extractTableOfContents(article.content);
|
|
97
106
|
const articleUrl =
|
|
98
|
-
article.meta.canonicalUrl ??
|
|
107
|
+
article.meta.canonicalUrl ??
|
|
108
|
+
buildBlogPath(normalizedBasePath, article.slug);
|
|
99
109
|
|
|
100
110
|
// Use the featured image URL provided by Bloggy (hosted on Bloggy's domain)
|
|
101
111
|
const imageUrl = article.featuredImage;
|
|
@@ -113,7 +123,7 @@ export default function ArticlePage({
|
|
|
113
123
|
<header className="mx-auto mb-8 max-w-4xl sm:mb-12 lg:mb-16">
|
|
114
124
|
<Link
|
|
115
125
|
href={navigation.backToHome.url}
|
|
116
|
-
className="text-muted-foreground
|
|
126
|
+
className="text-muted-foreground group focus-visible:ring-ring inline-flex touch-manipulation items-center space-x-2 rounded-md px-2 py-1 transition-colors hover:text-[var(--blog-accent)] focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none sm:space-x-3"
|
|
117
127
|
aria-label={navigation.backToHome.label ?? "Return to blog"}
|
|
118
128
|
>
|
|
119
129
|
<ArrowLeft
|
|
@@ -149,8 +159,13 @@ export default function ArticlePage({
|
|
|
149
159
|
{article.author && (
|
|
150
160
|
<div className="flex items-center gap-2">
|
|
151
161
|
<span className="sr-only">Author</span>
|
|
152
|
-
<
|
|
153
|
-
|
|
162
|
+
<Link
|
|
163
|
+
href={buildAuthorPath(normalizedBasePath, article.author.slug)}
|
|
164
|
+
className="inline-flex items-center gap-2 font-medium transition-colors hover:text-[var(--blog-accent)] hover:no-underline"
|
|
165
|
+
>
|
|
166
|
+
<AuthorAvatar author={article.author} size="md" />
|
|
167
|
+
{article.author.name}
|
|
168
|
+
</Link>
|
|
154
169
|
{article.author.isGuest && (
|
|
155
170
|
<Badge variant="outline" className="text-xs">
|
|
156
171
|
Guest Author
|
|
@@ -168,7 +183,9 @@ export default function ArticlePage({
|
|
|
168
183
|
Published {dateMeta.publishedDateLabel}
|
|
169
184
|
</time>
|
|
170
185
|
<span className="text-border">•</span>
|
|
171
|
-
<span className="font-medium">
|
|
186
|
+
<span className="font-medium">
|
|
187
|
+
{dateMeta.publishedRelativeLabel}
|
|
188
|
+
</span>
|
|
172
189
|
<span className="text-border">•</span>
|
|
173
190
|
<span className="font-medium">{readingTime}</span>
|
|
174
191
|
</div>
|
|
@@ -179,6 +196,14 @@ export default function ArticlePage({
|
|
|
179
196
|
</time>
|
|
180
197
|
)}
|
|
181
198
|
|
|
199
|
+
{showShareLinks && (
|
|
200
|
+
<ArticleTopActions
|
|
201
|
+
title={article.title}
|
|
202
|
+
url={articleUrl}
|
|
203
|
+
size="sm"
|
|
204
|
+
/>
|
|
205
|
+
)}
|
|
206
|
+
|
|
182
207
|
<div className="bg-border h-px w-6 sm:w-8" aria-hidden="true"></div>
|
|
183
208
|
|
|
184
209
|
{(article.topics ?? []).length > 0 ? (
|
|
@@ -190,10 +215,7 @@ export default function ArticlePage({
|
|
|
190
215
|
<Link
|
|
191
216
|
key={topic.id}
|
|
192
217
|
href={buildBlogPath(
|
|
193
|
-
(navigation?.backToHome?.url ?? "/blog").replace(
|
|
194
|
-
/\/$/,
|
|
195
|
-
"",
|
|
196
|
-
),
|
|
218
|
+
(navigation?.backToHome?.url ?? "/blog").replace(/\/$/, ""),
|
|
197
219
|
`topics/${topic.slug}`,
|
|
198
220
|
)}
|
|
199
221
|
className="rounded-full border border-[var(--blog-accent-border)] bg-[var(--blog-accent-bg)] px-3 py-1 text-xs font-medium text-[var(--blog-text-accent)] transition-colors hover:border-[var(--blog-text-accent)] hover:text-[var(--blog-text-accent-hover)]"
|
|
@@ -235,9 +257,16 @@ export default function ArticlePage({
|
|
|
235
257
|
className="article-content mx-auto grid max-w-6xl grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_14rem] lg:gap-12"
|
|
236
258
|
>
|
|
237
259
|
<div className="min-w-0">
|
|
238
|
-
<
|
|
260
|
+
<ArticleReadingProgress
|
|
261
|
+
items={tocItems}
|
|
262
|
+
contentId="article-body"
|
|
263
|
+
variant="mobile"
|
|
264
|
+
/>
|
|
239
265
|
|
|
240
|
-
<div
|
|
266
|
+
<div
|
|
267
|
+
id="article-body"
|
|
268
|
+
className="prose prose-sm sm:prose-base lg:prose-lg xl:prose-xl prose-gray dark:prose-invert prose-headings:scroll-mt-20 prose-headings:font-light prose-headings:text-balance prose-headings:text-[var(--blog-text-accent)] prose-h1:text-2xl sm:prose-h1:text-3xl lg:prose-h1:text-4xl prose-h2:text-xl sm:prose-h2:text-2xl lg:prose-h2:text-3xl prose-h3:text-lg sm:prose-h3:text-xl lg:prose-h3:text-2xl prose-p:leading-relaxed prose-p:text-pretty prose-a:text-[var(--blog-text-accent)] prose-a:transition-colors hover:prose-a:text-[var(--blog-text-accent-hover)] max-w-none"
|
|
269
|
+
>
|
|
241
270
|
<MDXRemote
|
|
242
271
|
source={article.content}
|
|
243
272
|
options={articleMdxOptions}
|
|
@@ -246,31 +275,47 @@ export default function ArticlePage({
|
|
|
246
275
|
</div>
|
|
247
276
|
</div>
|
|
248
277
|
|
|
249
|
-
<
|
|
278
|
+
<ArticleReadingProgress
|
|
279
|
+
items={tocItems}
|
|
280
|
+
contentId="article-body"
|
|
281
|
+
variant="desktop"
|
|
282
|
+
showProgress={false}
|
|
283
|
+
/>
|
|
250
284
|
</div>
|
|
251
285
|
|
|
252
286
|
{/* Author Bio */}
|
|
253
287
|
{article.author?.bio && (
|
|
254
|
-
<div className="author-bio mx-auto mt-12 max-w-4xl">
|
|
288
|
+
<div id="about-author" className="author-bio mx-auto mt-12 max-w-4xl">
|
|
255
289
|
<Separator className="mb-8" />
|
|
256
290
|
<Card className="border-[var(--blog-accent-border)] bg-[var(--blog-accent-bg)]">
|
|
257
291
|
<CardContent className="flex flex-col gap-4 p-5 sm:flex-row sm:items-start sm:p-6">
|
|
258
292
|
<div className="shrink-0">
|
|
259
293
|
<AuthorAvatar author={article.author} size="lg" />
|
|
260
294
|
</div>
|
|
261
|
-
<div className="min-w-0 space-y-2">
|
|
262
|
-
<div>
|
|
263
|
-
<
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
295
|
+
<div className="min-w-0 flex-1 space-y-2">
|
|
296
|
+
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
|
297
|
+
<div className="min-w-0">
|
|
298
|
+
<p className="text-muted-foreground text-sm font-medium">
|
|
299
|
+
Written by
|
|
300
|
+
</p>
|
|
301
|
+
<h2 className="text-foreground flex flex-wrap items-center gap-2 text-xl font-semibold">
|
|
302
|
+
<Link
|
|
303
|
+
href={buildAuthorPath(
|
|
304
|
+
normalizedBasePath,
|
|
305
|
+
article.author.slug,
|
|
306
|
+
)}
|
|
307
|
+
className="transition-colors hover:text-[var(--blog-accent)] hover:no-underline"
|
|
308
|
+
>
|
|
309
|
+
{article.author.name}
|
|
310
|
+
</Link>
|
|
311
|
+
{article.author.isGuest && (
|
|
312
|
+
<Badge variant="outline" className="text-xs">
|
|
313
|
+
Guest Author
|
|
314
|
+
</Badge>
|
|
315
|
+
)}
|
|
316
|
+
</h2>
|
|
317
|
+
</div>
|
|
318
|
+
<AuthorSocialLinks author={article.author} />
|
|
274
319
|
</div>
|
|
275
320
|
<p className="text-muted-foreground leading-relaxed">
|
|
276
321
|
{article.author.bio}
|
|
@@ -306,7 +351,7 @@ export default function ArticlePage({
|
|
|
306
351
|
<Separator className="mb-8" />
|
|
307
352
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
308
353
|
{navigation?.previousArticle && (
|
|
309
|
-
<Card className="group hover:border-[var(--blog-accent-border)] hover:bg-[var(--blog-accent-bg)]
|
|
354
|
+
<Card className="group cursor-pointer transition-colors hover:border-[var(--blog-accent-border)] hover:bg-[var(--blog-accent-bg)]">
|
|
310
355
|
<Link href={navigation.previousArticle.url} className="block">
|
|
311
356
|
{navigation.previousArticle.featuredImage && (
|
|
312
357
|
<ArticleCardImage
|
|
@@ -321,7 +366,7 @@ export default function ArticlePage({
|
|
|
321
366
|
<div className="text-muted-foreground mb-2 text-sm font-medium">
|
|
322
367
|
← Previous Article
|
|
323
368
|
</div>
|
|
324
|
-
<div className="text-foreground group-hover:text-[var(--blog-text-accent)]
|
|
369
|
+
<div className="text-foreground transition-colors group-hover:text-[var(--blog-text-accent)]">
|
|
325
370
|
{navigation.previousArticle.title}
|
|
326
371
|
</div>
|
|
327
372
|
</CardContent>
|
|
@@ -331,7 +376,7 @@ export default function ArticlePage({
|
|
|
331
376
|
{navigation?.nextArticle && (
|
|
332
377
|
<Card
|
|
333
378
|
className={cn(
|
|
334
|
-
"group hover:border-[var(--blog-accent-border)] hover:bg-[var(--blog-accent-bg)]
|
|
379
|
+
"group cursor-pointer transition-colors hover:border-[var(--blog-accent-border)] hover:bg-[var(--blog-accent-bg)]",
|
|
335
380
|
!navigation?.previousArticle && "md:col-start-2",
|
|
336
381
|
)}
|
|
337
382
|
>
|
|
@@ -349,7 +394,7 @@ export default function ArticlePage({
|
|
|
349
394
|
<div className="text-muted-foreground mb-2 text-sm font-medium">
|
|
350
395
|
Next Article →
|
|
351
396
|
</div>
|
|
352
|
-
<div className="text-foreground group-hover:text-[var(--blog-text-accent)]
|
|
397
|
+
<div className="text-foreground transition-colors group-hover:text-[var(--blog-text-accent)]">
|
|
353
398
|
{navigation.nextArticle.title}
|
|
354
399
|
</div>
|
|
355
400
|
</CardContent>
|
|
@@ -380,72 +425,6 @@ export default function ArticlePage({
|
|
|
380
425
|
);
|
|
381
426
|
}
|
|
382
427
|
|
|
383
|
-
function ArticleTableOfContents({
|
|
384
|
-
items,
|
|
385
|
-
variant,
|
|
386
|
-
}: {
|
|
387
|
-
items: ReturnType<typeof extractTableOfContents>;
|
|
388
|
-
variant: "mobile" | "desktop";
|
|
389
|
-
}) {
|
|
390
|
-
if (items.length === 0) {
|
|
391
|
-
return null;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
const list = (
|
|
395
|
-
<ul className="space-y-2 text-sm">
|
|
396
|
-
{items.map((item) => (
|
|
397
|
-
<li key={item.id} className={item.level === 3 ? "pl-4" : undefined}>
|
|
398
|
-
<a
|
|
399
|
-
href={`#${item.id}`}
|
|
400
|
-
className="text-muted-foreground hover:text-[var(--blog-text-accent)] block leading-snug transition-colors"
|
|
401
|
-
>
|
|
402
|
-
{item.text}
|
|
403
|
-
</a>
|
|
404
|
-
</li>
|
|
405
|
-
))}
|
|
406
|
-
</ul>
|
|
407
|
-
);
|
|
408
|
-
|
|
409
|
-
if (variant === "mobile") {
|
|
410
|
-
return (
|
|
411
|
-
<details className="group mb-6 rounded-2xl border border-[var(--blog-accent-border)] bg-[var(--blog-accent-bg)] lg:hidden">
|
|
412
|
-
<summary className="text-foreground flex cursor-pointer list-none items-center justify-between gap-3 px-4 py-3 text-sm font-semibold [&::-webkit-details-marker]:hidden">
|
|
413
|
-
<span>On this page</span>
|
|
414
|
-
<span className="text-muted-foreground flex items-center gap-2 text-xs font-medium">
|
|
415
|
-
{formatTableOfContentsSummary(items.length)}
|
|
416
|
-
<span
|
|
417
|
-
className="text-[var(--blog-text-accent)] transition-transform group-open:rotate-180"
|
|
418
|
-
aria-hidden="true"
|
|
419
|
-
>
|
|
420
|
-
↓
|
|
421
|
-
</span>
|
|
422
|
-
</span>
|
|
423
|
-
</summary>
|
|
424
|
-
<nav
|
|
425
|
-
aria-label="Table of contents"
|
|
426
|
-
className="border-t border-[var(--blog-accent-border)] px-4 py-4"
|
|
427
|
-
>
|
|
428
|
-
{list}
|
|
429
|
-
</nav>
|
|
430
|
-
</details>
|
|
431
|
-
);
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
return (
|
|
435
|
-
<aside className="hidden lg:block">
|
|
436
|
-
<nav
|
|
437
|
-
aria-label="Table of contents"
|
|
438
|
-
className="sticky top-24 max-h-[calc(100vh-8rem)] overflow-y-auto border-l border-border pl-5"
|
|
439
|
-
>
|
|
440
|
-
<p className="text-muted-foreground mb-3 text-xs font-semibold tracking-wider uppercase">
|
|
441
|
-
On this page
|
|
442
|
-
</p>
|
|
443
|
-
{list}
|
|
444
|
-
</nav>
|
|
445
|
-
</aside>
|
|
446
|
-
);
|
|
447
|
-
}
|
|
448
|
-
|
|
449
428
|
function AuthorAvatar({
|
|
450
429
|
author,
|
|
451
430
|
size,
|
|
@@ -482,6 +461,93 @@ function AuthorAvatar({
|
|
|
482
461
|
);
|
|
483
462
|
}
|
|
484
463
|
|
|
464
|
+
interface AuthorSocialLinkView {
|
|
465
|
+
key: string;
|
|
466
|
+
href: string;
|
|
467
|
+
label: string;
|
|
468
|
+
external: boolean;
|
|
469
|
+
icon: React.ReactNode;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function AuthorSocialLinks({ author }: { author: PublishedAuthor }) {
|
|
473
|
+
const links = getAuthorSocialLinks(author);
|
|
474
|
+
if (links.length === 0) return null;
|
|
475
|
+
|
|
476
|
+
return (
|
|
477
|
+
<div className="flex flex-wrap items-center gap-1 sm:justify-end">
|
|
478
|
+
{links.map((link) => (
|
|
479
|
+
<a
|
|
480
|
+
key={link.key}
|
|
481
|
+
href={link.href}
|
|
482
|
+
target={link.external ? "_blank" : undefined}
|
|
483
|
+
rel={link.external ? "noopener noreferrer" : undefined}
|
|
484
|
+
aria-label={`${author.name} ${link.label}`}
|
|
485
|
+
className="text-muted-foreground hover:bg-background/60 hover:text-foreground inline-flex size-7 items-center justify-center rounded-full transition-colors"
|
|
486
|
+
>
|
|
487
|
+
{link.icon}
|
|
488
|
+
</a>
|
|
489
|
+
))}
|
|
490
|
+
</div>
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function getAuthorSocialLinks(author: PublishedAuthor): AuthorSocialLinkView[] {
|
|
495
|
+
const links = author.socialLinks;
|
|
496
|
+
if (!links) return [];
|
|
497
|
+
|
|
498
|
+
const socialLinks: Array<AuthorSocialLinkView | false | undefined> = [
|
|
499
|
+
links.website
|
|
500
|
+
? {
|
|
501
|
+
key: "website",
|
|
502
|
+
href: links.website,
|
|
503
|
+
label: "website",
|
|
504
|
+
external: true,
|
|
505
|
+
icon: <Globe className="size-4" aria-hidden="true" />,
|
|
506
|
+
}
|
|
507
|
+
: undefined,
|
|
508
|
+
links.x
|
|
509
|
+
? {
|
|
510
|
+
key: "x",
|
|
511
|
+
href: links.x,
|
|
512
|
+
label: "on X",
|
|
513
|
+
external: true,
|
|
514
|
+
icon: <FaXTwitter className="size-4" aria-hidden="true" />,
|
|
515
|
+
}
|
|
516
|
+
: undefined,
|
|
517
|
+
links.linkedin
|
|
518
|
+
? {
|
|
519
|
+
key: "linkedin",
|
|
520
|
+
href: links.linkedin,
|
|
521
|
+
label: "on LinkedIn",
|
|
522
|
+
external: true,
|
|
523
|
+
icon: <FaLinkedinIn className="size-4" aria-hidden="true" />,
|
|
524
|
+
}
|
|
525
|
+
: undefined,
|
|
526
|
+
links.github
|
|
527
|
+
? {
|
|
528
|
+
key: "github",
|
|
529
|
+
href: links.github,
|
|
530
|
+
label: "on GitHub",
|
|
531
|
+
external: true,
|
|
532
|
+
icon: <FaGithub className="size-4" aria-hidden="true" />,
|
|
533
|
+
}
|
|
534
|
+
: undefined,
|
|
535
|
+
links.email
|
|
536
|
+
? {
|
|
537
|
+
key: "email",
|
|
538
|
+
href: `mailto:${links.email}`,
|
|
539
|
+
label: "email",
|
|
540
|
+
external: false,
|
|
541
|
+
icon: <Mail className="size-4" aria-hidden="true" />,
|
|
542
|
+
}
|
|
543
|
+
: undefined,
|
|
544
|
+
];
|
|
545
|
+
|
|
546
|
+
return socialLinks.filter((link): link is AuthorSocialLinkView =>
|
|
547
|
+
Boolean(link),
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
|
|
485
551
|
function getAuthorInitials(name: string): string {
|
|
486
552
|
const initials = name
|
|
487
553
|
.trim()
|
|
@@ -528,36 +594,29 @@ function ArticleShareLinks({ title, url }: { title: string; url: string }) {
|
|
|
528
594
|
|
|
529
595
|
return (
|
|
530
596
|
<div className="flex flex-wrap items-center justify-center gap-2 sm:gap-3">
|
|
531
|
-
<
|
|
532
|
-
<Share2 className="size-4" aria-hidden="true" />
|
|
533
|
-
Share
|
|
534
|
-
</span>
|
|
597
|
+
<ArticleTopActions title={title} url={url} size="sm" />
|
|
535
598
|
<a
|
|
536
599
|
href={`https://x.com/intent/tweet?text=${encodedTitle}&url=${encodedUrl}`}
|
|
537
600
|
target="_blank"
|
|
538
601
|
rel="noopener noreferrer"
|
|
539
602
|
aria-label="Share on X"
|
|
540
|
-
className="text-muted-foreground hover:border-
|
|
603
|
+
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)]"
|
|
541
604
|
>
|
|
542
|
-
<
|
|
543
|
-
X
|
|
544
|
-
</span>
|
|
605
|
+
<FaXTwitter className="size-4" aria-hidden="true" />
|
|
545
606
|
</a>
|
|
546
607
|
<a
|
|
547
608
|
href={`https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}`}
|
|
548
609
|
target="_blank"
|
|
549
610
|
rel="noopener noreferrer"
|
|
550
611
|
aria-label="Share on LinkedIn"
|
|
551
|
-
className="text-muted-foreground hover:border-
|
|
612
|
+
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)]"
|
|
552
613
|
>
|
|
553
|
-
<
|
|
554
|
-
in
|
|
555
|
-
</span>
|
|
614
|
+
<FaLinkedinIn className="size-4" aria-hidden="true" />
|
|
556
615
|
</a>
|
|
557
616
|
<a
|
|
558
617
|
href={`mailto:?subject=${encodedTitle}&body=${encodedUrl}`}
|
|
559
618
|
aria-label="Share by email"
|
|
560
|
-
className="text-muted-foreground hover:border-
|
|
619
|
+
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)]"
|
|
561
620
|
>
|
|
562
621
|
<Mail className="size-4" aria-hidden="true" />
|
|
563
622
|
</a>
|
|
@@ -582,7 +641,7 @@ function RelatedArticles({
|
|
|
582
641
|
<Link
|
|
583
642
|
key={article.id}
|
|
584
643
|
href={buildBlogPath(basePath, article.slug)}
|
|
585
|
-
className="group overflow-hidden rounded-2xl border
|
|
644
|
+
className="group border-border overflow-hidden rounded-2xl border transition-colors hover:border-[var(--blog-accent-border)] hover:bg-[var(--blog-accent-bg)] hover:no-underline"
|
|
586
645
|
>
|
|
587
646
|
{article.featuredImage && (
|
|
588
647
|
<ArticleCardImage
|
|
@@ -594,7 +653,7 @@ function RelatedArticles({
|
|
|
594
653
|
/>
|
|
595
654
|
)}
|
|
596
655
|
<div className="p-4">
|
|
597
|
-
<h3 className="text-foreground group-hover:text-[var(--blog-text-accent)]
|
|
656
|
+
<h3 className="text-foreground line-clamp-3 font-semibold transition-colors group-hover:text-[var(--blog-text-accent)]">
|
|
598
657
|
{article.title}
|
|
599
658
|
</h3>
|
|
600
659
|
{article.excerpt && (
|
|
@@ -612,7 +671,7 @@ function RelatedArticles({
|
|
|
612
671
|
|
|
613
672
|
function ArticleCardImage({ src, alt }: { src: string; alt: string }) {
|
|
614
673
|
return (
|
|
615
|
-
<div className="aspect-[16/9] overflow-hidden border-b
|
|
674
|
+
<div className="border-border aspect-[16/9] overflow-hidden border-b bg-[var(--blog-accent-bg)]">
|
|
616
675
|
<Image
|
|
617
676
|
src={src}
|
|
618
677
|
alt={alt}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
import AuthorPage, { generateAuthorPageMetadata } from "./page";
|
|
6
|
+
import type {
|
|
7
|
+
PublishedArticle,
|
|
8
|
+
PublishedAuthor,
|
|
9
|
+
PublishedSite,
|
|
10
|
+
} from "../../../../types/contracts";
|
|
11
|
+
|
|
12
|
+
const site: PublishedSite = {
|
|
13
|
+
id: "site-1",
|
|
14
|
+
name: "Test Blog",
|
|
15
|
+
tagline: "Testing author pages",
|
|
16
|
+
description: "A test blog",
|
|
17
|
+
domain: "example.com",
|
|
18
|
+
toneAdjectives: [],
|
|
19
|
+
locale: "en",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const author: PublishedAuthor = {
|
|
23
|
+
id: "author-1",
|
|
24
|
+
name: "Jane Doe",
|
|
25
|
+
slug: "jane-doe",
|
|
26
|
+
bio: "Writes about product systems.",
|
|
27
|
+
avatarUrl: "https://cdn.example.com/authors/jane.jpg",
|
|
28
|
+
socialLinks: {
|
|
29
|
+
website: "https://jane.example.com",
|
|
30
|
+
linkedin: "https://www.linkedin.com/in/jane-doe",
|
|
31
|
+
},
|
|
32
|
+
pronouns: "she/her",
|
|
33
|
+
isGuest: true,
|
|
34
|
+
toneAdjectives: ["practical", "direct"],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function article(slug: string, title: string): PublishedArticle {
|
|
38
|
+
return {
|
|
39
|
+
id: slug,
|
|
40
|
+
slug,
|
|
41
|
+
title,
|
|
42
|
+
excerpt: `${title} excerpt.`,
|
|
43
|
+
content: "# Article",
|
|
44
|
+
readingSeconds: 180,
|
|
45
|
+
wordCount: 500,
|
|
46
|
+
publishedAt: "2026-05-01T10:00:00.000Z",
|
|
47
|
+
updatedAt: "2026-05-01T10:00:00.000Z",
|
|
48
|
+
author,
|
|
49
|
+
site,
|
|
50
|
+
topics: [],
|
|
51
|
+
meta: {
|
|
52
|
+
keywords: ["test"],
|
|
53
|
+
canonicalUrl: `https://example.com/blog/${slug}`,
|
|
54
|
+
},
|
|
55
|
+
version: 1,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe("AuthorPage", () => {
|
|
60
|
+
it("renders author profile details and that author's published articles", () => {
|
|
61
|
+
const html = renderToStaticMarkup(
|
|
62
|
+
React.createElement(AuthorPage, {
|
|
63
|
+
author,
|
|
64
|
+
articles: [
|
|
65
|
+
article("first-post", "First Post"),
|
|
66
|
+
article("second-post", "Second Post"),
|
|
67
|
+
],
|
|
68
|
+
site,
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
expect(html).toContain("Jane Doe");
|
|
73
|
+
expect(html).toContain("Writes about product systems.");
|
|
74
|
+
expect(html).toContain("Guest Author");
|
|
75
|
+
expect(html).not.toContain("she/her");
|
|
76
|
+
expect(html).not.toContain('aria-label="Writing style"');
|
|
77
|
+
expect(html).toContain("https://jane.example.com");
|
|
78
|
+
expect(html).toContain("https://www.linkedin.com/in/jane-doe");
|
|
79
|
+
expect(html).toContain("First Post");
|
|
80
|
+
expect(html).toContain("Second Post");
|
|
81
|
+
expect(html).toContain("/blog/first-post");
|
|
82
|
+
expect(html).toContain("/blog/second-post");
|
|
83
|
+
expect(html).not.toContain(">\\/blog\\/authors\\/jane-doe<");
|
|
84
|
+
expect(html).toContain(
|
|
85
|
+
"https%3A%2F%2Fcdn.example.com%2Fauthors%2Fjane.jpg",
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("generates canonical metadata for the author route", () => {
|
|
90
|
+
const metadata = generateAuthorPageMetadata(author, site, "/blog");
|
|
91
|
+
|
|
92
|
+
expect(metadata.title).toBe("Jane Doe - Test Blog");
|
|
93
|
+
expect(metadata.description).toBe("Writes about product systems.");
|
|
94
|
+
expect(metadata.openGraph.url).toBe(
|
|
95
|
+
"https://example.com/blog/authors/jane-doe",
|
|
96
|
+
);
|
|
97
|
+
expect(metadata.openGraph.images).toEqual([
|
|
98
|
+
{ url: "https://cdn.example.com/authors/jane.jpg", alt: "Jane Doe" },
|
|
99
|
+
]);
|
|
100
|
+
expect(metadata.twitter.card).toBe("summary_large_image");
|
|
101
|
+
expect(metadata.twitter.images).toEqual([
|
|
102
|
+
"https://cdn.example.com/authors/jane.jpg",
|
|
103
|
+
]);
|
|
104
|
+
expect(metadata.alternates.canonical).toBe(
|
|
105
|
+
"https://example.com/blog/authors/jane-doe",
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
});
|