@fullstackdatasolutions/articles 0.12.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +559 -11
  3. package/dist/index.cjs +984 -388
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.cts +383 -21
  6. package/dist/index.d.ts +383 -21
  7. package/dist/index.js +968 -376
  8. package/dist/index.js.map +1 -1
  9. package/dist/nextjs.cjs +74 -6
  10. package/dist/nextjs.cjs.map +1 -1
  11. package/dist/nextjs.d.cts +149 -0
  12. package/dist/nextjs.d.ts +149 -0
  13. package/dist/nextjs.js +74 -6
  14. package/dist/nextjs.js.map +1 -1
  15. package/dist/server.cjs +665 -27
  16. package/dist/server.cjs.map +1 -1
  17. package/dist/server.d.cts +357 -3
  18. package/dist/server.d.ts +357 -3
  19. package/dist/server.js +643 -29
  20. package/dist/server.js.map +1 -1
  21. package/package.json +1 -1
  22. package/src/ArticleCard.tsx +42 -6
  23. package/src/ArticleContent.tsx +144 -5
  24. package/src/ArticleDetailHero.tsx +33 -1
  25. package/src/ArticleNavigation.tsx +32 -1
  26. package/src/ArticleSchemas.tsx +43 -39
  27. package/src/ArticleSocialShare.tsx +54 -10
  28. package/src/ArticlesPage.tsx +56 -5
  29. package/src/AuthorArticlesPage.tsx +308 -14
  30. package/src/AuthorCard.tsx +1 -1
  31. package/src/CategoryArticlesPage.tsx +34 -2
  32. package/src/LatestArticles.tsx +28 -1
  33. package/src/LatestArticlesSection.tsx +15 -1
  34. package/src/PaginationNav.tsx +78 -0
  35. package/src/RelatedArticlesSection.tsx +58 -0
  36. package/src/SeriesArticlesPage.tsx +66 -0
  37. package/src/__tests__/ArticleCard.test.tsx +63 -3
  38. package/src/__tests__/ArticleContent.test.tsx +143 -0
  39. package/src/__tests__/ArticleDetailHero.test.tsx +30 -0
  40. package/src/__tests__/ArticleNavigation.test.tsx +81 -3
  41. package/src/__tests__/ArticleSchemas.test.tsx +155 -81
  42. package/src/__tests__/ArticleSocialShare.test.tsx +54 -0
  43. package/src/__tests__/ArticlesPage.test.tsx +131 -0
  44. package/src/__tests__/AuthorArticlesPage.test.tsx +304 -3
  45. package/src/__tests__/CategoryArticlesPage.test.tsx +116 -1
  46. package/src/__tests__/LatestArticles.test.tsx +52 -0
  47. package/src/__tests__/LatestArticlesSection.test.tsx +28 -0
  48. package/src/__tests__/PaginationNav.test.tsx +73 -0
  49. package/src/__tests__/RelatedArticlesSection.test.tsx +132 -0
  50. package/src/__tests__/SeriesArticlesPage.test.tsx +121 -0
  51. package/src/__tests__/eventTracking.test.tsx +145 -0
  52. package/src/__tests__/events.test.ts +82 -0
  53. package/src/__tests__/markdown.test.ts +78 -1
  54. package/src/__tests__/pagination.test.ts +178 -0
  55. package/src/__tests__/seoUtils-authors.test.ts +37 -0
  56. package/src/__tests__/seoUtils.test.ts +246 -0
  57. package/src/__tests__/server-articles.test.ts +356 -1
  58. package/src/__tests__/validateArticles.test.ts +312 -0
  59. package/src/articleTypes.ts +109 -0
  60. package/src/articlesConfig.ts +45 -1
  61. package/src/eventTracking.tsx +97 -0
  62. package/src/events.ts +105 -0
  63. package/src/index.ts +26 -1
  64. package/src/markdown.ts +41 -0
  65. package/src/pagination.ts +93 -0
  66. package/src/seoUtils.ts +198 -11
  67. package/src/server-articles.ts +199 -6
  68. package/src/server.ts +46 -2
  69. package/src/validateArticles.ts +260 -0
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
  /**
@@ -86,6 +240,14 @@ type ArticlesSection = 'hero' | 'search' | 'featured' | 'latest' | 'categories'
86
240
  interface ArticlesTheme {
87
241
  /** Font family for the articles section. Example: `"'Inter', sans-serif"` */
88
242
  fontFamily?: string;
