@fullstackdatasolutions/articles 0.11.0 → 1.0.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.
Files changed (71) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +568 -6
  3. package/dist/index.cjs +970 -389
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.cts +375 -21
  6. package/dist/index.d.ts +375 -21
  7. package/dist/index.js +954 -377
  8. package/dist/index.js.map +1 -1
  9. package/dist/nextjs.cjs +74 -6
  10. package/dist/nextjs.cjs.map +1 -1
  11. package/dist/nextjs.d.cts +141 -0
  12. package/dist/nextjs.d.ts +141 -0
  13. package/dist/nextjs.js +74 -6
  14. package/dist/nextjs.js.map +1 -1
  15. package/dist/server.cjs +665 -27
  16. package/dist/server.cjs.map +1 -1
  17. package/dist/server.d.cts +349 -3
  18. package/dist/server.d.ts +349 -3
  19. package/dist/server.js +643 -29
  20. package/dist/server.js.map +1 -1
  21. package/package.json +1 -1
  22. package/src/ArticleCard.tsx +37 -1
  23. package/src/ArticleContent.tsx +144 -5
  24. package/src/ArticleDetailHero.tsx +23 -0
  25. package/src/ArticleNavigation.tsx +32 -1
  26. package/src/ArticleSchemas.tsx +43 -39
  27. package/src/ArticleSocialShare.tsx +54 -10
  28. package/src/ArticlesPage.tsx +65 -7
  29. package/src/AuthorArticlesPage.tsx +308 -14
  30. package/src/AuthorCard.tsx +1 -1
  31. package/src/CategoryArticlesPage.tsx +34 -2
  32. package/src/LatestArticles.tsx +28 -1
  33. package/src/LatestArticlesSection.tsx +15 -1
  34. package/src/PaginationNav.tsx +78 -0
  35. package/src/RelatedArticlesSection.tsx +55 -0
  36. package/src/SeriesArticlesPage.tsx +66 -0
  37. package/src/__tests__/ArticleCard.test.tsx +63 -3
  38. package/src/__tests__/ArticleContent.test.tsx +143 -0
  39. package/src/__tests__/ArticleDetailHero.test.tsx +30 -0
  40. package/src/__tests__/ArticleNavigation.test.tsx +81 -3
  41. package/src/__tests__/ArticleSchemas.test.tsx +155 -81
  42. package/src/__tests__/ArticleSocialShare.test.tsx +54 -0
  43. package/src/__tests__/ArticlesPage.test.tsx +148 -0
  44. package/src/__tests__/AuthorArticlesPage.test.tsx +304 -3
  45. package/src/__tests__/CategoryArticlesPage.test.tsx +116 -1
  46. package/src/__tests__/LatestArticles.test.tsx +52 -0
  47. package/src/__tests__/LatestArticlesSection.test.tsx +28 -0
  48. package/src/__tests__/PaginationNav.test.tsx +73 -0
  49. package/src/__tests__/RelatedArticlesSection.test.tsx +132 -0
  50. package/src/__tests__/SeriesArticlesPage.test.tsx +121 -0
  51. package/src/__tests__/eventTracking.test.tsx +145 -0
  52. package/src/__tests__/events.test.ts +82 -0
  53. package/src/__tests__/markdown.test.ts +78 -1
  54. package/src/__tests__/pagination.test.ts +178 -0
  55. package/src/__tests__/seoUtils-authors.test.ts +37 -0
  56. package/src/__tests__/seoUtils.test.ts +246 -0
  57. package/src/__tests__/server-articles.test.ts +356 -1
  58. package/src/__tests__/useArticles.test.ts +28 -0
  59. package/src/__tests__/validateArticles.test.ts +312 -0
  60. package/src/articleTypes.ts +109 -0
  61. package/src/articlesConfig.ts +37 -1
  62. package/src/eventTracking.tsx +97 -0
  63. package/src/events.ts +105 -0
  64. package/src/index.ts +26 -1
  65. package/src/markdown.ts +41 -0
  66. package/src/pagination.ts +93 -0
  67. package/src/seoUtils.ts +198 -11
  68. package/src/server-articles.ts +199 -6
  69. package/src/server.ts +46 -2
  70. package/src/useArticles.ts +10 -5
  71. package/src/validateArticles.ts +260 -0
