@xexr/blog-platform 1.0.10 → 1.0.12

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
@@ -237,7 +237,7 @@ export default async function ArticlePage({
237
237
  }
238
238
  ```
239
239
 
240
- > **Note**: The `navigation` prop is optional. You can omit `previousArticle` and `nextArticle` if you don't want article navigation links.
240
+ > **Note**: The `navigation` prop is optional. You can omit `previousArticle` and `nextArticle` if you don't want article navigation links. Bloggy can also publish a site-scoped `newsletter` configuration; pass it through to `BlogHomePage` or `BlogArticlePage` if you are not using the article/site objects returned by Bloggy storage directly. The newsletter form posts back to Bloggy's `/api/newsletter/subscribe` endpoint so subscribers are captured consistently across host apps.
241
241
 
242
242
  **`app/blog/authors/[slug]/page.tsx`** - Author profile pages:
243
243
 
@@ -1,7 +1,11 @@
1
1
  import type { NextRequest, NextResponse } from "next/server";
2
2
  import { getStorage } from "@/lib/database";
3
3
  import { getBlogPlatformVersion, parseVersionHeader } from "@/lib/version";
4
- import { requireAuth, authenticateRequest } from "@/lib/auth";
4
+ import {
5
+ requireAuth,
6
+ authenticateRequest,
7
+ createTokenFingerprint,
8
+ } from "@/lib/auth";
5
9
  import { enforceRateLimit, getRateLimitHeaders } from "@/lib/ratelimit";
6
10
  import { createCorsResponse, handleCorsOptions } from "@/lib/cors";
7
11
  import type {
@@ -32,9 +36,9 @@ export async function POST(
32
36
  return createCorsResponse(request, authCheck.error!, { status: 401 });
33
37
  }
34
38
 
35
- // Apply rate limiting using the authentication token
36
- const token = auth.token ?? "anonymous";
37
- const rateLimitCheck = await enforceRateLimit(token);
39
+ // Apply rate limiting without exposing raw authentication tokens to Redis.
40
+ const tokenFingerprint = createTokenFingerprint(auth.token ?? "anonymous");
41
+ const rateLimitCheck = await enforceRateLimit(tokenFingerprint);
38
42
  if (!rateLimitCheck.allowed) {
39
43
  // Extract rate limit info from error response, with safe fallback
40
44
  const rateLimitData =
@@ -1,7 +1,11 @@
1
1
  import { type NextRequest, NextResponse } from "next/server";
2
2
  import { getStorage } from "@/lib/database";
3
3
  import { getBlogPlatformVersion, parseVersionHeader } from "@/lib/version";
4
- import { requireAuth, authenticateRequest } from "@/lib/auth";
4
+ import {
5
+ requireAuth,
6
+ authenticateRequest,
7
+ createTokenFingerprint,
8
+ } from "@/lib/auth";
5
9
  import { enforceRateLimit, getRateLimitHeaders } from "@/lib/ratelimit";
6
10
  import { createCorsResponse, handleCorsOptions } from "@/lib/cors";
7
11
  import { isValidSlug } from "@/app/api/blog/publish/route"; // Reuse slug validation from publish
@@ -29,10 +33,9 @@ export async function GET(
29
33
  return NextResponse.json(authCheck.error!, { status: 401 });
30
34
  }
31
35
 
32
- const token = auth.token ?? "anonymous";
33
-
34
- // Rate limiting (same as publish: 5/min per token)
35
- const rateLimitCheck = await enforceRateLimit(token);
36
+ // Rate limiting (same as publish: 5/min per token fingerprint)
37
+ const tokenFingerprint = createTokenFingerprint(auth.token ?? "anonymous");
38
+ const rateLimitCheck = await enforceRateLimit(tokenFingerprint);
36
39
  if (!rateLimitCheck.allowed) {
37
40
  const rateLimitData = (rateLimitCheck.errorResponse.error?.details
38
41
  ?.rateLimit as {
@@ -1,7 +1,11 @@
1
1
  import { type NextRequest, NextResponse } from "next/server";
2
2
  import { getStorage } from "@/lib/database";
3
3
  import { getBlogPlatformVersion, parseVersionHeader } from "@/lib/version";
4
- import { requireAuth, authenticateRequest } from "@/lib/auth";
4
+ import {
5
+ requireAuth,
6
+ authenticateRequest,
7
+ createTokenFingerprint,
8
+ } from "@/lib/auth";
5
9
  import { enforceRateLimit, getRateLimitHeaders } from "@/lib/ratelimit";
6
10
  import { createCorsResponse, handleCorsOptions } from "@/lib/cors";
7
11
  import type { UnpublishPayload, PublishResult } from "@/types/contracts";
@@ -28,9 +32,9 @@ export async function POST(
28
32
  return NextResponse.json(authCheck.error!, { status: 401 });
29
33
  }
30
34
 
31
- // Apply rate limiting using the authentication token
32
- const token = auth.token ?? "anonymous";
33
- const rateLimitCheck = await enforceRateLimit(token);
35
+ // Apply rate limiting without exposing raw authentication tokens to Redis.
36
+ const tokenFingerprint = createTokenFingerprint(auth.token ?? "anonymous");
37
+ const rateLimitCheck = await enforceRateLimit(tokenFingerprint);
34
38
  if (!rateLimitCheck.allowed) {
35
39
  // Extract rate limit info from error response, with safe fallback
36
40
  const rateLimitData =
@@ -6,7 +6,11 @@ import {
6
6
  type VersionCompatibilityResult,
7
7
  type VersionInfo,
8
8
  } from "@/lib/version";
9
- import { requireAuth, authenticateRequest } from "@/lib/auth";
9
+ import {
10
+ requireAuth,
11
+ authenticateRequest,
12
+ createTokenFingerprint,
13
+ } from "@/lib/auth";
10
14
  import { enforceRateLimit, getRateLimitHeaders } from "@/lib/ratelimit";
11
15
  import { createCorsResponse, handleCorsOptions } from "@/lib/cors";
12
16
 
@@ -67,9 +71,9 @@ export async function GET(
67
71
  return createCorsResponse(request, errorResponse, { status: 401 });
68
72
  }
69
73
 
70
- // Apply rate limiting using the authentication token
71
- const token = auth.token ?? "anonymous";
72
- const rateLimitCheck = await enforceRateLimit(token);
74
+ // Apply rate limiting without exposing raw authentication tokens to Redis.
75
+ const tokenFingerprint = createTokenFingerprint(auth.token ?? "anonymous");
76
+ const rateLimitCheck = await enforceRateLimit(tokenFingerprint);
73
77
  if (!rateLimitCheck.allowed) {
74
78
  // Extract rate limit info from error response, with safe fallback
75
79
  const rateLimitData =
@@ -1,8 +1,11 @@
1
1
  import type { ReactElement } from "react";
2
- import type { PublishedArticle } from "../../../types/contracts";
2
+ import type {
3
+ PublishedArticle,
4
+ PublishedNewsletterSettings,
5
+ } from "../../../types/contracts";
3
6
  import type { BlogTheme } from "../../../lib/article-ui";
4
7
 
5
- interface ArticlePageProps {
8
+ export interface ArticlePageProps {
6
9
  article: PublishedArticle;
7
10
  className?: string;
8
11
  navigation?: {
@@ -31,6 +34,8 @@ interface ArticlePageProps {
31
34
  href: string;
32
35
  label: string;
33
36
  };
37
+ newsletter?: PublishedNewsletterSettings;
38
+ newsletterPreviewMode?: boolean;
34
39
  relatedArticles?: PublishedArticle[];
35
40
  showShareLinks?: boolean;
36
41
  }
@@ -5,6 +5,7 @@ import { MDXRemote } from "next-mdx-remote-client/rsc";
5
5
  import {
6
6
  type PublishedArticle,
7
7
  type PublishedAuthor,
8
+ type PublishedNewsletterSettings,
8
9
  } from "../../../types/contracts";
9
10
  import { cn } from "../../../lib/utils";
10
11
  import { ArrowLeft, ArrowUpRight, Globe, Mail } from "lucide-react";
@@ -25,14 +26,16 @@ import {
25
26
  buildBlogPath,
26
27
  normalizeBlogBasePath,
27
28
  } from "../../../lib/urls";
29
+ import { NewsletterSignup } from "../../../components/NewsletterSignup";
28
30
  import {
31
+ BLOG_PAGE_TITLE_GRADIENT_CLASS,
29
32
  type BlogTheme,
30
33
  extractTableOfContents,
31
34
  formatArticleDateMeta,
32
35
  getBlogThemeStyle,
33
36
  } from "../../../lib/article-ui";
34
37
 
35
- interface ArticlePageProps {
38
+ export interface ArticlePageProps {
36
39
  article: PublishedArticle;
37
40
  className?: string;
38
41
  /** Optional navigation links */
@@ -65,6 +68,10 @@ interface ArticlePageProps {
65
68
  href: string;
66
69
  label: string;
67
70
  };
71
+ /** Optional Bloggy-owned newsletter capture configuration. */
72
+ newsletter?: PublishedNewsletterSettings;
73
+ /** Render interactive preview success without persisting subscribers. */
74
+ newsletterPreviewMode?: boolean;
68
75
  /** Optional related articles supplied by the host site. */
69
76
  relatedArticles?: PublishedArticle[];
70
77
  /** Whether to render static article share links. */
@@ -133,6 +140,8 @@ export default function ArticlePage({
133
140
  basePath = "/blog",
134
141
  theme,
135
142
  postArticleCta,
143
+ newsletter,
144
+ newsletterPreviewMode = false,
136
145
  relatedArticles = [],
137
146
  showShareLinks = true,
138
147
  }: ArticlePageProps) {
@@ -146,6 +155,7 @@ export default function ArticlePage({
146
155
  const articleUrl =
147
156
  article.meta.canonicalUrl ??
148
157
  buildBlogPath(normalizedBasePath, article.slug);
158
+ const newsletterSettings = newsletter ?? article.site.newsletter;
149
159
 
150
160
  // Use the featured image URL provided by Bloggy (hosted on Bloggy's domain)
151
161
  const imageUrl = article.featuredImage;
@@ -227,7 +237,12 @@ export default function ArticlePage({
227
237
  {/* Article Header */}
228
238
  <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">
229
239
  {/* Title */}
230
- <h1 className="article-title bg-linear-to-br from-[var(--blog-accent-hover)] via-[var(--blog-text-accent)] to-fuchsia-200 bg-clip-text px-4 text-2xl leading-tight font-light tracking-tight text-balance text-transparent sm:px-0 sm:text-3xl md:text-4xl lg:text-5xl xl:text-6xl">
240
+ <h1
241
+ className={cn(
242
+ "article-title px-4 text-2xl leading-tight font-light tracking-tight text-balance sm:px-0 sm:text-3xl md:text-4xl lg:text-5xl xl:text-6xl",
243
+ BLOG_PAGE_TITLE_GRADIENT_CLASS,
244
+ )}
245
+ >
231
246
  {article.title}
232
247
  </h1>
233
248
 
@@ -431,11 +446,22 @@ export default function ArticlePage({
431
446
  )}
432
447
 
433
448
  {((postArticleCta ?? false) ||
449
+ (newsletterSettings?.enabled ?? false) ||
434
450
  showShareLinks ||
435
451
  relatedArticles.length > 0) && (
436
452
  <div className="mx-auto mt-12 max-w-4xl space-y-8">
437
453
  <Separator />
438
454
  {postArticleCta && <PostArticleCta cta={postArticleCta} />}
455
+ {newsletterSettings?.enabled && (
456
+ <NewsletterSignup
457
+ siteId={article.site.id}
458
+ settings={newsletterSettings}
459
+ sourceSurface="article"
460
+ sourceArticleSlug={article.slug}
461
+ sourceUrl={articleUrl}
462
+ previewMode={newsletterPreviewMode}
463
+ />
464
+ )}
439
465
  {showShareLinks && (
440
466
  <ArticleShareLinks title={article.title} url={articleUrl} />
441
467
  )}
@@ -70,6 +70,9 @@ describe("AuthorPage", () => {
70
70
  );
71
71
 
72
72
  expect(html).toContain("Jane Doe");
73
+ expect(html).toContain("bg-linear-to-br");
74
+ expect(html).toContain("via-[var(--blog-text-accent)]");
75
+ expect(html).toContain("to-fuchsia-200");
73
76
  expect(html).toContain("Writes about product systems.");
74
77
  expect(html).toContain("Guest Author");
75
78
  expect(html).not.toContain("she/her");
@@ -17,7 +17,11 @@ import {
17
17
  buildAuthorPath,
18
18
  normalizeBlogBasePath,
19
19
  } from "../../../../lib/urls";
20
- import { type BlogTheme, getBlogThemeStyle } from "../../../../lib/article-ui";
20
+ import {
21
+ BLOG_PAGE_TITLE_GRADIENT_CLASS,
22
+ type BlogTheme,
23
+ getBlogThemeStyle,
24
+ } from "../../../../lib/article-ui";
21
25
  import type {
22
26
  PublishedArticle,
23
27
  PublishedAuthor,
@@ -96,7 +100,12 @@ export default function AuthorPage({
96
100
  </Badge>
97
101
  )}
98
102
  </div>
99
- <h1 className="text-foreground text-4xl leading-tight font-semibold text-balance sm:text-5xl">
103
+ <h1
104
+ className={cn(
105
+ "text-4xl leading-tight font-light text-balance sm:text-5xl",
106
+ BLOG_PAGE_TITLE_GRADIENT_CLASS,
107
+ )}
108
+ >
100
109
  {author.name}
101
110
  </h1>
102
111
  </div>
@@ -0,0 +1,59 @@
1
+ import type { ReactElement } from "react";
2
+ import type {
3
+ PublishedArticle,
4
+ PublishedNewsletterSettings,
5
+ PublishedSite,
6
+ } from "../../types/contracts";
7
+ import type { BlogTheme } from "../../lib/article-ui";
8
+
9
+ export interface BlogHomePageProps {
10
+ articles: PublishedArticle[];
11
+ siteName?: string;
12
+ siteTagline?: string;
13
+ className?: string;
14
+ site?: PublishedSite;
15
+ includeStructuredData?: boolean;
16
+ basePath?: string;
17
+ pagination?: {
18
+ currentPage: number;
19
+ totalPages: number;
20
+ baseUrl?: string;
21
+ };
22
+ theme?: BlogTheme;
23
+ newsletter?: PublishedNewsletterSettings;
24
+ newsletterPreviewMode?: boolean;
25
+ }
26
+
27
+ export default function BlogHomePage(props: BlogHomePageProps): ReactElement;
28
+
29
+ export function generateBlogHomeMetadata(
30
+ siteName?: string,
31
+ siteTagline?: string,
32
+ siteUrl?: string,
33
+ pagination?: {
34
+ currentPage: number;
35
+ totalPages: number;
36
+ },
37
+ basePath?: string,
38
+ ): {
39
+ title: string;
40
+ description: string;
41
+ openGraph: {
42
+ title: string;
43
+ description: string;
44
+ type: string;
45
+ url?: string;
46
+ };
47
+ twitter: {
48
+ card: string;
49
+ title: string;
50
+ description: string;
51
+ };
52
+ alternates: {
53
+ canonical?: string;
54
+ };
55
+ other: {
56
+ prev?: string;
57
+ next?: string;
58
+ };
59
+ };
@@ -0,0 +1,128 @@
1
+ import React from "react";
2
+ import { renderToPipeableStream } from "react-dom/server";
3
+ import { PassThrough } from "stream";
4
+ import { describe, expect, it } from "vitest";
5
+
6
+ import BlogHomePage from "./page";
7
+ import type { PublishedArticle } from "../../types/contracts";
8
+
9
+ const article: PublishedArticle = {
10
+ id: "article-1",
11
+ slug: "useful-routines",
12
+ title: "Useful routines",
13
+ excerpt: "A practical guide to keeping routines useful.",
14
+ content: "Routines work best when they are easy to maintain.",
15
+ readingSeconds: 360,
16
+ wordCount: 900,
17
+ publishedAt: "2026-05-06T12:00:00.000Z",
18
+ updatedAt: "2026-05-07T12:00:00.000Z",
19
+ author: null,
20
+ site: {
21
+ id: "site-1",
22
+ name: "Example Blog",
23
+ tagline: "Useful writing",
24
+ description: "Articles about useful systems.",
25
+ domain: "example.com",
26
+ toneAdjectives: [],
27
+ locale: "en",
28
+ },
29
+ meta: {
30
+ canonicalUrl: "https://example.com/blog/useful-routines",
31
+ keywords: [],
32
+ },
33
+ version: 1,
34
+ };
35
+
36
+ describe("BlogHomePage", () => {
37
+ it("uses the article title gradient for the blog home title and renders browse links", async () => {
38
+ const html = await renderToHtml(
39
+ React.createElement(BlogHomePage, {
40
+ articles: [article],
41
+ siteName: "Example Blog",
42
+ siteTagline: "Useful writing",
43
+ basePath: "/new-blog",
44
+ }),
45
+ );
46
+
47
+ expect(html).toContain("Example Blog");
48
+ expect(html).toContain("bg-linear-to-br");
49
+ expect(html).toContain("via-[var(--blog-text-accent)]");
50
+ expect(html).toContain("to-fuchsia-200");
51
+ expect(html).toContain('aria-label="Browse blog"');
52
+ expect(html).toContain('href="/new-blog/topics"');
53
+ expect(html).toContain('href="/new-blog/authors"');
54
+ });
55
+
56
+ it("links article topic chips to their topic archive pages", async () => {
57
+ const html = await renderToHtml(
58
+ React.createElement(BlogHomePage, {
59
+ articles: [
60
+ {
61
+ ...article,
62
+ topics: [
63
+ {
64
+ id: "topic-1",
65
+ name: "Recurring work and routines",
66
+ slug: "recurring-work-and-routines",
67
+ isPrimary: true,
68
+ },
69
+ ],
70
+ },
71
+ ],
72
+ basePath: "/new-blog",
73
+ }),
74
+ );
75
+
76
+ expect(html).toContain(
77
+ 'href="/new-blog/topics/recurring-work-and-routines"',
78
+ );
79
+ expect(html).toContain("Recurring work and routines");
80
+ });
81
+
82
+ it("renders the newsletter form when configured", async () => {
83
+ const html = await renderToHtml(
84
+ React.createElement(BlogHomePage, {
85
+ articles: [article],
86
+ siteName: "Example Blog",
87
+ newsletter: {
88
+ enabled: true,
89
+ title: "Get product updates",
90
+ description: "Follow new articles and product notes.",
91
+ buttonLabel: "Get updates",
92
+ captureBaseUrl: "https://bloggy.example.com",
93
+ publicSubscribeToken: "public_subscribe_token",
94
+ },
95
+ }),
96
+ );
97
+
98
+ expect(html).toContain("Get product updates");
99
+ expect(html).toContain("Follow new articles and product notes.");
100
+ expect(html).toContain('type="email"');
101
+ expect(html).toContain('name="name"');
102
+ expect(html.indexOf("Get product updates")).toBeGreaterThan(
103
+ html.indexOf("Useful routines"),
104
+ );
105
+ });
106
+ });
107
+
108
+ function renderToHtml(element: React.ReactElement): Promise<string> {
109
+ return new Promise((resolve, reject) => {
110
+ let html = "";
111
+ const stream = new PassThrough();
112
+ stream.setEncoding("utf8");
113
+ stream.on("data", (chunk: string) => {
114
+ html += chunk;
115
+ });
116
+ stream.on("end", () => resolve(html));
117
+ stream.on("error", reject);
118
+
119
+ const { pipe } = renderToPipeableStream(element, {
120
+ onAllReady() {
121
+ pipe(stream);
122
+ },
123
+ onError(error) {
124
+ reject(error instanceof Error ? error : new Error(String(error)));
125
+ },
126
+ });
127
+ });
128
+ }
@@ -4,7 +4,9 @@ import Image from "next/image";
4
4
  import {
5
5
  type PublishedArticle,
6
6
  type PublishedAuthor,
7
+ type PublishedNewsletterSettings,
7
8
  type PublishedSite,
9
+ type PublishedTopic,
8
10
  } from "../../types/contracts";
9
11
  import { cn } from "../../lib/utils";
10
12
  import Pagination from "../../components/Pagination";
@@ -18,7 +20,12 @@ import {
18
20
  normalizeBlogBasePath,
19
21
  } from "../../lib/urls";
20
22
  import { formatDistanceToNow } from "date-fns";
21
- import { type BlogTheme, getBlogThemeStyle } from "../../lib/article-ui";
23
+ import {
24
+ BLOG_PAGE_TITLE_GRADIENT_CLASS,
25
+ type BlogTheme,
26
+ getBlogThemeStyle,
27
+ } from "../../lib/article-ui";
28
+ import { NewsletterSignup } from "../../components/NewsletterSignup";
22
29
 
23
30
  interface BlogHomePageProps {
24
31
  articles: PublishedArticle[];
@@ -39,6 +46,10 @@ interface BlogHomePageProps {
39
46
  };
40
47
  /** Optional theme values for host-site brand integration. */
41
48
  theme?: BlogTheme;
49
+ /** Optional Bloggy-owned newsletter capture configuration. */
50
+ newsletter?: PublishedNewsletterSettings;
51
+ /** Render interactive preview success without persisting subscribers. */
52
+ newsletterPreviewMode?: boolean;
42
53
  }
43
54
 
44
55
  /**
@@ -58,8 +69,12 @@ export default function BlogHomePage({
58
69
  basePath = "/blog",
59
70
  pagination,
60
71
  theme,
72
+ newsletter,
73
+ newsletterPreviewMode = false,
61
74
  }: BlogHomePageProps) {
62
75
  const normalizedBasePath = normalizeBlogBasePath(basePath) || "/";
76
+ const newsletterSettings = newsletter ?? site?.newsletter;
77
+ const newsletterSiteId = site?.id ?? articles[0]?.site.id;
63
78
 
64
79
  return (
65
80
  <div
@@ -72,7 +87,12 @@ export default function BlogHomePage({
72
87
  {/* Site Header */}
73
88
  {siteName && (
74
89
  <header className="blog-header mb-8 text-center sm:mb-12 lg:mb-16">
75
- <h1 className="blog-title mb-2 bg-linear-to-r from-[var(--blog-accent-hover)] to-[var(--blog-accent)] bg-clip-text text-3xl font-semibold tracking-tight text-balance text-transparent sm:text-4xl lg:text-5xl">
90
+ <h1
91
+ className={cn(
92
+ "blog-title mb-2 text-3xl font-light tracking-tight text-balance sm:text-4xl lg:text-5xl",
93
+ BLOG_PAGE_TITLE_GRADIENT_CLASS,
94
+ )}
95
+ >
76
96
  {siteName}
77
97
  </h1>
78
98
  {siteTagline && (
@@ -80,6 +100,30 @@ export default function BlogHomePage({
80
100
  {siteTagline}
81
101
  </p>
82
102
  )}
103
+ <nav
104
+ aria-label="Browse blog"
105
+ className="mt-5 flex flex-wrap items-center justify-center gap-2 text-sm"
106
+ >
107
+ <Link
108
+ href={normalizedBasePath}
109
+ aria-current="page"
110
+ className="rounded-full border border-[var(--blog-accent-border)] bg-[var(--blog-accent-bg)] px-3 py-1.5 font-medium text-[var(--blog-text-accent)] transition-colors hover:border-[var(--blog-text-accent)] hover:text-[var(--blog-text-accent-hover)] hover:no-underline"
111
+ >
112
+ All articles
113
+ </Link>
114
+ <Link
115
+ href={buildBlogPath(normalizedBasePath, "topics")}
116
+ className="text-muted-foreground rounded-full border border-transparent px-3 py-1.5 font-medium transition-colors hover:border-[var(--blog-accent-border)] hover:text-[var(--blog-accent)] hover:no-underline"
117
+ >
118
+ Topics
119
+ </Link>
120
+ <Link
121
+ href={buildBlogPath(normalizedBasePath, "authors")}
122
+ className="text-muted-foreground rounded-full border border-transparent px-3 py-1.5 font-medium transition-colors hover:border-[var(--blog-accent-border)] hover:text-[var(--blog-accent)] hover:no-underline"
123
+ >
124
+ Authors
125
+ </Link>
126
+ </nav>
83
127
  </header>
84
128
  )}
85
129
 
@@ -119,6 +163,16 @@ export default function BlogHomePage({
119
163
  )}
120
164
  </main>
121
165
 
166
+ {newsletterSettings?.enabled && newsletterSiteId && (
167
+ <NewsletterSignup
168
+ siteId={newsletterSiteId}
169
+ settings={newsletterSettings}
170
+ sourceSurface="blog_home"
171
+ previewMode={newsletterPreviewMode}
172
+ className="mt-10 sm:mt-12"
173
+ />
174
+ )}
175
+
122
176
  {/* JSON-LD Structured Data */}
123
177
  {includeStructuredData &&
124
178
  site &&
@@ -228,12 +282,11 @@ function ArticlePreview({
228
282
  {(article.topics ?? []).length > 0 && (
229
283
  <div className="flex flex-wrap gap-2">
230
284
  {(article.topics ?? []).map((topic) => (
231
- <span
285
+ <TopicChip
232
286
  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>
287
+ topic={topic}
288
+ basePath={basePath}
289
+ />
237
290
  ))}
238
291
  </div>
239
292
  )}
@@ -281,12 +334,11 @@ function ArticlePreview({
281
334
  {(article.topics ?? []).length > 0 && (
282
335
  <div className="flex flex-wrap gap-2">
283
336
  {(article.topics ?? []).map((topic) => (
284
- <span
337
+ <TopicChip
285
338
  key={topic.id}
286
- className="rounded-full border border-[var(--blog-accent-border)] px-2.5 py-1 text-xs font-medium text-[var(--blog-text-accent)]"
287
- >
288
- {topic.name}
289
- </span>
339
+ topic={topic}
340
+ basePath={basePath}
341
+ />
290
342
  ))}
291
343
  </div>
292
344
  )}
@@ -315,6 +367,28 @@ function ArticlePreview({
315
367
  );
316
368
  }
317
369
 
370
+ function TopicChip({
371
+ topic,
372
+ basePath,
373
+ }: {
374
+ topic: PublishedTopic;
375
+ basePath: string;
376
+ }) {
377
+ const className =
378
+ "rounded-full border border-[var(--blog-accent-border)] px-2.5 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)]";
379
+ const slug = topic.slug.trim();
380
+
381
+ if (!slug) {
382
+ return <span className={className}>{topic.name}</span>;
383
+ }
384
+
385
+ return (
386
+ <Link href={buildBlogPath(basePath, `topics/${slug}`)} className={className}>
387
+ {topic.name}
388
+ </Link>
389
+ );
390
+ }
391
+
318
392
  function AuthorLink({
319
393
  author,
320
394
  basePath,
@@ -77,6 +77,9 @@ describe("BlogAuthorsIndexPage", () => {
77
77
  );
78
78
 
79
79
  expect(html).toContain("dypt authors");
80
+ expect(html).toContain("bg-linear-to-br");
81
+ expect(html).toContain("via-[var(--blog-text-accent)]");
82
+ expect(html).toContain("to-fuchsia-200");
80
83
  expect(html).toContain("Jane Doe");
81
84
  expect(html).toContain("Alex Stone");
82
85
  expect(html).toContain("/new-blog/authors/jane-doe");
@@ -4,7 +4,10 @@ import Link from "next/link";
4
4
 
5
5
  import type { PublishedArticle, PublishedAuthor } from "../types/contracts";
6
6
  import type { BlogTheme } from "../lib/article-ui";
7
- import { getBlogThemeStyle } from "../lib/article-ui";
7
+ import {
8
+ BLOG_PAGE_TITLE_GRADIENT_CLASS,
9
+ getBlogThemeStyle,
10
+ } from "../lib/article-ui";
8
11
  import { getAuthorsFromArticles } from "../lib/authors";
9
12
  import { buildAuthorPath, normalizeBlogBasePath } from "../lib/urls";
10
13
  import { cn } from "../lib/utils";
@@ -44,7 +47,12 @@ export function BlogAuthorsIndexPage({
44
47
  >
45
48
  Back to all articles
46
49
  </Link>
47
- <h1 className="text-4xl font-semibold tracking-tight text-balance sm:text-5xl">
50
+ <h1
51
+ className={cn(
52
+ "text-4xl font-light tracking-tight text-balance sm:text-5xl",
53
+ BLOG_PAGE_TITLE_GRADIENT_CLASS,
54
+ )}
55
+ >
48
56
  {title}
49
57
  </h1>
50
58
  <p className="text-muted-foreground mx-auto max-w-2xl text-lg text-pretty">