243
+ /**
244
+ * Font family for headings only (article title, card titles, section
245
+ * headings) - falls back to `fontFamily` when omitted. Lets a site use a
246
+ * distinct display face for headings (e.g. a serif) while keeping a
247
+ * separate body font, without hardcoding either into the package.
248
+ * Example: `"'Cinzel', serif"`
249
+ */
250
+ headerFontFamily?: string;
89
251
  /** Color for article card titles and section headings. Example: `'#111827'` */
90
252
  headerColor?: string;
91
253
  /** Color for body and excerpt text. Example: `'#6b7280'` */
@@ -137,6 +299,18 @@ interface HeroConfig {
137
299
  }
138
300
  /** Controls how article body links set target/rel attributes. */
139
301
  type LinkTargetStrategy = 'external-new-tab' | 'all-new-tab' | 'same-tab';
302
+ /**
303
+ * Controls how listing pages (the articles index, category pages, author
304
+ * pages) surface articles beyond the first `pageSize`.
305
+ * - `'load-more'` (default): client-only "Load more" button, no URL change.
306
+ * Byte-for-byte identical to pre-27D behavior.
307
+ * - `'pages'`: real, directly-navigable paginated routes (`/articles/page/2`,
308
+ * `/articles/category/[category]/page/2`, `/articles/authors/[author]/page/2`)
309
+ * with SSR content, prev/next links, and per-page canonical metadata. The
310
+ * route *files* live in the consuming app - see the pagination primitives
311
+ * exported from `./server` and the `PaginationNav` component.
312
+ */
313
+ type ListingPagination = 'load-more' | 'pages';
140
314
  /** React components that article MDX bodies can reference by JSX tag name. */
141
315
  type MdxComponents = Record<string, ComponentType<never>>;
142
316
  type ArticleBreadcrumbToken = 'home' | 'articles' | 'primaryCategory' | 'folderPath' | 'articleTitle';
@@ -219,6 +393,28 @@ interface ArticlesConfig {
219
393
  linkTargetStrategy?: LinkTargetStrategy;
220
394
  /** Extra components exposed to article MDX bodies by JSX tag name. */
221
395
  mdxComponents?: MdxComponents;
396
+ /**
397
+ * Chooses how listing pages surface articles beyond the first `pageSize`.
398
+ * Default: `'load-more'` (unchanged pre-27D behavior). Set to `'pages'` to
399
+ * opt into real, crawlable paginated routes instead.
400
+ */
401
+ listingPagination?: ListingPagination;
402
+ /**
403
+ * "Start here" curated reader journeys, keyed by an app-chosen path key.
404
+ * Distinct from the label-only `series` field/`seriesSlug` pair - a path
405
+ * can cross series and categories. Every `PathDefinition.articles` slug
406
+ * must exist and not be `draft: true`; validate with `validateArticles`
407
+ * before publishing, since a broken reference produces a dead journey
408
+ * step rather than a build-time failure otherwise.
409
+ */
410
+ paths?: Record<string, PathDefinition>;
411
+ /**
412
+ * Vendor-neutral event callback (Phase 27F). Fired by components/hooks at
413
+ * meaningful reader-journey moments (see `ArticleEvent` in `events.ts`).
414
+ * No PII in any payload. The package never talks to an analytics/email
415
+ * vendor directly - translate events to PostHog/etc. in this callback.
416
+ */
417
+ onEvent?: ArticleEventHandler;
222
418
  }
223
419
  declare function getBreadcrumbsConfig(config: ArticlesConfig): BreadcrumbsConfig;
224
420
 
@@ -242,7 +438,45 @@ declare function searchArticles(query: string, config?: ArticlesConfig): Promise
242
438
  declare function categoryToSlug(category: string): string;
243
439
  declare function getAllCategories(): Promise<CategoryInfo[]>;
244
440
  declare function getArticlesByCategory(categorySlug: string, config?: ArticlesConfig): Promise<Article[]>;
441
+ declare function getRelatedArticlesByCategory(currentSlug: string, category: string, limit?: number, config?: ArticlesConfig): Promise<Article[]>;
245
442
  declare function getArticlesByAuthor(authorSlug: string, config: ArticlesConfig): Promise<Article[]>;