package/src/events.ts ADDED
@@ -0,0 +1,105 @@
1
+ // Vendor-neutral article event contract (Phase 27F). The package emits
2
+ // these typed events at the right places in existing components/hooks and
3
+ // hands them to `config.onEvent` - it never talks to PostHog/Plunk/any
4
+ // analytics or email vendor directly (see package.json dependencies, which
5
+ // stay clean of them). No PII in any payload: only slugs/IDs/enums, never
6
+ // emails, names-as-identifiers, or free text.
7
+
8
+ export type ArticleEventName =
9
+ | 'article_viewed'
10
+ | 'meaningful_read'
11
+ | 'author_clicked'
12
+ | 'cta_viewed'
13
+ | 'cta_clicked'
14
+ | 'shared'
15
+ | 'related_article_clicked'
16
+ | 'path_step_advanced'
17
+
18
+ interface ArticleEventBase<Name extends ArticleEventName> {
19
+ name: Name
20
+ /** `Date.now()` at emit time. */
21
+ timestamp: number
22
+ }
23
+
24
+ export interface ArticleViewedEvent extends ArticleEventBase<'article_viewed'> {
25
+ articleSlug: string
26
+ category?: string
27
+ seriesSlug?: string
28
+ }
29
+
30
+ /** Fired once per view after the reader has spent roughly half the article's estimated read time on the page (see `ArticleViewTracker`). */
31
+ export interface MeaningfulReadEvent extends ArticleEventBase<'meaningful_read'> {
32
+ articleSlug: string
33
+ }
34
+
35
+ export interface AuthorClickedEvent extends ArticleEventBase<'author_clicked'> {
36
+ articleSlug: string
37
+ authorSlug: string
38
+ }
39
+
40
+ /** `ctaId` is `primaryAction.actionId`, an `AuthorProfile.primaryCta` slug, or a `PathDefinition` key - always an app-chosen ID, never label text. */
41
+ export interface CtaViewedEvent extends ArticleEventBase<'cta_viewed'> {
42
+ ctaId: string
43
+ articleSlug?: string
44
+ }
45
+
46
+ export interface CtaClickedEvent extends ArticleEventBase<'cta_clicked'> {
47
+ ctaId: string
48
+ articleSlug?: string
49
+ }
50
+
51
+ export interface SharedEvent extends ArticleEventBase<'shared'> {
52
+ articleSlug: string
53
+ /** Share channel key, e.g. `'linkedin'`, `'copy-link'` - never the shared URL/message text. */
54
+ channel: string
55
+ }
56
+
57
+ export interface RelatedArticleClickedEvent extends ArticleEventBase<'related_article_clicked'> {
58
+ fromSlug: string
59
+ toSlug: string
60
+ source: 'path' | 'series' | 'category'
61
+ }
62
+
63
+ export interface PathStepAdvancedEvent extends ArticleEventBase<'path_step_advanced'> {
64
+ pathKey: string
65
+ fromSlug: string
66
+ toSlug: string
67
+ direction: 'previous' | 'next'
68
+ }
69
+
70
+ export type ArticleEvent =
71
+ | ArticleViewedEvent
72
+ | MeaningfulReadEvent
73
+ | AuthorClickedEvent
74
+ | CtaViewedEvent
75
+ | CtaClickedEvent
76
+ | SharedEvent
77
+ | RelatedArticleClickedEvent
78
+ | PathStepAdvancedEvent
79
+
80
+ /** Register this on `ArticlesConfig.onEvent` to receive every emitted event and translate it to your own analytics stack. */
81
+ export type ArticleEventHandler = (event: ArticleEvent) => void
82
+
83
+ // `Omit<ArticleEvent, 'timestamp'>` alone would collapse the discriminated
84
+ // union to its common keys (TypeScript computes `keyof` on a union as the
85
+ // intersection of each member's keys), losing every event-specific field.
86
+ // A distributive conditional type over the naked `T` preserves each
87
+ // member's own shape instead.
88
+ type DistributiveOmitTimestamp<T> = T extends ArticleEvent ? Omit<T, 'timestamp'> : never
89
+
90
+ /**
91
+ * Safely invokes `handler` with `event`, stamping `timestamp`. Swallows any
92
+ * error thrown by the consuming app's handler - a broken analytics
93
+ * integration must never break article rendering.
94
+ */
95
+ export function emitArticleEvent(
96
+ handler: ArticleEventHandler | undefined,
97
+ event: DistributiveOmitTimestamp<ArticleEvent>
98
+ ): void {
99
+ if (!handler) return
100
+ try {
101
+ handler({ ...event, timestamp: Date.now() } as ArticleEvent)
102
+ } catch {
103
+ // consuming app's handler errors must never break rendering
104
+ }
105
+ }
package/src/index.ts CHANGED
@@ -11,17 +11,21 @@ export { FeaturedArticle } from './FeaturedArticle'
11
11
  export { LatestArticles } from './LatestArticles'
