@xexr/blog-platform 1.0.8 → 1.0.10

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/README.md CHANGED
@@ -170,9 +170,23 @@ export default async function BlogPage({
170
170
 
171
171
  ```tsx
172
172
  import "@/lib/blog-platform";
173
- import { BlogArticlePage, getStorage } from "@xexr/blog-platform";
173
+ import {
174
+ BlogArticlePage,
175
+ getStorage,
176
+ generateArticleMetadata,
177
+ } from "@xexr/blog-platform";
174
178
  import { notFound } from "next/navigation";
175
179
 
180
+ export async function generateMetadata({
181
+ params,
182
+ }: {
183
+ params: Promise<{ slug: string }>;
184
+ }) {
185
+ const { slug } = await params;
186
+ const article = await getStorage().getArticle(slug);
187
+ return article ? generateArticleMetadata(article) : {};
188
+ }
189
+
176
190
  export default async function ArticlePage({
177
191
  params,
178
192
  }: {
@@ -195,8 +209,10 @@ export default async function ArticlePage({
195
209
 
196
210
  // Find current article index
197
211
  const currentIndex = articles.findIndex((a) => a.slug === slug);
198
- const previousArticle = currentIndex > 0 ? articles[currentIndex - 1] : undefined;
199
- const nextArticle = currentIndex < articles.length - 1 ? articles[currentIndex + 1] : undefined;
212
+ const previousArticle =
213
+ currentIndex > 0 ? articles[currentIndex - 1] : undefined;
214
+ const nextArticle =
215
+ currentIndex < articles.length - 1 ? articles[currentIndex + 1] : undefined;
200
216
 
201
217
  return (
202
218
  <BlogArticlePage
@@ -223,6 +239,51 @@ export default async function ArticlePage({
223
239
 
224
240
  > **Note**: The `navigation` prop is optional. You can omit `previousArticle` and `nextArticle` if you don't want article navigation links.
225
241
 
242
+ **`app/blog/authors/[slug]/page.tsx`** - Author profile pages:
243
+
244
+ ```tsx
245
+ import "@/lib/blog-platform";
246
+ import {
247
+ BlogAuthorPage,
248
+ generateAuthorPageMetadata,
249
+ loadAuthorArchive,
250
+ } from "@xexr/blog-platform";
251
+ import { notFound } from "next/navigation";
252
+
253
+ export async function generateMetadata({
254
+ params,
255
+ }: {
256
+ params: Promise<{ slug: string }>;
257
+ }) {
258
+ const { slug } = await params;
259
+ const archive = await loadAuthorArchive(slug);
260
+ return archive
261
+ ? generateAuthorPageMetadata(archive.author, archive.articles[0]!.site)
262
+ : {};
263
+ }
264
+
265
+ export default async function AuthorPage({
266
+ params,
267
+ }: {
268
+ params: Promise<{ slug: string }>;
269
+ }) {
270
+ const { slug } = await params;
271
+ const archive = await loadAuthorArchive(slug);
272
+ if (!archive) {
273
+ notFound();
274
+ }
275
+
276
+ return (
277
+ <BlogAuthorPage
278
+ author={archive.author}
279
+ articles={archive.articles}
280
+ site={archive.articles[0]!.site}
281
+ navigation={{ backToHome: { url: "/blog" } }}
282
+ />
283
+ );
284
+ }
285
+ ```
286
+
226
287
  ### 2. Set Up Publishing API
227
288
 
228
289
  **`app/api/blog/publish/route.ts`**:
@@ -362,8 +423,8 @@ const nextConfig = {
362
423
  images: {
363
424
  remotePatterns: [
364
425
  {
365
- protocol: 'https',
366
- hostname: 'bloggy.poyzer.download', // Replace with your Bloggy instance domain
426
+ protocol: "https",
427
+ hostname: "bloggy.poyzer.download", // Replace with your Bloggy instance domain
367
428
  },
368
429
  ],
369
430
  },
@@ -454,11 +515,11 @@ Before you can publish articles, ensure you have:
454
515
 
455
516
  **Troubleshooting Connection Test**:
456
517
 
457
- | Issue | Solution |
458
- |-------|----------|
459
- | ❌ Invalid credentials | Check that `BLOG_PLATFORM_API_KEYS` in your .env matches the token you entered |
460
- | ❌ Connection failed | Verify your Blog Platform API URL is correct and accessible |
461
- | ❌ Version incompatible | Upgrade your blog platform package to match Bloggy's version |
518
+ | Issue | Solution |
519
+ | ----------------------- | ------------------------------------------------------------------------------ |
520
+ | ❌ Invalid credentials | Check that `BLOG_PLATFORM_API_KEYS` in your .env matches the token you entered |
521
+ | ❌ Connection failed | Verify your Blog Platform API URL is correct and accessible |
522
+ | ❌ Version incompatible | Upgrade your blog platform package to match Bloggy's version |
462
523
 
463
524
  ### Step 2: Publish an Article
464
525
 
@@ -473,7 +534,6 @@ Before you can publish articles, ensure you have:
473
534
 
474
535
  3. **Configure Publication Settings**:
475
536
  The publish modal opens with pre-filled fields:
476
-
477
537
  - **URL Slug**: Auto-generated from the title (lowercase, hyphens)
478
538
  - Preview: `https://yourblog.com/blog/your-article-slug`
479
539
  - Editable: Change to your preferred slug
@@ -523,6 +583,7 @@ Before you can publish articles, ensure you have:
523
583
  - `dateModified` updates in JSON-LD, `datePublished` remains unchanged
524
584
 
525
585
  **What Gets Updated**:
586
+
526
587
  - ✅ Article content (title, body, images)
527
588
  - ✅ Meta description
528
589
  - ✅ `dateModified` timestamp
@@ -551,38 +612,44 @@ Before you can publish articles, ensure you have:
551
612
 
552
613
  ### Publishing Status Reference
553
614
 
554
- | Status | Description | Available Actions |
555
- |--------|-------------|-------------------|
556
- | **Never Published** | Article has never been published | ✅ Publish |
557
- | **Published** | Article is live on blog | ✅ Republish (update)<br>✅ Unpublish |
558
- | **Failed** | Publishing failed due to error | ✅ Retry publish |
559
- | **Publishing** | Publish in progress | ⏳ Wait (typically < 5s) |
615
+ | Status | Description | Available Actions |
616
+ | ------------------- | -------------------------------- | ------------------------------------- |
617
+ | **Never Published** | Article has never been published | ✅ Publish |
618
+ | **Published** | Article is live on blog | ✅ Republish (update)<br>✅ Unpublish |
619
+ | **Failed** | Publishing failed due to error | ✅ Retry publish |
620
+ | **Publishing** | Publish in progress | ⏳ Wait (typically < 5s) |
560
621
 
561
622
  ### Error Handling & Troubleshooting
562
623
 
563
624
  #### Common Publishing Errors
564
625
 
565
626
  **PUB003: Slug Already Exists**
627
+
566
628
  - **Cause**: Another article is already using this slug
567
629
  - **Solution**: Choose a different slug (add date, number, or variation)
568
630
 
569
631
  **PUB005: Invalid Credentials**
632
+
570
633
  - **Cause**: Authentication token doesn't match
571
634
  - **Solution**: Update token in platform settings and re-test connection
572
635
 
573
636
  **PUB006: Content Validation Failed**
637
+
574
638
  - **Cause**: Article doesn't meet publishing requirements
575
639
  - **Solution**: Check error details (usually min 50 words required)
576
640
 
577
641
  **PUB007: Platform Unavailable**
642
+
578
643
  - **Cause**: Blog platform is down or unreachable
579
644
  - **Solution**: Check your blog is running, then retry publish
580
645
 
581
646
  **PUB008: No Platform Configured**
647
+
582
648
  - **Cause**: Publishing platform not set up for this site
583
649
  - **Solution**: Go to Blog → Edit → Publishing and configure platform
584
650
 
585
651
  **PUB009: Version Incompatible**
652
+
586
653
  - **Cause**: Bloggy version doesn't match blog platform version (major mismatch)
587
654
  - **Solution**: Upgrade your `@xexr/blog-platform` package
588
655
 
@@ -601,11 +668,13 @@ If publishing fails:
601
668
  The blog platform displays author information based on Bloggy's author settings:
602
669
 
603
670
  **Author Resolution Order**:
671
+
604
672
  1. **Article Author**: If article has an assigned author, use that author
605
673
  2. **Site Default Author**: If no article author, use site's default author
606
674
  3. **No Author**: If neither available, publish without author byline
607
675
 
608
676
  **Guest Author Display**:
677
+
609
678
  - Guest authors (marked with `isGuest: true`) show **Guest Author** badge
610
679
  - Author bio card displays if `author.bio` is present
611
680
  - Author profile links are not included (post-MVP feature)
@@ -628,22 +697,26 @@ The blog platform displays author information based on Bloggy's author settings:
628
697
  When publishing articles, the blog platform automatically handles:
629
698
 
630
699
  **Meta Tags**:
700
+
631
701
  - `<title>`: Article title
632
702
  - `<meta name="description">`: Your custom meta description
633
703
  - `<link rel="canonical">`: Self-canonical URL
634
704
 
635
705
  **Structured Data (JSON-LD)**:
706
+
636
707
  - BlogPosting schema with full article metadata
637
708
  - Author Person schema (if author present)
638
709
  - Organization schema for publisher
639
710
  - BreadcrumbList for navigation
640
711
 
641
712
  **OpenGraph Tags**:
713
+
642
714
  - `og:title`, `og:description`, `og:url`
643
715
  - `og:type="article"`
644
716
  - Featured image as `og:image` (if available)
645
717
 
646
718
  **Image Optimization**:
719
+
647
720
  - Images served via Cloudflare CDN
648
721
  - Automatic WebP conversion
649
722
  - Responsive sizing
@@ -656,11 +729,13 @@ The blog platform uses **semantic versioning** to ensure compatibility:
656
729
  **Version Format**: `MAJOR.MINOR.PATCH`
657
730
 
658
731
  **Compatibility Rules**:
732
+
659
733
  - ✅ **Patch Differences** (1.0.0 ↔ 1.0.3): Always compatible
660
734
  - ⚠️ **Minor Differences** (1.0.0 ↔ 1.1.0): Compatible with warnings
661
735
  - ❌ **Major Differences** (1.0.0 ↔ 2.0.0): Incompatible, blocks publishing
662
736
 
663
737
  **Checking Compatibility**:
738
+
664
739
  1. Go to Blog → Edit → Publishing
665
740
  2. Click **Test Connection**
666
741
  3. View compatibility status in test results
@@ -674,12 +749,14 @@ The blog platform uses **semantic versioning** to ensure compatibility:
674
749
  Publishing API is rate-limited to **5 requests per minute per authentication token**.
675
750
 
676
751
  **Rate Limit Behavior**:
752
+
677
753
  - Each publish/unpublish counts as 1 request
678
754
  - Version check counts as 1 request
679
755
  - Limit resets every 60 seconds
680
756
  - Exceeded limits return HTTP 429 with `Retry-After` header
681
757
 
682
758
  **If Rate Limited**:
759
+
683
760
  1. Wait for reset (shown in error message)
684
761
  2. System automatically retries after delay
685
762
  3. Avoid rapid republishing during testing
@@ -687,12 +764,14 @@ Publishing API is rate-limited to **5 requests per minute per authentication tok
687
764
  ### Security & Privacy
688
765
 
689
766
  **Credential Security**:
767
+
690
768
  - All tokens encrypted with AES-256-GCM before storage
691
769
  - Tokens never displayed after save (shown as "••••")
692
770
  - HTTPS required for all API communication
693
771
  - Authentication required on all blog platform endpoints
694
772
 
695
773
  **Content Privacy**:
774
+
696
775
  - Only accepted articles can be published
697
776
  - Draft articles remain private in Bloggy
698
777
  - Unpublished articles return 404 on blog
@@ -884,8 +963,8 @@ border-border /* Border colors */
884
963
  ```css
885
964
  /* globals.css - Tailwind v4 */
886
965
  @theme inline {
887
- --color-primary: oklch(0.6 0.2 250); /* Custom blue */
888
- --color-foreground: oklch(0.2 0.02 250); /* Dark text */
966
+ --color-primary: oklch(0.6 0.2 250); /* Custom blue */
967
+ --color-foreground: oklch(0.2 0.02 250); /* Dark text */
889
968
  /* ... other theme variables */
890
969
  }
891
970
  ```
@@ -911,9 +990,9 @@ module.exports = {
911
990
  typography: {
912
991
  DEFAULT: {
913
992
  css: {
914
- maxWidth: '80ch', // Wider content
993
+ maxWidth: "80ch", // Wider content
915
994
  h1: {
916
- fontSize: '3rem', // Larger headings
995
+ fontSize: "3rem", // Larger headings
917
996
  },
918
997
  // ... custom prose styles
919
998
  },
@@ -1014,7 +1093,9 @@ Dark mode is automatically supported via Tailwind's `dark:` variant:
1014
1093
  // app/layout.tsx
1015
1094
  export default function RootLayout({ children }) {
1016
1095
  return (
1017
- <html lang="en" className="dark"> {/* Add 'dark' class */}
1096
+ <html lang="en" className="dark">
1097
+ {" "}
1098
+ {/* Add 'dark' class */}
1018
1099
  <body>{children}</body>
1019
1100
  </html>
1020
1101
  );
@@ -1056,8 +1137,8 @@ The blog platform follows these design principles:
1056
1137
  ```css
1057
1138
  /* globals.css */
1058
1139
  @theme inline {
1059
- --color-primary: oklch(0.6 0.25 140); /* Green primary */
1060
- --color-primary-foreground: oklch(1 0 0); /* White text on primary */
1140
+ --color-primary: oklch(0.6 0.25 140); /* Green primary */
1141
+ --color-primary-foreground: oklch(1 0 0); /* White text on primary */
1061
1142
  }
1062
1143
  ```
1063
1144
 
@@ -1069,7 +1150,9 @@ Result: All blog links, CTAs, and accent colors become green.
1069
1150
  // app/blog/[slug]/page.tsx
1070
1151
  export default function ArticlePage({ params }) {
1071
1152
  return (
1072
- <div className="container mx-auto max-w-5xl"> {/* Wider than default */}
1153
+ <div className="container mx-auto max-w-5xl">
1154
+ {" "}
1155
+ {/* Wider than default */}
1073
1156
  <BlogArticlePage article={article} />
1074
1157
  </div>
1075
1158
  );
@@ -1085,12 +1168,10 @@ import { AuthorByline as DefaultByline } from "@xexr/blog-platform";
1085
1168
  function CustomAuthorByline({ author }) {
1086
1169
  return (
1087
1170
  <div className="flex items-center gap-4">
1088
- <img src={author.avatar} className="w-16 h-16 rounded-full" />
1171
+ <img src={author.avatar} className="h-16 w-16 rounded-full" />
1089
1172
  <div>
1090
1173
  <DefaultByline author={author} />
1091
- <p className="text-sm text-muted-foreground">
1092
- @{author.twitter}
1093
- </p>
1174
+ <p className="text-muted-foreground text-sm">@{author.twitter}</p>
1094
1175
  </div>
1095
1176
  </div>
1096
1177
  );
@@ -20,7 +20,11 @@ import {
20
20
  createStructuredDataScript,
21
21
  } from "../../../lib/structured-data";
22
22
  import { articleMdxOptions } from "../../../lib/mdx";
23
- import { buildBlogPath, normalizeBlogBasePath } from "../../../lib/urls";
23
+ import {
24
+ buildAuthorPath,
25
+ buildBlogPath,
26
+ normalizeBlogBasePath,
27
+ } from "../../../lib/urls";
24
28
  import {
25
29
  type BlogTheme,
26
30
  extractTableOfContents,
@@ -82,6 +86,46 @@ function formatReadingTime(seconds: number): string {
82
86
  return `${minutes} min read`;
83
87
  }
84
88
 
89
+ function formatCreditUrl(url: string): string {
90
+ try {
91
+ return new URL(url).hostname.replace(/^www\./, "");
92
+ } catch {
93
+ return url;
94
+ }
95
+ }
96
+
97
+ function FeaturedImageCredit({
98
+ creditText,
99
+ creditUrl,
100
+ }: {
101
+ creditText?: string;
102
+ creditUrl?: string;
103
+ }) {
104
+ if (!creditText && !creditUrl) {
105
+ return null;
106
+ }
107
+
108
+ const label = creditText ?? formatCreditUrl(creditUrl ?? "");
109
+
110
+ return (
111
+ <span>
112
+ Credit:{" "}
113
+ {creditUrl ? (
114
+ <a
115
+ href={creditUrl}
116
+ target="_blank"
117
+ rel="noreferrer"
118
+ className="underline underline-offset-2 transition-colors hover:text-[var(--blog-text-accent)]"
119
+ >
120
+ {label}
121
+ </a>
122
+ ) : (
123
+ label
124
+ )}
125
+ </span>
126
+ );
127
+ }
128
+
85
129
  export default function ArticlePage({
86
130
  article,
87
131
  className,
@@ -105,6 +149,11 @@ export default function ArticlePage({
105
149
 
106
150
  // Use the featured image URL provided by Bloggy (hosted on Bloggy's domain)
107
151
  const imageUrl = article.featuredImage;
152
+ const hasFeaturedImageMeta = Boolean(
153
+ article.featuredImageCaption ??
154
+ article.featuredImageCreditText ??
155
+ article.featuredImageCreditUrl,
156
+ );
108
157
 
109
158
  return (
110
159
  <article
@@ -136,6 +185,45 @@ export default function ArticlePage({
136
185
  </header>
137
186
  )}
138
187
 
188
+ <style>{`
189
+ .article-content figure > p {
190
+ margin-top: 0 !important;
191
+ margin-bottom: 0 !important;
192
+ }
193
+
194
+ .article-content figure figcaption {
195
+ margin-top: 0.5rem !important;
196
+ text-align: center;
197
+ font-size: 0.875rem !important;
198
+ line-height: 1.35 !important;
199
+ color: hsl(var(--muted-foreground));
200
+ }
201
+
202
+ .article-content figure figcaption .blog-image-credit {
203
+ display: block;
204
+ margin-top: 0.25rem;
205
+ font-size: 0.75rem;
206
+ line-height: 1.25;
207
+ letter-spacing: 0.025em;
208
+ opacity: 0.8;
209
+ }
210
+
211
+ .article-content figure figcaption > span:not(.blog-image-credit) {
212
+ margin-left: 0.5rem;
213
+ padding-left: 0.5rem;
214
+ border-left: 1px solid var(--blog-accent-border);
215
+ font-size: 0.75rem;
216
+ letter-spacing: 0.025em;
217
+ opacity: 0.8;
218
+ }
219
+
220
+ .article-content figure figcaption a {
221
+ color: inherit;
222
+ text-decoration-line: underline;
223
+ text-underline-offset: 0.18em;
224
+ }
225
+ `}</style>
226
+
139
227
  {/* Article Header */}
140
228
  <header className="article-header mx-auto mb-6 max-w-4xl space-y-4 text-center sm:mb-8 sm:space-y-6 lg:mb-12 lg:space-y-8">
141
229
  {/* Title */}
@@ -155,8 +243,13 @@ export default function ArticlePage({
155
243
  {article.author && (
156
244
  <div className="flex items-center gap-2">
157
245
  <span className="sr-only">Author</span>
158
- <AuthorAvatar author={article.author} size="md" />
159
- <span className="font-medium">{article.author.name}</span>
246
+ <Link
247
+ href={buildAuthorPath(normalizedBasePath, article.author.slug)}
248
+ className="inline-flex items-center gap-2 font-medium transition-colors hover:text-[var(--blog-accent)] hover:no-underline"
249
+ >
250
+ <AuthorAvatar author={article.author} size="md" />
251
+ {article.author.name}
252
+ </Link>
160
253
  {article.author.isGuest && (
161
254
  <Badge variant="outline" className="text-xs">
162
255
  Guest Author
@@ -228,18 +321,38 @@ export default function ArticlePage({
228
321
 
229
322
  {/* Featured Image */}
230
323
  {imageUrl && (
231
- <div className="article-featured-image mx-auto my-8 aspect-[16/9] w-full max-w-4xl overflow-hidden rounded-3xl border border-[var(--blog-accent-border)] shadow-[0_24px_80px_rgb(168_85_247_/_0.14)] sm:my-12 lg:my-16">
232
- <Image
233
- src={imageUrl}
234
- alt={
235
- article.featuredImageAlt ?? `Featured image for ${article.title}`
236
- }
237
- className="h-full w-full object-cover"
238
- width={800}
239
- height={450}
240
- priority
241
- />
242
- </div>
324
+ <figure className="article-featured-image mx-auto my-8 w-full max-w-4xl sm:my-12 lg:my-16">
325
+ <div className="aspect-[16/9] overflow-hidden rounded-3xl border border-[var(--blog-accent-border)] shadow-[0_24px_80px_rgb(168_85_247_/_0.14)]">
326
+ <Image
327
+ src={imageUrl}
328
+ alt={
329
+ article.featuredImageAlt ??
330
+ `Featured image for ${article.title}`
331
+ }
332
+ className="h-full w-full object-cover"
333
+ width={800}
334
+ height={450}
335
+ priority
336
+ />
337
+ </div>
338
+
339
+ {hasFeaturedImageMeta && (
340
+ <figcaption className="text-muted-foreground mt-3 space-y-1 px-2 text-center text-sm leading-relaxed sm:px-0">
341
+ {article.featuredImageCaption && (
342
+ <p>{article.featuredImageCaption}</p>
343
+ )}
344
+ {(article.featuredImageCreditText ??
345
+ article.featuredImageCreditUrl) && (
346
+ <p className="text-xs">
347
+ <FeaturedImageCredit
348
+ creditText={article.featuredImageCreditText}
349
+ creditUrl={article.featuredImageCreditUrl}
350
+ />
351
+ </p>
352
+ )}
353
+ </figcaption>
354
+ )}
355
+ </figure>
243
356
  )}
244
357
 
245
358
  {/* Article Content */}
@@ -256,7 +369,7 @@ export default function ArticlePage({
256
369
 
257
370
  <div
258
371
  id="article-body"
259
- 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"
372
+ 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)] [&_figure>p]:!my-0 [&_figcaption]:!mt-2 [&_figcaption]:text-center [&_figcaption]:!text-sm [&_figcaption]:!leading-snug [&_figcaption]:text-muted-foreground [&_figcaption_.blog-image-credit]:mt-1 [&_figcaption_.blog-image-credit]:block [&_figcaption_.blog-image-credit]:text-xs [&_figcaption_.blog-image-credit]:tracking-wide [&_figcaption_.blog-image-credit]:text-muted-foreground/80 [&_figcaption>span:not(.blog-image-credit)]:ml-2 [&_figcaption>span:not(.blog-image-credit)]:border-l [&_figcaption>span:not(.blog-image-credit)]:border-[var(--blog-accent-border)] [&_figcaption>span:not(.blog-image-credit)]:pl-2 [&_figcaption>span:not(.blog-image-credit)]:text-xs [&_figcaption>span:not(.blog-image-credit)]:tracking-wide [&_figcaption>span:not(.blog-image-credit)]:text-muted-foreground/80 max-w-none"
260
373
  >
261
374
  <MDXRemote
262
375
  source={article.content}
@@ -290,7 +403,15 @@ export default function ArticlePage({
290
403
  Written by
291
404
  </p>
292
405
  <h2 className="text-foreground flex flex-wrap items-center gap-2 text-xl font-semibold">
293
- {article.author.name}
406
+ <Link
407
+ href={buildAuthorPath(
408
+ normalizedBasePath,
409
+ article.author.slug,
410
+ )}
411
+ className="transition-colors hover:text-[var(--blog-accent)] hover:no-underline"
412
+ >
413
+ {article.author.name}
414
+ </Link>
294
415
  {article.author.isGuest && (
295
416
  <Badge variant="outline" className="text-xs">
296
417
  Guest Author
@@ -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
+ });