@xexr/blog-platform 1.0.1 → 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 +270 -49
- package/dist/app/blog/page.tsx +41 -14
- package/dist/index.d.mts +60 -6
- package/dist/index.d.ts +60 -6
- 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 +10 -8
|
@@ -3,21 +3,23 @@ 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,
|
|
19
13
|
createStructuredDataScript,
|
|
20
14
|
} from "../../../lib/structured-data";
|
|
15
|
+
import { articleMdxOptions } from "../../../lib/mdx";
|
|
16
|
+
import { buildBlogPath, normalizeBlogBasePath } from "../../../lib/urls";
|
|
17
|
+
import {
|
|
18
|
+
type BlogTheme,
|
|
19
|
+
extractTableOfContents,
|
|
20
|
+
formatArticleDateMeta,
|
|
21
|
+
getBlogThemeStyle,
|
|
22
|
+
} from "../../../lib/article-ui";
|
|
21
23
|
|
|
22
24
|
interface ArticlePageProps {
|
|
23
25
|
article: PublishedArticle;
|
|
@@ -37,6 +39,21 @@ interface ArticlePageProps {
|
|
|
37
39
|
title: string;
|
|
38
40
|
};
|
|
39
41
|
};
|
|
42
|
+
/** Public path where this blog is mounted, e.g. "/blog" or "/new-blog". */
|
|
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;
|
|
40
57
|
}
|
|
41
58
|
|
|
42
59
|
/**
|
|
@@ -58,29 +75,36 @@ export default function ArticlePage({
|
|
|
58
75
|
article,
|
|
59
76
|
className,
|
|
60
77
|
navigation,
|
|
78
|
+
basePath = "/blog",
|
|
79
|
+
theme,
|
|
80
|
+
postArticleCta,
|
|
81
|
+
relatedArticles = [],
|
|
82
|
+
showShareLinks = true,
|
|
61
83
|
}: ArticlePageProps) {
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
const isUpdated = updatedDate.getTime() !== publishedDate.getTime();
|
|
65
|
-
const formattedDate = formatDistanceToNow(publishedDate, { addSuffix: true });
|
|
84
|
+
const normalizedBasePath = normalizeBlogBasePath(basePath) || "/";
|
|
85
|
+
const dateMeta = formatArticleDateMeta(article.publishedAt, article.updatedAt);
|
|
66
86
|
const readingTime = formatReadingTime(article.readingSeconds);
|
|
87
|
+
const tocItems = extractTableOfContents(article.content);
|
|
88
|
+
const articleUrl =
|
|
89
|
+
article.meta.canonicalUrl ?? buildBlogPath(normalizedBasePath, article.slug);
|
|
67
90
|
|
|
68
91
|
// Use the featured image URL provided by Bloggy (hosted on Bloggy's domain)
|
|
69
92
|
const imageUrl = article.featuredImage;
|
|
70
93
|
|
|
71
94
|
return (
|
|
72
95
|
<article
|
|
96
|
+
style={getBlogThemeStyle(theme)}
|
|
73
97
|
className={cn(
|
|
74
|
-
"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",
|
|
75
99
|
className,
|
|
76
100
|
)}
|
|
77
101
|
>
|
|
78
102
|
{/* Navigation */}
|
|
79
103
|
{navigation?.backToHome && (
|
|
80
|
-
<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">
|
|
81
105
|
<Link
|
|
82
106
|
href={navigation.backToHome.url}
|
|
83
|
-
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"
|
|
84
108
|
aria-label={navigation.backToHome.label ?? "Return to blog"}
|
|
85
109
|
>
|
|
86
110
|
<ArrowLeft
|
|
@@ -98,15 +122,15 @@ export default function ArticlePage({
|
|
|
98
122
|
)}
|
|
99
123
|
|
|
100
124
|
{/* Article Header */}
|
|
101
|
-
<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">
|
|
102
126
|
{/* Title */}
|
|
103
|
-
<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">
|
|
104
128
|
{article.title}
|
|
105
129
|
</h1>
|
|
106
130
|
|
|
107
131
|
{/* Excerpt */}
|
|
108
132
|
{article.excerpt && (
|
|
109
|
-
<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">
|
|
110
134
|
{article.excerpt}
|
|
111
135
|
</p>
|
|
112
136
|
)}
|
|
@@ -129,17 +153,19 @@ export default function ArticlePage({
|
|
|
129
153
|
<time
|
|
130
154
|
dateTime={article.publishedAt}
|
|
131
155
|
className="font-medium"
|
|
132
|
-
aria-label={`Published ${
|
|
156
|
+
aria-label={`Published ${dateMeta.publishedDateLabel}`}
|
|
133
157
|
>
|
|
134
|
-
Published {
|
|
158
|
+
Published {dateMeta.publishedDateLabel}
|
|
135
159
|
</time>
|
|
136
160
|
<span className="text-border">•</span>
|
|
161
|
+
<span className="font-medium">{dateMeta.publishedRelativeLabel}</span>
|
|
162
|
+
<span className="text-border">•</span>
|
|
137
163
|
<span className="font-medium">{readingTime}</span>
|
|
138
164
|
</div>
|
|
139
165
|
|
|
140
|
-
{isUpdated && (
|
|
166
|
+
{dateMeta.isUpdated && (
|
|
141
167
|
<time dateTime={article.updatedAt} className="text-xs">
|
|
142
|
-
Updated {
|
|
168
|
+
Updated {dateMeta.updatedDateLabel}
|
|
143
169
|
</time>
|
|
144
170
|
)}
|
|
145
171
|
|
|
@@ -156,7 +182,7 @@ export default function ArticlePage({
|
|
|
156
182
|
|
|
157
183
|
{/* Featured Image */}
|
|
158
184
|
{imageUrl && (
|
|
159
|
-
<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">
|
|
160
186
|
<Image
|
|
161
187
|
src={imageUrl}
|
|
162
188
|
alt={
|
|
@@ -171,50 +197,85 @@ export default function ArticlePage({
|
|
|
171
197
|
)}
|
|
172
198
|
|
|
173
199
|
{/* Article Content */}
|
|
174
|
-
<div className="article-content">
|
|
175
|
-
<div className="
|
|
176
|
-
<
|
|
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>
|
|
177
207
|
</div>
|
|
208
|
+
|
|
209
|
+
<ArticleTableOfContents items={tocItems} variant="desktop" />
|
|
178
210
|
</div>
|
|
179
211
|
|
|
180
212
|
{/* Author Bio */}
|
|
181
213
|
{article.author?.bio && (
|
|
182
|
-
<div className="author-bio mt-12">
|
|
214
|
+
<div className="author-bio mx-auto mt-12 max-w-4xl">
|
|
183
215
|
<Separator className="mb-8" />
|
|
184
|
-
<Card>
|
|
185
|
-
<
|
|
186
|
-
<
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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>
|
|
198
241
|
</div>
|
|
199
242
|
</CardContent>
|
|
200
243
|
</Card>
|
|
201
244
|
</div>
|
|
202
245
|
)}
|
|
203
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
|
+
|
|
204
265
|
{/* Article Navigation */}
|
|
205
266
|
{(Boolean(navigation?.previousArticle) ||
|
|
206
267
|
Boolean(navigation?.nextArticle)) && (
|
|
207
|
-
<nav className="article-nav mt-12">
|
|
268
|
+
<nav className="article-nav mx-auto mt-12 max-w-4xl">
|
|
208
269
|
<Separator className="mb-8" />
|
|
209
270
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
210
271
|
{navigation?.previousArticle && (
|
|
211
|
-
<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">
|
|
212
273
|
<Link href={navigation.previousArticle.url} className="block">
|
|
213
274
|
<CardContent className="p-4">
|
|
214
275
|
<div className="text-muted-foreground mb-2 text-sm font-medium">
|
|
215
276
|
← Previous Article
|
|
216
277
|
</div>
|
|
217
|
-
<div className="text-foreground group-hover:text-
|
|
278
|
+
<div className="text-foreground group-hover:text-[var(--blog-accent)] transition-colors">
|
|
218
279
|
{navigation.previousArticle.title}
|
|
219
280
|
</div>
|
|
220
281
|
</CardContent>
|
|
@@ -224,7 +285,7 @@ export default function ArticlePage({
|
|
|
224
285
|
{navigation?.nextArticle && (
|
|
225
286
|
<Card
|
|
226
287
|
className={cn(
|
|
227
|
-
"group hover:bg-accent
|
|
288
|
+
"group hover:border-[var(--blog-accent-border)] hover:bg-[var(--blog-accent-bg)] cursor-pointer transition-colors",
|
|
228
289
|
!navigation?.previousArticle && "md:col-start-2",
|
|
229
290
|
)}
|
|
230
291
|
>
|
|
@@ -233,7 +294,7 @@ export default function ArticlePage({
|
|
|
233
294
|
<div className="text-muted-foreground mb-2 text-sm font-medium">
|
|
234
295
|
Next Article →
|
|
235
296
|
</div>
|
|
236
|
-
<div className="text-foreground group-hover:text-
|
|
297
|
+
<div className="text-foreground group-hover:text-[var(--blog-accent)] transition-colors">
|
|
237
298
|
{navigation.nextArticle.title}
|
|
238
299
|
</div>
|
|
239
300
|
</CardContent>
|
|
@@ -247,7 +308,9 @@ export default function ArticlePage({
|
|
|
247
308
|
{/* JSON-LD Structured Data */}
|
|
248
309
|
{(() => {
|
|
249
310
|
const structuredDataProps = createStructuredDataScript(
|
|
250
|
-
generateCompleteArticleStructuredData(article
|
|
311
|
+
generateCompleteArticleStructuredData(article, {
|
|
312
|
+
pathPrefix: normalizedBasePath,
|
|
313
|
+
}),
|
|
251
314
|
);
|
|
252
315
|
return (
|
|
253
316
|
<script
|
|
@@ -262,15 +325,173 @@ export default function ArticlePage({
|
|
|
262
325
|
);
|
|
263
326
|
}
|
|
264
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
|
+
|
|
265
482
|
/**
|
|
266
483
|
* Generate metadata for the article page for SEO optimization.
|
|
267
484
|
* This function should be used in the Next.js metadata API.
|
|
268
485
|
*/
|
|
269
|
-
export function generateArticleMetadata(
|
|
486
|
+
export function generateArticleMetadata(
|
|
487
|
+
article: PublishedArticle,
|
|
488
|
+
basePath = "/blog",
|
|
489
|
+
) {
|
|
270
490
|
const title = article.meta.title ?? article.title;
|
|
271
491
|
const description = article.meta.description ?? article.excerpt;
|
|
272
492
|
const canonicalUrl =
|
|
273
|
-
article.meta.canonicalUrl ??
|
|
493
|
+
article.meta.canonicalUrl ??
|
|
494
|
+
`${article.site.domain}${buildBlogPath(basePath, article.slug)}`;
|
|
274
495
|
const ogImage = article.meta.ogImage ?? article.featuredImage;
|
|
275
496
|
|
|
276
497
|
return {
|
package/dist/app/blog/page.tsx
CHANGED
|
@@ -11,7 +11,12 @@ import {
|
|
|
11
11
|
generateBlogHomeStructuredData,
|
|
12
12
|
createStructuredDataScript,
|
|
13
13
|
} from "../../lib/structured-data";
|
|
14
|
+
import { buildBlogPath, normalizeBlogBasePath } from "../../lib/urls";
|
|
14
15
|
import { formatDistanceToNow } from "date-fns";
|
|
16
|
+
import {
|
|
17
|
+
type BlogTheme,
|
|
18
|
+
getBlogThemeStyle,
|
|
19
|
+
} from "../../lib/article-ui";
|
|
15
20
|
|
|
16
21
|
interface BlogHomePageProps {
|
|
17
22
|
articles: PublishedArticle[];
|
|
@@ -22,12 +27,16 @@ interface BlogHomePageProps {
|
|
|
22
27
|
site?: PublishedSite;
|
|
23
28
|
/** Whether to include structured data */
|
|
24
29
|
includeStructuredData?: boolean;
|
|
30
|
+
/** Public path where this blog is mounted, e.g. "/blog" or "/new-blog". */
|
|
31
|
+
basePath?: string;
|
|
25
32
|
/** Pagination configuration */
|
|
26
33
|
pagination?: {
|
|
27
34
|
currentPage: number;
|
|
28
35
|
totalPages: number;
|
|
29
36
|
baseUrl?: string;
|
|
30
37
|
};
|
|
38
|
+
/** Optional theme values for host-site brand integration. */
|
|
39
|
+
theme?: BlogTheme;
|
|
31
40
|
}
|
|
32
41
|
|
|
33
42
|
/**
|
|
@@ -44,10 +53,15 @@ export default function BlogHomePage({
|
|
|
44
53
|
className,
|
|
45
54
|
site,
|
|
46
55
|
includeStructuredData = false,
|
|
56
|
+
basePath = "/blog",
|
|
47
57
|
pagination,
|
|
58
|
+
theme,
|
|
48
59
|
}: BlogHomePageProps) {
|
|
60
|
+
const normalizedBasePath = normalizeBlogBasePath(basePath) || "/";
|
|
61
|
+
|
|
49
62
|
return (
|
|
50
63
|
<div
|
|
64
|
+
style={getBlogThemeStyle(theme)}
|
|
51
65
|
className={cn(
|
|
52
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",
|
|
53
67
|
className,
|
|
@@ -56,7 +70,7 @@ export default function BlogHomePage({
|
|
|
56
70
|
{/* Site Header */}
|
|
57
71
|
{siteName && (
|
|
58
72
|
<header className="blog-header mb-8 text-center sm:mb-12 lg:mb-16">
|
|
59
|
-
<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">
|
|
60
74
|
{siteName}
|
|
61
75
|
</h1>
|
|
62
76
|
{siteTagline && (
|
|
@@ -81,7 +95,11 @@ export default function BlogHomePage({
|
|
|
81
95
|
) : (
|
|
82
96
|
<div className="space-y-0">
|
|
83
97
|
{articles.map((article) => (
|
|
84
|
-
<ArticlePreview
|
|
98
|
+
<ArticlePreview
|
|
99
|
+
key={article.id}
|
|
100
|
+
article={article}
|
|
101
|
+
basePath={normalizedBasePath}
|
|
102
|
+
/>
|
|
85
103
|
))}
|
|
86
104
|
</div>
|
|
87
105
|
)}
|
|
@@ -92,7 +110,7 @@ export default function BlogHomePage({
|
|
|
92
110
|
<Pagination
|
|
93
111
|
currentPage={pagination.currentPage}
|
|
94
112
|
totalPages={pagination.totalPages}
|
|
95
|
-
baseUrl={pagination.baseUrl ??
|
|
113
|
+
baseUrl={pagination.baseUrl ?? normalizedBasePath}
|
|
96
114
|
className="justify-center"
|
|
97
115
|
/>
|
|
98
116
|
</div>
|
|
@@ -104,7 +122,9 @@ export default function BlogHomePage({
|
|
|
104
122
|
site &&
|
|
105
123
|
(() => {
|
|
106
124
|
const structuredDataProps = createStructuredDataScript(
|
|
107
|
-
generateBlogHomeStructuredData(site, articles.slice(0, 5)
|
|
125
|
+
generateBlogHomeStructuredData(site, articles.slice(0, 5), {
|
|
126
|
+
pathPrefix: normalizedBasePath,
|
|
127
|
+
}),
|
|
108
128
|
);
|
|
109
129
|
return (
|
|
110
130
|
<script
|
|
@@ -130,8 +150,14 @@ function formatReadingTime(seconds: number): string {
|
|
|
130
150
|
/**
|
|
131
151
|
* Individual article preview component for the blog listing.
|
|
132
152
|
*/
|
|
133
|
-
function ArticlePreview({
|
|
134
|
-
|
|
153
|
+
function ArticlePreview({
|
|
154
|
+
article,
|
|
155
|
+
basePath,
|
|
156
|
+
}: {
|
|
157
|
+
article: PublishedArticle;
|
|
158
|
+
basePath: string;
|
|
159
|
+
}) {
|
|
160
|
+
const articleUrl = buildBlogPath(basePath, article.slug);
|
|
135
161
|
const formattedDate = formatDistanceToNow(new Date(article.publishedAt), {
|
|
136
162
|
addSuffix: true,
|
|
137
163
|
});
|
|
@@ -144,13 +170,13 @@ function ArticlePreview({ article }: { article: PublishedArticle }) {
|
|
|
144
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">
|
|
145
171
|
<Link
|
|
146
172
|
href={articleUrl}
|
|
147
|
-
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"
|
|
148
174
|
aria-label={`Read article: ${article.title}`}
|
|
149
175
|
>
|
|
150
176
|
{/* Mobile/Small Tablet: Vertical layout */}
|
|
151
177
|
<div className="space-y-4 lg:hidden">
|
|
152
178
|
{imageUrl && (
|
|
153
|
-
<div className="relative aspect-[16/10] w-full overflow-hidden rounded-
|
|
179
|
+
<div className="relative aspect-[16/10] w-full overflow-hidden rounded-2xl">
|
|
154
180
|
<Image
|
|
155
181
|
src={imageUrl}
|
|
156
182
|
alt={
|
|
@@ -165,11 +191,11 @@ function ArticlePreview({ article }: { article: PublishedArticle }) {
|
|
|
165
191
|
)}
|
|
166
192
|
|
|
167
193
|
<div className="space-y-3">
|
|
168
|
-
<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">
|
|
169
195
|
{article.title}
|
|
170
196
|
</h2>
|
|
171
197
|
|
|
172
|
-
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm
|
|
198
|
+
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
|
|
173
199
|
{article.author && (
|
|
174
200
|
<>
|
|
175
201
|
<span className="font-medium">{article.author.name}</span>
|
|
@@ -198,7 +224,7 @@ function ArticlePreview({ article }: { article: PublishedArticle }) {
|
|
|
198
224
|
{/* Large Tablet/Desktop: Horizontal layout */}
|
|
199
225
|
<div className="hidden items-start gap-6 lg:flex xl:gap-8">
|
|
200
226
|
{imageUrl && (
|
|
201
|
-
<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">
|
|
202
228
|
<Image
|
|
203
229
|
src={imageUrl}
|
|
204
230
|
alt={
|
|
@@ -214,7 +240,7 @@ function ArticlePreview({ article }: { article: PublishedArticle }) {
|
|
|
214
240
|
|
|
215
241
|
<div className="flex min-h-[180px] flex-1 flex-col justify-between space-y-4 xl:min-h-[200px]">
|
|
216
242
|
<div className="space-y-4">
|
|
217
|
-
<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">
|
|
218
244
|
{article.title}
|
|
219
245
|
</h2>
|
|
220
246
|
|
|
@@ -225,7 +251,7 @@ function ArticlePreview({ article }: { article: PublishedArticle }) {
|
|
|
225
251
|
)}
|
|
226
252
|
</div>
|
|
227
253
|
|
|
228
|
-
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm
|
|
254
|
+
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
|
|
229
255
|
{article.author && (
|
|
230
256
|
<>
|
|
231
257
|
<span className="font-medium">{article.author.name}</span>
|
|
@@ -261,6 +287,7 @@ export function generateBlogHomeMetadata(
|
|
|
261
287
|
currentPage: number;
|
|
262
288
|
totalPages: number;
|
|
263
289
|
},
|
|
290
|
+
basePath = "/blog",
|
|
264
291
|
) {
|
|
265
292
|
const baseTitle = siteName ? `${siteName} - Blog` : "Blog";
|
|
266
293
|
const title =
|
|
@@ -272,7 +299,7 @@ export function generateBlogHomeMetadata(
|
|
|
272
299
|
siteDescription ?? "Read our latest articles and insights.";
|
|
273
300
|
|
|
274
301
|
// Generate pagination URLs for SEO
|
|
275
|
-
const baseUrl = "/
|
|
302
|
+
const baseUrl = normalizeBlogBasePath(basePath) || "/";
|
|
276
303
|
const canonicalUrl = siteUrl
|
|
277
304
|
? `${siteUrl}${pagination && pagination.currentPage > 1 ? `${baseUrl}?page=${pagination.currentPage}` : baseUrl}`
|
|
278
305
|
: undefined;
|