12
12
  export { LatestArticlesSection } from './LatestArticlesSection'
13
13
  export { CategoryArticlesPage } from './CategoryArticlesPage'
14
+ export { SeriesArticlesPage } from './SeriesArticlesPage'
14
15
  export { AuthorArticlesPage } from './AuthorArticlesPage'
15
16
  export { AuthorCard, AuthorSocialLinks } from './AuthorCard'
16
17
  export { AuthorDetailHero } from './AuthorDetailHero'
17
18
  export { Breadcrumb, BreadcrumbSchema } from './Breadcrumb'
18
- export { ArticleSchema, ArticleSEO, CollectionPageSchema, FAQPageSchema } from './ArticleSchemas'
19
+ export { ArticleSEO, CollectionPageSchema, FAQPageSchema } from './ArticleSchemas'
19
20
 
20
21
  export { ArticleSocialShare } from './ArticleSocialShare'
21
22
  export { ArticleNavigation } from './ArticleNavigation'
23
+ export { RelatedArticlesSection } from './RelatedArticlesSection'
22
24
  export { ArticleBackLink } from './ArticleBackLink'
23
25
  export { ArticleTOC } from './ArticleTOC'
24
26
  export { ScrollToTop } from './ScrollToTop'
27
+ export { PaginationNav } from './PaginationNav'
28
+ export { ArticleViewTracker, CtaViewTracker } from './eventTracking'
25
29
 
26
30
  // Comments
27
31
  export { CommentsSection } from './CommentsSection'
