@xexr/blog-platform 1.0.2 → 1.0.3
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/blog/[slug]/page.tsx +255 -46
- package/dist/app/blog/page.tsx +14 -6
- package/dist/index.d.mts +47 -4
- package/dist/index.d.ts +47 -4
- package/dist/index.js +10 -9
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +10 -9
- package/dist/index.mjs.map +1 -1
- package/package.json +9 -9
|
@@ -3,16 +3,10 @@ import Image from "next/image";
|
|
|
3
3
|
import Link from "next/link";
|
|
4
4
|
import { MDXRemote } from "next-mdx-remote-client/rsc";
|
|
5
5
|
import { type PublishedArticle } from "../../../types/contracts";
|
|
6
|
-
import { format, formatDistanceToNow } from "date-fns";
|
|
7
6
|
import { cn } from "../../../lib/utils";
|
|
8
|
-
import { ArrowLeft } from "lucide-react";
|
|
7
|
+
import { ArrowLeft, ArrowUpRight, Mail, Share2 } from "lucide-react";
|
|
9
8
|
import { Badge } from "../../../components/ui/badge";
|
|
10
|
-
import {
|
|
11
|
-
Card,
|
|
12
|
-
CardContent,
|
|
13
|
-
CardHeader,
|
|
14
|
-
CardTitle,
|
|
15
|
-
} from "../../../components/ui/card";
|
|
9
|
+
import { Card, CardContent } from "../../../components/ui/card";
|
|
16
10
|
import { Separator } from "../../../components/ui/separator";
|
|
17
11
|
import {
|
|
18
12
|
generateCompleteArticleStructuredData,
|
|
@@ -20,6 +14,12 @@ import {
|
|
|
20
14
|
} from "../../../lib/structured-data";
|
|
21
15
|
import { articleMdxOptions } from "../../../lib/mdx";
|
|
22
16
|
import { buildBlogPath, normalizeBlogBasePath } from "../../../lib/urls";
|
|
17
|
+
import {
|
|
18
|
+
type BlogTheme,
|
|
19
|
+
extractTableOfContents,
|
|
20
|
+
formatArticleDateMeta,
|
|
21
|
+
getBlogThemeStyle,
|
|
22
|
+
} from "../../../lib/article-ui";
|
|
23
23
|
|
|
24
24
|
interface ArticlePageProps {
|
|
25
25
|
article: PublishedArticle;
|
|
@@ -41,6 +41,19 @@ interface ArticlePageProps {
|
|
|
41
41
|
};
|
|
42
42
|
/** Public path where this blog is mounted, e.g. "/blog" or "/new-blog". */
|
|
43
43
|
basePath?: string;
|
|
44
|
+
/** Optional theme values for host-site brand integration. */
|
|
45
|
+
theme?: BlogTheme;
|
|
46
|
+
/** Optional post-article CTA supplied by the host site. */
|
|
47
|
+
postArticleCta?: {
|
|
48
|
+
title: string;
|
|
49
|
+
description?: string;
|
|
50
|
+
href: string;
|
|
51
|
+
label: string;
|
|
52
|
+
};
|
|
53
|
+
/** Optional related articles supplied by the host site. */
|
|
54
|
+
relatedArticles?: PublishedArticle[];
|
|
55
|
+
/** Whether to render static article share links. */
|
|
56
|
+
showShareLinks?: boolean;
|
|
44
57
|
}
|
|
45
58
|
|
|
46
59
|
/**
|
|
@@ -63,30 +76,35 @@ export default function ArticlePage({
|
|
|
63
76
|
className,
|
|
64
77
|
navigation,
|
|
65
78
|
basePath = "/blog",
|
|
79
|
+
theme,
|
|
80
|
+
postArticleCta,
|
|
81
|
+
relatedArticles = [],
|
|
82
|
+
showShareLinks = true,
|
|
66
83
|
}: ArticlePageProps) {
|
|
67
84
|
const normalizedBasePath = normalizeBlogBasePath(basePath) || "/";
|
|
68
|
-
const
|
|
69
|
-
const updatedDate = new Date(article.updatedAt);
|
|
70
|
-
const isUpdated = updatedDate.getTime() !== publishedDate.getTime();
|
|
71
|
-
const formattedDate = formatDistanceToNow(publishedDate, { addSuffix: true });
|
|
85
|
+
const dateMeta = formatArticleDateMeta(article.publishedAt, article.updatedAt);
|
|
72
86
|
const readingTime = formatReadingTime(article.readingSeconds);
|
|
87
|
+
const tocItems = extractTableOfContents(article.content);
|
|
88
|
+
const articleUrl =
|
|
89
|
+
article.meta.canonicalUrl ?? buildBlogPath(normalizedBasePath, article.slug);
|
|
73
90
|
|
|
74
91
|
// Use the featured image URL provided by Bloggy (hosted on Bloggy's domain)
|
|
75
92
|
const imageUrl = article.featuredImage;
|
|
76
93
|
|
|
77
94
|
return (
|
|
78
95
|
<article
|
|
96
|
+
style={getBlogThemeStyle(theme)}
|
|
79
97
|
className={cn(
|
|
80
|
-
"article-page mx-auto max-w-
|
|
98
|
+
"article-page mx-auto max-w-6xl px-4 py-6 pb-12 sm:px-6 sm:py-8 sm:pb-16 lg:px-8 lg:py-12 lg:pb-24",
|
|
81
99
|
className,
|
|
82
100
|
)}
|
|
83
101
|
>
|
|
84
102
|
{/* Navigation */}
|
|
85
103
|
{navigation?.backToHome && (
|
|
86
|
-
<header className="mb-8 sm:mb-12 lg:mb-16">
|
|
104
|
+
<header className="mx-auto mb-8 max-w-4xl sm:mb-12 lg:mb-16">
|
|
87
105
|
<Link
|
|
88
106
|
href={navigation.backToHome.url}
|
|
89
|
-
className="text-muted-foreground hover:text-
|
|
107
|
+
className="text-muted-foreground hover:text-[var(--blog-accent)] group focus-visible:ring-ring inline-flex touch-manipulation items-center space-x-2 rounded-md px-2 py-1 transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none sm:space-x-3"
|
|
90
108
|
aria-label={navigation.backToHome.label ?? "Return to blog"}
|
|
91
109
|
>
|
|
92
110
|
<ArrowLeft
|
|
@@ -104,15 +122,15 @@ export default function ArticlePage({
|
|
|
104
122
|
)}
|
|
105
123
|
|
|
106
124
|
{/* Article Header */}
|
|
107
|
-
<header className="article-header mb-6 space-y-4 text-center sm:mb-8 sm:space-y-6 lg:mb-12 lg:space-y-8">
|
|
125
|
+
<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">
|
|
108
126
|
{/* Title */}
|
|
109
|
-
<h1 className="article-title text-foreground px-4 text-2xl leading-tight font-light tracking-tight sm:px-0 sm:text-3xl md:text-4xl lg:text-5xl xl:text-6xl">
|
|
127
|
+
<h1 className="article-title text-foreground 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">
|
|
110
128
|
{article.title}
|
|
111
129
|
</h1>
|
|
112
130
|
|
|
113
131
|
{/* Excerpt */}
|
|
114
132
|
{article.excerpt && (
|
|
115
|
-
<p className="article-excerpt text-muted-foreground mx-auto mt-6 max-w-xs px-4 text-base leading-relaxed sm:mt-8 sm:max-w-lg sm:px-0 sm:text-lg md:max-w-2xl md:text-xl lg:max-w-4xl lg:text-2xl">
|
|
133
|
+
<p className="article-excerpt text-muted-foreground mx-auto mt-6 max-w-xs px-4 text-base leading-relaxed text-balance sm:mt-8 sm:max-w-lg sm:px-0 sm:text-lg md:max-w-2xl md:text-xl lg:max-w-4xl lg:text-2xl">
|
|
116
134
|
{article.excerpt}
|
|
117
135
|
</p>
|
|
118
136
|
)}
|
|
@@ -135,17 +153,19 @@ export default function ArticlePage({
|
|
|
135
153
|
<time
|
|
136
154
|
dateTime={article.publishedAt}
|
|
137
155
|
className="font-medium"
|
|
138
|
-
aria-label={`Published ${
|
|
156
|
+
aria-label={`Published ${dateMeta.publishedDateLabel}`}
|
|
139
157
|
>
|
|
140
|
-
Published {
|
|
158
|
+
Published {dateMeta.publishedDateLabel}
|
|
141
159
|
</time>
|
|
142
160
|
<span className="text-border">•</span>
|
|
161
|
+
<span className="font-medium">{dateMeta.publishedRelativeLabel}</span>
|
|
162
|
+
<span className="text-border">•</span>
|
|
143
163
|
<span className="font-medium">{readingTime}</span>
|
|
144
164
|
</div>
|
|
145
165
|
|
|
146
|
-
{isUpdated && (
|
|
166
|
+
{dateMeta.isUpdated && (
|
|
147
167
|
<time dateTime={article.updatedAt} className="text-xs">
|
|
148
|
-
Updated {
|
|
168
|
+
Updated {dateMeta.updatedDateLabel}
|
|
149
169
|
</time>
|
|
150
170
|
)}
|
|
151
171
|
|
|
@@ -162,7 +182,7 @@ export default function ArticlePage({
|
|
|
162
182
|
|
|
163
183
|
{/* Featured Image */}
|
|
164
184
|
{imageUrl && (
|
|
165
|
-
<div className="article-featured-image
|
|
185
|
+
<div className="article-featured-image mx-auto my-8 aspect-[16/9] w-full max-w-4xl overflow-hidden rounded-2xl sm:my-12 lg:my-16">
|
|
166
186
|
<Image
|
|
167
187
|
src={imageUrl}
|
|
168
188
|
alt={
|
|
@@ -177,50 +197,85 @@ export default function ArticlePage({
|
|
|
177
197
|
)}
|
|
178
198
|
|
|
179
199
|
{/* Article Content */}
|
|
180
|
-
<div className="article-content">
|
|
181
|
-
<div className="
|
|
182
|
-
<
|
|
200
|
+
<div className="article-content mx-auto grid max-w-6xl grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_14rem] lg:gap-12">
|
|
201
|
+
<div className="min-w-0">
|
|
202
|
+
<ArticleTableOfContents items={tocItems} variant="mobile" />
|
|
203
|
+
|
|
204
|
+
<div className="prose prose-sm sm:prose-base lg:prose-lg xl:prose-xl prose-gray dark:prose-invert prose-headings:scroll-mt-20 prose-headings:font-light prose-headings:text-balance prose-headings:text-[var(--blog-accent)] prose-h1:text-2xl sm:prose-h1:text-3xl lg:prose-h1:text-4xl prose-h2:text-xl sm:prose-h2:text-2xl lg:prose-h2:text-3xl prose-h3:text-lg sm:prose-h3:text-xl lg:prose-h3:text-2xl prose-p:leading-relaxed prose-p:text-pretty prose-img:mx-auto prose-img:rounded-2xl prose-a:text-[var(--blog-accent)] prose-a:transition-colors hover:prose-a:text-[var(--blog-accent-hover)] max-w-none">
|
|
205
|
+
<MDXRemote source={article.content} options={articleMdxOptions} />
|
|
206
|
+
</div>
|
|
183
207
|
</div>
|
|
208
|
+
|
|
209
|
+
<ArticleTableOfContents items={tocItems} variant="desktop" />
|
|
184
210
|
</div>
|
|
185
211
|
|
|
186
212
|
{/* Author Bio */}
|
|
187
213
|
{article.author?.bio && (
|
|
188
|
-
<div className="author-bio mt-12">
|
|
214
|
+
<div className="author-bio mx-auto mt-12 max-w-4xl">
|
|
189
215
|
<Separator className="mb-8" />
|
|
190
|
-
<Card>
|
|
191
|
-
<
|
|
192
|
-
<
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
216
|
+
<Card className="border-[var(--blog-accent-border)] bg-[var(--blog-accent-bg)]">
|
|
217
|
+
<CardContent className="flex flex-col gap-4 p-5 sm:flex-row sm:items-start sm:p-6">
|
|
218
|
+
<div
|
|
219
|
+
className="flex size-12 shrink-0 items-center justify-center rounded-full bg-[var(--blog-accent)] text-lg font-semibold text-white"
|
|
220
|
+
aria-hidden="true"
|
|
221
|
+
>
|
|
222
|
+
{article.author.name.slice(0, 1)}
|
|
223
|
+
</div>
|
|
224
|
+
<div className="min-w-0 space-y-2">
|
|
225
|
+
<div>
|
|
226
|
+
<p className="text-muted-foreground text-sm font-medium">
|
|
227
|
+
Written by
|
|
228
|
+
</p>
|
|
229
|
+
<h2 className="text-foreground flex flex-wrap items-center gap-2 text-xl font-semibold">
|
|
230
|
+
{article.author.name}
|
|
231
|
+
{article.author.isGuest && (
|
|
232
|
+
<Badge variant="outline" className="text-xs">
|
|
233
|
+
Guest Author
|
|
234
|
+
</Badge>
|
|
235
|
+
)}
|
|
236
|
+
</h2>
|
|
237
|
+
</div>
|
|
238
|
+
<p className="text-muted-foreground leading-relaxed">
|
|
239
|
+
{article.author.bio}
|
|
240
|
+
</p>
|
|
204
241
|
</div>
|
|
205
242
|
</CardContent>
|
|
206
243
|
</Card>
|
|
207
244
|
</div>
|
|
208
245
|
)}
|
|
209
246
|
|
|
247
|
+
{((postArticleCta ?? false) ||
|
|
248
|
+
showShareLinks ||
|
|
249
|
+
relatedArticles.length > 0) && (
|
|
250
|
+
<div className="mx-auto mt-12 max-w-4xl space-y-8">
|
|
251
|
+
<Separator />
|
|
252
|
+
{postArticleCta && <PostArticleCta cta={postArticleCta} />}
|
|
253
|
+
{showShareLinks && (
|
|
254
|
+
<ArticleShareLinks title={article.title} url={articleUrl} />
|
|
255
|
+
)}
|
|
256
|
+
{relatedArticles.length > 0 && (
|
|
257
|
+
<RelatedArticles
|
|
258
|
+
articles={relatedArticles.slice(0, 3)}
|
|
259
|
+
basePath={normalizedBasePath}
|
|
260
|
+
/>
|
|
261
|
+
)}
|
|
262
|
+
</div>
|
|
263
|
+
)}
|
|
264
|
+
|
|
210
265
|
{/* Article Navigation */}
|
|
211
266
|
{(Boolean(navigation?.previousArticle) ||
|
|
212
267
|
Boolean(navigation?.nextArticle)) && (
|
|
213
|
-
<nav className="article-nav mt-12">
|
|
268
|
+
<nav className="article-nav mx-auto mt-12 max-w-4xl">
|
|
214
269
|
<Separator className="mb-8" />
|
|
215
270
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
216
271
|
{navigation?.previousArticle && (
|
|
217
|
-
<Card className="group hover:bg-accent
|
|
272
|
+
<Card className="group hover:border-[var(--blog-accent-border)] hover:bg-[var(--blog-accent-bg)] cursor-pointer transition-colors">
|
|
218
273
|
<Link href={navigation.previousArticle.url} className="block">
|
|
219
274
|
<CardContent className="p-4">
|
|
220
275
|
<div className="text-muted-foreground mb-2 text-sm font-medium">
|
|
221
276
|
← Previous Article
|
|
222
277
|
</div>
|
|
223
|
-
<div className="text-foreground group-hover:text-
|
|
278
|
+
<div className="text-foreground group-hover:text-[var(--blog-accent)] transition-colors">
|
|
224
279
|
{navigation.previousArticle.title}
|
|
225
280
|
</div>
|
|
226
281
|
</CardContent>
|
|
@@ -230,7 +285,7 @@ export default function ArticlePage({
|
|
|
230
285
|
{navigation?.nextArticle && (
|
|
231
286
|
<Card
|
|
232
287
|
className={cn(
|
|
233
|
-
"group hover:bg-accent
|
|
288
|
+
"group hover:border-[var(--blog-accent-border)] hover:bg-[var(--blog-accent-bg)] cursor-pointer transition-colors",
|
|
234
289
|
!navigation?.previousArticle && "md:col-start-2",
|
|
235
290
|
)}
|
|
236
291
|
>
|
|
@@ -239,7 +294,7 @@ export default function ArticlePage({
|
|
|
239
294
|
<div className="text-muted-foreground mb-2 text-sm font-medium">
|
|
240
295
|
Next Article →
|
|
241
296
|
</div>
|
|
242
|
-
<div className="text-foreground group-hover:text-
|
|
297
|
+
<div className="text-foreground group-hover:text-[var(--blog-accent)] transition-colors">
|
|
243
298
|
{navigation.nextArticle.title}
|
|
244
299
|
</div>
|
|
245
300
|
</CardContent>
|
|
@@ -270,6 +325,160 @@ export default function ArticlePage({
|
|
|
270
325
|
);
|
|
271
326
|
}
|
|
272
327
|
|
|
328
|
+
function ArticleTableOfContents({
|
|
329
|
+
items,
|
|
330
|
+
variant,
|
|
331
|
+
}: {
|
|
332
|
+
items: ReturnType<typeof extractTableOfContents>;
|
|
333
|
+
variant: "mobile" | "desktop";
|
|
334
|
+
}) {
|
|
335
|
+
if (items.length === 0) {
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const list = (
|
|
340
|
+
<ul className="space-y-2 text-sm">
|
|
341
|
+
{items.map((item) => (
|
|
342
|
+
<li key={item.id} className={item.level === 3 ? "pl-4" : undefined}>
|
|
343
|
+
<a
|
|
344
|
+
href={`#${item.id}`}
|
|
345
|
+
className="text-muted-foreground hover:text-[var(--blog-accent)] block leading-snug transition-colors"
|
|
346
|
+
>
|
|
347
|
+
{item.text}
|
|
348
|
+
</a>
|
|
349
|
+
</li>
|
|
350
|
+
))}
|
|
351
|
+
</ul>
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
if (variant === "mobile") {
|
|
355
|
+
return (
|
|
356
|
+
<nav
|
|
357
|
+
aria-label="Table of contents"
|
|
358
|
+
className="mb-8 rounded-2xl border border-[var(--blog-accent-border)] bg-[var(--blog-accent-bg)] p-4 lg:hidden"
|
|
359
|
+
>
|
|
360
|
+
<p className="text-foreground mb-3 text-sm font-semibold">
|
|
361
|
+
On this page
|
|
362
|
+
</p>
|
|
363
|
+
{list}
|
|
364
|
+
</nav>
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return (
|
|
369
|
+
<aside className="hidden lg:block">
|
|
370
|
+
<nav
|
|
371
|
+
aria-label="Table of contents"
|
|
372
|
+
className="sticky top-24 max-h-[calc(100vh-8rem)] overflow-y-auto border-l border-border pl-5"
|
|
373
|
+
>
|
|
374
|
+
<p className="text-muted-foreground mb-3 text-xs font-semibold tracking-wider uppercase">
|
|
375
|
+
On this page
|
|
376
|
+
</p>
|
|
377
|
+
{list}
|
|
378
|
+
</nav>
|
|
379
|
+
</aside>
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function PostArticleCta({
|
|
384
|
+
cta,
|
|
385
|
+
}: {
|
|
386
|
+
cta: NonNullable<ArticlePageProps["postArticleCta"]>;
|
|
387
|
+
}) {
|
|
388
|
+
return (
|
|
389
|
+
<div className="rounded-2xl border border-[var(--blog-accent-border)] bg-[var(--blog-accent-bg)] p-5 sm:p-6">
|
|
390
|
+
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
|
391
|
+
<div className="space-y-1">
|
|
392
|
+
<h2 className="text-foreground text-xl font-semibold">{cta.title}</h2>
|
|
393
|
+
{cta.description && (
|
|
394
|
+
<p className="text-muted-foreground leading-relaxed">
|
|
395
|
+
{cta.description}
|
|
396
|
+
</p>
|
|
397
|
+
)}
|
|
398
|
+
</div>
|
|
399
|
+
<Link
|
|
400
|
+
href={cta.href}
|
|
401
|
+
className="inline-flex shrink-0 items-center justify-center gap-2 rounded-full bg-[var(--blog-accent)] px-4 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
|
|
402
|
+
>
|
|
403
|
+
{cta.label}
|
|
404
|
+
<ArrowUpRight className="size-4" aria-hidden="true" />
|
|
405
|
+
</Link>
|
|
406
|
+
</div>
|
|
407
|
+
</div>
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function ArticleShareLinks({ title, url }: { title: string; url: string }) {
|
|
412
|
+
const encodedTitle = encodeURIComponent(title);
|
|
413
|
+
const encodedUrl = encodeURIComponent(url);
|
|
414
|
+
|
|
415
|
+
return (
|
|
416
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
417
|
+
<span className="text-muted-foreground inline-flex items-center gap-2 text-sm font-medium">
|
|
418
|
+
<Share2 className="size-4" aria-hidden="true" />
|
|
419
|
+
Share
|
|
420
|
+
</span>
|
|
421
|
+
<a
|
|
422
|
+
href={`https://x.com/intent/tweet?text=${encodedTitle}&url=${encodedUrl}`}
|
|
423
|
+
target="_blank"
|
|
424
|
+
rel="noopener noreferrer"
|
|
425
|
+
className="text-muted-foreground hover:border-[var(--blog-accent-border)] hover:text-[var(--blog-accent)] rounded-full border border-border px-3 py-1.5 text-sm transition-colors"
|
|
426
|
+
>
|
|
427
|
+
X
|
|
428
|
+
</a>
|
|
429
|
+
<a
|
|
430
|
+
href={`https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}`}
|
|
431
|
+
target="_blank"
|
|
432
|
+
rel="noopener noreferrer"
|
|
433
|
+
className="text-muted-foreground hover:border-[var(--blog-accent-border)] hover:text-[var(--blog-accent)] rounded-full border border-border px-3 py-1.5 text-sm transition-colors"
|
|
434
|
+
>
|
|
435
|
+
LinkedIn
|
|
436
|
+
</a>
|
|
437
|
+
<a
|
|
438
|
+
href={`mailto:?subject=${encodedTitle}&body=${encodedUrl}`}
|
|
439
|
+
className="text-muted-foreground hover:border-[var(--blog-accent-border)] hover:text-[var(--blog-accent)] inline-flex items-center gap-1.5 rounded-full border border-border px-3 py-1.5 text-sm transition-colors"
|
|
440
|
+
>
|
|
441
|
+
<Mail className="size-3.5" aria-hidden="true" />
|
|
442
|
+
Email
|
|
443
|
+
</a>
|
|
444
|
+
</div>
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function RelatedArticles({
|
|
449
|
+
articles,
|
|
450
|
+
basePath,
|
|
451
|
+
}: {
|
|
452
|
+
articles: PublishedArticle[];
|
|
453
|
+
basePath: string;
|
|
454
|
+
}) {
|
|
455
|
+
return (
|
|
456
|
+
<section className="space-y-4" aria-labelledby="related-articles-title">
|
|
457
|
+
<h2 id="related-articles-title" className="text-xl font-semibold">
|
|
458
|
+
You might also like
|
|
459
|
+
</h2>
|
|
460
|
+
<div className="grid gap-4 md:grid-cols-3">
|
|
461
|
+
{articles.map((article) => (
|
|
462
|
+
<Link
|
|
463
|
+
key={article.id}
|
|
464
|
+
href={buildBlogPath(basePath, article.slug)}
|
|
465
|
+
className="group rounded-2xl border border-border p-4 transition-colors hover:border-[var(--blog-accent-border)] hover:bg-[var(--blog-accent-bg)] hover:no-underline"
|
|
466
|
+
>
|
|
467
|
+
<h3 className="text-foreground group-hover:text-[var(--blog-accent)] line-clamp-3 font-semibold transition-colors">
|
|
468
|
+
{article.title}
|
|
469
|
+
</h3>
|
|
470
|
+
{article.excerpt && (
|
|
471
|
+
<p className="text-muted-foreground mt-2 line-clamp-3 text-sm leading-relaxed">
|
|
472
|
+
{article.excerpt}
|
|
473
|
+
</p>
|
|
474
|
+
)}
|
|
475
|
+
</Link>
|
|
476
|
+
))}
|
|
477
|
+
</div>
|
|
478
|
+
</section>
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
|
|
273
482
|
/**
|
|
274
483
|
* Generate metadata for the article page for SEO optimization.
|
|
275
484
|
* This function should be used in the Next.js metadata API.
|
package/dist/app/blog/page.tsx
CHANGED
|
@@ -13,6 +13,10 @@ import {
|
|
|
13
13
|
} from "../../lib/structured-data";
|
|
14
14
|
import { buildBlogPath, normalizeBlogBasePath } from "../../lib/urls";
|
|
15
15
|
import { formatDistanceToNow } from "date-fns";
|
|
16
|
+
import {
|
|
17
|
+
type BlogTheme,
|
|
18
|
+
getBlogThemeStyle,
|
|
19
|
+
} from "../../lib/article-ui";
|
|
16
20
|
|
|
17
21
|
interface BlogHomePageProps {
|
|
18
22
|
articles: PublishedArticle[];
|
|
@@ -31,6 +35,8 @@ interface BlogHomePageProps {
|
|
|
31
35
|
totalPages: number;
|
|
32
36
|
baseUrl?: string;
|
|
33
37
|
};
|
|
38
|
+
/** Optional theme values for host-site brand integration. */
|
|
39
|
+
theme?: BlogTheme;
|
|
34
40
|
}
|
|
35
41
|
|
|
36
42
|
/**
|
|
@@ -49,11 +55,13 @@ export default function BlogHomePage({
|
|
|
49
55
|
includeStructuredData = false,
|
|
50
56
|
basePath = "/blog",
|
|
51
57
|
pagination,
|
|
58
|
+
theme,
|
|
52
59
|
}: BlogHomePageProps) {
|
|
53
60
|
const normalizedBasePath = normalizeBlogBasePath(basePath) || "/";
|
|
54
61
|
|
|
55
62
|
return (
|
|
56
63
|
<div
|
|
64
|
+
style={getBlogThemeStyle(theme)}
|
|
57
65
|
className={cn(
|
|
58
66
|
"blog-home-page container mx-auto max-w-5xl px-4 py-8 sm:px-6 sm:py-12 lg:px-8 lg:py-16",
|
|
59
67
|
className,
|
|
@@ -62,7 +70,7 @@ export default function BlogHomePage({
|
|
|
62
70
|
{/* Site Header */}
|
|
63
71
|
{siteName && (
|
|
64
72
|
<header className="blog-header mb-8 text-center sm:mb-12 lg:mb-16">
|
|
65
|
-
<h1 className="blog-title
|
|
73
|
+
<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">
|
|
66
74
|
{siteName}
|
|
67
75
|
</h1>
|
|
68
76
|
{siteTagline && (
|
|
@@ -162,13 +170,13 @@ function ArticlePreview({
|
|
|
162
170
|
<article className="group border-border mb-6 block border-b pb-6 last:mb-0 last:border-b-0 last:pb-0 sm:mb-8 sm:pb-8">
|
|
163
171
|
<Link
|
|
164
172
|
href={articleUrl}
|
|
165
|
-
className="focus-visible:ring-ring block rounded-
|
|
173
|
+
className="focus-visible:ring-ring block rounded-2xl hover:no-underline focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
|
166
174
|
aria-label={`Read article: ${article.title}`}
|
|
167
175
|
>
|
|
168
176
|
{/* Mobile/Small Tablet: Vertical layout */}
|
|
169
177
|
<div className="space-y-4 lg:hidden">
|
|
170
178
|
{imageUrl && (
|
|
171
|
-
<div className="relative aspect-[16/10] w-full overflow-hidden rounded-
|
|
179
|
+
<div className="relative aspect-[16/10] w-full overflow-hidden rounded-2xl">
|
|
172
180
|
<Image
|
|
173
181
|
src={imageUrl}
|
|
174
182
|
alt={
|
|
@@ -183,7 +191,7 @@ function ArticlePreview({
|
|
|
183
191
|
)}
|
|
184
192
|
|
|
185
193
|
<div className="space-y-3">
|
|
186
|
-
<h2 className="group-hover:text-
|
|
194
|
+
<h2 className="group-hover:text-[var(--blog-accent)] text-lg leading-tight font-bold text-balance transition-colors sm:text-xl md:text-2xl">
|
|
187
195
|
{article.title}
|
|
188
196
|
</h2>
|
|
189
197
|
|
|
@@ -216,7 +224,7 @@ function ArticlePreview({
|
|
|
216
224
|
{/* Large Tablet/Desktop: Horizontal layout */}
|
|
217
225
|
<div className="hidden items-start gap-6 lg:flex xl:gap-8">
|
|
218
226
|
{imageUrl && (
|
|
219
|
-
<div className="relative aspect-[16/10] w-64 shrink-0 overflow-hidden rounded-
|
|
227
|
+
<div className="relative aspect-[16/10] w-64 shrink-0 overflow-hidden rounded-2xl xl:w-72">
|
|
220
228
|
<Image
|
|
221
229
|
src={imageUrl}
|
|
222
230
|
alt={
|
|
@@ -232,7 +240,7 @@ function ArticlePreview({
|
|
|
232
240
|
|
|
233
241
|
<div className="flex min-h-[180px] flex-1 flex-col justify-between space-y-4 xl:min-h-[200px]">
|
|
234
242
|
<div className="space-y-4">
|
|
235
|
-
<h2 className="group-hover:text-
|
|
243
|
+
<h2 className="group-hover:text-[var(--blog-accent)] text-2xl leading-tight font-bold text-balance transition-colors xl:text-3xl">
|
|
236
244
|
{article.title}
|
|
237
245
|
</h2>
|
|
238
246
|
|
package/dist/index.d.mts
CHANGED
|
@@ -1,14 +1,42 @@
|
|
|
1
1
|
import { PublishedArticle, PublishedSite, PublishResult, PublishedAuthor } from './types/index.mjs';
|
|
2
2
|
export { PublishPayload } from './types/index.mjs';
|
|
3
3
|
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
4
|
-
import * as class_variance_authority_types from 'class-variance-authority/types';
|
|
5
4
|
import * as React from 'react';
|
|
5
|
+
import { CSSProperties } from 'react';
|
|
6
|
+
import * as class_variance_authority_types from 'class-variance-authority/types';
|
|
6
7
|
import { VariantProps } from 'class-variance-authority';
|
|
7
8
|
import { ClassValue } from 'clsx';
|
|
8
9
|
import { NextRequest, NextResponse } from 'next/server';
|
|
9
10
|
import * as drizzle_orm_sqlite_core from 'drizzle-orm/sqlite-core';
|
|
10
11
|
import { LibSQLDatabase } from 'drizzle-orm/libsql';
|
|
11
12
|
|
|
13
|
+
interface BlogTheme {
|
|
14
|
+
accentColor?: string;
|
|
15
|
+
accentHoverColor?: string;
|
|
16
|
+
accentBackgroundColor?: string;
|
|
17
|
+
accentBorderColor?: string;
|
|
18
|
+
}
|
|
19
|
+
interface TocItem {
|
|
20
|
+
id: string;
|
|
21
|
+
text: string;
|
|
22
|
+
level: 2 | 3;
|
|
23
|
+
}
|
|
24
|
+
interface ArticleDateMeta {
|
|
25
|
+
publishedDateLabel: string;
|
|
26
|
+
publishedRelativeLabel: string;
|
|
27
|
+
updatedDateLabel: string;
|
|
28
|
+
isUpdated: boolean;
|
|
29
|
+
}
|
|
30
|
+
type BlogThemeStyle = CSSProperties & {
|
|
31
|
+
"--blog-accent": string;
|
|
32
|
+
"--blog-accent-hover": string;
|
|
33
|
+
"--blog-accent-bg": string;
|
|
34
|
+
"--blog-accent-border": string;
|
|
35
|
+
};
|
|
36
|
+
declare function getBlogThemeStyle(theme?: BlogTheme): BlogThemeStyle;
|
|
37
|
+
declare function formatArticleDateMeta(publishedAt: string, updatedAt: string): ArticleDateMeta;
|
|
38
|
+
declare function extractTableOfContents(markdown: string): TocItem[];
|
|
39
|
+
|
|
12
40
|
interface BlogHomePageProps {
|
|
13
41
|
articles: PublishedArticle[];
|
|
14
42
|
siteName?: string;
|
|
@@ -26,6 +54,8 @@ interface BlogHomePageProps {
|
|
|
26
54
|
totalPages: number;
|
|
27
55
|
baseUrl?: string;
|
|
28
56
|
};
|
|
57
|
+
/** Optional theme values for host-site brand integration. */
|
|
58
|
+
theme?: BlogTheme;
|
|
29
59
|
}
|
|
30
60
|
/**
|
|
31
61
|
* Blog home page component that displays a list of published articles.
|
|
@@ -34,7 +64,7 @@ interface BlogHomePageProps {
|
|
|
34
64
|
* @xexr/blog-platform package. It provides a clean, SEO-friendly
|
|
35
65
|
* article listing with proper metadata.
|
|
36
66
|
*/
|
|
37
|
-
declare function BlogHomePage({ articles, siteName, siteTagline, className, site, includeStructuredData, basePath, pagination, }: BlogHomePageProps): react_jsx_runtime.JSX.Element;
|
|
67
|
+
declare function BlogHomePage({ articles, siteName, siteTagline, className, site, includeStructuredData, basePath, pagination, theme, }: BlogHomePageProps): react_jsx_runtime.JSX.Element;
|
|
38
68
|
/**
|
|
39
69
|
* Generate metadata for the blog home page for SEO optimization.
|
|
40
70
|
* This function should be used in the Next.js metadata API.
|
|
@@ -85,8 +115,21 @@ interface ArticlePageProps {
|
|
|
85
115
|
};
|
|
86
116
|
/** Public path where this blog is mounted, e.g. "/blog" or "/new-blog". */
|
|
87
117
|
basePath?: string;
|
|
118
|
+
/** Optional theme values for host-site brand integration. */
|
|
119
|
+
theme?: BlogTheme;
|
|
120
|
+
/** Optional post-article CTA supplied by the host site. */
|
|
121
|
+
postArticleCta?: {
|
|
122
|
+
title: string;
|
|
123
|
+
description?: string;
|
|
124
|
+
href: string;
|
|
125
|
+
label: string;
|
|
126
|
+
};
|
|
127
|
+
/** Optional related articles supplied by the host site. */
|
|
128
|
+
relatedArticles?: PublishedArticle[];
|
|
129
|
+
/** Whether to render static article share links. */
|
|
130
|
+
showShareLinks?: boolean;
|
|
88
131
|
}
|
|
89
|
-
declare function ArticlePage({ article, className, navigation, basePath, }: ArticlePageProps): react_jsx_runtime.JSX.Element;
|
|
132
|
+
declare function ArticlePage({ article, className, navigation, basePath, theme, postArticleCta, relatedArticles, showShareLinks, }: ArticlePageProps): react_jsx_runtime.JSX.Element;
|
|
90
133
|
/**
|
|
91
134
|
* Generate metadata for the article page for SEO optimization.
|
|
92
135
|
* This function should be used in the Next.js metadata API.
|
|
@@ -1807,4 +1850,4 @@ declare function OPTIONS(request: NextRequest): Promise<NextResponse>;
|
|
|
1807
1850
|
|
|
1808
1851
|
declare const PACKAGE_VERSION: string;
|
|
1809
1852
|
|
|
1810
|
-
export { ApiKeyAuth, type AuthConfig, type AuthResult, ArticlePage as BlogArticlePage, BlogHomePage, type BlogPlatformConfig, type BlogPlatformStorage, type BlogPostingSchema, type BlogSitemapConfig, type BreadcrumbListSchema, Button, type CanonicalUrlOptions, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, DrizzleStorage, EnvAuth, type GeneratedMetadata, InMemoryStorage, type LinkTag, type MetaTag, type MetadataOptions, type NextjsSitemapEntry, NoAuth, type OrganizationSchema, PACKAGE_VERSION, Pagination$1 as Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, type PersonSchema, PublishResult, PublishedArticle, PublishedAuthor, PublishedSite, type RateLimitConfig, type RateLimitResult, Pagination as ShadcnPagination, type SitemapChangeFreq, type SitemapData, type SitemapEntry, type SitemapOptions, StructuredDataError, type StructuredDataOptions, UninitializedAuth, type WebSiteSchema, autoConfigureRateLimit, bpArticles, bpOperations, buildBlogPath, buttonVariants, checkRateLimit, cn, configureRateLimit, createBlogSitemapRoute, createDynamicBlogSitemap, createRateLimitErrorResponse, createStructuredDataScript, enforceRateLimit, extractKeywordsFromArticle, extractTokenFromHeader, generateArticleMetadata, generateArticleStructuredData, generateAuthorStructuredData, generateBlogHomeMetadata, generateBlogHomeStructuredData, generateBlogSitemapEntries, generateBreadcrumbStructuredData, generateCanonicalUrl, generateCanonicalUrlFromSlug, generateCompleteArticleStructuredData, generateOrganizationStructuredData, generatePaginationMetadata, generateSiteMetadata, generateSitemap, generateSitemapIndex, generateWebSiteStructuredData, getAuthConfig, getBaseUrl, getDrizzleAdapterSql, getPathPrefixFromBlogUrl, getRateLimitHeaders, getStorage, initBlogPlatform, initBlogPlatformDev, initBlogPlatformOnce, initBlogPlatformProd, initEnvAuth, isValidCanonicalUrl, 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 };
|
|
1853
|
+
export { ApiKeyAuth, type AuthConfig, type AuthResult, ArticlePage as BlogArticlePage, BlogHomePage, type BlogPlatformConfig, type BlogPlatformStorage, type BlogPostingSchema, type BlogSitemapConfig, type BlogTheme, type BreadcrumbListSchema, Button, type CanonicalUrlOptions, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, DrizzleStorage, EnvAuth, type GeneratedMetadata, InMemoryStorage, type LinkTag, type MetaTag, type MetadataOptions, type NextjsSitemapEntry, NoAuth, type OrganizationSchema, PACKAGE_VERSION, Pagination$1 as Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, type PersonSchema, PublishResult, PublishedArticle, PublishedAuthor, PublishedSite, type RateLimitConfig, type RateLimitResult, Pagination as ShadcnPagination, type SitemapChangeFreq, type SitemapData, type SitemapEntry, type SitemapOptions, StructuredDataError, type StructuredDataOptions, UninitializedAuth, type WebSiteSchema, autoConfigureRateLimit, bpArticles, bpOperations, buildBlogPath, buttonVariants, checkRateLimit, cn, configureRateLimit, createBlogSitemapRoute, createDynamicBlogSitemap, createRateLimitErrorResponse, createStructuredDataScript, enforceRateLimit, extractKeywordsFromArticle, extractTableOfContents, extractTokenFromHeader, formatArticleDateMeta, generateArticleMetadata, generateArticleStructuredData, generateAuthorStructuredData, generateBlogHomeMetadata, generateBlogHomeStructuredData, generateBlogSitemapEntries, generateBreadcrumbStructuredData, generateCanonicalUrl, generateCanonicalUrlFromSlug, generateCompleteArticleStructuredData, generateOrganizationStructuredData, generatePaginationMetadata, generateSiteMetadata, generateSitemap, generateSitemapIndex, generateWebSiteStructuredData, getAuthConfig, getBaseUrl, getBlogThemeStyle, getDrizzleAdapterSql, getPathPrefixFromBlogUrl, getRateLimitHeaders, getStorage, initBlogPlatform, initBlogPlatformDev, initBlogPlatformOnce, initBlogPlatformProd, initEnvAuth, isValidCanonicalUrl, 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 };
|