@fullstackdatasolutions/articles 0.12.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.
- package/CHANGELOG.md +24 -0
- package/README.md +550 -3
- package/dist/index.cjs +960 -383
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +372 -20
- package/dist/index.d.ts +372 -20
- package/dist/index.js +944 -371
- package/dist/index.js.map +1 -1
- package/dist/nextjs.cjs +74 -6
- package/dist/nextjs.cjs.map +1 -1
- package/dist/nextjs.d.cts +141 -0
- package/dist/nextjs.d.ts +141 -0
- package/dist/nextjs.js +74 -6
- package/dist/nextjs.js.map +1 -1
- package/dist/server.cjs +665 -27
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +349 -3
- package/dist/server.d.ts +349 -3
- package/dist/server.js +643 -29
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
- package/src/ArticleCard.tsx +37 -1
- package/src/ArticleContent.tsx +144 -5
- package/src/ArticleDetailHero.tsx +23 -0
- package/src/ArticleNavigation.tsx +32 -1
- package/src/ArticleSchemas.tsx +43 -39
- package/src/ArticleSocialShare.tsx +54 -10
- package/src/ArticlesPage.tsx +55 -5
- package/src/AuthorArticlesPage.tsx +308 -14
- package/src/AuthorCard.tsx +1 -1
- package/src/CategoryArticlesPage.tsx +34 -2
- package/src/LatestArticles.tsx +28 -1
- package/src/LatestArticlesSection.tsx +15 -1
- package/src/PaginationNav.tsx +78 -0
- package/src/RelatedArticlesSection.tsx +55 -0
- package/src/SeriesArticlesPage.tsx +66 -0
- package/src/__tests__/ArticleCard.test.tsx +63 -3
- package/src/__tests__/ArticleContent.test.tsx +143 -0
- package/src/__tests__/ArticleDetailHero.test.tsx +30 -0
- package/src/__tests__/ArticleNavigation.test.tsx +81 -3
- package/src/__tests__/ArticleSchemas.test.tsx +155 -81
- package/src/__tests__/ArticleSocialShare.test.tsx +54 -0
- package/src/__tests__/ArticlesPage.test.tsx +131 -0
- package/src/__tests__/AuthorArticlesPage.test.tsx +304 -3
- package/src/__tests__/CategoryArticlesPage.test.tsx +116 -1
- package/src/__tests__/LatestArticles.test.tsx +52 -0
- package/src/__tests__/LatestArticlesSection.test.tsx +28 -0
- package/src/__tests__/PaginationNav.test.tsx +73 -0
- package/src/__tests__/RelatedArticlesSection.test.tsx +132 -0
- package/src/__tests__/SeriesArticlesPage.test.tsx +121 -0
- package/src/__tests__/eventTracking.test.tsx +145 -0
- package/src/__tests__/events.test.ts +82 -0
- package/src/__tests__/markdown.test.ts +78 -1
- package/src/__tests__/pagination.test.ts +178 -0
- package/src/__tests__/seoUtils-authors.test.ts +37 -0
- package/src/__tests__/seoUtils.test.ts +246 -0
- package/src/__tests__/server-articles.test.ts +356 -1
- package/src/__tests__/validateArticles.test.ts +312 -0
- package/src/articleTypes.ts +109 -0
- package/src/articlesConfig.ts +37 -1
- package/src/eventTracking.tsx +97 -0
- package/src/events.ts +105 -0
- package/src/index.ts +26 -1
- package/src/markdown.ts +41 -0
- package/src/pagination.ts +93 -0
- package/src/seoUtils.ts +198 -11
- package/src/server-articles.ts +199 -6
- package/src/server.ts +46 -2
- package/src/validateArticles.ts +260 -0
package/src/ArticlesPage.tsx
CHANGED
|
@@ -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
|
|
|
@@ -156,10 +160,29 @@ export function ArticlesPage({
|
|
|
156
160
|
config,
|
|
157
161
|
initialArticles,
|
|
158
162
|
initialCategories,
|
|
163
|
+
page,
|
|
164
|
+
totalPages,
|
|
165
|
+
totalCount,
|
|
159
166
|
}: Readonly<{
|
|
160
167
|
config: ArticlesConfig
|
|
161
168
|
initialArticles?: Article[]
|
|
162
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
|
|
163
186
|
}>) {
|
|
164
187
|
const state = useArticles(initialArticles, initialCategories)
|
|
165
188
|
const layout = config.layout ?? DEFAULT_LAYOUT
|
|
@@ -167,21 +190,48 @@ export function ArticlesPage({
|
|
|
167
190
|
const categoriesPageSize = config.categoriesPageSize ?? DEFAULT_CATEGORIES_PAGE_SIZE
|
|
168
191
|
const themeVars = buildThemeVars(config.theme)
|
|
169
192
|
|
|
170
|
-
const
|
|
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')
|
|
171
204
|
const articlesWithoutFeatured = hasFeatured ? state.articles.slice(1) : state.articles
|
|
172
205
|
const displayedArticles = state.searchQuery ? state.articles : articlesWithoutFeatured
|
|
173
206
|
|
|
174
|
-
const
|
|
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
|
|
175
220
|
|
|
176
221
|
return (
|
|
177
222
|
<div style={themeVars}>
|
|
178
|
-
{
|
|
223
|
+
{effectiveLayout.map((section) => renderSection(section, ctx, config))}
|
|
179
224
|
{state.articles.length > 0 && (
|
|
180
225
|
<CollectionPageSchema
|
|
181
226
|
title={`Articles | ${config.siteName}`}
|
|
182
|
-
description={`Browse all ${
|
|
227
|
+
description={`Browse all ${articleCount} articles on ${config.siteName}.`}
|
|
183
228
|
url={`${config.siteUrl}/articles`}
|
|
184
|
-
articleCount={
|
|
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
|
+
}))}
|
|
185
235
|
/>
|
|
186
236
|
)}
|
|
187
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
|
-
|
|
37
|
-
|
|
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
|
-
<
|
|
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,
|
|
302
|
+
__html: JSON.stringify(getPersonSchema(author, config, articleCount)),
|
|
45
303
|
}}
|
|
46
304
|
/>
|
|
47
|
-
<
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
}
|
package/src/AuthorCard.tsx
CHANGED
|
@@ -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({
|
|
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>
|
package/src/LatestArticles.tsx
CHANGED
|
@@ -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
|
|
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>
|