443
+ declare function getArticlesBySeries(seriesSlug: string, config?: ArticlesConfig): Promise<Article[]>;
444
+ /**
445
+ * Series-aware sibling of `getAdjacentArticles`: walks `seriesOrder` within
446
+ * one series instead of global date order. `previous`/`next` follow series
447
+ * order (ascending), not chronology.
448
+ */
449
+ declare function getAdjacentArticlesInSeries(currentSlug: string, seriesSlug: string, config?: ArticlesConfig): Promise<{
450
+ previous: Article | null;
451
+ next: Article | null;
452
+ }>;
453
+ /** Looks up one configured `PathDefinition` by its app-chosen key. */
454
+ declare function getPath(pathKey: string, config: ArticlesConfig): PathDefinition | null;
455
+ /** 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. */
456
+ declare function getPathArticles(pathKey: string, config: ArticlesConfig): Promise<Article[]>;
457
+ type RelatedContentSource = 'path' | 'series' | 'category';
458
+ interface RelatedContentResult {
459
+ source: RelatedContentSource;
460
+ /** Heading for a related-content UI - the path's `name`, the article's `series` label, or "More in {category}". */
461
+ heading: string;
462
+ articles: Article[];
463
+ /** Set only when `source === 'path'`. */
464
+ pathKey?: string;
465
+ /** Set only when `source === 'path'` - the path's one configured next action. */
466
+ nextAction?: {
467
+ label: string;
468
+ href: string;
469
+ };
470
+ }
471
+ /**
472
+ * Reusable related-content selection (Phase 27F): prefers a configured
473
+ * `Path` containing this article first, then the article's `seriesSlug`,
474
+ * falling back to 27B's `getRelatedArticlesByCategory` (imported, not
475
+ * reimplemented) when neither a path nor a series applies - the plain
476
+ * chronological-within-category behavior stays the fallback, not a full
477
+ * replacement.
478
+ */
479
+ declare function getRelatedContent(article: Article, config: ArticlesConfig, limit?: number): Promise<RelatedContentResult>;
246
480
 
247
481
  declare function generateRssFeed(articles: Article[], config: ArticlesConfig): string;
