@xexr/blog-platform 1.1.4 → 1.1.7

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.
Files changed (39) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/app/blog/[slug]/page.tsx +10 -3
  3. package/dist/app/blog/page.tsx +12 -11
  4. package/dist/app/blog/series/[slug]/page.d.ts +28 -0
  5. package/dist/app/blog/series/[slug]/page.tsx +39 -0
  6. package/dist/app/blog/series/page.d.ts +24 -0
  7. package/dist/app/blog/series/page.tsx +28 -0
  8. package/dist/components/ArchiveStructuredData.tsx +61 -0
  9. package/dist/components/SeriesArchivePages.tsx +222 -0
  10. package/dist/components/SeriesNavigation.tsx +117 -0
  11. package/dist/components/TopicArchivePages.test.ts +52 -4
  12. package/dist/components/TopicArchivePages.tsx +14 -4
  13. package/dist/{index-Dr8HaKGm.d.mts → index-DsXjuPn4.d.mts} +45 -1
  14. package/dist/{index-Dr8HaKGm.d.ts → index-DsXjuPn4.d.ts} +45 -1
  15. package/dist/index.d.mts +86 -5
  16. package/dist/index.d.ts +86 -5
  17. package/dist/index.js +14 -14
  18. package/dist/index.js.map +1 -1
  19. package/dist/index.mjs +14 -14
  20. package/dist/index.mjs.map +1 -1
  21. package/dist/lib/related-articles.d.mts +3 -2
  22. package/dist/lib/related-articles.d.ts +3 -2
  23. package/dist/lib/series.ts +55 -0
  24. package/dist/lib/sitemap.ts +78 -46
  25. package/dist/lib/storage/drizzle.ts +9 -0
  26. package/dist/lib/structured-data.ts +10 -11
  27. package/dist/lib/urls.ts +7 -0
  28. package/dist/{sitemap-BmCh8BpN.d.ts → sitemap-B64QGT4l.d.ts} +11 -4
  29. package/dist/{sitemap-Dbl4CUGI.d.mts → sitemap-Dy7Q4ATM.d.mts} +11 -4
  30. package/dist/sitemap.d.mts +2 -2
  31. package/dist/sitemap.d.ts +2 -2
  32. package/dist/sitemap.js +5 -5
  33. package/dist/sitemap.js.map +1 -1
  34. package/dist/sitemap.mjs +5 -5
  35. package/dist/sitemap.mjs.map +1 -1
  36. package/dist/types/contracts.ts +61 -0
  37. package/dist/types/index.d.mts +1 -1
  38. package/dist/types/index.d.ts +1 -1
  39. package/package.json +12 -13
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.7
4
+
5
+ ### Patch Changes
6
+
7
+ - Correct article subjects and author expertise, add topic and series archive structured data, and omit paginated URLs from sitemaps.
8
+ - 5e14632: Raise the `next` peer dependency range to `^15.5.28 || ^16.4.1`, excluding Next.js releases affected by the October 2026 Next.js security advisories (GHSA-2464-fhr4-w9xw, GHSA-p3m5-59mp-jv5r, GHSA-673q-mjm6-r5xr).
9
+
3
10
  ## 1.1.1
4
11
 
5
12
  ### Patch Changes
@@ -40,6 +47,7 @@
40
47
  This release requires consumers to use drizzle-orm >=0.44 and @libsql/client >=0.15.
41
48
 
42
49
  Updated packages:
50
+
43
51
  - @libsql/client: ^0.14.0 → ^0.15.0
44
52
  - drizzle-orm: ^0.38.0 → ^0.44.0
45
53
  - @radix-ui/react-separator: ^1.1.0 → ^1.1.1
@@ -28,6 +28,7 @@ import {
28
28
  normalizeBlogBasePath,
29
29
  } from "../../../lib/urls";
30
30
  import { NewsletterSignup } from "../../../components/NewsletterSignup";
