@xexr/blog-platform 0.2.0

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.
@@ -0,0 +1,304 @@
1
+ import React from "react";
2
+ import Image from "next/image";
3
+ import Link from "next/link";
4
+ import { MDXRemote } from "next-mdx-remote-client/rsc";
5
+ import { type PublishedArticle } from "../../../types/contracts";
6
+ import { format, formatDistanceToNow } from "date-fns";
7
+ import { cn } from "../../../lib/utils";
8
+ import { ArrowLeft } from "lucide-react";
9
+ import { Badge } from "../../../components/ui/badge";
10
+ import {
11
+ Card,
12
+ CardContent,
13
+ CardHeader,
14
+ CardTitle,
15
+ } from "../../../components/ui/card";
16
+ import { Separator } from "../../../components/ui/separator";
17
+ import {
18
+ generateCompleteArticleStructuredData,
19
+ createStructuredDataScript,
20
+ } from "../../../lib/structured-data";
21
+
22
+ interface ArticlePageProps {
23
+ article: PublishedArticle;
24
+ className?: string;
25
+ /** Optional navigation links */
26
+ navigation?: {
27
+ backToHome?: {
28
+ url: string;
29
+ label?: string;
30
+ };
31
+ previousArticle?: {
32
+ url: string;
33
+ title: string;
34
+ };
35
+ nextArticle?: {
36
+ url: string;
37
+ title: string;
38
+ };
39
+ };
40
+ }
41
+
42
+ /**
43
+ * Individual article page component that displays full article content.
44
+ *
45
+ * This component is designed to be used by customers who install the
46
+ * @xexr/blog-platform package. It provides a complete article view
47
+ * with proper SEO metadata, structured data, and accessibility features.
48
+ */
49
+ /**
50
+ * Helper function to format reading time in minutes
51
+ */
52
+ function formatReadingTime(seconds: number): string {
53
+ const minutes = Math.ceil(seconds / 60);
54
+ return `${minutes} min read`;
55
+ }
56
+
57
+ export default function ArticlePage({
58
+ article,
59
+ className,
60
+ navigation,
61
+ }: ArticlePageProps) {
62
+ const publishedDate = new Date(article.publishedAt);
63
+ const updatedDate = new Date(article.updatedAt);
64
+ const isUpdated = updatedDate.getTime() !== publishedDate.getTime();
65
+ const formattedDate = formatDistanceToNow(publishedDate, { addSuffix: true });
66
+ const readingTime = formatReadingTime(article.readingSeconds);
67
+
68
+ // Use the featured image URL provided by Bloggy (hosted on Bloggy's domain)
69
+ const imageUrl = article.featuredImage;
70
+
71
+ return (
72
+ <article
73
+ className={cn(
74
+ "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",
75
+ className,
76
+ )}
77
+ >
78
+ {/* Navigation */}
79
+ {navigation?.backToHome && (
80
+ <header className="mb-8 sm:mb-12 lg:mb-16">
81
+ <Link
82
+ href={navigation.backToHome.url}
83
+ 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"
84
+ aria-label={navigation.backToHome.label ?? "Return to blog"}
85
+ >
86
+ <ArrowLeft
87
+ className="h-4 w-4 transition-transform group-hover:-translate-x-1 sm:h-5 sm:w-5"
88
+ aria-hidden="true"
89
+ />
90
+ <span className="text-sm font-medium sm:text-base">
91
+ <span className="hidden sm:inline">
92
+ {navigation.backToHome.label ?? "Back to all articles"}
93
+ </span>
94
+ <span className="sm:hidden">Back</span>
95
+ </span>
96
+ </Link>
97
+ </header>
98
+ )}
99
+
100
+ {/* 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">
102
+ {/* 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">
104
+ {article.title}
105
+ </h1>
106
+
107
+ {/* Excerpt */}
108
+ {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">
110
+ {article.excerpt}
111
+ </p>
112
+ )}
113
+
114
+ {/* Article Metadata */}
115
+ <div className="article-meta text-muted-foreground flex flex-col items-center gap-2 text-sm sm:gap-3 sm:text-base">
116
+ {article.author && (
117
+ <div className="flex items-center gap-2">
118
+ <span className="sr-only">Author</span>
119
+ <span className="font-medium">{article.author.name}</span>
120
+ {article.author.isGuest && (
121
+ <Badge variant="outline" className="text-xs">
122
+ Guest Author
123
+ </Badge>
124
+ )}
125
+ </div>
126
+ )}
127
+
128
+ <div className="flex flex-wrap items-center justify-center gap-x-3 gap-y-1">
129
+ <time
130
+ dateTime={article.publishedAt}
131
+ className="font-medium"
132
+ aria-label={`Published ${formattedDate}`}
133
+ >
134
+ Published {formattedDate}
135
+ </time>
136
+ <span className="text-border">•</span>
137
+ <span className="font-medium">{readingTime}</span>
138
+ </div>
139
+
140
+ {isUpdated && (
141
+ <time dateTime={article.updatedAt} className="text-xs">
142
+ Updated {format(updatedDate, "MMMM d, yyyy")}
143
+ </time>
144
+ )}
145
+
146
+ <div className="bg-border h-px w-6 sm:w-8" aria-hidden="true"></div>
147
+
148
+ <span
149
+ className="text-xs tracking-wider uppercase sm:text-sm"
150
+ role="text"
151
+ >
152
+ From {article.site.name}
153
+ </span>
154
+ </div>
155
+ </header>
156
+
157
+ {/* Featured Image */}
158
+ {imageUrl && (
159
+ <div className="article-featured-image relative my-8 aspect-[16/9] w-full overflow-hidden rounded-lg sm:my-12 lg:my-16">
160
+ <Image
161
+ src={imageUrl}
162
+ alt={
163
+ article.featuredImageAlt ?? `Featured image for ${article.title}`
164
+ }
165
+ className="h-full w-full object-cover"
166
+ width={800}
167
+ height={450}
168
+ priority
169
+ />
170
+ </div>
171
+ )}
172
+
173
+ {/* Article Content */}
174
+ <div className="article-content">
175
+ <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">
176
+ <MDXRemote source={article.content} />
177
+ </div>
178
+ </div>
179
+
180
+ {/* Author Bio */}
181
+ {article.author?.bio && (
182
+ <div className="author-bio mt-12">
183
+ <Separator className="mb-8" />
184
+ <Card>
185
+ <CardHeader>
186
+ <CardTitle className="flex items-center gap-2">
187
+ About {article.author.name}
188
+ {article.author.isGuest && (
189
+ <Badge variant="outline" className="text-xs">
190
+ Guest Author
191
+ </Badge>
192
+ )}
193
+ </CardTitle>
194
+ </CardHeader>
195
+ <CardContent>
196
+ <div className="prose dark:prose-invert">
197
+ <p className="text-muted-foreground">{article.author.bio}</p>
198
+ </div>
199
+ </CardContent>
200
+ </Card>
201
+ </div>
202
+ )}
203
+
204
+ {/* Article Navigation */}
205
+ {(Boolean(navigation?.previousArticle) ||
206
+ Boolean(navigation?.nextArticle)) && (
207
+ <nav className="article-nav mt-12">
208
+ <Separator className="mb-8" />
209
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
210
+ {navigation?.previousArticle && (
211
+ <Card className="group hover:bg-accent/50 cursor-pointer transition-colors">
212
+ <Link href={navigation.previousArticle.url} className="block">
213
+ <CardContent className="p-4">
214
+ <div className="text-muted-foreground mb-2 text-sm font-medium">
215
+ ← Previous Article
216
+ </div>
217
+ <div className="text-foreground group-hover:text-primary transition-colors">
218
+ {navigation.previousArticle.title}
219
+ </div>
220
+ </CardContent>
221
+ </Link>
222
+ </Card>
223
+ )}
224
+ {navigation?.nextArticle && (
225
+ <Card
226
+ className={cn(
227
+ "group hover:bg-accent/50 cursor-pointer transition-colors",
228
+ !navigation?.previousArticle && "md:col-start-2",
229
+ )}
230
+ >
231
+ <Link href={navigation.nextArticle.url} className="block">
232
+ <CardContent className="p-4 md:text-right">
233
+ <div className="text-muted-foreground mb-2 text-sm font-medium">
234
+ Next Article →
235
+ </div>
236
+ <div className="text-foreground group-hover:text-primary transition-colors">
237
+ {navigation.nextArticle.title}
238
+ </div>
239
+ </CardContent>
240
+ </Link>
241
+ </Card>
242
+ )}
243
+ </div>
244
+ </nav>
245
+ )}
246
+
247
+ {/* JSON-LD Structured Data */}
248
+ {(() => {
249
+ const structuredDataProps = createStructuredDataScript(
250
+ generateCompleteArticleStructuredData(article),
251
+ );
252
+ return (
253
+ <script
254
+ type={structuredDataProps.type}
255
+ dangerouslySetInnerHTML={
256
+ structuredDataProps.dangerouslySetInnerHTML
257
+ }
258
+ />
259
+ );
260
+ })()}
261
+ </article>
262
+ );
263
+ }
264
+
265
+ /**
266
+ * Generate metadata for the article page for SEO optimization.
267
+ * This function should be used in the Next.js metadata API.
268
+ */
269
+ export function generateArticleMetadata(article: PublishedArticle) {
270
+ const title = article.meta.title ?? article.title;
271
+ const description = article.meta.description ?? article.excerpt;
272
+ const canonicalUrl =
273
+ article.meta.canonicalUrl ?? `${article.site.domain}/blog/${article.slug}`;
274
+ const ogImage = article.meta.ogImage ?? article.featuredImage;
275
+
276
+ return {
277
+ title,
278
+ description,
279
+ keywords: article.meta.keywords.join(", "),
280
+ authors: article.author ? [{ name: article.author.name }] : undefined,
281
+ openGraph: {
282
+ title,
283
+ description,
284
+ type: "article",
285
+ url: canonicalUrl,
286
+ images: ogImage
287
+ ? [{ url: ogImage, alt: article.featuredImageAlt ?? title }]
288
+ : undefined,
289
+ publishedTime: article.publishedAt,
290
+ modifiedTime: article.updatedAt,
291
+ authors: article.author ? [article.author.name] : undefined,
292
+ siteName: article.site.name,
293
+ },
294
+ twitter: {
295
+ card: "summary_large_image",
296
+ title,
297
+ description,
298
+ images: ogImage ? [ogImage] : undefined,
299
+ },
300
+ alternates: {
301
+ canonical: canonicalUrl,
302
+ },
303
+ };
304
+ }
@@ -0,0 +1,316 @@
1
+ import React from "react";
2
+ import Link from "next/link";
3
+ import Image from "next/image";
4
+ import {
5
+ type PublishedArticle,
6
+ type PublishedSite,
7
+ } from "../../types/contracts";
8
+ import { cn } from "../../lib/utils";
9
+ import Pagination from "../../components/Pagination";
10
+ import {
11
+ generateBlogHomeStructuredData,
12
+ createStructuredDataScript,
13
+ } from "../../lib/structured-data";
14
+ import { formatDistanceToNow } from "date-fns";
15
+
16
+ interface BlogHomePageProps {
17
+ articles: PublishedArticle[];
18
+ siteName?: string;
19
+ siteTagline?: string;
20
+ className?: string;
21
+ /** Site data for structured data generation */
22
+ site?: PublishedSite;
23
+ /** Whether to include structured data */
24
+ includeStructuredData?: boolean;
25
+ /** Pagination configuration */
26
+ pagination?: {
27
+ currentPage: number;
28
+ totalPages: number;
29
+ baseUrl?: string;
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Blog home page component that displays a list of published articles.
35
+ *
36
+ * This component is designed to be used by customers who install the
37
+ * @xexr/blog-platform package. It provides a clean, SEO-friendly
38
+ * article listing with proper metadata.
39
+ */
40
+ export default function BlogHomePage({
41
+ articles,
42
+ siteName,
43
+ siteTagline,
44
+ className,
45
+ site,
46
+ includeStructuredData = false,
47
+ pagination,
48
+ }: BlogHomePageProps) {
49
+ return (
50
+ <div
51
+ className={cn(
52
+ "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
+ className,
54
+ )}
55
+ >
56
+ {/* Site Header */}
57
+ {siteName && (
58
+ <header className="blog-header mb-8 text-center sm:mb-12 lg:mb-16">
59
+ <h1 className="blog-title text-foreground mb-2 text-3xl font-light tracking-tight sm:text-4xl lg:text-5xl">
60
+ {siteName}
61
+ </h1>
62
+ {siteTagline && (
63
+ <p className="blog-tagline text-muted-foreground text-base sm:text-lg">
64
+ {siteTagline}
65
+ </p>
66
+ )}
67
+ </header>
68
+ )}
69
+
70
+ {/* Articles List */}
71
+ <main className="articles-list">
72
+ {articles.length === 0 ? (
73
+ <div className="no-articles text-muted-foreground py-16 text-center sm:py-24 lg:py-32">
74
+ <h2 className="mb-3 text-xl font-light sm:mb-4 sm:text-2xl lg:text-3xl">
75
+ No articles yet
76
+ </h2>
77
+ <p className="text-muted-foreground/70 mx-auto max-w-md text-base sm:text-lg">
78
+ This blog doesn&apos;t have any published articles yet.
79
+ </p>
80
+ </div>
81
+ ) : (
82
+ <div className="space-y-0">
83
+ {articles.map((article) => (
84
+ <ArticlePreview key={article.id} article={article} />
85
+ ))}
86
+ </div>
87
+ )}
88
+
89
+ {/* Pagination */}
90
+ {pagination && pagination.totalPages > 1 && (
91
+ <div className="pagination-wrapper mt-12">
92
+ <Pagination
93
+ currentPage={pagination.currentPage}
94
+ totalPages={pagination.totalPages}
95
+ baseUrl={pagination.baseUrl ?? "/blog"}
96
+ className="justify-center"
97
+ />
98
+ </div>
99
+ )}
100
+ </main>
101
+
102
+ {/* JSON-LD Structured Data */}
103
+ {includeStructuredData &&
104
+ site &&
105
+ (() => {
106
+ const structuredDataProps = createStructuredDataScript(
107
+ generateBlogHomeStructuredData(site, articles.slice(0, 5)),
108
+ );
109
+ return (
110
+ <script
111
+ type={structuredDataProps.type}
112
+ dangerouslySetInnerHTML={
113
+ structuredDataProps.dangerouslySetInnerHTML
114
+ }
115
+ />
116
+ );
117
+ })()}
118
+ </div>
119
+ );
120
+ }
121
+
122
+ /**
123
+ * Helper function to format reading time in minutes
124
+ */
125
+ function formatReadingTime(seconds: number): string {
126
+ const minutes = Math.ceil(seconds / 60);
127
+ return `${minutes} min read`;
128
+ }
129
+
130
+ /**
131
+ * Individual article preview component for the blog listing.
132
+ */
133
+ function ArticlePreview({ article }: { article: PublishedArticle }) {
134
+ const articleUrl = `/blog/${article.slug}`;
135
+ const formattedDate = formatDistanceToNow(new Date(article.publishedAt), {
136
+ addSuffix: true,
137
+ });
138
+ const readingTime = formatReadingTime(article.readingSeconds);
139
+
140
+ // Use the featured image URL provided by Bloggy (hosted on Bloggy's domain)
141
+ const imageUrl = article.featuredImage;
142
+
143
+ return (
144
+ <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
+ <Link
146
+ href={articleUrl}
147
+ className="focus-visible:ring-ring block rounded-lg hover:no-underline focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
148
+ aria-label={`Read article: ${article.title}`}
149
+ >
150
+ {/* Mobile/Small Tablet: Vertical layout */}
151
+ <div className="space-y-4 lg:hidden">
152
+ {imageUrl && (
153
+ <div className="relative aspect-[16/10] w-full overflow-hidden rounded-lg">
154
+ <Image
155
+ src={imageUrl}
156
+ alt={
157
+ article.featuredImageAlt ??
158
+ `Featured image for ${article.title}`
159
+ }
160
+ width={400}
161
+ height={250}
162
+ className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
163
+ />
164
+ </div>
165
+ )}
166
+
167
+ <div className="space-y-3">
168
+ <h2 className="group-hover:text-primary text-lg leading-tight font-bold transition-colors sm:text-xl md:text-2xl">
169
+ {article.title}
170
+ </h2>
171
+
172
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted-foreground">
173
+ {article.author && (
174
+ <>
175
+ <span className="font-medium">{article.author.name}</span>
176
+ <span className="text-border">•</span>
177
+ </>
178
+ )}
179
+ <time
180
+ dateTime={article.publishedAt}
181
+ className="font-medium"
182
+ aria-label={`Published ${formattedDate}`}
183
+ >
184
+ {formattedDate}
185
+ </time>
186
+ <span className="text-border">•</span>
187
+ <span className="font-medium">{readingTime}</span>
188
+ </div>
189
+
190
+ {article.excerpt && (
191
+ <p className="text-muted-foreground line-clamp-3 text-sm leading-relaxed sm:text-base">
192
+ {article.excerpt}
193
+ </p>
194
+ )}
195
+ </div>
196
+ </div>
197
+
198
+ {/* Large Tablet/Desktop: Horizontal layout */}
199
+ <div className="hidden items-start gap-6 lg:flex xl:gap-8">
200
+ {imageUrl && (
201
+ <div className="relative aspect-[16/10] w-64 shrink-0 overflow-hidden rounded-lg xl:w-72">
202
+ <Image
203
+ src={imageUrl}
204
+ alt={
205
+ article.featuredImageAlt ??
206
+ `Featured image for ${article.title}`
207
+ }
208
+ width={288}
209
+ height={180}
210
+ className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
211
+ />
212
+ </div>
213
+ )}
214
+
215
+ <div className="flex min-h-[180px] flex-1 flex-col justify-between space-y-4 xl:min-h-[200px]">
216
+ <div className="space-y-4">
217
+ <h2 className="group-hover:text-primary text-2xl leading-tight font-bold transition-colors xl:text-3xl">
218
+ {article.title}
219
+ </h2>
220
+
221
+ {article.excerpt && (
222
+ <p className="text-muted-foreground line-clamp-3 text-base leading-relaxed xl:text-lg">
223
+ {article.excerpt}
224
+ </p>
225
+ )}
226
+ </div>
227
+
228
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted-foreground">
229
+ {article.author && (
230
+ <>
231
+ <span className="font-medium">{article.author.name}</span>
232
+ <span className="text-border">•</span>
233
+ </>
234
+ )}
235
+ <time
236
+ dateTime={article.publishedAt}
237
+ className="font-medium"
238
+ aria-label={`Published ${formattedDate}`}
239
+ >
240
+ {formattedDate}
241
+ </time>
242
+ <span className="text-border">•</span>
243
+ <span className="font-medium">{readingTime}</span>
244
+ </div>
245
+ </div>
246
+ </div>
247
+ </Link>
248
+ </article>
249
+ );
250
+ }
251
+
252
+ /**
253
+ * Generate metadata for the blog home page for SEO optimization.
254
+ * This function should be used in the Next.js metadata API.
255
+ */
256
+ export function generateBlogHomeMetadata(
257
+ siteName?: string,
258
+ siteDescription?: string,
259
+ siteUrl?: string,
260
+ pagination?: {
261
+ currentPage: number;
262
+ totalPages: number;
263
+ },
264
+ ) {
265
+ const baseTitle = siteName ? `${siteName} - Blog` : "Blog";
266
+ const title =
267
+ pagination && pagination.currentPage > 1
268
+ ? `${baseTitle} - Page ${pagination.currentPage}`
269
+ : baseTitle;
270
+
271
+ const description =
272
+ siteDescription ?? "Read our latest articles and insights.";
273
+
274
+ // Generate pagination URLs for SEO
275
+ const baseUrl = "/blog";
276
+ const canonicalUrl = siteUrl
277
+ ? `${siteUrl}${pagination && pagination.currentPage > 1 ? `${baseUrl}?page=${pagination.currentPage}` : baseUrl}`
278
+ : undefined;
279
+
280
+ const prevUrl =
281
+ pagination && pagination.currentPage > 1
282
+ ? siteUrl
283
+ ? `${siteUrl}${pagination.currentPage === 2 ? baseUrl : `${baseUrl}?page=${pagination.currentPage - 1}`}`
284
+ : undefined
285
+ : undefined;
286
+
287
+ const nextUrl =
288
+ pagination && pagination.currentPage < pagination.totalPages
289
+ ? siteUrl
290
+ ? `${siteUrl}${baseUrl}?page=${pagination.currentPage + 1}`
291
+ : undefined
292
+ : undefined;
293
+
294
+ return {
295
+ title,
296
+ description,
297
+ openGraph: {
298
+ title,
299
+ description,
300
+ type: "website",
301
+ ...(siteUrl && { url: canonicalUrl }),
302
+ },
303
+ twitter: {
304
+ card: "summary_large_image",
305
+ title,
306
+ description,
307
+ },
308
+ alternates: {
309
+ ...(canonicalUrl && { canonical: canonicalUrl }),
310
+ },
311
+ other: {
312
+ ...(prevUrl && { prev: prevUrl }),
313
+ ...(nextUrl && { next: nextUrl }),
314
+ },
315
+ };
316
+ }