@xexr/blog-platform 1.0.11 → 1.0.13

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
@@ -442,37 +442,31 @@ Add blog content to your sitemap for better SEO:
442
442
  **`app/sitemap.ts`**:
443
443
 
444
444
  ```tsx
445
- import { generateBlogSitemapEntries } from "@xexr/blog-platform";
445
+ import { createDynamicBlogSitemap } from "@xexr/blog-platform/sitemap";
446
446
  import type { MetadataRoute } from "next";
447
447
  import { getBlogData } from "@/lib/blog"; // Your blog data fetching
448
448
 
449
- export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
450
- // Your existing static routes
451
- const staticRoutes: MetadataRoute.Sitemap = [
452
- {
453
- url: "https://yoursite.com",
454
- lastModified: new Date(),
455
- changeFrequency: "yearly",
456
- priority: 1,
457
- },
458
- // ... other routes
459
- ];
460
-
461
- // Generate blog entries
462
- const { site, articles, authors } = await getBlogData();
463
- const blogEntries = await generateBlogSitemapEntries(
464
- site,
465
- articles,
466
- authors,
467
- {
468
- includeAuthorPages: true,
469
- changeFrequency: { articles: "monthly" },
470
- priority: { articles: 0.8 },
471
- },
472
- );
473
-
474
- return [...staticRoutes, ...blogEntries];
475
- }
449
+ const staticRoutes: MetadataRoute.Sitemap = [
450
+ {
451
+ url: "https://yoursite.com",
452
+ lastModified: new Date(),
453
+ changeFrequency: "yearly",
454
+ priority: 1,
455
+ },
456
+ // ... other host routes
457
+ ];
458
+
459
+ export default createDynamicBlogSitemap({
460
+ staticRoutes,
461
+ getSite: async () => (await getBlogData()).site,
462
+ getArticles: async () => (await getBlogData()).articles,
463
+ options: {
464
+ includeAuthorPages: true,
465
+ includeTopicPages: true,
466
+ changeFrequency: { articles: "monthly" },
467
+ priority: { articles: 0.8 },
468
+ },
469
+ });
476
470
  ```
477
471
 
478
472
  > 📖 **For detailed sitemap configuration options**, see [SITEMAP-INTEGRATION.md](./SITEMAP-INTEGRATION.md)
@@ -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 =
@@ -28,6 +28,7 @@ import {
28
28
  } from "../../../lib/urls";
29
29
  import { NewsletterSignup } from "../../../components/NewsletterSignup";
