@xexr/blog-platform 1.1.4 → 1.1.5

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.
@@ -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,212 @@
1
+ import React from "react";
2
+ import Image from "next/image.js";
3
+ import Link from "next/link.js";
4
+ import { formatDistanceToNow } from "date-fns";
5
+
6
+ import type { PublishedArticle } from "../types/contracts";
7
+ import type { BlogTheme } from "../lib/article-ui";
8
+ import {
9
+ BLOG_PAGE_TITLE_GRADIENT_CLASS,
10
+ getBlogThemeStyle,
11
+ } from "../lib/article-ui";
12
+ import { getAllSeries, getSeriesArchive } from "../lib/series";
13
+ import {
14
+ buildBlogPath,
15
+ buildSeriesPath,
16
+ normalizeBlogBasePath,
17
+ } from "../lib/urls";
18
+ import { cn } from "../lib/utils";
19
+
20
+ interface BlogSeriesIndexPageProps {
21
+ articles: PublishedArticle[];
22
+ basePath?: string;
23
+ title?: string;
24
+ description?: string;
25
+ className?: string;
26
+ theme?: BlogTheme;
27
+ }
28
+
29
+ interface BlogSeriesArchivePageProps {
30
+ articles: PublishedArticle[];
31
+ seriesSlug: string;
32
+ basePath?: string;
33
+ className?: string;
34
+ theme?: BlogTheme;
35
+ }
36
+
37
+ export function BlogSeriesIndexPage({
38
+ articles,
39
+ basePath = "/blog",
40
+ title = "Series",
41
+ description = "Browse related article series.",
42
+ className,
43
+ theme,
44
+ }: BlogSeriesIndexPageProps) {
45
+ const normalizedBasePath = normalizeBlogBasePath(basePath) || "/";
46
+ const allSeries = getAllSeries(articles);
47
+
48
+ return (
49
+ <main
50
+ style={getBlogThemeStyle(theme)}
51
+ className={cn(
52
+ "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",
53
+ className,
54
+ )}
55
+ >
56
+ <header className="mb-10 space-y-3 text-center">
57
+ <Link
58
+ href={normalizedBasePath}
59
+ className="text-muted-foreground text-sm font-medium transition-colors hover:text-[var(--blog-accent)]"
60
+ >
61
+ Back to all articles
62
+ </Link>
63
+ <h1
64
+ className={cn(
65
+ "text-4xl font-light tracking-tight text-balance sm:text-5xl",
66
+ BLOG_PAGE_TITLE_GRADIENT_CLASS,
67
+ )}
68
+ >
69
+ {title}
70
+ </h1>
71
+ <p className="text-muted-foreground mx-auto max-w-2xl text-lg text-pretty">
72
+ {description}
73
+ </p>
74
+ </header>
75
+
76
+ {allSeries.length === 0 ? (
77
+ <p className="text-muted-foreground rounded-2xl border p-8 text-center">
78
+ No series have published articles yet.
79
+ </p>
80
+ ) : (
81
+ <div className="grid gap-4 md:grid-cols-2">
82
+ {allSeries.map((series) => (
83
+ <Link
84
+ key={series.slug}
85
+ href={buildSeriesPath(normalizedBasePath, series.slug)}
86
+ 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"
87
+ >
88
+ <p className="text-muted-foreground mb-2 text-sm font-medium">
89
+ {series.total} {series.total === 1 ? "article" : "articles"}
90
+ </p>
91
+ <h2 className="text-xl font-semibold transition-colors group-hover:text-[var(--blog-accent)]">
92
+ {series.title}
93
+ </h2>
94
+ {series.description && (
95
+ <p className="text-muted-foreground mt-2 line-clamp-3 text-sm leading-relaxed">
96
+ {series.description}
97
+ </p>
98
+ )}
99
+ </Link>
100
+ ))}
101
+ </div>
102
+ )}
103
+ </main>
104
+ );
105
+ }
106
+
107
+ export function BlogSeriesArchivePage({
108
+ articles,
109
+ seriesSlug,
110
+ basePath = "/blog",
111
+ className,
112
+ theme,
113
+ }: BlogSeriesArchivePageProps) {
114
+ const normalizedBasePath = normalizeBlogBasePath(basePath) || "/";
115
+ const archive = getSeriesArchive(articles, seriesSlug);
116
+
117
+ return (
118
+ <main
119
+ style={getBlogThemeStyle(theme)}
120
+ className={cn(
121
+ "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",
122
+ className,
123
+ )}
124
+ >
125
+ <header className="mb-10 space-y-3 text-center">
126
+ <Link
127
+ href={buildBlogPath(normalizedBasePath, "series")}
128
+ className="text-muted-foreground text-sm font-medium transition-colors hover:text-[var(--blog-accent)]"
129
+ >
130
+ Back to all series
131
+ </Link>
132
+ <h1
133
+ className={cn(
134
+ "text-4xl font-light tracking-tight text-balance sm:text-5xl",
135
+ BLOG_PAGE_TITLE_GRADIENT_CLASS,
136
+ )}
137
+ >
138
+ {archive?.series.title ?? "Series not found"}
139
+ </h1>
140
+ {archive?.series.description && (
141
+ <p className="text-muted-foreground mx-auto max-w-2xl text-lg text-pretty">
142
+ {archive.series.description}
143
+ </p>
144
+ )}
145
+ </header>
146
+
147
+ {!archive ? (
148
+ <p className="text-muted-foreground rounded-2xl border p-8 text-center">
149
+ No published articles were found for this series.
150
+ </p>
151
+ ) : (
152
+ <div className="grid gap-5 md:grid-cols-2">
153
+ {archive.articles.map((article) => (
154
+ <SeriesArticleCard
155
+ key={article.id}
156
+ article={article}
157
+ basePath={normalizedBasePath}
158
+ />
159
+ ))}
160
+ </div>
161
+ )}
162
+ </main>
163
+ );
164
+ }
165
+
166
+ function SeriesArticleCard({
167
+ article,
168
+ basePath,
169
+ }: {
170
+ article: PublishedArticle;
171
+ basePath: string;
172
+ }) {
173
+ const articleUrl = buildBlogPath(basePath, article.slug);
174
+ const publishedAgo = formatDistanceToNow(new Date(article.publishedAt), {
175
+ addSuffix: true,
176
+ });
177
+
178
+ return (
179
+ <Link
180
+ href={articleUrl}
181
+ 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"
182
+ >
183
+ {article.featuredImage && (
184
+ <div className="relative aspect-[16/9] overflow-hidden">
185
+ <Image
186
+ src={article.featuredImage}
187
+ alt={article.featuredImageAlt ?? article.title}
188
+ width={640}
189
+ height={360}
190
+ className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
191
+ />
192
+ </div>
193
+ )}
194
+ <div className="space-y-3 p-5">
195
+ {article.series && (
196
+ <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)]">
197
+ Part {article.series.position}
198
+ </span>
199
+ )}
200
+ <h2 className="text-xl font-semibold text-balance transition-colors group-hover:text-[var(--blog-accent)]">
201
+ {article.title}
202
+ </h2>
203
+ {article.excerpt && (
204
+ <p className="text-muted-foreground line-clamp-3 text-sm leading-relaxed">
205
+ {article.excerpt}
206
+ </p>
207
+ )}
208
+ <p className="text-muted-foreground text-sm">{publishedAgo}</p>
209
+ </div>
210
+ </Link>
211
+ );
212
+ }
@@ -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
+ }
@@ -122,6 +122,46 @@ interface PublishedTopic {
122
122
  /** Whether this is the article's primary topic */
123
123
  readonly isPrimary: boolean;
124
124
  }
