@xexr/blog-platform 1.0.11 → 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/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/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 +2 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +10 -10
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +10 -10
- package/dist/index.mjs.map +1 -1
- package/dist/lib/article-ui.ts +3 -0
- package/dist/lib/auth.ts +6 -2
- package/package.json +1 -1
|
@@ -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,
|
|
@@ -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">
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
BlogTopicArchivePage,
|
|
7
|
+
BlogTopicsIndexPage,
|
|
8
|
+
} from "./TopicArchivePages";
|
|
9
|
+
import type { PublishedArticle, PublishedSite } from "../types/contracts";
|
|
10
|
+
|
|
11
|
+
const site: PublishedSite = {
|
|
12
|
+
id: "site-1",
|
|
13
|
+
name: "Test Blog",
|
|
14
|
+
tagline: "Testing topic pages",
|
|
15
|
+
description: "A test blog",
|
|
16
|
+
domain: "example.com",
|
|
17
|
+
toneAdjectives: [],
|
|
18
|
+
locale: "en",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const article: PublishedArticle = {
|
|
22
|
+
id: "article-1",
|
|
23
|
+
slug: "useful-routines",
|
|
24
|
+
title: "Useful routines",
|
|
25
|
+
excerpt: "A practical guide to keeping routines useful.",
|
|
26
|
+
content: "# Article",
|
|
27
|
+
readingSeconds: 180,
|
|
28
|
+
wordCount: 500,
|
|
29
|
+
publishedAt: "2026-05-01T10:00:00.000Z",
|
|
30
|
+
updatedAt: "2026-05-01T10:00:00.000Z",
|
|
31
|
+
author: null,
|
|
32
|
+
site,
|
|
33
|
+
topics: [
|
|
34
|
+
{
|
|
35
|
+
id: "topic-1",
|
|
36
|
+
name: "Recurring work and routines",
|
|
37
|
+
slug: "recurring-work-and-routines",
|
|
38
|
+
description: "Systems for routines that repeat.",
|
|
39
|
+
isPrimary: true,
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
meta: {
|
|
43
|
+
keywords: ["test"],
|
|
44
|
+
canonicalUrl: "https://example.com/blog/useful-routines",
|
|
45
|
+
},
|
|
46
|
+
version: 1,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
describe("Blog topic archive pages", () => {
|
|
50
|
+
it("uses the article title gradient on the all topics heading", () => {
|
|
51
|
+
const html = renderToStaticMarkup(
|
|
52
|
+
React.createElement(BlogTopicsIndexPage, {
|
|
53
|
+
articles: [article],
|
|
54
|
+
basePath: "/new-blog",
|
|
55
|
+
title: "dypt topics",
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
expect(html).toContain("dypt topics");
|
|
60
|
+
expect(html).toContain("bg-linear-to-br");
|
|
61
|
+
expect(html).toContain("via-[var(--blog-text-accent)]");
|
|
62
|
+
expect(html).toContain("to-fuchsia-200");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("uses the article title gradient on a topic archive heading", () => {
|
|
66
|
+
const html = renderToStaticMarkup(
|
|
67
|
+
React.createElement(BlogTopicArchivePage, {
|
|
68
|
+
articles: [article],
|
|
69
|
+
topicSlug: "recurring-work-and-routines",
|
|
70
|
+
basePath: "/new-blog",
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
expect(html).toContain("Recurring work and routines");
|
|
75
|
+
expect(html).toContain("bg-linear-to-br");
|
|
76
|
+
expect(html).toContain("via-[var(--blog-text-accent)]");
|
|
77
|
+
expect(html).toContain("to-fuchsia-200");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -5,7 +5,10 @@ import { formatDistanceToNow } from "date-fns";
|
|
|
5
5
|
|
|
6
6
|
import type { PublishedArticle, PublishedTopic } from "../types/contracts";
|
|
7
7
|
import type { BlogTheme } from "../lib/article-ui";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
BLOG_PAGE_TITLE_GRADIENT_CLASS,
|
|
10
|
+
getBlogThemeStyle,
|
|
11
|
+
} from "../lib/article-ui";
|
|
9
12
|
import { buildBlogPath, normalizeBlogBasePath } from "../lib/urls";
|
|
10
13
|
import { cn } from "../lib/utils";
|
|
11
14
|
import { getAllTopics, getTopicArchive } from "../lib/topics";
|
|
@@ -53,7 +56,12 @@ export function BlogTopicsIndexPage({
|
|
|
53
56
|
>
|
|
54
57
|
Back to all articles
|
|
55
58
|
</Link>
|
|
56
|
-
<h1
|
|
59
|
+
<h1
|
|
60
|
+
className={cn(
|
|
61
|
+
"text-4xl font-light tracking-tight text-balance sm:text-5xl",
|
|
62
|
+
BLOG_PAGE_TITLE_GRADIENT_CLASS,
|
|
63
|
+
)}
|
|
64
|
+
>
|
|
57
65
|
{title}
|
|
58
66
|
</h1>
|
|
59
67
|
<p className="text-muted-foreground mx-auto max-w-2xl text-lg text-pretty">
|
|
@@ -125,7 +133,12 @@ export function BlogTopicArchivePage({
|
|
|
125
133
|
>
|
|
126
134
|
Back to all topics
|
|
127
135
|
</Link>
|
|
128
|
-
<h1
|
|
136
|
+
<h1
|
|
137
|
+
className={cn(
|
|
138
|
+
"text-4xl font-light tracking-tight text-balance sm:text-5xl",
|
|
139
|
+
BLOG_PAGE_TITLE_GRADIENT_CLASS,
|
|
140
|
+
)}
|
|
141
|
+
>
|
|
129
142
|
{archive?.topic.name ?? "Topic not found"}
|
|
130
143
|
</h1>
|
|
131
144
|
{archive?.topic.description && (
|
package/dist/index.d.mts
CHANGED
|
@@ -464,6 +464,7 @@ interface AuthResult {
|
|
|
464
464
|
/** Additional user/context information */
|
|
465
465
|
context?: Record<string, unknown>;
|
|
466
466
|
}
|
|
467
|
+
declare function createTokenFingerprint(token: string): string;
|
|
467
468
|
/**
|
|
468
469
|
* Default token extraction from Authorization header
|
|
469
470
|
* Supports both "Bearer <token>" and "Token <token>" formats
|
|
@@ -2012,4 +2013,4 @@ declare function OPTIONS(request: NextRequest): Promise<NextResponse>;
|
|
|
2012
2013
|
|
|
2013
2014
|
declare const PACKAGE_VERSION: string;
|
|
2014
2015
|
|
|
2015
|
-
export { ApiKeyAuth, type AuthConfig, type AuthResult, type AuthorArchive, type AuthorArchiveOptions, ArticlePage as BlogArticlePage, AuthorPage as BlogAuthorPage, BlogAuthorsIndexPage, BlogHomePage, type BlogPlatformConfig, type BlogPlatformStorage, type BlogPostingSchema, type BlogSitemapConfig, type BlogTheme, BlogTopicArchivePage, BlogTopicsIndexPage, type BreadcrumbListSchema, Button, type CanonicalUrlOptions, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, DrizzleStorage, EnvAuth, type GeneratedMetadata, InMemoryStorage, type LinkTag, type MetaTag, type MetadataOptions, NEWSLETTER_SUCCESS_MESSAGE, NewsletterSignup, type NewsletterSignupProps, type NextjsSitemapEntry, NoAuth, type OrganizationSchema, PACKAGE_VERSION, Pagination$1 as Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, type PersonSchema, PublishResult, PublishedArticle, PublishedAuthor, PublishedNewsletterSettings, PublishedSite, PublishedTopic, type RateLimitConfig, type RateLimitResult, Pagination as ShadcnPagination, type SitemapChangeFreq, type SitemapData, type SitemapEntry, type SitemapOptions, StructuredDataError, type StructuredDataOptions, type TopicArchive, UninitializedAuth, type WebSiteSchema, autoConfigureRateLimit, bpArticles, bpOperations, buildAuthorPath, buildBlogPath, buttonVariants, checkRateLimit, cn, configureRateLimit, createBlogSitemapRoute, createDynamicBlogSitemap, createRateLimitErrorResponse, createStructuredDataScript, enforceRateLimit, extractKeywordsFromArticle, extractTableOfContents, extractTokenFromHeader, formatArticleDateMeta, generateArticleMetadata, generateArticleStructuredData, generateAuthorPageMetadata, generateAuthorStructuredData, generateBlogHomeMetadata, generateBlogHomeStructuredData, generateBlogSitemapEntries, generateBreadcrumbStructuredData, generateCanonicalUrl, generateCanonicalUrlFromSlug, generateCompleteArticleStructuredData, generateOrganizationStructuredData, generatePaginationMetadata, generateSiteMetadata, generateSitemap, generateSitemapIndex, generateWebSiteStructuredData, getAllTopics, getArticlePrimaryTopic, getAuthConfig, getAuthorArchive, getAuthorsFromArticles, getBaseUrl, getBlogThemeStyle, getDrizzleAdapterSql, getPathPrefixFromBlogUrl, getRateLimitHeaders, getStorage, getTopicArchive, initBlogPlatform, initBlogPlatformDev, initBlogPlatformOnce, initBlogPlatformProd, initEnvAuth, isValidCanonicalUrl, loadAuthorArchive, metadataToHtml, normalizeBlogBasePath, normalizeDomain, normalizeSlug, POST$1 as publishHandler, safeGenerateArticleStructuredData, setAuthConfig, setStorage, shouldSkipAuth, sitemapToXml, GET as slugExistsHandler, OPTIONS as slugExistsOptionsHandler, structuredDataToJsonLd, POST as unpublishHandler, updateArticleCanonicalUrl, usePagination, validateMetadata, validateSitemap, validateStructuredData, GET$1 as versionHandler };
|
|
2016
|
+
export { ApiKeyAuth, type AuthConfig, type AuthResult, type AuthorArchive, type AuthorArchiveOptions, ArticlePage as BlogArticlePage, AuthorPage as BlogAuthorPage, BlogAuthorsIndexPage, BlogHomePage, type BlogPlatformConfig, type BlogPlatformStorage, type BlogPostingSchema, type BlogSitemapConfig, type BlogTheme, BlogTopicArchivePage, BlogTopicsIndexPage, type BreadcrumbListSchema, Button, type CanonicalUrlOptions, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, DrizzleStorage, EnvAuth, type GeneratedMetadata, InMemoryStorage, type LinkTag, type MetaTag, type MetadataOptions, NEWSLETTER_SUCCESS_MESSAGE, NewsletterSignup, type NewsletterSignupProps, type NextjsSitemapEntry, NoAuth, type OrganizationSchema, PACKAGE_VERSION, Pagination$1 as Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, type PersonSchema, PublishResult, PublishedArticle, PublishedAuthor, PublishedNewsletterSettings, PublishedSite, PublishedTopic, type RateLimitConfig, type RateLimitResult, Pagination as ShadcnPagination, type SitemapChangeFreq, type SitemapData, type SitemapEntry, type SitemapOptions, StructuredDataError, type StructuredDataOptions, type TopicArchive, UninitializedAuth, type WebSiteSchema, autoConfigureRateLimit, bpArticles, bpOperations, buildAuthorPath, buildBlogPath, buttonVariants, checkRateLimit, cn, configureRateLimit, createBlogSitemapRoute, createDynamicBlogSitemap, createRateLimitErrorResponse, createStructuredDataScript, createTokenFingerprint, enforceRateLimit, extractKeywordsFromArticle, extractTableOfContents, extractTokenFromHeader, formatArticleDateMeta, generateArticleMetadata, generateArticleStructuredData, generateAuthorPageMetadata, generateAuthorStructuredData, generateBlogHomeMetadata, generateBlogHomeStructuredData, generateBlogSitemapEntries, generateBreadcrumbStructuredData, generateCanonicalUrl, generateCanonicalUrlFromSlug, generateCompleteArticleStructuredData, generateOrganizationStructuredData, generatePaginationMetadata, generateSiteMetadata, generateSitemap, generateSitemapIndex, generateWebSiteStructuredData, getAllTopics, getArticlePrimaryTopic, getAuthConfig, getAuthorArchive, getAuthorsFromArticles, getBaseUrl, getBlogThemeStyle, getDrizzleAdapterSql, getPathPrefixFromBlogUrl, getRateLimitHeaders, getStorage, getTopicArchive, initBlogPlatform, initBlogPlatformDev, initBlogPlatformOnce, initBlogPlatformProd, initEnvAuth, isValidCanonicalUrl, loadAuthorArchive, metadataToHtml, normalizeBlogBasePath, normalizeDomain, normalizeSlug, POST$1 as publishHandler, safeGenerateArticleStructuredData, setAuthConfig, setStorage, shouldSkipAuth, sitemapToXml, GET as slugExistsHandler, OPTIONS as slugExistsOptionsHandler, structuredDataToJsonLd, POST as unpublishHandler, updateArticleCanonicalUrl, usePagination, validateMetadata, validateSitemap, validateStructuredData, GET$1 as versionHandler };
|
package/dist/index.d.ts
CHANGED
|
@@ -464,6 +464,7 @@ interface AuthResult {
|
|
|
464
464
|
/** Additional user/context information */
|
|
465
465
|
context?: Record<string, unknown>;
|
|
466
466
|
}
|
|
467
|
+
declare function createTokenFingerprint(token: string): string;
|
|
467
468
|
/**
|
|
468
469
|
* Default token extraction from Authorization header
|
|
469
470
|
* Supports both "Bearer <token>" and "Token <token>" formats
|
|
@@ -2012,4 +2013,4 @@ declare function OPTIONS(request: NextRequest): Promise<NextResponse>;
|
|
|
2012
2013
|
|
|
2013
2014
|
declare const PACKAGE_VERSION: string;
|
|
2014
2015
|
|
|
2015
|
-
export { ApiKeyAuth, type AuthConfig, type AuthResult, type AuthorArchive, type AuthorArchiveOptions, ArticlePage as BlogArticlePage, AuthorPage as BlogAuthorPage, BlogAuthorsIndexPage, BlogHomePage, type BlogPlatformConfig, type BlogPlatformStorage, type BlogPostingSchema, type BlogSitemapConfig, type BlogTheme, BlogTopicArchivePage, BlogTopicsIndexPage, type BreadcrumbListSchema, Button, type CanonicalUrlOptions, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, DrizzleStorage, EnvAuth, type GeneratedMetadata, InMemoryStorage, type LinkTag, type MetaTag, type MetadataOptions, NEWSLETTER_SUCCESS_MESSAGE, NewsletterSignup, type NewsletterSignupProps, type NextjsSitemapEntry, NoAuth, type OrganizationSchema, PACKAGE_VERSION, Pagination$1 as Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, type PersonSchema, PublishResult, PublishedArticle, PublishedAuthor, PublishedNewsletterSettings, PublishedSite, PublishedTopic, type RateLimitConfig, type RateLimitResult, Pagination as ShadcnPagination, type SitemapChangeFreq, type SitemapData, type SitemapEntry, type SitemapOptions, StructuredDataError, type StructuredDataOptions, type TopicArchive, UninitializedAuth, type WebSiteSchema, autoConfigureRateLimit, bpArticles, bpOperations, buildAuthorPath, buildBlogPath, buttonVariants, checkRateLimit, cn, configureRateLimit, createBlogSitemapRoute, createDynamicBlogSitemap, createRateLimitErrorResponse, createStructuredDataScript, enforceRateLimit, extractKeywordsFromArticle, extractTableOfContents, extractTokenFromHeader, formatArticleDateMeta, generateArticleMetadata, generateArticleStructuredData, generateAuthorPageMetadata, generateAuthorStructuredData, generateBlogHomeMetadata, generateBlogHomeStructuredData, generateBlogSitemapEntries, generateBreadcrumbStructuredData, generateCanonicalUrl, generateCanonicalUrlFromSlug, generateCompleteArticleStructuredData, generateOrganizationStructuredData, generatePaginationMetadata, generateSiteMetadata, generateSitemap, generateSitemapIndex, generateWebSiteStructuredData, getAllTopics, getArticlePrimaryTopic, getAuthConfig, getAuthorArchive, getAuthorsFromArticles, getBaseUrl, getBlogThemeStyle, getDrizzleAdapterSql, getPathPrefixFromBlogUrl, getRateLimitHeaders, getStorage, getTopicArchive, initBlogPlatform, initBlogPlatformDev, initBlogPlatformOnce, initBlogPlatformProd, initEnvAuth, isValidCanonicalUrl, loadAuthorArchive, metadataToHtml, normalizeBlogBasePath, normalizeDomain, normalizeSlug, POST$1 as publishHandler, safeGenerateArticleStructuredData, setAuthConfig, setStorage, shouldSkipAuth, sitemapToXml, GET as slugExistsHandler, OPTIONS as slugExistsOptionsHandler, structuredDataToJsonLd, POST as unpublishHandler, updateArticleCanonicalUrl, usePagination, validateMetadata, validateSitemap, validateStructuredData, GET$1 as versionHandler };
|
|
2016
|
+
export { ApiKeyAuth, type AuthConfig, type AuthResult, type AuthorArchive, type AuthorArchiveOptions, ArticlePage as BlogArticlePage, AuthorPage as BlogAuthorPage, BlogAuthorsIndexPage, BlogHomePage, type BlogPlatformConfig, type BlogPlatformStorage, type BlogPostingSchema, type BlogSitemapConfig, type BlogTheme, BlogTopicArchivePage, BlogTopicsIndexPage, type BreadcrumbListSchema, Button, type CanonicalUrlOptions, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, DrizzleStorage, EnvAuth, type GeneratedMetadata, InMemoryStorage, type LinkTag, type MetaTag, type MetadataOptions, NEWSLETTER_SUCCESS_MESSAGE, NewsletterSignup, type NewsletterSignupProps, type NextjsSitemapEntry, NoAuth, type OrganizationSchema, PACKAGE_VERSION, Pagination$1 as Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, type PersonSchema, PublishResult, PublishedArticle, PublishedAuthor, PublishedNewsletterSettings, PublishedSite, PublishedTopic, type RateLimitConfig, type RateLimitResult, Pagination as ShadcnPagination, type SitemapChangeFreq, type SitemapData, type SitemapEntry, type SitemapOptions, StructuredDataError, type StructuredDataOptions, type TopicArchive, UninitializedAuth, type WebSiteSchema, autoConfigureRateLimit, bpArticles, bpOperations, buildAuthorPath, buildBlogPath, buttonVariants, checkRateLimit, cn, configureRateLimit, createBlogSitemapRoute, createDynamicBlogSitemap, createRateLimitErrorResponse, createStructuredDataScript, createTokenFingerprint, enforceRateLimit, extractKeywordsFromArticle, extractTableOfContents, extractTokenFromHeader, formatArticleDateMeta, generateArticleMetadata, generateArticleStructuredData, generateAuthorPageMetadata, generateAuthorStructuredData, generateBlogHomeMetadata, generateBlogHomeStructuredData, generateBlogSitemapEntries, generateBreadcrumbStructuredData, generateCanonicalUrl, generateCanonicalUrlFromSlug, generateCompleteArticleStructuredData, generateOrganizationStructuredData, generatePaginationMetadata, generateSiteMetadata, generateSitemap, generateSitemapIndex, generateWebSiteStructuredData, getAllTopics, getArticlePrimaryTopic, getAuthConfig, getAuthorArchive, getAuthorsFromArticles, getBaseUrl, getBlogThemeStyle, getDrizzleAdapterSql, getPathPrefixFromBlogUrl, getRateLimitHeaders, getStorage, getTopicArchive, initBlogPlatform, initBlogPlatformDev, initBlogPlatformOnce, initBlogPlatformProd, initEnvAuth, isValidCanonicalUrl, loadAuthorArchive, metadataToHtml, normalizeBlogBasePath, normalizeDomain, normalizeSlug, POST$1 as publishHandler, safeGenerateArticleStructuredData, setAuthConfig, setStorage, shouldSkipAuth, sitemapToXml, GET as slugExistsHandler, OPTIONS as slugExistsOptionsHandler, structuredDataToJsonLd, POST as unpublishHandler, updateArticleCanonicalUrl, usePagination, validateMetadata, validateSitemap, validateStructuredData, GET$1 as versionHandler };
|