@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.
- package/CHANGELOG.md +30 -0
- package/README.md +568 -6
- package/dist/index.cjs +970 -389
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +375 -21
- package/dist/index.d.ts +375 -21
- package/dist/index.js +954 -377
- 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 +65 -7
- 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 +148 -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__/useArticles.test.ts +28 -0
- 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/useArticles.ts +10 -5
- 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 {
|
|
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
|
|
117
|
-
|
|
118
|
-
|
|
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: `${
|
|
172
|
+
title: `${search.title} | ${config.siteName}`,
|
|
126
173
|
description,
|
|
127
174
|
keywords: [...(article.tags ?? []).map((tag) => tag.toLowerCase())].join(', '),
|
|
128
175
|
openGraph: {
|
|
129
|
-
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:
|
|
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:
|
|
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('-')
|