125
+ /**
126
+ * Compact link metadata for an article within a published series.
127
+ */
128
+ interface PublishedSeriesArticleLink {
129
+ /** Unique article identifier */
130
+ readonly id: string;
131
+ /** URL-friendly article slug */
132
+ readonly slug: string;
133
+ /** Display title */
134
+ readonly title: string;
135
+ /** Ordered position within the series */
136
+ readonly position: number;
137
+ /** Whether this entry is the current article */
138
+ readonly isCurrent: boolean;
139
+ /** Optional excerpt for archive/list rendering */
140
+ readonly excerpt?: string;
141
+ }
142
+ /**
143
+ * Reader-facing series context for a published article.
144
+ */
145
+ interface PublishedSeriesContext {
146
+ /** Unique series identifier */
147
+ readonly id: string;
148
+ /** Display title */
149
+ readonly title: string;
150
+ /** URL-friendly series slug */
151
+ readonly slug: string;
152
+ /** Optional public description */
153
+ readonly description?: string;
154
+ /** Current article position in the series */
155
+ readonly position: number;
156
+ /** Number of visible articles in the series context */
157
+ readonly total: number;
158
+ /** Previous published article in the series, when available */
159
+ readonly previous?: PublishedSeriesArticleLink;
160
+ /** Next published article in the series, when available */
161
+ readonly next?: PublishedSeriesArticleLink;
162
+ /** Ordered article list for compact navigation and archive pages */
163
+ readonly articles: readonly PublishedSeriesArticleLink[];
164
+ }
125
165
  /**
126
166
  * Represents a published article with all necessary metadata for rendering
127
167
  * on external blog platforms.
@@ -153,6 +193,8 @@ interface PublishedArticle {
153
193
  readonly cta?: PublishedArticleCta;
154
194
  /** Public topic taxonomy assigned to the article */
