@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 +22 -28
- package/dist/app/api/blog/publish/route.ts +8 -4
- package/dist/app/api/blog/slugs/[slug]/route.ts +8 -5
- package/dist/app/api/blog/unpublish/route.ts +8 -4
- package/dist/app/api/blog/version/route.ts +8 -4
- package/dist/app/blog/[slug]/page.tsx +7 -1
- package/dist/app/blog/authors/[slug]/page.test.ts +3 -0
- package/dist/app/blog/authors/[slug]/page.tsx +11 -2
- package/dist/app/blog/page.test.ts +45 -0
- package/dist/app/blog/page.tsx +66 -12
- package/dist/components/ArticleReadingProgress.tsx +118 -87
- package/dist/components/AuthorPages.test.ts +3 -0
- package/dist/components/AuthorPages.tsx +10 -2
- package/dist/components/TopicArchivePages.test.ts +79 -0
- package/dist/components/TopicArchivePages.tsx +16 -3
- package/dist/index.d.mts +4 -410
- package/dist/index.d.ts +4 -410
- package/dist/index.js +9 -84
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +9 -84
- package/dist/index.mjs.map +1 -1
- package/dist/lib/article-ui.ts +3 -0
- package/dist/lib/auth.ts +6 -2
- package/dist/lib/sitemap.ts +115 -7
- package/dist/sitemap-BPTdXb9C.d.ts +422 -0
- package/dist/sitemap-Dz9DB7py.d.mts +422 -0
- package/dist/sitemap.d.mts +2 -0
- package/dist/sitemap.d.ts +2 -0
- package/dist/sitemap.js +6 -0
- package/dist/sitemap.js.map +1 -0
- package/dist/sitemap.mjs +6 -0
- package/dist/sitemap.mjs.map +1 -0
- package/package.json +6 -1
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 {
|
|
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
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
{
|
|
468
|
-
|
|
469
|
-
|
|
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 {
|
|
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
|
|
36
|
-
const
|
|
37
|
-
const rateLimitCheck = await enforceRateLimit(
|
|
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 {
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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 {
|
|
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
|
|
32
|
-
const
|
|
33
|
-
const rateLimitCheck = await enforceRateLimit(
|
|
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 {
|
|
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
|
|
71
|
-
const
|
|
72
|
-
const rateLimitCheck = await enforceRateLimit(
|
|
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
|
|
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 {
|
|
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
|
|
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, {
|
package/dist/app/blog/page.tsx
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
<
|
|
285
|
+
<TopicChip
|
|
252
286
|
key={topic.id}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
<
|
|
337
|
+
<TopicChip
|
|
305
338
|
key={topic.id}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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 {
|
|
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
|
|
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">
|