@@ -41,6 +45,7 @@ export type {
41
45
  CategoryDescription,
42
46
  CommentsConfig,
43
47
  LinkTargetStrategy,
48
+ ListingPagination,
44
49
  MdxComponents,
45
50
  BreadcrumbsConfig,
46
51
  BreadcrumbLabels,
@@ -67,6 +72,26 @@ export type {
67
72
  CategoryInfo,
68
73
  FaqItem,
69
74
  HowToStep,
75
+ PathDefinition,
76
+ ProofItem,
77
+ RichText,
78
+ RichTextSection,
70
79
  TocItem,
71
80
  } from './articleTypes'
72
81
  export type { ArticleComment, ArticleCommentWithReplies } from './commentTypes'
82
+ export type { CollectionPageItem } from './ArticleSchemas'
83
+ export type { ListingPaginationContext } from './pagination'
84
+ export type { AuthorPageSection } from './AuthorArticlesPage'
85
+ export type {
86
+ ArticleEvent,
87
+ ArticleEventHandler,
88
+ ArticleEventName,
89
+ ArticleViewedEvent,
90
+ MeaningfulReadEvent,
91
+ AuthorClickedEvent,
92
+ CtaViewedEvent,
93
+ CtaClickedEvent,
94
+ SharedEvent,
95
+ RelatedArticleClickedEvent,
96
+ PathStepAdvancedEvent,
97
+ } from './events'
package/src/markdown.ts CHANGED
@@ -390,6 +390,47 @@ function extractHeadingItem(node: Element): TocItem | null {
390
390
  return { id, depth: Number.parseInt(match[1], 10), text }
391
391
  }
392
392
 
393
+ export interface ContentSlotBoundaries {
394
+ /** Character offset (into the raw markdown source) right after the first paragraph - the "intro" boundary. */
395
+ introEnd: number
396
+ /** Character offset right after the middle paragraph - the "mid content" boundary. */
397
+ mid: number
398
+ /** Total top-level paragraph count found. */
399
+ paragraphCount: number
400
+ }
401
+
402
+ /**
403
+ * Resolves deterministic `afterIntro`/`midContent` split points from the
404
+ * raw markdown/MDX source's parsed AST (mdast paragraph node offsets) -
405
+ * never from string-splitting rendered HTML, which is fragile by
406
+ * construction (see Phase 27F plan notes). Returns `null` when the source
407
+ * has no top-level paragraphs, or fails to parse (e.g. MDX with JSX syntax
408
+ * remark-parse doesn't understand) - callers should treat `null` as "only
409
+ * `afterHero`/`afterContent` are available for this article", not throw.
410
+ */
411
+ interface MdastNode {
412
+ type: string
413
+ position?: { start: { offset?: number }; end: { offset?: number } }
414
+ children?: MdastNode[]
415
+ }
416
+
417
+ export function getContentSlotBoundaries(markdown: string): ContentSlotBoundaries | null {
418
+ try {
419
+ const tree = remark().use(remarkParse).use(remarkGfm).parse(markdown) as unknown as MdastNode
420
+ const paragraphs = (tree.children ?? []).filter(
421
+ (node): node is MdastNode & { position: NonNullable<MdastNode['position']> } =>
422
+ node.type === 'paragraph' && Boolean(node.position)
423
+ )
424
+ if (paragraphs.length === 0) return null
425
+ const introEnd = paragraphs[0].position.end.offset ?? 0
426
+ const midIndex = Math.floor(paragraphs.length / 2)
427
+ const mid = paragraphs[midIndex].position.end.offset ?? introEnd
428
+ return { introEnd, mid: Math.max(mid, introEnd), paragraphCount: paragraphs.length }
429
+ } catch {
430
+ return null
431
+ }
432
+ }
433
+
393
434
  export async function extractToc(markdown: string): Promise<TocItem[]> {
394
435
  const headings: TocItem[] = []
395
436
  const collectHeadings: Plugin<[], Root> = () => (tree: Root) => {
@@ -0,0 +1,93 @@
1
+ // Pure pagination math shared by both the server data-prep side (route files
2
+ // slicing articles per page, generateStaticParams) and the client-safe
3
+ // PaginationNav component (building prev/next hrefs). No fs/next dependency
4
+ // so it's safe to import from both `index.ts` and `server.ts` entry points.
5
+ import type { Article } from './articleTypes'
6
+
7
+ export interface PaginatedArticles {
8
+ /** Articles belonging to this page only (already sliced). */
9
+ articles: Article[]
10
+ /** Clamped to the range `[1, totalPages]`. */
11
+ page: number
12
+ totalPages: number
13
+ hasPrevious: boolean
14
+ hasNext: boolean
15
+ }
16
+
17
+ /** Context threaded from a listing page component down into `LatestArticles`/`PaginationNav` in `'pages'` mode. */
18
+ export interface ListingPaginationContext {
19
+ page: number
20
+ totalPages: number
21
+ /** Un-paginated route path for this listing, e.g. `/articles` or `/articles/category/campaigns`. */
22
+ basePath: string
23
+ }
24
+
25
+ export interface PaginationLinks {
26
+ /** This page's own canonical URL - never points back to page 1 for page > 1. */
27
+ canonicalUrl: string
28
+ prevUrl: string | null
29
+ nextUrl: string | null
30
+ }
31
+
32
+ export function getTotalPages(totalCount: number, pageSize: number): number {
33
+ if (totalCount <= 0 || pageSize <= 0) return 1
34
+ return Math.max(1, Math.ceil(totalCount / pageSize))
35
+ }
36
+
37
+ /** Slices `articles` to the requested page, clamping out-of-range page numbers into `[1, totalPages]`. */
38
+ export function paginateArticles(
39
+ articles: Article[],
40
+ page: number,
41
+ pageSize: number
42
+ ): PaginatedArticles {
43
+ const totalPages = getTotalPages(articles.length, pageSize)
44
+ const requestedPage = Math.trunc(page) || 1
45
+ const clampedPage = Math.min(Math.max(requestedPage, 1), totalPages)
46
+ const start = (clampedPage - 1) * pageSize
47
+ return {
48
+ articles: articles.slice(start, start + pageSize),
49
+ page: clampedPage,
50
+ totalPages,
51
+ hasPrevious: clampedPage > 1,
52
+ hasNext: clampedPage < totalPages,
53
+ }
54
+ }
55
+
56
+ /** Page 1 is the un-suffixed `basePath` itself; page N>1 is `${basePath}/page/${N}`. */
57
+ export function buildPageUrl(basePath: string, page: number): string {
58
+ const base = basePath.replace(/\/$/, '')
59
+ return page > 1 ? `${base}/page/${page}` : base
60
+ }
61
+
62
+ export function buildPaginationLinks(
63
+ basePath: string,
64
+ page: number,
65
+ totalPages: number
66
+ ): PaginationLinks {
67
+ return {
68
+ canonicalUrl: buildPageUrl(basePath, page),
69
+ prevUrl: page > 1 ? buildPageUrl(basePath, page - 1) : null,
70
+ nextUrl: page < totalPages ? buildPageUrl(basePath, page + 1) : null,
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Static params for pages 2..totalPages (page 1 has no `/page/1` route - it's
76
+ * served by the un-paginated base route). For nested dynamic segments (e.g.
77
+ * `/articles/category/[category]/page/[page]`), combine this per-category in
78
+ * the consuming app's `generateStaticParams` - see README.
79
+ */
80
+ export function generateListingPageStaticParams(totalPages: number): { page: string }[] {
81
+ const params: { page: string }[] = []
82
+ for (let page = 2; page <= totalPages; page++) params.push({ page: String(page) })
83
+ return params
84
+ }
85
+
86
+ export function parsePageParam(raw: string | undefined | null): number {
87
+ const parsed = Number.parseInt(raw ?? '', 10)
88
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 1
89
+ }
90
+
91
+ export function isPageOutOfRange(page: number, totalPages: number): boolean {
92
+ return page < 1 || page > totalPages
93
+ }
package/src/seoUtils.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  getAllCategories,
7
7
  getArticleAuthors,
8
8
  getArticlesByCategory,
9
+ getArticlesBySeries,
9
10
  getAuthorBySlug,
10
11
  getArticleMarkdownUrl,
11
12
  getAvailableArticleSlugs,
@@ -20,6 +21,7 @@ import {
20
21
  type CategoryBreadcrumbEntry,
21
22
  type CustomBreadcrumbItem,
22
23
  } from './articlesConfig'
24
+ import { buildPaginationLinks } from './pagination'
23
25
  import type { Article, AuthorProfile, BreadcrumbItem } from './articleTypes'
24
26
 
25
27
  function escapeXml(str: string): string {
@@ -89,6 +91,17 @@ export function generateAuthorStaticParams(config: ArticlesConfig): { author: st
89
91
  return getAllAuthors(config).map((author) => ({ author: author.slug }))
90
92
  }
91
93
 
94
+ /** Static params for `/articles/series/[series]` - one entry per distinct `seriesSlug` found across all articles. */
95
+ export async function generateSeriesStaticParams(
96
+ config?: ArticlesConfig
97
+ ): Promise<{ series: string }[]> {
98
+ const articles = await getAllArticles(config)
99
+ const seriesSlugs = new Set(
100
+ articles.map((article) => article.seriesSlug).filter((slug): slug is string => Boolean(slug))
101
+ )
102
+ return [...seriesSlugs].map((series) => ({ series }))
103
+ }
104
+
92
105
  function resolveImageUrl(featuredImage: string, siteUrl: string): string {
93
106
  const base = siteUrl.replace(/\/$/, '')
94
107
  if (featuredImage.startsWith('http://') || featuredImage.startsWith('https://')) {
@@ -97,6 +110,41 @@ function resolveImageUrl(featuredImage: string, siteUrl: string): string {
97
110
  return `${base}/${featuredImage.replace(/^\/+/, '')}`
98
111
  }
99
112
 
113
+ // Discovery metadata channel separation (Phase 27F): `searchTitle`/
114
+ // `searchDescription` feed the `<title>`/meta-description channel ONLY,
115
+ // `socialTitle`/`socialDescription`/`socialImage` feed Open Graph/Twitter
116
+ // Card ONLY. Canonical URLs, JSON-LD (`ArticleSEO`), `ArticleCard`, and RSS
117
+ // all keep reading `title`/`excerpt`/`featuredImage` directly and are
118
+ // untouched by either override - each surface pulls from exactly one
119
+ // source, never "apply every override everywhere".
120
+ export function resolveSearchMetadata(
121
+ article: Pick<Article, 'title' | 'excerpt' | 'searchTitle' | 'searchDescription'>,
122
+ config: Pick<ArticlesConfig, 'siteName'>
123
+ ): { title: string; description: string } {
124
+ return {
125
+ title: article.searchTitle ?? article.title,
126
+ description:
127
+ article.searchDescription ??
128
+ article.excerpt ??
129
+ `Read ${article.title} on ${config.siteName}.`,
130
+ }
131
+ }
132
+
133
+ export function resolveSocialMetadata(
134
+ article: Pick<
135
+ Article,
136
+ 'title' | 'excerpt' | 'featuredImage' | 'socialTitle' | 'socialDescription' | 'socialImage'
137
+ >,
138
+ siteUrl: string
139
+ ): { title: string; description: string; imageUrl: string } {
140
+ const image = article.socialImage ?? article.featuredImage
141
+ return {
142
+ title: article.socialTitle ?? article.title,
143
+ description: article.socialDescription ?? article.excerpt ?? '',
144
+ imageUrl: image ? resolveImageUrl(image, siteUrl) : `${siteUrl}/placeholder-logo.png`,
145
+ }
146
+ }
147
+
100
148
  export async function generateArticleMetadata(
101
149
  slug: string,
102
150
  config: ArticlesConfig
@@ -113,24 +161,23 @@ export async function generateArticleMetadata(
113
161
  const siteUrl = config.siteUrl.replace(/\/$/, '')
114
162
  const articleUrl = `${siteUrl}/articles/${slug}`
115
163
  const canonicalUrl = article.canonicalUrl ?? articleUrl
116
- const imageUrl = article.featuredImage
117
- ? resolveImageUrl(article.featuredImage, siteUrl)
118
- : `${siteUrl}/placeholder-logo.png`
119
- const description = article.excerpt ?? `Read ${article.title} on ${config.siteName}.`
164
+ const search = resolveSearchMetadata(article, config)
165
+ const social = resolveSocialMetadata(article, siteUrl)
166
+ const description = search.description
120
167
  const showAuthor = config.showAuthor !== false
121
168
  const markdownUrl = getArticleMarkdownUrl(article, config)
122
169
  const authorNames = getArticleAuthors(article, config).map((author) => author.name)
123
170
 
124
171
  return {
125
- title: `${article.title} | ${config.siteName}`,
172
+ title: `${search.title} | ${config.siteName}`,
126
173
  description,
127
174
  keywords: [...(article.tags ?? []).map((tag) => tag.toLowerCase())].join(', '),
128
175
  openGraph: {
129
- title: article.title,
130
- description,
176
+ title: social.title,
177
+ description: social.description || description,
131
178
  url: articleUrl,
132
179
  siteName: config.siteName,
133
- images: [{ url: imageUrl, width: 1200, height: 630, alt: article.title }],
180
+ images: [{ url: social.imageUrl, width: 1200, height: 630, alt: social.title }],
134
181
  locale: 'en_US',
135
182
  type: 'article',
136
183
  ...(article.date && { publishedTime: article.date }),
@@ -140,9 +187,9 @@ export async function generateArticleMetadata(
140
187
  },
141
188
  twitter: {
142
189
  card: 'summary_large_image',
143
- title: article.title,
144
- description,
145
- images: [imageUrl],
190
+ title: social.title,
191
+ description: social.description || description,
192
+ images: [social.imageUrl],
146
193
  },
147
194
  alternates: {
148
195
  canonical: canonicalUrl,
@@ -270,6 +317,55 @@ export async function generateCategoryMetadata(
270
317
  }
271
318
  }
272
319
 
320
+ /** Metadata for a series landing page, analogous to `generateCategoryMetadata`. Series display name comes from the first matching article's label-only `series` string, falling back to `seriesSlug` itself. */
321
+ export async function generateSeriesMetadata(
322
+ seriesSlug: string,
323
+ config: ArticlesConfig
324
+ ): Promise<Metadata> {
325
+ const articles = await getArticlesBySeries(seriesSlug, config)
326
+
327
+ if (articles.length === 0) return { title: 'Series Not Found' }
328
+
329
+ const seriesName = articles[0].series ?? seriesSlug
330
+ const siteUrl = config.siteUrl.replace(/\/$/, '')
331
+ const seriesUrl = `${siteUrl}/articles/series/${seriesSlug}`
332
+ const description = `Follow the ${seriesName} series - ${articles.length} article${articles.length === 1 ? '' : 's'} on ${config.siteName}.`
333
+ const title = `${seriesName} Series | ${config.siteName}`
334
+
335
+ return {
336
+ title,
337
+ description,
338
+ openGraph: {
339
+ title: `${seriesName} Series`,
340
+ description,
341
+ url: seriesUrl,
342
+ siteName: config.siteName,
343
+ images: [{ url: articles[0].featuredImage }],
344
+ type: 'website',
345
+ locale: 'en_US',
346
+ },
347
+ twitter: {
348
+ card: 'summary_large_image',
349
+ title: `${seriesName} Series`,
350
+ description,
351
+ },
352
+ alternates: {
353
+ canonical: seriesUrl,
354
+ },
355
+ robots: {
356
+ index: true,
357
+ follow: true,
358
+ googleBot: {
359
+ index: true,
360
+ follow: true,
361
+ 'max-video-preview': -1,
362
+ 'max-image-preview': 'large',
363
+ 'max-snippet': -1,
364
+ },
365
+ },
366
+ }
367
+ }
368
+
273
369
  export async function generateAuthorMetadata(
274
370
  authorSlug: string,
275
371
  config: ArticlesConfig
@@ -310,6 +406,97 @@ export async function generateAuthorMetadata(
310
406
  }
311
407
  }
312
408
 
409
+ // Google Search Central's pagination guidance no longer treats rel=next/prev
410
+ // as an indexing or ranking signal (confirmed dropped in 2019) - the
411
+ // documented current recommendation is a unique, self-referencing canonical
412
+ // per paginated page (never pointing page 2+ back to page 1) plus real
413
+ // crawlable <a href> links between pages (handled by `PaginationNav`), which
414
+ // is what these functions and that component together provide. rel=next/prev
415
+ // itself is still valid HTML and still read by Bing and some third-party
416
+ // tools/crawlers, so `PaginationNav` still emits it - it's just not what
417
+ // makes these pages indexable to Google. Paginated pages stay index/follow
418
+ // (inherited from the wrapped `generate*Metadata` call below) - the point of
419
+ // this feature is making page 2+ indexable, not excluding it.
420
+ function withPaginationMeta(
421
+ base: Metadata,
422
+ basePath: string,
423
+ page: number,
424
+ totalPages: number
425
+ ): Metadata {
426
+ // Not-found responses from the wrapped generate*Metadata call (e.g.
427
+ // "Category Not Found") never set `alternates` - leave them untouched
428
+ // rather than decorating an error title/canonical with page info.
429
+ if (!base.alternates) return base
430
+
431
+ const { canonicalUrl } = buildPaginationLinks(basePath, page, totalPages)
432
+ const pageSuffix = page > 1 ? ` - Page ${page}` : ''
433
+ const title = typeof base.title === 'string' ? `${base.title}${pageSuffix}` : base.title
434
+ const openGraph = base.openGraph
435
+ ? {
436
+ ...base.openGraph,
437
+ title:
438
+ typeof base.openGraph.title === 'string'
439
+ ? `${base.openGraph.title}${pageSuffix}`
440
+ : base.openGraph.title,
441
+ url: canonicalUrl,
442
+ }
443
+ : base.openGraph
444
+ const twitter = base.twitter
445
+ ? {
446
+ ...base.twitter,
447
+ title:
448
+ typeof base.twitter.title === 'string'
449
+ ? `${base.twitter.title}${pageSuffix}`
450
+ : base.twitter.title,
451
+ }
452
+ : base.twitter
453
+
454
+ return {
455
+ ...base,
456
+ title,
457
+ openGraph,
458
+ twitter,
459
+ alternates: { ...base.alternates, canonical: canonicalUrl },
460
+ }
461
+ }
462
+
463
+ /** Per-page metadata for `/articles/page/[page]` in `listingPagination: 'pages'` mode. Page 1 is identical to `generateArticlesIndexMetadata`. */
464
+ export function generateArticlesIndexPageMetadata(
465
+ page: number,
466
+ totalPages: number,
467
+ config: ArticlesConfig
468
+ ): Metadata {
469
+ const base = generateArticlesIndexMetadata(config)
470
+ const siteUrl = config.siteUrl.replace(/\/$/, '')
471
+ return withPaginationMeta(base, `${siteUrl}/articles`, page, totalPages)
472
+ }
473
+
474
+ /** Per-page metadata for `/articles/category/[category]/page/[page]` in `listingPagination: 'pages'` mode. */
475
+ export async function generateCategoryPageMetadata(
476
+ categorySlug: string,
477
+ page: number,
478
+ totalPages: number,
479
+ config: ArticlesConfig
480
+ ): Promise<Metadata> {
481
+ const base = await generateCategoryMetadata(categorySlug, config)
482
+ const siteUrl = config.siteUrl.replace(/\/$/, '')
483
+ return withPaginationMeta(base, `${siteUrl}/articles/category/${categorySlug}`, page, totalPages)
484
+ }
485
+
486
+ /** Per-page metadata for `/articles/authors/[author]/page/[page]` in `listingPagination: 'pages'` mode. */
487
+ export async function generateAuthorPageMetadata(
488
+ authorSlug: string,
489
+ page: number,
490
+ totalPages: number,
491
+ config: ArticlesConfig
492
+ ): Promise<Metadata> {
493
+ const base = await generateAuthorMetadata(authorSlug, config)
494
+ const author = getAuthorBySlug(authorSlug, config)
495
+ const siteUrl = config.siteUrl.replace(/\/$/, '')
496
+ const basePath = author?.url ?? `${siteUrl}/articles/authors/${authorSlug}`
497
+ return withPaginationMeta(base, basePath, page, totalPages)
498
+ }
499
+
313
500
  function formatCategoryName(category: string): string {
314
501
  return category
315
502
  .split('-')