@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
@@ -16,6 +16,7 @@ import {
16
16
  DEFAULT_LAYOUT,
17
17
  DEFAULT_PAGE_SIZE,
18
18
  } from './articlesConfig'
19
+ import type { ListingPaginationContext } from './pagination'
19
20
  import { useArticles } from './useArticles'
20
21
 
21
22
  interface SectionContext {
@@ -28,6 +29,7 @@ interface SectionContext {
28
29
  handleSearch: (query: string) => void
29
30
  pageSize: number
30
31
  categoriesPageSize: number
32
+ pagination?: ListingPaginationContext
31
33
  }
32
34
 
33
35
  function buildThemeVars(theme?: ArticlesTheme): React.CSSProperties {
@@ -63,6 +65,7 @@ function renderSection(
63
65
  handleSearch,
64
66
  pageSize,
65
67
  categoriesPageSize,
68
+ pagination,
66
69
  } = ctx
67
70
 
68
71
  switch (section) {
@@ -134,6 +137,7 @@ function renderSection(
134
137
  searchQuery={searchQuery}
135
138
  onClearSearch={() => handleSearch('')}
136
139
  pageSize={pageSize}
140
+ pagination={pagination}
137
141
  />
138
142
  )
139
143
 
@@ -152,28 +156,82 @@ function renderSection(
152
156
  }
153
157
  }
154
158
 
155
- export function ArticlesPage({ config }: Readonly<{ config: ArticlesConfig }>) {
156
- const state = useArticles()
159
+ export function ArticlesPage({
160
+ config,
161
+ initialArticles,
162
+ initialCategories,
163
+ page,
164
+ totalPages,
165
+ totalCount,
166
+ }: Readonly<{
167
+ config: ArticlesConfig
168
+ initialArticles?: Article[]
169
+ initialCategories?: CategoryInfo[]
170
+ /**
171
+ * Current page number in `listingPagination: 'pages'` mode. Ignored
172
+ * (along with `totalPages`/`totalCount`) unless `config.listingPagination
173
+ * === 'pages'` - the default `'load-more'` behavior never reads these.
174
+ * `initialArticles` should already be this page's slice (see
175
+ * `paginateArticles` from `./server`).
176
+ */
177
+ page?: number
178
+ /** Total page count in `'pages'` mode, from `getTotalPages`. */
179
+ totalPages?: number
180
+ /**
181
+ * True total article count across every page (not just `initialArticles`'
182
+ * length) - used for `CollectionPageSchema.articleCount`. Defaults to
183
+ * `state.articles.length`, matching prior behavior when omitted.
184
+ */
185
+ totalCount?: number
186
+ }>) {
187
+ const state = useArticles(initialArticles, initialCategories)
157
188
  const layout = config.layout ?? DEFAULT_LAYOUT
158
189
  const pageSize = config.pageSize ?? DEFAULT_PAGE_SIZE
159
190
  const categoriesPageSize = config.categoriesPageSize ?? DEFAULT_CATEGORIES_PAGE_SIZE
160
191
  const themeVars = buildThemeVars(config.theme)
161
192
 
162
- const hasFeatured = layout.includes('featured')
193
+ const isPagesMode =
194
+ config.listingPagination === 'pages' && page !== undefined && totalPages !== undefined
195
+ const currentPage = page ?? 1
196
+ // The pinned "featured" hero only makes sense on page 1 (it always pulls
197
+ // state.articles[0], which on page 2+ would just be that page's first
198
+ // article, not the site's actual latest) - drop it from the layout for
199
+ // page > 1 rather than rendering a misleading featured pick.
200
+ const effectiveLayout =
201
+ isPagesMode && currentPage > 1 ? layout.filter((section) => section !== 'featured') : layout
202
+
203
+ const hasFeatured = effectiveLayout.includes('featured')
163
204
  const articlesWithoutFeatured = hasFeatured ? state.articles.slice(1) : state.articles
164
205
  const displayedArticles = state.searchQuery ? state.articles : articlesWithoutFeatured
165
206
 
166
- const ctx: SectionContext = { ...state, displayedArticles, pageSize, categoriesPageSize }
207
+ const pagination: ListingPaginationContext | undefined = isPagesMode
208
+ ? { page: currentPage, totalPages: totalPages as number, basePath: '/articles' }
209
+ : undefined
210
+
211
+ const ctx: SectionContext = {
212
+ ...state,
213
+ displayedArticles,
214
+ pageSize,
215
+ categoriesPageSize,
216
+ pagination,
217
+ }
218
+
219
+ const articleCount = totalCount ?? state.articles.length
167
220
 
168
221
  return (
169
222
  <div style={themeVars}>
170
- {layout.map((section) => renderSection(section, ctx, config))}
223
+ {effectiveLayout.map((section) => renderSection(section, ctx, config))}
171
224
  {state.articles.length > 0 && (
172
225
  <CollectionPageSchema
173
226
  title={`Articles | ${config.siteName}`}
174
- description={`Browse all ${state.articles.length} articles on ${config.siteName}.`}
227
+ description={`Browse all ${articleCount} articles on ${config.siteName}.`}
175
228
  url={`${config.siteUrl}/articles`}
176
- articleCount={state.articles.length}
229
+ articleCount={articleCount}
230
+ items={displayedArticles.slice(0, pageSize).map((article, index) => ({
231
+ position: index + 1,
232
+ url: `${config.siteUrl}/articles/${article.slug}`,
233
+ name: article.title,
234
+ }))}
177
235
  />
178
236
  )}
179
237
  </div>
@@ -1,19 +1,69 @@
1
1
  'use client'
2
2
 
3
+ import type { ReactNode } from 'react'
3
4
  import Link from 'next/link'
5
+ import { CollectionPageSchema } from './ArticleSchemas'
6
+ import { AuthorDetailHero } from './AuthorDetailHero'
4
7
  import { LatestArticles } from './LatestArticles'
5
8
  import { getAuthorAvatar, getAuthorSameAs, getAuthorUrl } from './authorUtils'
9
+ import { emitArticleEvent } from './events'
10
+ import { CtaViewTracker } from './eventTracking'
6
11
  import { DEFAULT_PAGE_SIZE } from './articlesConfig'
7
12
  import type { ArticlesConfig } from './articlesConfig'
13
+ import type { ListingPaginationContext } from './pagination'
8
14
  import type { Article, AuthorProfile } from './articleTypes'
9
15
 
16
+ /**
17
+ * Ordered section keys for the composable author-page render API (Phase
18
+ * 27E). `'custom'` is the one consumer-supplied slot - pass its content via
19
+ * the `customSection` prop. Passing `sections` is fully opt-in: omitting it
20
+ * keeps `AuthorArticlesPage`'s original output (Person/CollectionPage JSON-LD
21
+ * + article list only, no hero) byte-for-byte unchanged.
22
+ */
23
+ export type AuthorPageSection =
24
+ | 'hero'
25
+ | 'promise'
26
+ | 'servesWho'
27
+ | 'originStory'
28
+ | 'principles'
29
+ | 'proof'
30
+ | 'cta'
31
+ | 'articles'
32
+ | 'custom'
33
+
10
34
  type AuthorArticlesPageProps = Readonly<{
11
35
  author: AuthorProfile
12
36
  articles: Article[]
13
37
  config: ArticlesConfig
38
+ /** Current page number in `listingPagination: 'pages'` mode. Ignored (with `totalPages`/`totalCount`) unless `config.listingPagination === 'pages'`. `articles` should already be this page's slice. */
39
+ page?: number
40
+ /** Total page count in `'pages'` mode, from `getTotalPages`. */
41
+ totalPages?: number
42
+ /** True total article count across every page. Defaults to `articles.length` (also used as the `Person` schema's `interactionStatistic` count). */
43
+ totalCount?: number
44
+ /**
45
+ * Ordered list of sections to render (Phase 27E composable render API).
46
+ * When omitted, `AuthorArticlesPage` renders exactly as it did before this
47
+ * prop existed - the article list only, no hero and no rich-profile
48
+ * sections, even if `author` has the new optional fields populated.
49
+ * Include `'hero'` to render `AuthorDetailHero` here instead of composing
50
+ * it separately; each other section renders nothing when its backing
51
+ * field (`author.promise`, `author.servesWho`, etc.) is unset.
52
+ */
53
+ sections?: AuthorPageSection[]
54
+ /** Content for the one `'custom'` slot in `sections`. Ignored unless `sections` includes `'custom'`. */
55
+ customSection?: ReactNode
14
56
  }>
15
57
 
16
58
  function getPersonSchema(author: AuthorProfile, config: ArticlesConfig, articleCount: number) {
59
+ // Phase 27E audit: `promise`/`servesWho`/`principles`/`credentials`/`proof`
60
+ // are intentionally NOT added here. `promise` and `principles` are
61
+ // audience-facing marketing copy, not encyclopedic facts; `servesWho` is an
62
+ // audience segment, not a `knowsAbout` topic; `credentials`/`proof` are
63
+ // unverifiable/self-reported claims. None meet the bar for schema.org
64
+ // structured data, so `description`/`knowsAbout`/`sameAs`/`image` stay
65
+ // sourced exactly as they were before this phase (bio, siteName, approved
66
+ // social links, resolved avatar).
17
67
  return {
18
68
  '@context': 'https://schema.org',
19
69
  '@type': 'Person',
@@ -33,28 +83,272 @@ function getPersonSchema(author: AuthorProfile, config: ArticlesConfig, articleC
33
83
  }
34
84
  }
35
85
 
36
- export function AuthorArticlesPage({ author, articles, config }: AuthorArticlesPageProps) {
37
- const pageSize = config.pageSize ?? DEFAULT_PAGE_SIZE
86
+ type AuthorSectionContext = Readonly<{
87
+ author: AuthorProfile
88
+ articleCount: number
89
+ articles: Article[]
90
+ pageSize: number
91
+ pagination?: ListingPaginationContext
92
+ customSection?: ReactNode
93
+ config?: ArticlesConfig
94
+ }>
95
+
96
+ function renderHeroSection(author: AuthorProfile, articleCount: number): ReactNode {
97
+ return <AuthorDetailHero key="hero" author={author} articleCount={articleCount} />
98
+ }
99
+
100
+ function renderPromiseSection(author: AuthorProfile): ReactNode {
101
+ return author.promise ? (
102
+ <section key="promise" aria-label="Author promise" className="bg-background py-12">
103
+ <div className="mx-auto max-w-3xl px-4 text-center sm:px-6 lg:px-8">
104
+ <p className="text-xl font-medium text-foreground md:text-2xl">{author.promise}</p>
105
+ </div>
106
+ </section>
107
+ ) : null
108
+ }
109
+
110
+ function renderServesWhoSection(author: AuthorProfile): ReactNode {
111
+ return author.servesWho && author.servesWho.length > 0 ? (
112
+ <section key="servesWho" aria-label="Who this author serves" className="bg-muted/40 py-12">
113
+ <div className="mx-auto max-w-3xl px-4 sm:px-6 lg:px-8">
114
+ <h2 className="mb-4 text-center text-2xl font-bold text-foreground">Who I help</h2>
115
+ <ul className="grid gap-3 sm:grid-cols-2">
116
+ {author.servesWho.map((who) => (
117
+ <li
118
+ key={who}
119
+ className="rounded-lg border border-border bg-card px-4 py-3 text-sm text-card-foreground"
120
+ >
121
+ {who}
122
+ </li>
123
+ ))}
124
+ </ul>
125
+ </div>
126
+ </section>
127
+ ) : null
128
+ }
129
+
130
+ function renderOriginStorySection(author: AuthorProfile): ReactNode {
131
+ return author.originStory && author.originStory.length > 0 ? (
132
+ <section key="originStory" aria-label="Author origin story" className="bg-background py-12">
133
+ <div className="mx-auto max-w-3xl px-4 sm:px-6 lg:px-8">
134
+ <h2 className="mb-6 text-2xl font-bold text-foreground">My story</h2>
135
+ <div className="space-y-6">
136
+ {author.originStory.map((block, blockIndex) => (
137
+ <div key={block.heading ?? blockIndex}>
138
+ {block.heading && (
139
+ <h3 className="mb-2 text-lg font-semibold text-foreground">{block.heading}</h3>
140
+ )}
141
+ {block.paragraphs.map((paragraph, paragraphIndex) => (
142
+ <p
143
+ key={`${block.heading ?? blockIndex}-${paragraph.slice(0, 40)}-${paragraphIndex}`}
144
+ className="mb-3 text-muted-foreground"
145
+ >
146
+ {paragraph}
147
+ </p>
148
+ ))}
149
+ </div>
150
+ ))}
151
+ </div>
152
+ </div>
153
+ </section>
154
+ ) : null
155
+ }
156
+
157
+ function renderPrinciplesSection(author: AuthorProfile): ReactNode {
158
+ return author.principles && author.principles.length > 0 ? (
159
+ <section key="principles" aria-label="Author principles" className="bg-muted/40 py-12">
160
+ <div className="mx-auto max-w-3xl px-4 sm:px-6 lg:px-8">
161
+ <h2 className="mb-4 text-center text-2xl font-bold text-foreground">What I believe</h2>
162
+ <ul className="space-y-3">
163
+ {author.principles.map((principle) => (
164
+ <li
165
+ key={principle}
166
+ className="rounded-lg border border-border bg-card px-4 py-3 text-sm text-card-foreground"
167
+ >
168
+ {principle}
169
+ </li>
170
+ ))}
171
+ </ul>
172
+ </div>
173
+ </section>
174
+ ) : null
175
+ }
176
+
177
+ function renderProofSection(author: AuthorProfile): ReactNode {
178
+ return author.proof && author.proof.length > 0 ? (
179
+ <section key="proof" aria-label="Author proof" className="bg-background py-12">
180
+ <div className="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8">
181
+ <h2 className="mb-6 text-center text-2xl font-bold text-foreground">Proof</h2>
182
+ <div className="grid gap-4 sm:grid-cols-2">
183
+ {author.proof.map((item) => (
184
+ <div
185
+ key={`${item.claim}-${item.url ?? item.source ?? ''}`}
186
+ className="rounded-lg border border-border bg-card p-4 text-card-foreground"
187
+ >
188
+ <p className="font-medium">{item.claim}</p>
189
+ {(item.source || item.url) && (
190
+ <p className="mt-1 text-sm text-muted-foreground">
191
+ {item.url ? (
192
+ <Link href={item.url} className="hover:text-primary hover:underline">
193
+ {item.source ?? item.url}
194
+ </Link>
195
+ ) : (
196
+ item.source
197
+ )}
198
+ </p>
199
+ )}
200
+ </div>
201
+ ))}
202
+ </div>
203
+ </div>
204
+ </section>
205
+ ) : null
206
+ }
207
+
208
+ function renderCtaSection(author: AuthorProfile, config?: ArticlesConfig): ReactNode {
209
+ return author.primaryCta ? (
210
+ <CtaViewTracker key="cta" ctaId={`author-cta:${author.slug}`} config={config}>
211
+ <section aria-label="Author call to action" className="bg-muted/40 py-12">
212
+ <div className="mx-auto max-w-2xl px-4 text-center sm:px-6 lg:px-8">
213
+ <Link
214
+ href={author.primaryCta.href}
215
+ onClick={() =>
216
+ emitArticleEvent(config?.onEvent, {
217
+ name: 'cta_clicked',
218
+ ctaId: `author-cta:${author.slug}`,
219
+ })
220
+ }
221
+ className="inline-flex items-center justify-center rounded-md bg-primary px-6 py-3 text-sm font-semibold text-primary-foreground hover:bg-primary/90"
222
+ >
223
+ {author.primaryCta.label}
224
+ </Link>
225
+ </div>
226
+ </section>
227
+ </CtaViewTracker>
228
+ ) : null
229
+ }
38
230
 
231
+ function renderArticlesSection(
232
+ author: AuthorProfile,
233
+ articles: Article[],
234
+ pageSize: number,
235
+ pagination?: ListingPaginationContext
236
+ ): ReactNode {
39
237
  return (
40
- <div>
238
+ <section key="articles" aria-label="Articles by this author" className="bg-background py-16">
239
+ <div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
240
+ <div className="mb-8 flex items-center justify-between gap-4">
241
+ <Link href="/articles" className="text-sm text-primary hover:underline">
242
+ All Articles
243
+ </Link>
244
+ <p className="text-sm text-muted-foreground">Articles by {author.name}</p>
245
+ </div>
246
+ <LatestArticles articles={articles} pageSize={pageSize} pagination={pagination} />
247
+ </div>
248
+ </section>
249
+ )
250
+ }
251
+
252
+ function renderAuthorSection(section: AuthorPageSection, ctx: AuthorSectionContext): ReactNode {
253
+ const { author, articleCount, articles, pageSize, pagination, customSection, config } = ctx
254
+
255
+ switch (section) {
256
+ case 'hero':
257
+ return renderHeroSection(author, articleCount)
258
+ case 'promise':
259
+ return renderPromiseSection(author)
260
+ case 'servesWho':
261
+ return renderServesWhoSection(author)
262
+ case 'originStory':
263
+ return renderOriginStorySection(author)
264
+ case 'principles':
265
+ return renderPrinciplesSection(author)
266
+ case 'proof':
267
+ return renderProofSection(author)
268
+ case 'cta':
269
+ return renderCtaSection(author, config)
270
+ case 'articles':
271
+ return renderArticlesSection(author, articles, pageSize, pagination)
272
+ case 'custom':
273
+ return customSection ?? null
274
+ default:
275
+ return null
276
+ }
277
+ }
278
+
279
+ export function AuthorArticlesPage({
280
+ author,
281
+ articles,
282
+ config,
283
+ page,
284
+ totalPages,
285
+ totalCount,
286
+ sections,
287
+ customSection,
288
+ }: AuthorArticlesPageProps) {
289
+ const pageSize = config.pageSize ?? DEFAULT_PAGE_SIZE
290
+ const siteUrl = config.siteUrl.replace(/\/$/, '')
291
+ const articleCount = totalCount ?? articles.length
292
+ const pagination: ListingPaginationContext | undefined =
293
+ config.listingPagination === 'pages' && page !== undefined && totalPages !== undefined
294
+ ? { page, totalPages, basePath: `/articles/authors/${author.slug}` }
295
+ : undefined
296
+
297
+ const schemas = (
298
+ <>
41
299
  <script
42
300
  type="application/ld+json"
43
301
  dangerouslySetInnerHTML={{
44
- __html: JSON.stringify(getPersonSchema(author, config, articles.length)),
302
+ __html: JSON.stringify(getPersonSchema(author, config, articleCount)),
45
303
  }}
46
304
  />
47
- <section className="bg-background py-16">
48
- <div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
49
- <div className="mb-8 flex items-center justify-between gap-4">
50
- <Link href="/articles" className="text-sm text-primary hover:underline">
51
- All Articles
52
- </Link>
53
- <p className="text-sm text-muted-foreground">Articles by {author.name}</p>
305
+ <CollectionPageSchema
306
+ title={`${author.name} | ${config.siteName}`}
307
+ description={`Articles by ${author.name} on ${config.siteName}.`}
308
+ url={author.url ?? `${siteUrl}/articles/authors/${author.slug}`}
309
+ articleCount={articleCount}
310
+ items={articles.slice(0, pageSize).map((article, index) => ({
311
+ position: index + 1,
312
+ url: `${siteUrl}/articles/${article.slug}`,
313
+ name: article.title,
314
+ }))}
315
+ />
316
+ </>
317
+ )
318
+
319
+ if (!sections) {
320
+ return (
321
+ <div>
322
+ {schemas}
323
+ <section className="bg-background py-16">
324
+ <div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
325
+ <div className="mb-8 flex items-center justify-between gap-4">
326
+ <Link href="/articles" className="text-sm text-primary hover:underline">
327
+ All Articles
328
+ </Link>
329
+ <p className="text-sm text-muted-foreground">Articles by {author.name}</p>
330
+ </div>
331
+ <LatestArticles articles={articles} pageSize={pageSize} pagination={pagination} />
54
332
  </div>
55
- <LatestArticles articles={articles} pageSize={pageSize} />
56
- </div>
57
- </section>
333
+ </section>
334
+ </div>
335
+ )
336
+ }
337
+
338
+ const sectionCtx: AuthorSectionContext = {
339
+ author,
340
+ articleCount,
341
+ articles,
342
+ pageSize,
343
+ pagination,
344
+ customSection,
345
+ config,
346
+ }
347
+
348
+ return (
349
+ <div>
350
+ {schemas}
351
+ {sections.map((section) => renderAuthorSection(section, sectionCtx))}
58
352
  </div>
59
353
  )
60
354
  }
@@ -22,7 +22,7 @@ type AuthorCardProps = Readonly<{
22
22
  showSocial?: boolean
23
23
  }>
24
24
 
25
- function getInitials(name: string): string {
25
+ export function getInitials(name: string): string {
26
26
  return name
27
27
  .split(' ')
28
28
  .filter(Boolean)
@@ -3,6 +3,7 @@
3
3
  import Image from 'next/image'
4
4
  import Link from 'next/link'
5
5
  import { Breadcrumb } from './Breadcrumb'
6
+ import { CollectionPageSchema } from './ArticleSchemas'
6
7
  import { LatestArticles } from './LatestArticles'
7
8
  import {
8
9
  breadcrumbsAreEnabled,
@@ -12,6 +13,7 @@ import {
12
13
  type CategoryBreadcrumbEntry,
13
14
  type CustomBreadcrumbItem,
14
15
  } from './articlesConfig'
16
+ import type { ListingPaginationContext } from './pagination'
15
17
  import type { Article, BreadcrumbItem } from './articleTypes'
16
18
 
17
19
  function getCategoryDescription(
@@ -29,6 +31,12 @@ type CategoryArticlesPageProps = Readonly<{
29
31
  category: string
30
32
  articles: Article[]
31
33
  config: ArticlesConfig
34
+ /** Current page number in `listingPagination: 'pages'` mode. Ignored (with `totalPages`/`totalCount`) unless `config.listingPagination === 'pages'`. `articles` should already be this page's slice. */
35
+ page?: number
36
+ /** Total page count in `'pages'` mode, from `getTotalPages`. */
37
+ totalPages?: number
38
+ /** True total article count across every page. Defaults to `articles.length`. */
39
+ totalCount?: number
32
40
  }>
33
41
 
34
42
  function buildCategoryBreadcrumbItems(
@@ -74,7 +82,14 @@ function buildCategoryBreadcrumbEntry(
74
82
  return [{ name: context.categoryName }]
75
83
  }
76
84
 
77
- export function CategoryArticlesPage({ category, articles, config }: CategoryArticlesPageProps) {
85
+ export function CategoryArticlesPage({
86
+ category,
87
+ articles,
88
+ config,
89
+ page,
90
+ totalPages,
91
+ totalCount,
92
+ }: CategoryArticlesPageProps) {
78
93
  if (articles.length === 0) return null
79
94
 
80
95
  const categoryName = articles[0].category
@@ -83,9 +98,26 @@ export function CategoryArticlesPage({ category, articles, config }: CategoryArt
83
98
  const pageSize = config.pageSize ?? DEFAULT_PAGE_SIZE
84
99
  const breadcrumbConfig = getBreadcrumbsConfig(config)
85
100
  const breadcrumbItems = buildCategoryBreadcrumbItems(config, categoryName)
101
+ const siteUrl = config.siteUrl.replace(/\/$/, '')
102
+ const articleCount = totalCount ?? articles.length
103
+ const pagination: ListingPaginationContext | undefined =
104
+ config.listingPagination === 'pages' && page !== undefined && totalPages !== undefined
105
+ ? { page, totalPages, basePath: `/articles/category/${category}` }
106
+ : undefined
86
107
 
87
108
  return (
88
109
  <div>
110
+ <CollectionPageSchema
111
+ title={`${categoryName} | ${config.siteName}`}
112
+ description={description.short}
113
+ url={`${siteUrl}/articles/category/${category}`}
114
+ articleCount={articleCount}
115
+ items={articles.slice(0, pageSize).map((article, index) => ({
116
+ position: index + 1,
117
+ url: `${siteUrl}/articles/${article.slug}`,
118
+ name: article.title,
119
+ }))}
120
+ />
89
121
  {breadcrumbsAreEnabled(config) && (
90
122
  <Breadcrumb
91
123
  items={breadcrumbItems}
@@ -144,7 +176,7 @@ export function CategoryArticlesPage({ category, articles, config }: CategoryArt
144
176
  </Link>
145
177
  <p className="text-sm text-muted-foreground">{description.short}</p>
146
178
  </div>
147
- <LatestArticles articles={articles} pageSize={pageSize} />
179
+ <LatestArticles articles={articles} pageSize={pageSize} pagination={pagination} />
148
180
  </div>
149
181
  </section>
150
182
  </div>
@@ -3,19 +3,46 @@
3
3
  import { useState, useEffect } from 'react'
4
4
  import type { Article } from './articleTypes'
5
5
  import { ArticleCard } from './ArticleCard'
6
+ import { PaginationNav } from './PaginationNav'
7
+ import type { ListingPaginationContext } from './pagination'
6
8
 
7
9
  interface LatestArticlesProps {
8
10
  readonly articles: Article[]
9
11
  readonly pageSize: number
12
+ /**
13
+ * Switches from the default client-only "Load more" button to real
14
+ * paginated routes: renders every article in `articles` as-is (the caller
15
+ * has already sliced to the current page, e.g. via `paginateArticles`) and
16
+ * a `PaginationNav` instead of the button. Omit to keep the existing
17
+ * `listingPagination: 'load-more'` behavior, unchanged.
18
+ */
19
+ readonly pagination?: ListingPaginationContext
10
20
  }
11
21
 
12
- export function LatestArticles({ articles, pageSize }: Readonly<LatestArticlesProps>) {
22
+ export function LatestArticles({ articles, pageSize, pagination }: Readonly<LatestArticlesProps>) {
13
23
  const [visibleCount, setVisibleCount] = useState(pageSize)
14
24
 
15
25
  useEffect(() => {
16
26
  setVisibleCount(pageSize)
17
27
  }, [articles, pageSize])
18
28
 
29
+ if (pagination) {
30
+ return (
31
+ <div>
32
+ <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
33
+ {articles.map((article) => (
34
+ <ArticleCard key={article.slug} article={article} />
35
+ ))}
36
+ </div>
37
+ <PaginationNav
38
+ basePath={pagination.basePath}
39
+ page={pagination.page}
40
+ totalPages={pagination.totalPages}
41
+ />
42
+ </div>
43
+ )
44
+ }
45
+
19
46
  const visible = articles.slice(0, visibleCount)
20
47
  const hasMore = visibleCount < articles.length
21
48
 
@@ -3,12 +3,20 @@
3
3
  import { Search } from 'lucide-react'
4
4
  import type { Article } from './articleTypes'
5
5
  import { LatestArticles } from './LatestArticles'
6
+ import type { ListingPaginationContext } from './pagination'
6
7
 
7
8
  interface LatestArticlesSectionProps {
8
9
  readonly articles: Article[]
9
10
  readonly searchQuery: string
10
11
  readonly onClearSearch: () => void
11
12
  readonly pageSize?: number
13
+ /**
14
+ * `'pages'` mode pagination context. Ignored while `searchQuery` is set -
15
+ * search results stay unpaginated/client-only in both `listingPagination`
16
+ * modes (search URLs aren't meant to be indexed, so real pagination adds
17
+ * no SEO value there).
18
+ */
19
+ readonly pagination?: ListingPaginationContext
12
20
  }
13
21
 
14
22
  function DescriptionText({
@@ -30,6 +38,7 @@ export function LatestArticlesSection({
30
38
  searchQuery,
31
39
  onClearSearch,
32
40
  pageSize = 6,
41
+ pagination,
33
42
  }: Readonly<LatestArticlesSectionProps>) {
34
43
  return (
35
44
  <section id="latest" className="py-16 bg-muted/50">
@@ -60,7 +69,12 @@ export function LatestArticlesSection({
60
69
  </div>
61
70
  </div>
62
71
  ) : (
63
- <LatestArticles articles={articles} pageSize={pageSize} key={searchQuery} />
72
+ <LatestArticles
73
+ articles={articles}
74
+ pageSize={pageSize}
75
+ pagination={searchQuery ? undefined : pagination}
76
+ key={searchQuery}
77
+ />
64
78
  )}
65
79
  </div>
66
80
  </section>