155
195
  readonly topics?: readonly PublishedTopic[];
196
+ /** Optional ordered series context for article navigation */
197
+ readonly series?: PublishedSeriesContext;
156
198
  /** Featured image URL (optional) */
157
199
  readonly featuredImage?: string;
158
200
  /** Alt text for featured image */
@@ -226,4 +268,4 @@ interface PublishResult {
226
268
  readonly idempotencyKey: string;
227
269
  }
228
270
 
229
- export type { PublishedTopic as P, PublishedArticle as a, PublishedSite as b, PublishedAuthor as c, PublishedNewsletterSettings as d, PublishedArticleCta as e, PublishResult as f, PublishPayload as g, PublishedAuthorSocialLinks as h };
271
+ export type { PublishedTopic as P, PublishedArticle as a, PublishedSite as b, PublishedAuthor as c, PublishedNewsletterSettings as d, PublishedArticleCta as e, PublishResult as f, PublishedSeriesContext as g, PublishPayload as h, PublishedAuthorSocialLinks as i };
@@ -122,6 +122,46 @@ interface PublishedTopic {
122
122
  /** Whether this is the article's primary topic */
123
123
  readonly isPrimary: boolean;
124
124
  }
125
+ /**
126
+ * Compact link metadata for an article within a published series.
127
+ */
128
+ interface PublishedSeriesArticleLink {
129
+ /** Unique article identifier */
130
+ readonly id: string;
131
+ /** URL-friendly article slug */
132
+ readonly slug: string;
133
+ /** Display title */
134
+ readonly title: string;
135
+ /** Ordered position within the series */
136
+ readonly position: number;
137
+ /** Whether this entry is the current article */
138
+ readonly isCurrent: boolean;
139
+ /** Optional excerpt for archive/list rendering */
140
+ readonly excerpt?: string;
141
+ }
142
+ /**
143
+ * Reader-facing series context for a published article.
144
+ */
145
+ interface PublishedSeriesContext {
146
+ /** Unique series identifier */
147
+ readonly id: string;
148
+ /** Display title */
149
+ readonly title: string;
150
+ /** URL-friendly series slug */
151
+ readonly slug: string;
152
+ /** Optional public description */
153
+ readonly description?: string;
154
+ /** Current article position in the series */
155
+ readonly position: number;
156
+ /** Number of visible articles in the series context */
157
+ readonly total: number;
158
+ /** Previous published article in the series, when available */
159
+ readonly previous?: PublishedSeriesArticleLink;
160
+ /** Next published article in the series, when available */
161
+ readonly next?: PublishedSeriesArticleLink;
162
+ /** Ordered article list for compact navigation and archive pages */
163
+ readonly articles: readonly PublishedSeriesArticleLink[];
164
+ }
125
165
  /**
126
166
  * Represents a published article with all necessary metadata for rendering
127
167
  * on external blog platforms.
@@ -153,6 +193,8 @@ interface PublishedArticle {
153
193
  readonly cta?: PublishedArticleCta;
154
194
  /** Public topic taxonomy assigned to the article */
155
195
  readonly topics?: readonly PublishedTopic[];
196
+ /** Optional ordered series context for article navigation */
197
+ readonly series?: PublishedSeriesContext;
156
198
  /** Featured image URL (optional) */
157
199
  readonly featuredImage?: string;
158
200
  /** Alt text for featured image */
@@ -226,4 +268,4 @@ interface PublishResult {
226
268
  readonly idempotencyKey: string;
227
269
  }
228
270
 
229
- export type { PublishedTopic as P, PublishedArticle as a, PublishedSite as b, PublishedAuthor as c, PublishedNewsletterSettings as d, PublishedArticleCta as e, PublishResult as f, PublishPayload as g, PublishedAuthorSocialLinks as h };
271
+ export type { PublishedTopic as P, PublishedArticle as a, PublishedSite as b, PublishedAuthor as c, PublishedNewsletterSettings as d, PublishedArticleCta as e, PublishResult as f, PublishedSeriesContext as g, PublishPayload as h, PublishedAuthorSocialLinks as i };