30
30
  import {
31
+ BLOG_PAGE_TITLE_GRADIENT_CLASS,
31
32
  type BlogTheme,
32
33
  extractTableOfContents,
33
34
  formatArticleDateMeta,
@@ -236,7 +237,12 @@ export default function ArticlePage({
236
237
  {/* Article Header */}
237
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">
238
239
  {/* Title */}
239
- <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
+ >
240
246
  {article.title}
241
247
  </h1>
242
248
 
@@ -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>
@@ -34,6 +34,51 @@ const article: PublishedArticle = {
34
34
  };
35
35
 
36
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
+
37
82
  it("renders the newsletter form when configured", async () => {
38
83
  const html = await renderToHtml(
39
84
  React.createElement(BlogHomePage, {
@@ -6,6 +6,7 @@ import {
6
6
  type PublishedAuthor,
7
7
  type PublishedNewsletterSettings,
8
8
  type PublishedSite,
9
+ type PublishedTopic,
9
10
  } from "../../types/contracts";
10
11
  import { cn } from "../../lib/utils";
11
12
  import Pagination from "../../components/Pagination";
@@ -19,7 +20,11 @@ import {
19
20
  normalizeBlogBasePath,
20
21
  } from "../../lib/urls";
21
22
  import { formatDistanceToNow } from "date-fns";
22
- 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";
23
28
  import { NewsletterSignup } from "../../components/NewsletterSignup";
24
29
 
25
30
  interface BlogHomePageProps {
@@ -82,7 +87,12 @@ export default function BlogHomePage({
82
87
  {/* Site Header */}
83
88
  {siteName && (
84
89
  <header className="blog-header mb-8 text-center sm:mb-12 lg:mb-16">
85
- <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
+ >
86
96
  {siteName}
87
97
  </h1>
88
98
  {siteTagline && (
@@ -90,6 +100,30 @@ export default function BlogHomePage({
90
100
  {siteTagline}
91
101
  </p>
92
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>
93
127
  </header>
94
128
  )}
95
129
 
@@ -248,12 +282,11 @@ function ArticlePreview({
248
282
  {(article.topics ?? []).length > 0 && (
249
283
  <div className="flex flex-wrap gap-2">
250
284
  {(article.topics ?? []).map((topic) => (
251
- <span
285
+ <TopicChip
252
286
  key={topic.id}
253
- className="rounded-full border border-[var(--blog-accent-border)] px-2.5 py-1 text-xs font-medium text-[var(--blog-text-accent)]"
254
- >
255
- {topic.name}
256
- </span>
287
+ topic={topic}
288
+ basePath={basePath}
289
+ />
257
290
  ))}
258
291
  </div>
259
292
  )}
@@ -301,12 +334,11 @@ function ArticlePreview({
301
334
  {(article.topics ?? []).length > 0 && (
302
335
  <div className="flex flex-wrap gap-2">
303
336
  {(article.topics ?? []).map((topic) => (
304
- <span
337
+ <TopicChip
305
338
  key={topic.id}
306
- className="rounded-full border border-[var(--blog-accent-border)] px-2.5 py-1 text-xs font-medium text-[var(--blog-text-accent)]"
307
- >
308
- {topic.name}
309
- </span>
339
+ topic={topic}
340
+ basePath={basePath}
341
+ />
310
342
  ))}
311
343
  </div>
312
344
  )}
@@ -335,6 +367,28 @@ function ArticlePreview({
335
367
  );
336
368
  }
337
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
+
338
392
  function AuthorLink({
339
393
  author,
340
394
  basePath,
@@ -1,3 +1,7 @@
1
+ "use client";
2
+
3
+ import { useEffect, useMemo } from "react";
4
+
1
5
  import {
2
6
  formatTableOfContentsSummary,
3
7
  getTocItemState,
@@ -18,9 +22,15 @@ export function ArticleReadingProgress({
18
22
  showProgress = true,
19
23
  }: ArticleReadingProgressProps) {
20
24
  const activeItemId = items[0]?.id ?? null;
25
+ const itemIds = useMemo(() => items.map((item) => item.id), [items]);
21
26
 
22
27
  return (
23
28
  <>
29
+ <ArticleReadingProgressController
30
+ contentId={contentId}
31
+ itemIds={itemIds}
32
+ />
33
+
24
34
  {showProgress && (
25
35
  <div
26
36
  data-blog-reading-progress={contentId}
@@ -40,18 +50,118 @@ export function ArticleReadingProgress({
40
50
  activeItemId={activeItemId}
41
51
  variant={variant}
42
52
  />
43
-
44
- {showProgress && (
45
- <script
46
- dangerouslySetInnerHTML={{
47
- __html: getReadingProgressScript(contentId, items),
48
- }}
49
- />
50
- )}
51
53
  </>
52
54
  );
53
55
  }
54
56
 
57
+ function ArticleReadingProgressController({
58
+ contentId,
59
+ itemIds,
60
+ }: {
61
+ contentId: string;
62
+ itemIds: string[];
63
+ }) {
64
+ useEffect(() => {
65
+ let animationFrameId = 0;
66
+ let pendingHashItemId = decodeURIComponent(
67
+ window.location.hash.replace(/^#/, ""),
68
+ );
69
+
70
+ const setActiveItem = (activeItemId: string | null) => {
71
+ const tocLinks = Array.from(
72
+ document.querySelectorAll<HTMLElement>("[data-blog-toc-link]"),
73
+ );
74
+
75
+ for (const link of tocLinks) {
76
+ const isActive = link.getAttribute("data-blog-toc-link") === activeItemId;
77
+ link.setAttribute("data-active", isActive ? "true" : "false");
78
+ if (isActive) {
79
+ link.setAttribute("aria-current", "location");
80
+ } else {
81
+ link.removeAttribute("aria-current");
82
+ }
83
+ }
84
+ };
85
+
86
+ const updateScrollState = () => {
87
+ const contentElement = document.getElementById(contentId);
88
+ if (!contentElement) return;
89
+
90
+ const progressElements = Array.from(
91
+ document.querySelectorAll<HTMLElement>(
92
+ `[data-blog-reading-progress="${contentId}"]`,
93
+ ),
94
+ );
95
+ const contentRect = contentElement.getBoundingClientRect();
96
+ const scrollTop = window.scrollY;
97
+ const contentTop = contentRect.top + scrollTop;
98
+ const readableDistance = contentElement.offsetHeight - window.innerHeight;
99
+ const nextProgress =
100
+ readableDistance <= 0
101
+ ? scrollTop >= contentTop
102
+ ? 100
103
+ : 0
104
+ : ((scrollTop - contentTop) / readableDistance) * 100;
105
+ const progress = normalizeProgress(nextProgress);
106
+
107
+ for (const progressElement of progressElements) {
108
+ progressElement.style.transform = `scaleX(${progress / 100})`;
109
+ progressElement.setAttribute("aria-valuenow", String(Math.round(progress)));
110
+ }
111
+
112
+ const headings = itemIds
113
+ .map((id) => document.getElementById(id))
114
+ .filter((heading): heading is HTMLElement => Boolean(heading));
115
+ if (pendingHashItemId && itemIds.includes(pendingHashItemId)) {
116
+ setActiveItem(pendingHashItemId);
117
+ pendingHashItemId = "";
118
+ return;
119
+ }
120
+
121
+ const activeOffset = Math.max(160, Math.round(window.innerHeight * 0.33));
122
+ const activeHeading =
123
+ headings
124
+ .filter((heading) => heading.getBoundingClientRect().top <= activeOffset)
125
+ .at(-1) ??
126
+ headings[0] ??
127
+ null;
128
+
129
+ setActiveItem(activeHeading ? activeHeading.id : null);
130
+ };
131
+
132
+ const scheduleUpdate = () => {
133
+ window.cancelAnimationFrame(animationFrameId);
134
+ animationFrameId = window.requestAnimationFrame(updateScrollState);
135
+ };
136
+
137
+ const handleHashChange = () => {
138
+ pendingHashItemId = decodeURIComponent(
139
+ window.location.hash.replace(/^#/, ""),
140
+ );
141
+ scheduleUpdate();
142
+ };
143
+
144
+ scheduleUpdate();
145
+ window.addEventListener("scroll", scheduleUpdate, { passive: true });
146
+ window.addEventListener("resize", scheduleUpdate);
147
+ window.addEventListener("hashchange", handleHashChange);
148
+
149
+ return () => {
150
+ window.cancelAnimationFrame(animationFrameId);
151
+ window.removeEventListener("scroll", scheduleUpdate);
152
+ window.removeEventListener("resize", scheduleUpdate);
153
+ window.removeEventListener("hashchange", handleHashChange);
154
+ };
155
+ }, [contentId, itemIds]);
156
+
157
+ return null;
158
+ }
159
+
160
+ function normalizeProgress(value: number): number {
161
+ if (!Number.isFinite(value)) return 0;
162
+ return Math.min(100, Math.max(0, value));
163
+ }
164
+
55
165
  function ArticleTableOfContents({
56
166
  items,
57
167
  activeItemId,
@@ -127,82 +237,3 @@ function ArticleTableOfContents({
127
237
  </aside>
128
238
  );
129
239
  }
130
-
131
- function getReadingProgressScript(contentId: string, items: TocItem[]): string {
132
- return `
133
- (() => {
134
- const contentId = ${JSON.stringify(contentId)};
135
- const itemIds = ${JSON.stringify(items.map((item) => item.id))};
136
- const progressElement = document.querySelector('[data-blog-reading-progress="' + contentId + '"]');
137
- let animationFrameId = 0;
138
- let pendingHashItemId = decodeURIComponent(window.location.hash.replace(/^#/, ''));
139
-
140
- const normalizeProgress = (value) => {
141
- if (!Number.isFinite(value)) return 0;
142
- return Math.min(100, Math.max(0, value));
143
- };
144
-
145
- const setActiveItem = (activeItemId) => {
146
- const tocLinks = Array.from(document.querySelectorAll('[data-blog-toc-link]'));
147
-
148
- for (const link of tocLinks) {
149
- const isActive = link.getAttribute('data-blog-toc-link') === activeItemId;
150
- link.setAttribute('data-active', isActive ? 'true' : 'false');
151
- if (isActive) {
152
- link.setAttribute('aria-current', 'location');
153
- } else {
154
- link.removeAttribute('aria-current');
155
- }
156
- }
157
- };
158
-
159
- const updateScrollState = () => {
160
- const contentElement = document.getElementById(contentId);
161
- if (!contentElement) return;
162
-
163
- const contentRect = contentElement.getBoundingClientRect();
164
- const scrollTop = window.scrollY;
165
- const contentTop = contentRect.top + scrollTop;
166
- const readableDistance = contentElement.offsetHeight - window.innerHeight;
167
- const nextProgress = readableDistance <= 0
168
- ? scrollTop >= contentTop ? 100 : 0
169
- : ((scrollTop - contentTop) / readableDistance) * 100;
170
- const progress = normalizeProgress(nextProgress);
171
-
172
- if (progressElement) {
173
- progressElement.style.transform = 'scaleX(' + progress / 100 + ')';
174
- progressElement.setAttribute('aria-valuenow', String(Math.round(progress)));
175
- }
176
-
177
- const headings = itemIds
178
- .map((id) => document.getElementById(id))
179
- .filter(Boolean);
180
- if (pendingHashItemId && itemIds.includes(pendingHashItemId)) {
181
- setActiveItem(pendingHashItemId);
182
- pendingHashItemId = '';
183
- return;
184
- }
185
-
186
- const activeOffset = Math.max(160, Math.round(window.innerHeight * 0.33));
187
- const activeHeading = headings
188
- .filter((heading) => heading.getBoundingClientRect().top <= activeOffset)
189
- .at(-1) || headings[0] || null;
190
-
191
- setActiveItem(activeHeading ? activeHeading.id : null);
192
- };
193
-
194
- const scheduleUpdate = () => {
195
- window.cancelAnimationFrame(animationFrameId);
196
- animationFrameId = window.requestAnimationFrame(updateScrollState);
197
- };
198
-
199
- scheduleUpdate();
200
- window.addEventListener('scroll', scheduleUpdate, { passive: true });
201
- window.addEventListener('resize', scheduleUpdate);
202
- window.addEventListener('hashchange', () => {
203
- pendingHashItemId = decodeURIComponent(window.location.hash.replace(/^#/, ''));
204
- scheduleUpdate();
205
- });
206
- })();
207
- `;
208
- }
@@ -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">