@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.
Files changed (69) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +550 -3
  3. package/dist/index.cjs +960 -383
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.cts +372 -20
  6. package/dist/index.d.ts +372 -20
  7. package/dist/index.js +944 -371
  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 +141 -0
  12. package/dist/nextjs.d.ts +141 -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 +349 -3
  18. package/dist/server.d.ts +349 -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 +37 -1
  23. package/src/ArticleContent.tsx +144 -5
  24. package/src/ArticleDetailHero.tsx +23 -0
  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 +55 -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 +55 -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 +37 -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.cts 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
- 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 };
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 };