@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/dist/server.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ComponentType } from 'react';
|
|
1
|
+
import { ComponentType, ReactNode } from 'react';
|
|
2
2
|
import { Metadata, MetadataRoute } from 'next';
|
|
3
3
|
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
4
4
|
|
|
@@ -23,9 +23,12 @@ interface Article {
|
|
|
23
23
|
lastmod?: string;
|
|
24
24
|
author: string;
|
|
25
25
|
authors?: string[];
|
|
26
|
+
authorSlug?: string;
|
|
27
|
+
authorAvatar?: string;
|
|
26
28
|
category: string;
|
|
27
29
|
categories: string[];
|
|
28
30
|
readTime: string;
|
|
31
|
+
wordCount?: number;
|
|
29
32
|
featuredImage: string;
|
|
30
33
|
tags?: string[];
|
|
31
34
|
content?: string;
|
|
@@ -39,7 +42,65 @@ interface Article {
|
|
|
39
42
|
canonicalUrl?: string;
|
|
40
43
|
articleType?: string;
|
|
41
44
|
series?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Machine-safe series identifier (Phase 27F) - separate from the
|
|
47
|
+
* label-only `series` string, which stays supported unchanged for
|
|
48
|
+
* consumers who only set it. `seriesSlug`/`seriesOrder` turn `series` into
|
|
49
|
+
* a navigable reader journey via `getArticlesBySeries`/
|
|
50
|
+
* `getAdjacentArticlesInSeries`. Both optional; omitted on every article
|
|
51
|
+
* reproduces pre-27F behavior exactly.
|
|
52
|
+
*/
|
|
53
|
+
seriesSlug?: string;
|
|
54
|
+
/** Position within `seriesSlug`, ascending. Ties/omissions fall back to date order (see `getArticlesBySeries`). */
|
|
55
|
+
seriesOrder?: number;
|
|
42
56
|
aiCrawl?: boolean;
|
|
57
|
+
/**
|
|
58
|
+
* Discovery metadata overrides (Phase 27F), all optional and additive.
|
|
59
|
+
* `searchTitle`/`searchDescription` feed `generateArticleMetadata`'s
|
|
60
|
+
* `<title>`/meta description ONLY - canonical URLs, JSON-LD, RSS, and
|
|
61
|
+
* `ArticleCard` keep reading `title`/`excerpt` unchanged. `socialTitle`/
|
|
62
|
+
* `socialDescription`/`socialImage` feed Open Graph/Twitter Card output
|
|
63
|
+
* ONLY, falling back to `title`/`excerpt`/`featuredImage`. See
|
|
64
|
+
* `resolveSearchMetadata`/`resolveSocialMetadata` in `seoUtils.ts` for the
|
|
65
|
+
* exact fallback/sanitization rules.
|
|
66
|
+
*/
|
|
67
|
+
searchTitle?: string;
|
|
68
|
+
searchDescription?: string;
|
|
69
|
+
socialTitle?: string;
|
|
70
|
+
socialDescription?: string;
|
|
71
|
+
socialImage?: string;
|
|
72
|
+
/**
|
|
73
|
+
* References an app-owned CTA/offer by opaque ID (Phase 27F). The package
|
|
74
|
+
* never interprets `actionId` - it doesn't know about forms, email
|
|
75
|
+
* providers, or analytics vendors. The consuming app looks `actionId` up
|
|
76
|
+
* in its own registry when rendering a detail-page slot (see
|
|
77
|
+
* `ArticleContent`'s `afterHero`/`afterIntro`/`midContent`/`afterContent`
|
|
78
|
+
* props); an unmatched ID must render nothing, never throw.
|
|
79
|
+
*/
|
|
80
|
+
primaryAction?: {
|
|
81
|
+
actionId: string;
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* A "start here" curated reader journey that can cross series/categories -
|
|
86
|
+
* a distinct primitive from the label-only `series` field/`seriesSlug`
|
|
87
|
+
* pair (Phase 27F). Configured via `ArticlesConfig.paths`, keyed by an
|
|
88
|
+
* app-chosen path key. `articles` is an ordered list of slugs; every
|
|
89
|
+
* referenced slug must exist and not be `draft: true` - enforced by
|
|
90
|
+
* `validateArticles`, not silently at render time.
|
|
91
|
+
*/
|
|
92
|
+
interface PathDefinition {
|
|
93
|
+
/** Display name, e.g. "New GM Starter Path". */
|
|
94
|
+
name: string;
|
|
95
|
+
/** One-sentence value proposition shown on the path's landing/step UI. */
|
|
96
|
+
promise: string;
|
|
97
|
+
/** Ordered article slugs making up the journey. */
|
|
98
|
+
articles: string[];
|
|
99
|
+
/** The one next action offered once a reader completes the path. */
|
|
100
|
+
nextAction: {
|
|
101
|
+
label: string;
|
|
102
|
+
href: string;
|
|
103
|
+
};
|
|
43
104
|
}
|
|
44
105
|
interface CategoryInfo {
|
|
45
106
|
name: string;
|
|
@@ -64,6 +125,23 @@ interface AuthorSocial {
|
|
|
64
125
|
newsletter?: string;
|
|
65
126
|
other?: Record<string, string>;
|
|
66
127
|
}
|
|
128
|
+
/**
|
|
129
|
+
* One headed block of structured long-form content (used by
|
|
130
|
+
* `AuthorProfile.originStory`). An array of these, not a single HTML blob,
|
|
131
|
+
* so consuming apps can render/style each block themselves rather than
|
|
132
|
+
* `dangerouslySetInnerHTML`-ing raw markup.
|
|
133
|
+
*/
|
|
134
|
+
interface RichTextSection {
|
|
135
|
+
heading?: string;
|
|
136
|
+
paragraphs: string[];
|
|
137
|
+
}
|
|
138
|
+
type RichText = RichTextSection[];
|
|
139
|
+
/** A single sourceable claim used by `AuthorProfile.proof`. */
|
|
140
|
+
interface ProofItem {
|
|
141
|
+
claim: string;
|
|
142
|
+
source?: string;
|
|
143
|
+
url?: string;
|
|
144
|
+
}
|
|
67
145
|
interface AuthorProfile {
|
|
68
146
|
name: string;
|
|
69
147
|
slug: string;
|
|
@@ -71,12 +149,88 @@ interface AuthorProfile {
|
|
|
71
149
|
avatar?: string;
|
|
72
150
|
url?: string;
|
|
73
151
|
social?: AuthorSocial;
|
|
152
|
+
/** Short one-line audience promise, e.g. "Helping new GMs run confident first sessions." Optional, additive - omitted fields never change existing rendering. */
|
|
153
|
+
promise?: string;
|
|
154
|
+
/** Structured long-form origin story - see `RichText`/`RichTextSection`. */
|
|
155
|
+
originStory?: RichText;
|
|
156
|
+
/** Who this author's content/work is for, e.g. "New game masters", "Streaming DMs". */
|
|
157
|
+
servesWho?: string[];
|
|
158
|
+
/** Core beliefs/approach statements. */
|
|
159
|
+
principles?: string[];
|
|
160
|
+
/**
|
|
161
|
+
* Experience/credential claims (e.g. "10+ years running published campaigns").
|
|
162
|
+
* Intentionally never included in Person JSON-LD - unverifiable claims don't
|
|
163
|
+
* belong in structured data (see `getPersonSchema`/`getPersonSchemas`).
|
|
164
|
+
*/
|
|
165
|
+
credentials?: string[];
|
|
166
|
+
/** Concrete, sourceable proof points. */
|
|
167
|
+
proof?: ProofItem[];
|
|
168
|
+
/** Primary call-to-action rendered on the author's page. */
|
|
169
|
+
primaryCta?: {
|
|
170
|
+
label: string;
|
|
171
|
+
href: string;
|
|
172
|
+
};
|
|
74
173
|
}
|
|
75
174
|
interface BreadcrumbItem {
|
|
76
175
|
name: string;
|
|
77
176
|
url?: string;
|
|
78
177
|
}
|
|
79
178
|
|
|
179
|
+
type ArticleEventName = 'article_viewed' | 'meaningful_read' | 'author_clicked' | 'cta_viewed' | 'cta_clicked' | 'shared' | 'related_article_clicked' | 'path_step_advanced';
|
|
180
|
+
interface ArticleEventBase<Name extends ArticleEventName> {
|
|
181
|
+
name: Name;
|
|
182
|
+
/** `Date.now()` at emit time. */
|
|
183
|
+
timestamp: number;
|
|
184
|
+
}
|
|
185
|
+
interface ArticleViewedEvent extends ArticleEventBase<'article_viewed'> {
|
|
186
|
+
articleSlug: string;
|
|
187
|
+
category?: string;
|
|
188
|
+
seriesSlug?: string;
|
|
189
|
+
}
|
|
190
|
+
/** Fired once per view after the reader has spent roughly half the article's estimated read time on the page (see `ArticleViewTracker`). */
|
|
191
|
+
interface MeaningfulReadEvent extends ArticleEventBase<'meaningful_read'> {
|
|
192
|
+
articleSlug: string;
|
|
193
|
+
}
|
|
194
|
+
interface AuthorClickedEvent extends ArticleEventBase<'author_clicked'> {
|
|
195
|
+
articleSlug: string;
|
|
196
|
+
authorSlug: string;
|
|
197
|
+
}
|
|
198
|
+
/** `ctaId` is `primaryAction.actionId`, an `AuthorProfile.primaryCta` slug, or a `PathDefinition` key - always an app-chosen ID, never label text. */
|
|
199
|
+
interface CtaViewedEvent extends ArticleEventBase<'cta_viewed'> {
|
|
200
|
+
ctaId: string;
|
|
201
|
+
articleSlug?: string;
|
|
202
|
+
}
|
|
203
|
+
interface CtaClickedEvent extends ArticleEventBase<'cta_clicked'> {
|
|
204
|
+
ctaId: string;
|
|
205
|
+
articleSlug?: string;
|
|
206
|
+
}
|
|
207
|
+
interface SharedEvent extends ArticleEventBase<'shared'> {
|
|
208
|
+
articleSlug: string;
|
|
209
|
+
/** Share channel key, e.g. `'linkedin'`, `'copy-link'` - never the shared URL/message text. */
|
|
210
|
+
channel: string;
|
|
211
|
+
}
|
|
212
|
+
interface RelatedArticleClickedEvent extends ArticleEventBase<'related_article_clicked'> {
|
|
213
|
+
fromSlug: string;
|
|
214
|
+
toSlug: string;
|
|
215
|
+
source: 'path' | 'series' | 'category';
|
|
216
|
+
}
|
|
217
|
+
interface PathStepAdvancedEvent extends ArticleEventBase<'path_step_advanced'> {
|
|
218
|
+
pathKey: string;
|
|
219
|
+
fromSlug: string;
|
|
220
|
+
toSlug: string;
|
|
221
|
+
direction: 'previous' | 'next';
|
|
222
|
+
}
|
|
223
|
+
type ArticleEvent = ArticleViewedEvent | MeaningfulReadEvent | AuthorClickedEvent | CtaViewedEvent | CtaClickedEvent | SharedEvent | RelatedArticleClickedEvent | PathStepAdvancedEvent;
|
|
224
|
+
/** Register this on `ArticlesConfig.onEvent` to receive every emitted event and translate it to your own analytics stack. */
|
|
225
|
+
type ArticleEventHandler = (event: ArticleEvent) => void;
|
|
226
|
+
type DistributiveOmitTimestamp<T> = T extends ArticleEvent ? Omit<T, 'timestamp'> : never;
|
|
227
|
+
/**
|
|
228
|
+
* Safely invokes `handler` with `event`, stamping `timestamp`. Swallows any
|
|
229
|
+
* error thrown by the consuming app's handler - a broken analytics
|
|
230
|
+
* integration must never break article rendering.
|
|
231
|
+
*/
|
|
232
|
+
declare function emitArticleEvent(handler: ArticleEventHandler | undefined, event: DistributiveOmitTimestamp<ArticleEvent>): void;
|
|
233
|
+
|
|
80
234
|
/** Keys for each renderable section of the articles listing page. */
|
|
81
235
|
type ArticlesSection = 'hero' | 'search' | 'featured' | 'latest' | 'categories' | 'newsletter';
|
|
82
236
|
/**
|
|
@@ -137,6 +291,18 @@ interface HeroConfig {
|
|
|
137
291
|
}
|
|
138
292
|
/** Controls how article body links set target/rel attributes. */
|
|
139
293
|
type LinkTargetStrategy = 'external-new-tab' | 'all-new-tab' | 'same-tab';
|
|
294
|
+
/**
|
|
295
|
+
* Controls how listing pages (the articles index, category pages, author
|
|
296
|
+
* pages) surface articles beyond the first `pageSize`.
|
|
297
|
+
* - `'load-more'` (default): client-only "Load more" button, no URL change.
|
|
298
|
+
* Byte-for-byte identical to pre-27D behavior.
|
|
299
|
+
* - `'pages'`: real, directly-navigable paginated routes (`/articles/page/2`,
|
|
300
|
+
* `/articles/category/[category]/page/2`, `/articles/authors/[author]/page/2`)
|
|
301
|
+
* with SSR content, prev/next links, and per-page canonical metadata. The
|
|
302
|
+
* route *files* live in the consuming app - see the pagination primitives
|
|
303
|
+
* exported from `./server` and the `PaginationNav` component.
|
|
304
|
+
*/
|
|
305
|
+
type ListingPagination = 'load-more' | 'pages';
|
|
140
306
|
/** React components that article MDX bodies can reference by JSX tag name. */
|
|
141
307
|
type MdxComponents = Record<string, ComponentType<never>>;
|
|
142
308
|
type ArticleBreadcrumbToken = 'home' | 'articles' | 'primaryCategory' | 'folderPath' | 'articleTitle';
|
|
@@ -219,6 +385,28 @@ interface ArticlesConfig {
|
|
|
219
385
|
linkTargetStrategy?: LinkTargetStrategy;
|
|
220
386
|
/** Extra components exposed to article MDX bodies by JSX tag name. */
|
|
221
387
|
mdxComponents?: MdxComponents;
|
|
388
|
+
/**
|
|
389
|
+
* Chooses how listing pages surface articles beyond the first `pageSize`.
|
|
390
|
+
* Default: `'load-more'` (unchanged pre-27D behavior). Set to `'pages'` to
|
|
391
|
+
* opt into real, crawlable paginated routes instead.
|
|
392
|
+
*/
|
|
393
|
+
listingPagination?: ListingPagination;
|
|
394
|
+
/**
|
|
395
|
+
* "Start here" curated reader journeys, keyed by an app-chosen path key.
|
|
396
|
+
* Distinct from the label-only `series` field/`seriesSlug` pair - a path
|
|
397
|
+
* can cross series and categories. Every `PathDefinition.articles` slug
|
|
398
|
+
* must exist and not be `draft: true`; validate with `validateArticles`
|
|
399
|
+
* before publishing, since a broken reference produces a dead journey
|
|
400
|
+
* step rather than a build-time failure otherwise.
|
|
401
|
+
*/
|
|
402
|
+
paths?: Record<string, PathDefinition>;
|
|
403
|
+
/**
|
|
404
|
+
* Vendor-neutral event callback (Phase 27F). Fired by components/hooks at
|
|
405
|
+
* meaningful reader-journey moments (see `ArticleEvent` in `events.ts`).
|
|
406
|
+
* No PII in any payload. The package never talks to an analytics/email
|
|
407
|
+
* vendor directly - translate events to PostHog/etc. in this callback.
|
|
408
|
+
*/
|
|
409
|
+
onEvent?: ArticleEventHandler;
|
|
222
410
|
}
|
|
223
411
|
declare function getBreadcrumbsConfig(config: ArticlesConfig): BreadcrumbsConfig;
|
|
224
412
|
|
|
@@ -242,7 +430,45 @@ declare function searchArticles(query: string, config?: ArticlesConfig): Promise
|
|
|
242
430
|
declare function categoryToSlug(category: string): string;
|
|
243
431
|
declare function getAllCategories(): Promise<CategoryInfo[]>;
|
|
244
432
|
declare function getArticlesByCategory(categorySlug: string, config?: ArticlesConfig): Promise<Article[]>;
|
|
433
|
+
declare function getRelatedArticlesByCategory(currentSlug: string, category: string, limit?: number, config?: ArticlesConfig): Promise<Article[]>;
|
|
245
434
|
declare function getArticlesByAuthor(authorSlug: string, config: ArticlesConfig): Promise<Article[]>;
|
|
435
|
+
declare function getArticlesBySeries(seriesSlug: string, config?: ArticlesConfig): Promise<Article[]>;
|
|
436
|
+
/**
|
|
437
|
+
* Series-aware sibling of `getAdjacentArticles`: walks `seriesOrder` within
|
|
438
|
+
* one series instead of global date order. `previous`/`next` follow series
|
|
439
|
+
* order (ascending), not chronology.
|
|
440
|
+
*/
|
|
441
|
+
declare function getAdjacentArticlesInSeries(currentSlug: string, seriesSlug: string, config?: ArticlesConfig): Promise<{
|
|
442
|
+
previous: Article | null;
|
|
443
|
+
next: Article | null;
|
|
444
|
+
}>;
|
|
445
|
+
/** Looks up one configured `PathDefinition` by its app-chosen key. */
|
|
446
|
+
declare function getPath(pathKey: string, config: ArticlesConfig): PathDefinition | null;
|
|
447
|
+
/** Resolves a path's ordered slugs against the real article set, dropping any that don't resolve (e.g. a draft filtered out of `getAllArticles` in production) rather than throwing - use `validateArticles` to catch broken references before publishing. */
|
|
448
|
+
declare function getPathArticles(pathKey: string, config: ArticlesConfig): Promise<Article[]>;
|
|
449
|
+
type RelatedContentSource = 'path' | 'series' | 'category';
|
|
450
|
+
interface RelatedContentResult {
|
|
451
|
+
source: RelatedContentSource;
|
|
452
|
+
/** Heading for a related-content UI - the path's `name`, the article's `series` label, or "More in {category}". */
|
|
453
|
+
heading: string;
|
|
454
|
+
articles: Article[];
|
|
455
|
+
/** Set only when `source === 'path'`. */
|
|
456
|
+
pathKey?: string;
|
|
457
|
+
/** Set only when `source === 'path'` - the path's one configured next action. */
|
|
458
|
+
nextAction?: {
|
|
459
|
+
label: string;
|
|
460
|
+
href: string;
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Reusable related-content selection (Phase 27F): prefers a configured
|
|
465
|
+
* `Path` containing this article first, then the article's `seriesSlug`,
|
|
466
|
+
* falling back to 27B's `getRelatedArticlesByCategory` (imported, not
|
|
467
|
+
* reimplemented) when neither a path nor a series applies - the plain
|
|
468
|
+
* chronological-within-category behavior stays the fallback, not a full
|
|
469
|
+
* replacement.
|
|
470
|
+
*/
|
|
471
|
+
declare function getRelatedContent(article: Article, config: ArticlesConfig, limit?: number): Promise<RelatedContentResult>;
|
|
246
472
|
|
|
247
473
|
declare function generateRssFeed(articles: Article[], config: ArticlesConfig): string;
|
|
248
474
|
declare function generateArticleStaticParams(): {
|
|
@@ -254,17 +480,87 @@ declare function generateCategoryStaticParams(): Promise<{
|
|
|
254
480
|
declare function generateAuthorStaticParams(config: ArticlesConfig): {
|
|
255
481
|
author: string;
|
|
256
482
|
}[];
|
|
483
|
+
/** Static params for `/articles/series/[series]` - one entry per distinct `seriesSlug` found across all articles. */
|
|
484
|
+
declare function generateSeriesStaticParams(config?: ArticlesConfig): Promise<{
|
|
485
|
+
series: string;
|
|
486
|
+
}[]>;
|
|
487
|
+
declare function resolveSearchMetadata(article: Pick<Article, 'title' | 'excerpt' | 'searchTitle' | 'searchDescription'>, config: Pick<ArticlesConfig, 'siteName'>): {
|
|
488
|
+
title: string;
|
|
489
|
+
description: string;
|
|
490
|
+
};
|
|
491
|
+
declare function resolveSocialMetadata(article: Pick<Article, 'title' | 'excerpt' | 'featuredImage' | 'socialTitle' | 'socialDescription' | 'socialImage'>, siteUrl: string): {
|
|
492
|
+
title: string;
|
|
493
|
+
description: string;
|
|
494
|
+
imageUrl: string;
|
|
495
|
+
};
|
|
257
496
|
declare function generateArticleMetadata(slug: string, config: ArticlesConfig): Promise<Metadata>;
|
|
258
497
|
declare function generateArticlesIndexMetadata(config: ArticlesConfig): Metadata;
|
|
259
498
|
declare function generateCategoryMetadata(categorySlug: string, config: ArticlesConfig): Promise<Metadata>;
|
|
499
|
+
/** 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. */
|
|
500
|
+
declare function generateSeriesMetadata(seriesSlug: string, config: ArticlesConfig): Promise<Metadata>;
|
|
260
501
|
declare function generateAuthorMetadata(authorSlug: string, config: ArticlesConfig): Promise<Metadata>;
|
|
502
|
+
/** Per-page metadata for `/articles/page/[page]` in `listingPagination: 'pages'` mode. Page 1 is identical to `generateArticlesIndexMetadata`. */
|
|
503
|
+
declare function generateArticlesIndexPageMetadata(page: number, totalPages: number, config: ArticlesConfig): Metadata;
|
|
504
|
+
/** Per-page metadata for `/articles/category/[category]/page/[page]` in `listingPagination: 'pages'` mode. */
|
|
505
|
+
declare function generateCategoryPageMetadata(categorySlug: string, page: number, totalPages: number, config: ArticlesConfig): Promise<Metadata>;
|
|
506
|
+
/** Per-page metadata for `/articles/authors/[author]/page/[page]` in `listingPagination: 'pages'` mode. */
|
|
507
|
+
declare function generateAuthorPageMetadata(authorSlug: string, page: number, totalPages: number, config: ArticlesConfig): Promise<Metadata>;
|
|
261
508
|
declare function buildArticleBreadcrumbs(article: Pick<Article, 'slug' | 'title' | 'category'>, config: ArticlesConfig): BreadcrumbItem[];
|
|
262
509
|
declare function buildCategoryBreadcrumbs(category: string, config: ArticlesConfig, categoryName?: string): BreadcrumbItem[];
|
|
263
510
|
declare function buildAuthorBreadcrumbs(author: AuthorProfile, config: ArticlesConfig): BreadcrumbItem[];
|
|
264
511
|
declare function resolveAuthorAvatar(author: AuthorProfile, config: ArticlesConfig): string;
|
|
265
512
|
declare function getArticleSitemapEntries(baseUrlOrConfig: string | ArticlesConfig): Promise<MetadataRoute.Sitemap>;
|
|
266
513
|
|
|
514
|
+
interface PaginatedArticles {
|
|
515
|
+
/** Articles belonging to this page only (already sliced). */
|
|
516
|
+
articles: Article[];
|
|
517
|
+
/** Clamped to the range `[1, totalPages]`. */
|
|
518
|
+
page: number;
|
|
519
|
+
totalPages: number;
|
|
520
|
+
hasPrevious: boolean;
|
|
521
|
+
hasNext: boolean;
|
|
522
|
+
}
|
|
523
|
+
/** Context threaded from a listing page component down into `LatestArticles`/`PaginationNav` in `'pages'` mode. */
|
|
524
|
+
interface ListingPaginationContext {
|
|
525
|
+
page: number;
|
|
526
|
+
totalPages: number;
|
|
527
|
+
/** Un-paginated route path for this listing, e.g. `/articles` or `/articles/category/campaigns`. */
|
|
528
|
+
basePath: string;
|
|
529
|
+
}
|
|
530
|
+
interface PaginationLinks {
|
|
531
|
+
/** This page's own canonical URL - never points back to page 1 for page > 1. */
|
|
532
|
+
canonicalUrl: string;
|
|
533
|
+
prevUrl: string | null;
|
|
534
|
+
nextUrl: string | null;
|
|
535
|
+
}
|
|
536
|
+
declare function getTotalPages(totalCount: number, pageSize: number): number;
|
|
537
|
+
/** Slices `articles` to the requested page, clamping out-of-range page numbers into `[1, totalPages]`. */
|
|
538
|
+
declare function paginateArticles(articles: Article[], page: number, pageSize: number): PaginatedArticles;
|
|
539
|
+
/** Page 1 is the un-suffixed `basePath` itself; page N>1 is `${basePath}/page/${N}`. */
|
|
540
|
+
declare function buildPageUrl(basePath: string, page: number): string;
|
|
541
|
+
declare function buildPaginationLinks(basePath: string, page: number, totalPages: number): PaginationLinks;
|
|
542
|
+
/**
|
|
543
|
+
* Static params for pages 2..totalPages (page 1 has no `/page/1` route - it's
|
|
544
|
+
* served by the un-paginated base route). For nested dynamic segments (e.g.
|
|
545
|
+
* `/articles/category/[category]/page/[page]`), combine this per-category in
|
|
546
|
+
* the consuming app's `generateStaticParams` - see README.
|
|
547
|
+
*/
|
|
548
|
+
declare function generateListingPageStaticParams(totalPages: number): {
|
|
549
|
+
page: string;
|
|
550
|
+
}[];
|
|
551
|
+
declare function parsePageParam(raw: string | undefined | null): number;
|
|
552
|
+
declare function isPageOutOfRange(page: number, totalPages: number): boolean;
|
|
553
|
+
|
|
267
554
|
declare function markdownToHtml(markdown: string, articleSlug?: string, config?: ArticlesConfig): Promise<string>;
|
|
555
|
+
interface ContentSlotBoundaries {
|
|
556
|
+
/** Character offset (into the raw markdown source) right after the first paragraph - the "intro" boundary. */
|
|
557
|
+
introEnd: number;
|
|
558
|
+
/** Character offset right after the middle paragraph - the "mid content" boundary. */
|
|
559
|
+
mid: number;
|
|
560
|
+
/** Total top-level paragraph count found. */
|
|
561
|
+
paragraphCount: number;
|
|
562
|
+
}
|
|
563
|
+
declare function getContentSlotBoundaries(markdown: string): ContentSlotBoundaries | null;
|
|
268
564
|
declare function extractToc(markdown: string): Promise<TocItem[]>;
|
|
269
565
|
|
|
270
566
|
type ArticlesErrorCode = 'article-directory-read-failed' | 'article-load-failed' | 'article-markdown-load-failed' | 'markdown-conversion-failed' | 'unsafe-image-path';
|
|
@@ -278,12 +574,37 @@ type ArticlesErrorReport = Readonly<{
|
|
|
278
574
|
type ArticlesErrorHandler = (report: ArticlesErrorReport) => void;
|
|
279
575
|
declare function setArticlesErrorHandler(handler?: ArticlesErrorHandler): void;
|
|
280
576
|
|
|
577
|
+
/**
|
|
578
|
+
* Sanitized, non-PII context passed into `ArticleContent`'s slot render
|
|
579
|
+
* props - deliberately a narrow subset of `Article`, not the whole object
|
|
580
|
+
* (no raw `content`/`mdxSource`, no author email or anything author-PII).
|
|
581
|
+
*/
|
|
582
|
+
interface ArticleSlotContext {
|
|
583
|
+
slug: string;
|
|
584
|
+
title: string;
|
|
585
|
+
category: string;
|
|
586
|
+
tags: string[];
|
|
587
|
+
readTime: string;
|
|
588
|
+
wordCount?: number;
|
|
589
|
+
authorSlug?: string;
|
|
590
|
+
seriesSlug?: string;
|
|
591
|
+
primaryActionId?: string;
|
|
592
|
+
}
|
|
593
|
+
type ArticleSlotContent = ReactNode | ((context: ArticleSlotContext) => ReactNode);
|
|
281
594
|
type ArticleContentProps = Readonly<{
|
|
282
595
|
article: Article;
|
|
283
596
|
className?: string;
|
|
284
597
|
config?: ArticlesConfig;
|
|
598
|
+
/** Rendered immediately before the article body - the "around the body, not inside it" counterpart to `config.mdxComponents` (which places content *inside* MDX bodies). */
|
|
599
|
+
afterHero?: ArticleSlotContent;
|
|
600
|
+
/** Rendered right after the first paragraph, resolved deterministically from the parsed AST (see `getContentSlotBoundaries`). Falls back to not rendering (never a brittle string split) when the source has no detectable paragraphs, e.g. MDX using JSX-heavy syntax remark-parse can't read as plain markdown. */
|
|
601
|
+
afterIntro?: ArticleSlotContent;
|
|
602
|
+
/** Rendered after roughly the middle paragraph. Same fallback behavior as `afterIntro`. */
|
|
603
|
+
midContent?: ArticleSlotContent;
|
|
604
|
+
/** Rendered immediately after the article body. */
|
|
605
|
+
afterContent?: ArticleSlotContent;
|
|
285
606
|
}>;
|
|
286
|
-
declare function ArticleContent({ article, className, config }: ArticleContentProps): Promise<react_jsx_runtime.JSX.Element>;
|
|
607
|
+
declare function ArticleContent({ article, className, config, afterHero, afterIntro, midContent, afterContent, }: ArticleContentProps): Promise<react_jsx_runtime.JSX.Element>;
|
|
287
608
|
|
|
288
609
|
type ArticleTOCProps = Readonly<{
|
|
289
610
|
toc: TocItem[];
|
|
@@ -291,4 +612,29 @@ type ArticleTOCProps = Readonly<{
|
|
|
291
612
|
}>;
|
|
292
613
|
declare function ArticleTOC({ toc, className }: ArticleTOCProps): react_jsx_runtime.JSX.Element | null;
|
|
293
614
|
|
|
294
|
-
|
|
615
|
+
type ValidationSeverity = 'error' | 'warning';
|
|
616
|
+
interface ValidationIssue {
|
|
617
|
+
severity: ValidationSeverity;
|
|
618
|
+
/** Stable machine-readable code, e.g. `'duplicate-canonical-url'`. */
|
|
619
|
+
code: string;
|
|
620
|
+
message: string;
|
|
621
|
+
articleSlug?: string;
|
|
622
|
+
pathKey?: string;
|
|
623
|
+
}
|
|
624
|
+
interface ValidationResult {
|
|
625
|
+
ok: boolean;
|
|
626
|
+
errors: ValidationIssue[];
|
|
627
|
+
warnings: ValidationIssue[];
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Validates a loaded article set + config. Warnings cover optional
|
|
631
|
+
* discovery-field issues (missing excerpt/date, over-length search/social
|
|
632
|
+
* fields, category slug collisions); errors cover broken reader journeys
|
|
633
|
+
* (duplicate canonical URLs, unknown author references, series order
|
|
634
|
+
* collisions, missing/draft path references, unsafe URL schemes).
|
|
635
|
+
*/
|
|
636
|
+
declare function validateArticles(articles: Article[], config: ArticlesConfig): ValidationResult;
|
|
637
|
+
/** Convenience wrapper: loads every article via `getAllArticles(config)` (fs-dependent) then validates. Suitable for a consuming app's own `scripts/validate-articles.ts` invoked in CI before publish. */
|
|
638
|
+
declare function validateAllArticles(config: ArticlesConfig): Promise<ValidationResult>;
|
|
639
|
+
|
|
640
|
+
export { type Article, ArticleContent, type ArticleEvent, type ArticleEventHandler, type ArticleEventName, type ArticleSlotContent, type ArticleSlotContext, ArticleTOC, type ArticleViewedEvent, type ArticlesConfig, type ArticlesErrorCode, type ArticlesErrorContext, type ArticlesErrorHandler, type ArticlesErrorReport, type AuthorClickedEvent, type AuthorProfile, type AuthorSocial, type BreadcrumbItem, type CategoryInfo, type ContentSlotBoundaries, type CtaClickedEvent, type CtaViewedEvent, type LinkTargetStrategy, type ListingPagination, type ListingPaginationContext, type MeaningfulReadEvent, type PaginatedArticles, type PaginationLinks, type PathDefinition, type PathStepAdvancedEvent, type RelatedArticleClickedEvent, type RelatedContentResult, type RelatedContentSource, type SharedEvent, type TocItem, type ValidationIssue, type ValidationResult, type ValidationSeverity, buildArticleBreadcrumbs, buildAuthorBreadcrumbs, buildCategoryBreadcrumbs, buildPageUrl, buildPaginationLinks, categoryToSlug, emitArticleEvent, extractToc, generateArticleMetadata, generateArticleStaticParams, generateArticlesIndexMetadata, generateArticlesIndexPageMetadata, generateAuthorMetadata, generateAuthorPageMetadata, generateAuthorStaticParams, generateCategoryMetadata, generateCategoryPageMetadata, generateCategoryStaticParams, generateListingPageStaticParams, generateRssFeed, generateSeriesMetadata, generateSeriesStaticParams, getAdjacentArticles, getAdjacentArticlesInSeries, getAiRobotsTxtRules, getAllArticles, getAllAuthors, getAllCategories, getArticleAiHeaders, getArticleAuthors, getArticleMarkdown, getArticleMarkdownResponse, getArticleMarkdownUrl, getArticleMetadata, getArticleSitemapEntries, getArticlesByAuthor, getArticlesByCategory, getArticlesBySeries, getAuthorBySlug, getAvailableArticleSlugs, getBreadcrumbsConfig, getContentSlotBoundaries, getPath, getPathArticles, getRelatedArticlesByCategory, getRelatedContent, getTotalPages, isPageOutOfRange, markdownToHtml, paginateArticles, parsePageParam, resolveAuthorAvatar, resolveSearchMetadata, resolveSocialMetadata, sanitizeImagePath, searchArticles, setArticlesErrorHandler, validateAllArticles, validateArticles };
|