31
+ import { SeriesNavigation } from "../../../components/SeriesNavigation";
31
32
  import {
32
33
  BLOG_PAGE_TITLE_GRADIENT_CLASS,
33
34
  type BlogTheme,
@@ -162,8 +163,8 @@ export default function ArticlePage({
162
163
  const imageUrl = article.featuredImage;
163
164
  const hasFeaturedImageMeta = Boolean(
164
165
  article.featuredImageCaption ??
165
- article.featuredImageCreditText ??
166
- article.featuredImageCreditUrl,
166
+ article.featuredImageCreditText ??
167
+ article.featuredImageCreditUrl,
167
168
  );
168
169
  const resolvedRelatedArticles =
169
170
  relatedArticles.length > 0
@@ -386,7 +387,7 @@ export default function ArticlePage({
386
387
 
387
388
  <div
388
389
  id="article-body"
389
- 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-text-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-a:text-[var(--blog-text-accent)] prose-a:transition-colors hover:prose-a:text-[var(--blog-text-accent-hover)] [&_figure>p]:!my-0 [&_figcaption]:!mt-2 [&_figcaption]:text-center [&_figcaption]:!text-sm [&_figcaption]:!leading-snug [&_figcaption]:text-muted-foreground [&_figcaption_.blog-image-credit]:mt-1 [&_figcaption_.blog-image-credit]:block [&_figcaption_.blog-image-credit]:text-xs [&_figcaption_.blog-image-credit]:tracking-wide [&_figcaption_.blog-image-credit]:text-muted-foreground/80 [&_figcaption>span:not(.blog-image-credit)]:ml-2 [&_figcaption>span:not(.blog-image-credit)]:border-l [&_figcaption>span:not(.blog-image-credit)]:border-[var(--blog-accent-border)] [&_figcaption>span:not(.blog-image-credit)]:pl-2 [&_figcaption>span:not(.blog-image-credit)]:text-xs [&_figcaption>span:not(.blog-image-credit)]:tracking-wide [&_figcaption>span:not(.blog-image-credit)]:text-muted-foreground/80 max-w-none"
390
+ 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-text-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-a:text-[var(--blog-text-accent)] prose-a:transition-colors hover:prose-a:text-[var(--blog-text-accent-hover)] [&_figcaption]:text-muted-foreground [&_figcaption_.blog-image-credit]:text-muted-foreground/80 [&_figcaption>span:not(.blog-image-credit)]:text-muted-foreground/80 max-w-none [&_figcaption]:!mt-2 [&_figcaption]:text-center [&_figcaption]:!text-sm [&_figcaption]:!leading-snug [&_figcaption_.blog-image-credit]:mt-1 [&_figcaption_.blog-image-credit]:block [&_figcaption_.blog-image-credit]:text-xs [&_figcaption_.blog-image-credit]:tracking-wide [&_figcaption>span:not(.blog-image-credit)]:ml-2 [&_figcaption>span:not(.blog-image-credit)]:border-l [&_figcaption>span:not(.blog-image-credit)]:border-[var(--blog-accent-border)] [&_figcaption>span:not(.blog-image-credit)]:pl-2 [&_figcaption>span:not(.blog-image-credit)]:text-xs [&_figcaption>span:not(.blog-image-credit)]:tracking-wide [&_figure>p]:!my-0"
390
391
  >
391
392
  <MDXRemote
392
393
  source={article.content}
@@ -404,6 +405,12 @@ export default function ArticlePage({
404
405
  />
405
406
  </div>
406
407
 
408
+ <SeriesNavigation
409
+ article={article}
410
+ basePath={normalizedBasePath}
411
+ className="mx-auto mt-12 max-w-4xl"
412
+ />
413
+
407
414
  {/* Author Bio */}
408
415
  {article.author?.bio && (
409
416
  <div id="about-author" className="author-bio mx-auto mt-12 max-w-4xl">
@@ -117,6 +117,12 @@ export default function BlogHomePage({
117
117
  >
118
118
  Topics
119
119
  </Link>
120
+ <Link
121
+ href={buildBlogPath(normalizedBasePath, "series")}
122
+ className="text-muted-foreground rounded-full border border-transparent px-3 py-1.5 font-medium transition-colors hover:border-[var(--blog-accent-border)] hover:text-[var(--blog-accent)] hover:no-underline"
123
+ >
124
+ Series
125
+ </Link>
120
126
  <Link
121
127
  href={buildBlogPath(normalizedBasePath, "authors")}
122
128
  className="text-muted-foreground rounded-full border border-transparent px-3 py-1.5 font-medium transition-colors hover:border-[var(--blog-accent-border)] hover:text-[var(--blog-accent)] hover:no-underline"
@@ -282,11 +288,7 @@ function ArticlePreview({
282
288
  {(article.topics ?? []).length > 0 && (
283
289
  <div className="flex flex-wrap gap-2">
284
290
  {(article.topics ?? []).map((topic) => (
285
- <TopicChip
286
- key={topic.id}
287
- topic={topic}
288
- basePath={basePath}
289
- />
291
+ <TopicChip key={topic.id} topic={topic} basePath={basePath} />
290
292
  ))}
291
293
  </div>
292
294
  )}
@@ -334,11 +336,7 @@ function ArticlePreview({
334
336
  {(article.topics ?? []).length > 0 && (
335
337
  <div className="flex flex-wrap gap-2">
336
338
  {(article.topics ?? []).map((topic) => (
337
- <TopicChip
338
- key={topic.id}
339
- topic={topic}
340
- basePath={basePath}
341
- />
339
+ <TopicChip key={topic.id} topic={topic} basePath={basePath} />
342
340
  ))}
343
341
  </div>
344
342
  )}
@@ -383,7 +381,10 @@ function TopicChip({
383
381
  }
384
382
 
385
383
  return (
386
- <Link href={buildBlogPath(basePath, `topics/${slug}`)} className={className}>
384
+ <Link
385
+ href={buildBlogPath(basePath, `topics/${slug}`)}
386
+ className={className}
387
+ >
387
388
  {topic.name}
388
389
  </Link>
389
390
  );
@@ -0,0 +1,28 @@
1
+ import type { ReactElement } from "react";
2
+ import type { PublishedArticle } from "../../../../types/contracts";
3
+ import type { BlogTheme } from "../../../../lib/article-ui";
4
+
5
+ export interface BlogSeriesArchiveRoutePageProps {
6
+ articles: PublishedArticle[];
7
+ seriesSlug: string;
8
+ basePath?: string;
9
+ className?: string;
10
+ theme?: BlogTheme;
11
+ }
12
+
13
+ export default function BlogSeriesArchiveRoutePage(
14
+ props: BlogSeriesArchiveRoutePageProps,
15
+ ): ReactElement;
16
+
17
+ export function generateSeriesArchiveMetadata(options: {
18
+ title: string;
19
+ description?: string;
20
+ slug: string;
21
+ basePath?: string;
22
+ }): {
23
+ title: string;
24
+ description: string;
25
+ alternates: {
26
+ canonical: string;
27
+ };
28
+ };
@@ -0,0 +1,39 @@
1
+ import React from "react";
2
+
3
+ import { BlogSeriesArchivePage } from "../../../../components/SeriesArchivePages";
4
+ import type { PublishedArticle } from "../../../../types/contracts";
5
+ import type { BlogTheme } from "../../../../lib/article-ui";
6
+
7
+ export interface BlogSeriesArchiveRoutePageProps {
8
+ articles: PublishedArticle[];
9
+ seriesSlug: string;
10
+ basePath?: string;
11
+ className?: string;
12
+ theme?: BlogTheme;
13
+ }
14
+
15
+ export default function BlogSeriesArchiveRoutePage(
16
+ props: BlogSeriesArchiveRoutePageProps,
17
+ ) {
18
+ return <BlogSeriesArchivePage {...props} />;
19
+ }
20
+
21
+ export function generateSeriesArchiveMetadata({
22
+ title,
23
+ description,
24
+ slug,
25
+ basePath = "/blog",
26
+ }: {
27
+ title: string;
28
+ description?: string;
29
+ slug: string;
30
+ basePath?: string;
31
+ }) {
32
+ return {
33
+ title,
34
+ description: description ?? `Read the ${title} article series.`,
35
+ alternates: {
36
+ canonical: `${basePath.replace(/\/$/, "")}/series/${slug}`,
37
+ },
38
+ };
39
+ }
@@ -0,0 +1,24 @@
1
+ import type { ReactElement } from "react";
2
+ import type { PublishedArticle } from "../../../types/contracts";
3
+ import type { BlogTheme } from "../../../lib/article-ui";
4
+
5
+ export interface BlogSeriesPageProps {
6
+ articles: PublishedArticle[];
7
+ basePath?: string;
8
+ title?: string;
9
+ description?: string;
10
+ className?: string;
11
+ theme?: BlogTheme;
12
+ }
13
+
14
+ export default function BlogSeriesPage(
15
+ props: BlogSeriesPageProps,
16
+ ): ReactElement;
17
+
18
+ export function generateSeriesIndexMetadata(basePath?: string): {
19
+ title: string;
20
+ description: string;
21
+ alternates: {
22
+ canonical: string;
23
+ };
24
+ };
@@ -0,0 +1,28 @@
1
+ import React from "react";
2
+
3
+ import { BlogSeriesIndexPage } from "../../../components/SeriesArchivePages";
4
+ import type { PublishedArticle } from "../../../types/contracts";
5
+ import type { BlogTheme } from "../../../lib/article-ui";
6
+
7
+ export interface BlogSeriesPageProps {
8
+ articles: PublishedArticle[];
9
+ basePath?: string;
10
+ title?: string;
11
+ description?: string;
12
+ className?: string;
13
+ theme?: BlogTheme;
14
+ }
15
+
16
+ export default function BlogSeriesPage(props: BlogSeriesPageProps) {
17
+ return <BlogSeriesIndexPage {...props} />;
18
+ }
19
+
20
+ export function generateSeriesIndexMetadata(basePath = "/blog") {
21
+ return {
22
+ title: "Series",
23
+ description: "Browse related article series.",
24
+ alternates: {
25
+ canonical: `${basePath.replace(/\/$/, "")}/series`,
26
+ },
27
+ };
28
+ }
@@ -0,0 +1,61 @@
1
+ import React from "react";
2
+ import type { PublishedArticle } from "../types/contracts";
3
+ import {
4
+ generateBreadcrumbStructuredData,
5
+ structuredDataToJsonLd,
6
+ } from "../lib/structured-data";
7
+ import { buildBlogPath } from "../lib/urls";
8
+
9
+ export function ArchiveStructuredData({
10
+ articles,
11
+ title,
12
+ kind,
13
+ slug,
14
+ basePath,
15
+ }: {
16
+ articles: PublishedArticle[];
17
+ title: string;
18
+ kind: "topics" | "series";
19
+ slug: string;
20
+ basePath: string;
21
+ }) {
22
+ const site = articles[0]?.site;
23
+ if (!site) return null;
24
+ const domain = site.domain;
25
+ const archivePath = buildBlogPath(basePath, `${kind}/${slug}`);
26
+ const collection = {
27
+ "@context": "https://schema.org" as const,
28
+ "@type": "CollectionPage",
29
+ name: title,
30
+ url: `https://${domain}${archivePath}`,
31
+ mainEntity: {
32
+ "@type": "ItemList",
33
+ itemListElement: articles.map((article, index) => ({
34
+ "@type": "ListItem",
35
+ position: index + 1,
36
+ name: article.title,
37
+ url: `https://${domain}${buildBlogPath(basePath, article.slug)}`,
38
+ })),
39
+ },
40
+ };
41
+ const breadcrumbs = generateBreadcrumbStructuredData(
42
+ [
43
+ { name: "Home", url: "/" },
44
+ { name: site.name, url: basePath },
45
+ {
46
+ name: kind === "topics" ? "Topics" : "Series",
47
+ url: buildBlogPath(basePath, kind),
48
+ },
49
+ { name: title, url: archivePath },
50
+ ],
51
+ { customDomain: domain },
52
+ );
53
+ return (
54
+ <script
55
+ type="application/ld+json"
56
+ dangerouslySetInnerHTML={{
57
+ __html: structuredDataToJsonLd([collection, breadcrumbs]),
58
+ }}
59
+ />
60
+ );
61
+ }
@@ -0,0 +1,222 @@
1
+ import React from "react";
2
+ import { ArchiveStructuredData } from "./ArchiveStructuredData";
3
+ import Image from "next/image.js";
4
+ import Link from "next/link.js";
5
+ import { formatDistanceToNow } from "date-fns";
6
+
7
+ import type { PublishedArticle } from "../types/contracts";
8
+ import type { BlogTheme } from "../lib/article-ui";
9
+ import {
10
+ BLOG_PAGE_TITLE_GRADIENT_CLASS,
11
+ getBlogThemeStyle,
12
+ } from "../lib/article-ui";
13
+ import { getAllSeries, getSeriesArchive } from "../lib/series";
14
+ import {
15
+ buildBlogPath,
16
+ buildSeriesPath,
17
+ normalizeBlogBasePath,
18
+ } from "../lib/urls";
19
+ import { cn } from "../lib/utils";
20
+
21
+ interface BlogSeriesIndexPageProps {
22
+ articles: PublishedArticle[];
23
+ basePath?: string;
24
+ title?: string;
25
+ description?: string;
26
+ className?: string;
27
+ theme?: BlogTheme;
28
+ }
29
+
30
+ interface BlogSeriesArchivePageProps {
31
+ articles: PublishedArticle[];
32
+ seriesSlug: string;
33
+ basePath?: string;
34
+ className?: string;
35
+ theme?: BlogTheme;
36
+ }
37
+
38
+ export function BlogSeriesIndexPage({
39
+ articles,
40
+ basePath = "/blog",
41
+ title = "Series",
42
+ description = "Browse related article series.",
43
+ className,
44
+ theme,
45
+ }: BlogSeriesIndexPageProps) {
46
+ const normalizedBasePath = normalizeBlogBasePath(basePath) || "/";
47
+ const allSeries = getAllSeries(articles);
48
+
49
+ return (
50
+ <main
51
+ style={getBlogThemeStyle(theme)}
52
+ className={cn(
53
+ "blog-series-page container mx-auto max-w-5xl px-4 py-8 sm:px-6 sm:py-12 lg:px-8 lg:py-16",
54
+ className,
55
+ )}
56
+ >
57
+ <header className="mb-10 space-y-3 text-center">
58
+ <Link
59
+ href={normalizedBasePath}
60
+ className="text-muted-foreground text-sm font-medium transition-colors hover:text-[var(--blog-accent)]"
61
+ >
62
+ Back to all articles
63
+ </Link>
64
+ <h1
65
+ className={cn(
66
+ "text-4xl font-light tracking-tight text-balance sm:text-5xl",
67
+ BLOG_PAGE_TITLE_GRADIENT_CLASS,
68
+ )}
69
+ >
70
+ {title}
71
+ </h1>
72
+ <p className="text-muted-foreground mx-auto max-w-2xl text-lg text-pretty">
73
+ {description}
74
+ </p>
75
+ </header>
76
+
77
+ {allSeries.length === 0 ? (
78
+ <p className="text-muted-foreground rounded-2xl border p-8 text-center">
79
+ No series have published articles yet.
80
+ </p>
81
+ ) : (
82
+ <div className="grid gap-4 md:grid-cols-2">
83
+ {allSeries.map((series) => (
84
+ <Link
85
+ key={series.slug}
86
+ href={buildSeriesPath(normalizedBasePath, series.slug)}
87
+ className="group rounded-2xl border border-[var(--blog-accent-border)] bg-[var(--blog-card-bg)] p-5 transition hover:border-[var(--blog-accent)] hover:no-underline"
88
+ >
89
+ <p className="text-muted-foreground mb-2 text-sm font-medium">
90
+ {series.total} {series.total === 1 ? "article" : "articles"}
91
+ </p>
92
+ <h2 className="text-xl font-semibold transition-colors group-hover:text-[var(--blog-accent)]">
93
+ {series.title}
94
+ </h2>
95
+ {series.description && (
96
+ <p className="text-muted-foreground mt-2 line-clamp-3 text-sm leading-relaxed">
97
+ {series.description}
98
+ </p>
99
+ )}
100
+ </Link>
101
+ ))}
102
+ </div>
103
+ )}
104
+ </main>
105
+ );
106
+ }
107
+
108
+ export function BlogSeriesArchivePage({
109
+ articles,
110
+ seriesSlug,
111
+ basePath = "/blog",
112
+ className,
113
+ theme,
114
+ }: BlogSeriesArchivePageProps) {
115
+ const normalizedBasePath = normalizeBlogBasePath(basePath) || "/";
116
+ const archive = getSeriesArchive(articles, seriesSlug);
117
+
118
+ return (
119
+ <main
120
+ style={getBlogThemeStyle(theme)}
121
+ className={cn(
122
+ "blog-series-archive-page container mx-auto max-w-5xl px-4 py-8 sm:px-6 sm:py-12 lg:px-8 lg:py-16",
123
+ className,
124
+ )}
125
+ >
126
+ {archive && (
127
+ <ArchiveStructuredData
128
+ articles={archive.articles}
129
+ title={archive.series.title}
130
+ kind="series"
131
+ slug={seriesSlug}
132
+ basePath={normalizedBasePath}
133
+ />
134
+ )}
135
+ <header className="mb-10 space-y-3 text-center">
136
+ <Link
137
+ href={buildBlogPath(normalizedBasePath, "series")}
138
+ className="text-muted-foreground text-sm font-medium transition-colors hover:text-[var(--blog-accent)]"
139
+ >
140
+ Back to all series
141
+ </Link>
142
+ <h1
143
+ className={cn(
144
+ "text-4xl font-light tracking-tight text-balance sm:text-5xl",
145
+ BLOG_PAGE_TITLE_GRADIENT_CLASS,
146
+ )}
147
+ >
148
+ {archive?.series.title ?? "Series not found"}
149
+ </h1>
150
+ {archive?.series.description && (
151
+ <p className="text-muted-foreground mx-auto max-w-2xl text-lg text-pretty">
152
+ {archive.series.description}
153
+ </p>
154
+ )}
155
+ </header>
156
+
157
+ {!archive ? (
158
+ <p className="text-muted-foreground rounded-2xl border p-8 text-center">
159
+ No published articles were found for this series.
160
+ </p>
161
+ ) : (
162
+ <div className="grid gap-5 md:grid-cols-2">
163
+ {archive.articles.map((article) => (
164
+ <SeriesArticleCard
165
+ key={article.id}
166
+ article={article}
167
+ basePath={normalizedBasePath}
168
+ />
169
+ ))}
170
+ </div>
171
+ )}
172
+ </main>
173
+ );
174
+ }
175
+
176
+ function SeriesArticleCard({
177
+ article,
178
+ basePath,
179
+ }: {
180
+ article: PublishedArticle;
181
+ basePath: string;
182
+ }) {
183
+ const articleUrl = buildBlogPath(basePath, article.slug);
184
+ const publishedAgo = formatDistanceToNow(new Date(article.publishedAt), {
185
+ addSuffix: true,
186
+ });
187
+
188
+ return (
189
+ <Link
190
+ href={articleUrl}
191
+ className="group overflow-hidden rounded-2xl border border-[var(--blog-accent-border)] bg-[var(--blog-card-bg)] transition hover:border-[var(--blog-accent)] hover:no-underline"
192
+ >
193
+ {article.featuredImage && (
194
+ <div className="relative aspect-[16/9] overflow-hidden">
195
+ <Image
196
+ src={article.featuredImage}
197
+ alt={article.featuredImageAlt ?? article.title}
198
+ width={640}
199
+ height={360}
200
+ className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
201
+ />
202
+ </div>
203
+ )}
204
+ <div className="space-y-3 p-5">
205
+ {article.series && (
206
+ <span className="inline-flex rounded-full border border-[var(--blog-accent-border)] px-2.5 py-1 text-xs font-medium text-[var(--blog-text-accent)]">
207
+ Part {article.series.position}
208
+ </span>
209
+ )}
210
+ <h2 className="text-xl font-semibold text-balance transition-colors group-hover:text-[var(--blog-accent)]">
211
+ {article.title}
212
+ </h2>
213
+ {article.excerpt && (
214
+ <p className="text-muted-foreground line-clamp-3 text-sm leading-relaxed">
215
+ {article.excerpt}
216
+ </p>
217
+ )}
218
+ <p className="text-muted-foreground text-sm">{publishedAgo}</p>
219
+ </div>
220
+ </Link>
221
+ );
222
+ }
@@ -0,0 +1,117 @@
1
+ import React from "react";
2
+ import Link from "next/link.js";
3
+
4
+ import type { PublishedArticle } from "../types/contracts";
5
+ import { buildBlogPath, buildSeriesPath } from "../lib/urls";
6
+ import { cn } from "../lib/utils";
7
+
8
+ interface SeriesNavigationProps {
9
+ article: PublishedArticle;
10
+ basePath?: string;
11
+ className?: string;
12
+ }
13
+
14
+ export function SeriesNavigation({
15
+ article,
16
+ basePath = "/blog",
17
+ className,
18
+ }: SeriesNavigationProps) {
19
+ const series = article.series;
20
+
21
+ if (!series) {
22
+ return null;
23
+ }
24
+
25
+ return (
26
+ <section
27
+ aria-labelledby="article-series-title"
28
+ className={cn(
29
+ "rounded-2xl border border-[var(--blog-accent-border)] bg-[var(--blog-accent-bg)] p-5 sm:p-6",
30
+ className,
31
+ )}
32
+ >
33
+ <div className="flex flex-col gap-5 md:flex-row md:items-start md:justify-between">
34
+ <div className="space-y-2">
35
+ <p className="text-muted-foreground text-sm font-medium">
36
+ Part {series.position} of {series.total}
37
+ </p>
38
+ <h2 id="article-series-title" className="text-xl font-semibold">
39
+ <Link
40
+ href={buildSeriesPath(basePath, series.slug)}
41
+ className="transition-colors hover:text-[var(--blog-accent)] hover:no-underline"
42
+ >
43
+ {series.title}
44
+ </Link>
45
+ </h2>
46
+ {series.description && (
47
+ <p className="text-muted-foreground max-w-2xl leading-relaxed">
48
+ {series.description}
49
+ </p>
50
+ )}
51
+ </div>
52
+
53
+ {(series.previous ?? series.next) && (
54
+ <div className="grid shrink-0 gap-2 sm:grid-cols-2 md:min-w-64 md:grid-cols-1">
55
+ {series.previous && (
56
+ <Link
57
+ href={buildBlogPath(basePath, series.previous.slug)}
58
+ className="rounded-xl border border-[var(--blog-accent-border)] bg-[var(--blog-card-bg)] px-3 py-2 text-sm transition-colors hover:border-[var(--blog-accent)] hover:no-underline"
59
+ >
60
+ <span className="text-muted-foreground block text-xs">
61
+ Previous
62
+ </span>
63
+ <span className="line-clamp-2 font-medium">
64
+ {series.previous.title}
65
+ </span>
66
+ </Link>
67
+ )}
68
+ {series.next && (
69
+ <Link
70
+ href={buildBlogPath(basePath, series.next.slug)}
71
+ className="rounded-xl border border-[var(--blog-accent-border)] bg-[var(--blog-card-bg)] px-3 py-2 text-sm transition-colors hover:border-[var(--blog-accent)] hover:no-underline"
72
+ >
73
+ <span className="text-muted-foreground block text-xs">
74
+ Next
75
+ </span>
76
+ <span className="line-clamp-2 font-medium">
77
+ {series.next.title}
78
+ </span>
79
+ </Link>
80
+ )}
81
+ </div>
82
+ )}
83
+ </div>
84
+
85
+ {series.articles.length > 0 && (
86
+ <ol className="mt-5 grid gap-2">
87
+ {series.articles.map((seriesArticle) => (
88
+ <li key={seriesArticle.id}>
89
+ <Link
90
+ href={buildBlogPath(basePath, seriesArticle.slug)}
91
+ aria-current={seriesArticle.isCurrent ? "page" : undefined}
92
+ className={cn(
93
+ "hover:bg-background/60 flex gap-3 rounded-xl px-3 py-2 text-sm transition-colors hover:no-underline",
94
+ seriesArticle.isCurrent && "bg-background/70",
95
+ )}
96
+ >
97
+ <span className="text-muted-foreground shrink-0 tabular-nums">
98
+ {seriesArticle.position}
99
+ </span>
100
+ <span
101
+ className={cn(
102
+ "line-clamp-2",
103
+ seriesArticle.isCurrent
104
+ ? "font-semibold text-[var(--blog-text-accent)]"
105
+ : "font-medium",
106
+ )}
107
+ >
108
+ {seriesArticle.title}
109
+ </span>
110
+ </Link>
111
+ </li>
112
+ ))}
113
+ </ol>
114
+ )}
115
+ </section>
116
+ );
117
+ }