@fullstackdatasolutions/articles 0.12.0 → 1.1.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 (69) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +559 -11
  3. package/dist/index.cjs +984 -388
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.cts +383 -21
  6. package/dist/index.d.ts +383 -21
  7. package/dist/index.js +968 -376
  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 +149 -0
  12. package/dist/nextjs.d.ts +149 -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 +357 -3
  18. package/dist/server.d.ts +357 -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 +42 -6
  23. package/src/ArticleContent.tsx +144 -5
  24. package/src/ArticleDetailHero.tsx +33 -1
  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 +56 -5
  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 +58 -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 +131 -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__/validateArticles.test.ts +312 -0
  59. package/src/articleTypes.ts +109 -0
  60. package/src/articlesConfig.ts +45 -1
  61. package/src/eventTracking.tsx +97 -0
  62. package/src/events.ts +105 -0
  63. package/src/index.ts +26 -1
  64. package/src/markdown.ts +41 -0
  65. package/src/pagination.ts +93 -0
  66. package/src/seoUtils.ts +198 -11
  67. package/src/server-articles.ts +199 -6
  68. package/src/server.ts +46 -2
  69. 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,12 +29,14 @@ 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 {
34
36
  if (!theme) return {}
35
37
  const vars: Record<string, string> = {}
36
38
  if (theme.fontFamily) vars['--articles-font-family'] = theme.fontFamily
39
+ if (theme.headerFontFamily) vars['--articles-header-font-family'] = theme.headerFontFamily
37
40
  if (theme.headerColor) vars['--articles-header-color'] = theme.headerColor
38
41
  if (theme.textColor) vars['--articles-text-color'] = theme.textColor
39
42
  if (theme.backgroundColor) vars['--articles-bg-color'] = theme.backgroundColor
@@ -63,6 +66,7 @@ function renderSection(
63
66
  handleSearch,
64
67
  pageSize,
65
68
  categoriesPageSize,
69
+ pagination,
66
70
  } = ctx
67
71
 
68
72
  switch (section) {
@@ -134,6 +138,7 @@ function renderSection(
134
138
  searchQuery={searchQuery}
135
139
  onClearSearch={() => handleSearch('')}
136
140
  pageSize={pageSize}
141
+ pagination={pagination}
137
142
  />
138
143
  )
139
144
 
@@ -156,10 +161,29 @@ export function ArticlesPage({
156
161
  config,
157
162
  initialArticles,
158
163
  initialCategories,
164
+ page,
165
+ totalPages,
166
+ totalCount,
159
167
  }: Readonly<{
160
168
  config: ArticlesConfig
161
169
  initialArticles?: Article[]
162
170
  initialCategories?: CategoryInfo[]
171
+ /**
172
+ * Current page number in `listingPagination: 'pages'` mode. Ignored
173
+ * (along with `totalPages`/`totalCount`) unless `config.listingPagination
174
+ * === 'pages'` - the default `'load-more'` behavior never reads these.
175
+ * `initialArticles` should already be this page's slice (see
176
+ * `paginateArticles` from `./server`).
177
+ */
178
+ page?: number
179
+ /** Total page count in `'pages'` mode, from `getTotalPages`. */
180
+ totalPages?: number
181
+ /**
182
+ * True total article count across every page (not just `initialArticles`'
183
+ * length) - used for `CollectionPageSchema.articleCount`. Defaults to
184
+ * `state.articles.length`, matching prior behavior when omitted.
185
+ */
186
+ totalCount?: number
163
187
  }>) {
164
188
  const state = useArticles(initialArticles, initialCategories)
165
189
  const layout = config.layout ?? DEFAULT_LAYOUT
@@ -167,21 +191,48 @@ export function ArticlesPage({
167
191
  const categoriesPageSize = config.categoriesPageSize ?? DEFAULT_CATEGORIES_PAGE_SIZE
168
192
  const themeVars = buildThemeVars(config.theme)
169
193
 
170
- const hasFeatured = layout.includes('featured')
194
+ const isPagesMode =
195
+ config.listingPagination === 'pages' && page !== undefined && totalPages !== undefined
196
+ const currentPage = page ?? 1
197
+ // The pinned "featured" hero only makes sense on page 1 (it always pulls
198
+ // state.articles[0], which on page 2+ would just be that page's first
199
+ // article, not the site's actual latest) - drop it from the layout for
200
+ // page > 1 rather than rendering a misleading featured pick.
201
+ const effectiveLayout =
202
+ isPagesMode && currentPage > 1 ? layout.filter((section) => section !== 'featured') : layout
203
+
204
+ const hasFeatured = effectiveLayout.includes('featured')
171
205
  const articlesWithoutFeatured = hasFeatured ? state.articles.slice(1) : state.articles
172
206
  const displayedArticles = state.searchQuery ? state.articles : articlesWithoutFeatured
173
207
 
174
- const ctx: SectionContext = { ...state, displayedArticles, pageSize, categoriesPageSize }
208
+ const pagination: ListingPaginationContext | undefined = isPagesMode
209
+ ? { page: currentPage, totalPages: totalPages as number, basePath: '/articles' }
210
+ : undefined
211
+
212
+ const ctx: SectionContext = {
213
+ ...state,
214
+ displayedArticles,
215
+ pageSize,
216
+ categoriesPageSize,
217
+ pagination,
218
+ }
219
+
220
+ const articleCount = totalCount ?? state.articles.length
175
221
 
176
222
  return (
177
223
  <div style={themeVars}>
178
- {layout.map((section) => renderSection(section, ctx, config))}
224
+ {effectiveLayout.map((section) => renderSection(section, ctx, config))}
179
225
  {state.articles.length > 0 && (
180
226
  <CollectionPageSchema
181
227
  title={`Articles | ${config.siteName}`}
182
- description={`Browse all ${state.articles.length} articles on ${config.siteName}.`}
228
+ description={`Browse all ${articleCount} articles on ${config.siteName}.`}
183
229
  url={`${config.siteUrl}/articles`}
184
- articleCount={state.articles.length}
230
+ articleCount={articleCount}
231
+ items={displayedArticles.slice(0, pageSize).map((article, index) => ({
232
+ position: index + 1,
233
+ url: `${config.siteUrl}/articles/${article.slug}`,
234
+ name: article.title,
235
+ }))}
185
236
  />
186
237
  )}
187
238
  </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>