248
482
  declare function generateArticleStaticParams(): {
@@ -254,17 +488,87 @@ declare function generateCategoryStaticParams(): Promise<{
254
488
  declare function generateAuthorStaticParams(config: ArticlesConfig): {
255
489
  author: string;
256
490
  }[];
491
+ /** Static params for `/articles/series/[series]` - one entry per distinct `seriesSlug` found across all articles. */
492
+ declare function generateSeriesStaticParams(config?: ArticlesConfig): Promise<{
493
+ series: string;
494
+ }[]>;
495
+ declare function resolveSearchMetadata(article: Pick<Article, 'title' | 'excerpt' | 'searchTitle' | 'searchDescription'>, config: Pick<ArticlesConfig, 'siteName'>): {
496
+ title: string;
497
+ description: string;
498
+ };
499
+ declare function resolveSocialMetadata(article: Pick<Article, 'title' | 'excerpt' | 'featuredImage' | 'socialTitle' | 'socialDescription' | 'socialImage'>, siteUrl: string): {
500
+ title: string;
501
+ description: string;
502
+ imageUrl: string;
503
+ };
257
504
  declare function generateArticleMetadata(slug: string, config: ArticlesConfig): Promise<Metadata>;
258
505
  declare function generateArticlesIndexMetadata(config: ArticlesConfig): Metadata;
259
506
  declare function generateCategoryMetadata(categorySlug: string, config: ArticlesConfig): Promise<Metadata>;
507
+ /** 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. */
508
+ declare function generateSeriesMetadata(seriesSlug: string, config: ArticlesConfig): Promise<Metadata>;
260
509
  declare function generateAuthorMetadata(authorSlug: string, config: ArticlesConfig): Promise<Metadata>;
510
+ /** Per-page metadata for `/articles/page/[page]` in `listingPagination: 'pages'` mode. Page 1 is identical to `generateArticlesIndexMetadata`. */
511
+ declare function generateArticlesIndexPageMetadata(page: number, totalPages: number, config: ArticlesConfig): Metadata;
512
+ /** Per-page metadata for `/articles/category/[category]/page/[page]` in `listingPagination: 'pages'` mode. */
513
+ declare function generateCategoryPageMetadata(categorySlug: string, page: number, totalPages: number, config: ArticlesConfig): Promise<Metadata>;
514
+ /** Per-page metadata for `/articles/authors/[author]/page/[page]` in `listingPagination: 'pages'` mode. */
515
+ declare function generateAuthorPageMetadata(authorSlug: string, page: number, totalPages: number, config: ArticlesConfig): Promise<Metadata>;
261
516
  declare function buildArticleBreadcrumbs(article: Pick<Article, 'slug' | 'title' | 'category'>, config: ArticlesConfig): BreadcrumbItem[];
262
517
  declare function buildCategoryBreadcrumbs(category: string, config: ArticlesConfig, categoryName?: string): BreadcrumbItem[];
263
518
  declare function buildAuthorBreadcrumbs(author: AuthorProfile, config: ArticlesConfig): BreadcrumbItem[];
264
519
  declare function resolveAuthorAvatar(author: AuthorProfile, config: ArticlesConfig): string;
265
520
  declare function getArticleSitemapEntries(baseUrlOrConfig: string | ArticlesConfig): Promise<MetadataRoute.Sitemap>;
266
521
 
522
+ interface PaginatedArticles {
523
+ /** Articles belonging to this page only (already sliced). */
524
+ articles: Article[];
525
+ /** Clamped to the range `[1, totalPages]`. */
526
+ page: number;
527
+ totalPages: number;
528
+ hasPrevious: boolean;
529
+ hasNext: boolean;
530
+ }
531
+ /** Context threaded from a listing page component down into `LatestArticles`/`PaginationNav` in `'pages'` mode. */
532
+ interface ListingPaginationContext {
533
+ page: number;
534
+ totalPages: number;
535
+ /** Un-paginated route path for this listing, e.g. `/articles` or `/articles/category/campaigns`. */
536
+ basePath: string;
537
+ }
538
+ interface PaginationLinks {
539
+ /** This page's own canonical URL - never points back to page 1 for page > 1. */
540
+ canonicalUrl: string;
541
+ prevUrl: string | null;
542
+ nextUrl: string | null;
543
+ }
544
+ declare function getTotalPages(totalCount: number, pageSize: number): number;
545
+ /** Slices `articles` to the requested page, clamping out-of-range page numbers into `[1, totalPages]`. */
546
+ declare function paginateArticles(articles: Article[], page: number, pageSize: number): PaginatedArticles;
547
+ /** Page 1 is the un-suffixed `basePath` itself; page N>1 is `${basePath}/page/${N}`. */
548
+ declare function buildPageUrl(basePath: string, page: number): string;
549
+ declare function buildPaginationLinks(basePath: string, page: number, totalPages: number): PaginationLinks;
550
+ /**
551
+ * Static params for pages 2..totalPages (page 1 has no `/page/1` route - it's
552
+ * served by the un-paginated base route). For nested dynamic segments (e.g.
553
+ * `/articles/category/[category]/page/[page]`), combine this per-category in
554
+ * the consuming app's `generateStaticParams` - see README.
555
+ */
556
+ declare function generateListingPageStaticParams(totalPages: number): {
557
+ page: string;
558
+ }[];
559
+ declare function parsePageParam(raw: string | undefined | null): number;
560
+ declare function isPageOutOfRange(page: number, totalPages: number): boolean;
561
+
267
562
  declare function markdownToHtml(markdown: string, articleSlug?: string, config?: ArticlesConfig): Promise<string>;
563
+ interface ContentSlotBoundaries {
564
+ /** Character offset (into the raw markdown source) right after the first paragraph - the "intro" boundary. */
565
+ introEnd: number;
566
+ /** Character offset right after the middle paragraph - the "mid content" boundary. */
567
+ mid: number;
568
+ /** Total top-level paragraph count found. */
569
+ paragraphCount: number;
570
+ }
571
+ declare function getContentSlotBoundaries(markdown: string): ContentSlotBoundaries | null;
268
572
  declare function extractToc(markdown: string): Promise<TocItem[]>;
269
573
 
270
574
  type ArticlesErrorCode = 'article-directory-read-failed' | 'article-load-failed' | 'article-markdown-load-failed' | 'markdown-conversion-failed' | 'unsafe-image-path';
@@ -278,12 +582,37 @@ type ArticlesErrorReport = Readonly<{
278
582
  type ArticlesErrorHandler = (report: ArticlesErrorReport) => void;
279
583
  declare function setArticlesErrorHandler(handler?: ArticlesErrorHandler): void;
280
584
 
585
+ /**
586
+ * Sanitized, non-PII context passed into `ArticleContent`'s slot render
587
+ * props - deliberately a narrow subset of `Article`, not the whole object
588
+ * (no raw `content`/`mdxSource`, no author email or anything author-PII).
589
+ */
590
+ interface ArticleSlotContext {
591
+ slug: string;
592
+ title: string;
593
+ category: string;
594
+ tags: string[];
595
+ readTime: string;
596
+ wordCount?: number;
597
+ authorSlug?: string;
598
+ seriesSlug?: string;
599
+ primaryActionId?: string;
600
+ }
601
+ type ArticleSlotContent = ReactNode | ((context: ArticleSlotContext) => ReactNode);
281
602
  type ArticleContentProps = Readonly<{
282
603
  article: Article;
283
604
  className?: string;
284
605
  config?: ArticlesConfig;
606
+ /** Rendered immediately before the article body - the "around the body, not inside it" counterpart to `config.mdxComponents` (which places content *inside* MDX bodies). */
607
+ afterHero?: ArticleSlotContent;
608
+ /** 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. */
609
+ afterIntro?: ArticleSlotContent;
610
+ /** Rendered after roughly the middle paragraph. Same fallback behavior as `afterIntro`. */
611
+ midContent?: ArticleSlotContent;
612
+ /** Rendered immediately after the article body. */
613
+ afterContent?: ArticleSlotContent;
285
614
  }>;
286
- declare function ArticleContent({ article, className, config }: ArticleContentProps): Promise<react_jsx_runtime.JSX.Element>;
615
+ declare function ArticleContent({ article, className, config, afterHero, afterIntro, midContent, afterContent, }: ArticleContentProps): Promise<react_jsx_runtime.JSX.Element>;
287
616
 
288
617
  type ArticleTOCProps = Readonly<{
289
618
  toc: TocItem[];
@@ -291,4 +620,29 @@ type ArticleTOCProps = Readonly<{
291
620
  }>;
292
621
  declare function ArticleTOC({ toc, className }: ArticleTOCProps): react_jsx_runtime.JSX.Element | null;
293
622
 
294
- export { type Article, ArticleContent, ArticleTOC, type ArticlesConfig, type ArticlesErrorCode, type ArticlesErrorContext, type ArticlesErrorHandler, type ArticlesErrorReport, type AuthorProfile, type AuthorSocial, type BreadcrumbItem, type CategoryInfo, type LinkTargetStrategy, type TocItem, buildArticleBreadcrumbs, buildAuthorBreadcrumbs, buildCategoryBreadcrumbs, categoryToSlug, extractToc, generateArticleMetadata, generateArticleStaticParams, generateArticlesIndexMetadata, generateAuthorMetadata, generateAuthorStaticParams, generateCategoryMetadata, generateCategoryStaticParams, generateRssFeed, getAdjacentArticles, getAiRobotsTxtRules, getAllArticles, getAllAuthors, getAllCategories, getArticleAiHeaders, getArticleAuthors, getArticleMarkdown, getArticleMarkdownResponse, getArticleMarkdownUrl, getArticleMetadata, getArticleSitemapEntries, getArticlesByAuthor, getArticlesByCategory, getAuthorBySlug, getAvailableArticleSlugs, getBreadcrumbsConfig, markdownToHtml, resolveAuthorAvatar, sanitizeImagePath, searchArticles, setArticlesErrorHandler };
623
+ type ValidationSeverity = 'error' | 'warning';
624
+ interface ValidationIssue {
625
+ severity: ValidationSeverity;
626
+ /** Stable machine-readable code, e.g. `'duplicate-canonical-url'`. */
627
+ code: string;
628
+ message: string;
629
+ articleSlug?: string;
630
+ pathKey?: string;
631
+ }
632
+ interface ValidationResult {
633
+ ok: boolean;
634
+ errors: ValidationIssue[];
635
+ warnings: ValidationIssue[];
636
+ }
637
+ /**
638
+ * Validates a loaded article set + config. Warnings cover optional
639
+ * discovery-field issues (missing excerpt/date, over-length search/social
640
+ * fields, category slug collisions); errors cover broken reader journeys
641
+ * (duplicate canonical URLs, unknown author references, series order
642
+ * collisions, missing/draft path references, unsafe URL schemes).
643
+ */
644
+ declare function validateArticles(articles: Article[], config: ArticlesConfig): ValidationResult;
645
+ /** 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. */
646
+ declare function validateAllArticles(config: ArticlesConfig): Promise<ValidationResult>;
647
+
648
+ 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 };