@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.
@@ -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 publishedDate = new Date(article.publishedAt);
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-4xl px-4 py-6 pb-12 sm:px-6 sm:py-8 sm:pb-16 lg:px-8 lg:py-12 lg:pb-24",
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-foreground 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"
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 ${formattedDate}`}
156
+ aria-label={`Published ${dateMeta.publishedDateLabel}`}
139
157
  >
140
- Published {formattedDate}
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 {format(updatedDate, "MMMM d, yyyy")}
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 relative my-8 aspect-[16/9] w-full overflow-hidden rounded-lg sm:my-12 lg:my-16">
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="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-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-img:mx-auto prose-img:rounded-lg prose-a:transition-colors max-w-none">
182
- <MDXRemote source={article.content} options={articleMdxOptions} />
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
- <CardHeader>
192
- <CardTitle className="flex items-center gap-2">
193
- About {article.author.name}
194
- {article.author.isGuest && (
195
- <Badge variant="outline" className="text-xs">
196
- Guest Author
197
- </Badge>
198
- )}
199
- </CardTitle>
200
- </CardHeader>
201
- <CardContent>
202
- <div className="prose dark:prose-invert">
203
- <p className="text-muted-foreground">{article.author.bio}</p>
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/50 cursor-pointer transition-colors">
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-primary transition-colors">
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/50 cursor-pointer transition-colors",
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-primary transition-colors">
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.
@@ -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 text-foreground mb-2 text-3xl font-light tracking-tight sm:text-4xl lg:text-5xl">
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-lg hover:no-underline focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
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-lg">
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-primary text-lg leading-tight font-bold transition-colors sm:text-xl md:text-2xl">
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-lg xl:w-72">
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-primary text-2xl leading-tight font-bold transition-colors xl:text-3xl">
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 };