@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/README.md CHANGED
@@ -139,8 +139,10 @@ import {
139
139
  ArticleNavigation,
140
140
  ArticleSEO,
141
141
  ArticleSocialShare,
142
+ ArticleViewTracker,
142
143
  Breadcrumb,
143
144
  CommentsSection,
145
+ RelatedArticlesSection,
144
146
  ScrollToTop,
145
147
  } from '@fullstackdatasolutions/articles'
146
148
  import {
@@ -152,6 +154,7 @@ import {
152
154
  getAdjacentArticles,
153
155
  getArticleAuthors,
154
156
  getArticleMetadata,
157
+ getRelatedArticlesByCategory,
155
158
  } from '@fullstackdatasolutions/articles/server'
156
159
  import { siteConfig } from '@/config/articles'
157
160
  type ArticlePageProps = Readonly<{ params: Promise<{ slug: string[] }> }>
@@ -172,6 +175,7 @@ export default async function Page({ params }: ArticlePageProps) {
172
175
  const article = await getArticleMetadata(slug, siteConfig)
173
176
  if (!article) notFound()
174
177
  const { previous, next } = await getAdjacentArticles(slug)
178
+ const related = await getRelatedArticlesByCategory(slug, article.category, 3, siteConfig)
175
179
  const authors = getArticleAuthors(article, siteConfig)
176
180
  const breadcrumbItems = buildArticleBreadcrumbs(article, siteConfig)
177
181
  const siteUrl = siteConfig.siteUrl.replace(/\/$/, '')
@@ -183,6 +187,7 @@ export default async function Page({ params }: ArticlePageProps) {
183
187
  articleUrl={articleUrl}
184
188
  siteName={siteConfig.siteName}
185
189
  authors={authors}
190
+ config={siteConfig}
186
191
  />
187
192
  <Breadcrumb items={breadcrumbItems} />
188
193
  <ArticleDetailHero
@@ -190,12 +195,15 @@ export default async function Page({ params }: ArticlePageProps) {
190
195
  authors={authors}
191
196
  categoryBasePath="/articles/category"
192
197
  />
198
+ <ArticleViewTracker article={article} config={siteConfig} />
193
199
  {siteConfig.showToc !== false && article.toc && article.toc.length > 0 && (
194
200
  <ArticleTOC toc={article.toc} />
195
201
  )}
202
+ {/* See "Discovery & reader-journey layer" below for afterHero/afterIntro/midContent/afterContent slots */}
196
203
  <ArticleContent article={article} config={siteConfig} />
197
- <ArticleSocialShare title={article.title} url={articleUrl} excerpt={article.excerpt} />
204
+ <ArticleSocialShare title={article.title} url={articleUrl} excerpt={article.excerpt} articleSlug={slug} config={siteConfig} />
198
205
  {siteConfig.comments && <CommentsSection articleSlug={slug} config={siteConfig.comments} />}
206
+ <RelatedArticlesSection articles={related} category={article.category} />
199
207
  <ArticleNavigation previous={previous} next={next} basePath="/articles" />
200
208
  <ScrollToTop />
201
209
  </>
@@ -471,6 +479,7 @@ Use this if you don't need a custom API base path. For custom paths, use `create
471
479
  | `linkTargetStrategy` | `'external-new-tab' \| 'all-new-tab' \| 'same-tab'` | `'external-new-tab'` | Controls article body links. Internal links open in the same window by default; external `http`/`https` links open in a new window. |
472
480
  | `mdxComponents` | `Record<string, ComponentType<never>>` | - | Components exposed to article `.mdx` bodies by tag name. Merged with the built-in MDX image override. |
473
481
  | `description` | `string` | — | Short description used as the RSS feed channel description. Falls back to `siteName` if omitted. |
482
+ | `listingPagination` | `'load-more' \| 'pages'` | `'load-more'` | `'load-more'` keeps the client-only "Load more" button (no URL change). `'pages'` opts every listing surface into real, crawlable paginated routes - see the [Pagination](#pagination) section. |
474
483
 
475
484
  **Default layout order:** `['hero', 'search', 'featured', 'latest', 'categories']`
476
485
 
@@ -617,6 +626,7 @@ import { ArticleDetailHero } from '@fullstackdatasolutions/articles'
617
626
  - The date field is hidden by default. Pass `showDate={true}` to display it.
618
627
  - All layout styles are inline so the component renders correctly regardless of whether `@source` is configured for the package.
619
628
  - Hero image / categories behave automatically once you use this component; apps do not need to reimplement the hero section for those features.
629
+ - **Author authority statement (Phase 27E):** when `authors` has exactly one entry and that author's `AuthorProfile.promise` is set, `ArticleDetailHero` renders it as a one-sentence line under the byline. Omitted for zero or multiple authors, or when `promise` is unset - purely additive, no existing output changes.
620
630
 
621
631
  ---
622
632
 
@@ -667,6 +677,518 @@ const { previous, next } = await getAdjacentArticles(slug)
667
677
 
668
678
  ---
669
679
 
680
+ ## RelatedArticlesSection
681
+
682
+ `RelatedArticlesSection` renders a grid of other articles in the same category as the current article, using `ArticleCard` for each one - real `<a href>` links, not a client-only "Load more" interaction. Use `getRelatedArticlesByCategory` from the server entry to fetch the data.
683
+
684
+ ```tsx
685
+ import { RelatedArticlesSection } from '@fullstackdatasolutions/articles'
686
+ import { getRelatedArticlesByCategory } from '@fullstackdatasolutions/articles/server'
687
+
688
+ const related = await getRelatedArticlesByCategory(slug, article.category, 3, siteConfig)
689
+
690
+ <RelatedArticlesSection articles={related} category={article.category} />
691
+ ```
692
+
693
+ | Prop | Type | Required | Description |
694
+ | ---------- | ---------- | -------- | ----------------------------------------------------- |
695
+ | `articles` | `Article[]` | yes | Related articles to render, already excluding the current one |
696
+ | `category` | `string` | yes | Category name shown in the section heading ("More in {category}") |
697
+
698
+ `getRelatedArticlesByCategory(currentSlug, category, limit?, config?)` returns up to `limit` (default `3`) other articles sharing `category` with `currentSlug`, most recent first, built on the same category-slug matching `getArticlesByCategory` already uses. Returns `[]` (and the component renders nothing) when no other articles share the category.
699
+
700
+ ---
701
+
702
+ ## Pagination
703
+
704
+ By default (`listingPagination` unset, or `'load-more'`), `/articles`, every category page, and every author page use a client-only "Load more" button - clicking it never changes the URL, so a crawler that doesn't execute client JS never sees article 7+ on a listing with more than one page of content.
705
+
706
+ Set `listingPagination: 'pages'` in `ArticlesConfig` to opt into real, directly-navigable paginated routes instead: `/articles/page/2`, `/articles/category/[category]/page/2`, `/articles/authors/[author]/page/2`, each server-rendered with real `<a href>` prev/next links and its own canonical URL. This is a config choice, not a migration - `'load-more'` (the default) keeps working exactly as before, and each listing surface only switches to `'pages'` behavior when both `config.listingPagination === 'pages'` **and** the surface's `page`/`totalPages` props are passed in.
707
+
708
+ The package does not ship the route *files* themselves (Next.js requires them to live in your app's `app/` directory) - it exports the primitives to build them: pagination math (`./server`), a prev/next nav component (`PaginationNav`, from the main entry), and per-page metadata generators (`./server`).
709
+
710
+ ### Pagination primitives (`./server`)
711
+
712
+ | Export | Description |
713
+ | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
714
+ | `getTotalPages(totalCount, pageSize)` | Total page count, rounded up. Returns `1` for a zero/negative count or pageSize. |
715
+ | `paginateArticles(articles, page, pageSize)` | Slices `articles` to the requested page. Out-of-range page numbers are clamped into `[1, totalPages]`. Returns `{ articles, page, totalPages, hasPrevious, hasNext }`. |
716
+ | `buildPageUrl(basePath, page)` | Page 1 returns `basePath` unchanged; page N>1 returns `${basePath}/page/${N}`. |
717
+ | `buildPaginationLinks(basePath, page, totalPages)` | Returns `{ canonicalUrl, prevUrl, nextUrl }` (`prevUrl`/`nextUrl` are `null` at the ends). |
718
+ | `generateListingPageStaticParams(totalPages)` | Returns `{ page: string }[]` for pages `2..totalPages` (page 1 has no `/page/1` route - it's the base route itself). |
719
+ | `parsePageParam(raw)` | Parses a route param string to a page number; defaults to `1` for missing/invalid input. |
720
+ | `isPageOutOfRange(page, totalPages)` | `true` when `page < 1` or `page > totalPages` - call `notFound()` when this is `true`. |
721
+
722
+ ### `PaginationNav` (main entry)
723
+
724
+ Renders real Previous/Next `<a href>` links plus `<link rel="prev"/"next">` tags (React 19 hoists these into `<head>` automatically wherever they're rendered in the tree). Rendered automatically by `LatestArticles`/`ArticlesPage`/`CategoryArticlesPage`/`AuthorArticlesPage` when you pass pagination props - you generally won't render it directly.
725
+
726
+ | Prop | Type | Required | Description |
727
+ | ------------ | -------- | -------- | --------------------------------------------------------- |
728
+ | `basePath` | `string` | yes | Un-paginated route path, e.g. `/articles`. |
729
+ | `page` | `number` | yes | Current page number. |
730
+ | `totalPages` | `number` | yes | Total page count. Renders nothing when `totalPages <= 1`. |
731
+
732
+ **rel=next/prev and Google:** Google Search Central's pagination guidance no longer treats `rel="next"`/`rel="prev"` as an indexing or ranking signal (dropped in 2019) - what actually makes page 2+ indexable to Google is each page having its own self-referencing canonical (never pointing back to page 1) plus real crawlable links between pages, both of which are handled here (`generate*PageMetadata` below for the canonical, `PaginationNav`'s visible links for crawlability). `rel=next/prev` remains valid HTML and is still read by Bing and some third-party tools, so `PaginationNav` emits it anyway - it's just not the mechanism Google uses.
733
+
734
+ ### Per-page metadata (`./server`)
735
+
736
+ `generateArticlesIndexPageMetadata`, `generateCategoryPageMetadata`, and `generateAuthorPageMetadata` wrap the existing `generate*Metadata` functions, adding a `" - Page N"` title suffix (page 1 is unchanged) and pointing `alternates.canonical`/`openGraph.url` at that page's own URL instead of page 1's. Paginated pages stay `index, follow` - the entire point is making them indexable, not excluding them.
737
+
738
+ ### Wiring the routes
739
+
740
+ ```tsx
741
+ // app/articles/page/[page]/page.tsx
742
+ import { notFound } from 'next/navigation'
743
+ import { ArticlesPage } from '@fullstackdatasolutions/articles'
744
+ import {
745
+ generateArticlesIndexPageMetadata,
746
+ generateListingPageStaticParams,
747
+ getAllArticles,
748
+ getAllCategories,
749
+ getTotalPages,
750
+ isPageOutOfRange,
751
+ paginateArticles,
752
+ parsePageParam,
753
+ } from '@fullstackdatasolutions/articles/server'
754
+ import { siteConfig } from '@/config/articles'
755
+
756
+ type PageProps = Readonly<{ params: Promise<{ page: string }> }>
757
+
758
+ export async function generateStaticParams() {
759
+ const totalPages = getTotalPages((await getAllArticles(siteConfig)).length, siteConfig.pageSize ?? 6)
760
+ return generateListingPageStaticParams(totalPages)
761
+ }
762
+
763
+ export async function generateMetadata({ params }: PageProps) {
764
+ const page = parsePageParam((await params).page)
765
+ const totalPages = getTotalPages((await getAllArticles(siteConfig)).length, siteConfig.pageSize ?? 6)
766
+ return generateArticlesIndexPageMetadata(page, totalPages, siteConfig)
767
+ }
768
+
769
+ export default async function Page({ params }: PageProps) {
770
+ const page = parsePageParam((await params).page)
771
+ const [allArticles, categories] = await Promise.all([
772
+ getAllArticles(siteConfig),
773
+ getAllCategories(),
774
+ ])
775
+ const { articles, totalPages } = paginateArticles(allArticles, page, siteConfig.pageSize ?? 6)
776
+ if (isPageOutOfRange(page, totalPages)) notFound()
777
+ return (
778
+ <ArticlesPage
779
+ config={siteConfig}
780
+ initialArticles={articles}
781
+ initialCategories={categories}
782
+ page={page}
783
+ totalPages={totalPages}
784
+ totalCount={allArticles.length}
785
+ />
786
+ )
787
+ }
788
+
789
+ // app/articles/category/[category]/page/[page]/page.tsx
790
+ import { notFound } from 'next/navigation'
791
+ import { CategoryArticlesPage } from '@fullstackdatasolutions/articles'
792
+ import {
793
+ generateCategoryPageMetadata,
794
+ generateListingPageStaticParams,
795
+ getAllCategories,
796
+ getArticlesByCategory,
797
+ getTotalPages,
798
+ isPageOutOfRange,
799
+ paginateArticles,
800
+ parsePageParam,
801
+ } from '@fullstackdatasolutions/articles/server'
802
+ import { siteConfig } from '@/config/articles'
803
+
804
+ type CategoryPageProps = Readonly<{ params: Promise<{ category: string; page: string }> }>
805
+
806
+ export async function generateStaticParams() {
807
+ const categories = await getAllCategories()
808
+ const perCategory = await Promise.all(
809
+ categories.map(async (cat) => {
810
+ const totalPages = getTotalPages(
811
+ (await getArticlesByCategory(cat.slug, siteConfig)).length,
812
+ siteConfig.pageSize ?? 6
813
+ )
814
+ return generateListingPageStaticParams(totalPages).map((p) => ({
815
+ category: cat.slug,
816
+ page: p.page,
817
+ }))
818
+ })
819
+ )
820
+ return perCategory.flat()
821
+ }
822
+
823
+ export async function generateMetadata({ params }: CategoryPageProps) {
824
+ const { category, page: pageParam } = await params
825
+ const page = parsePageParam(pageParam)
826
+ const totalPages = getTotalPages(
827
+ (await getArticlesByCategory(category, siteConfig)).length,
828
+ siteConfig.pageSize ?? 6
829
+ )
830
+ return generateCategoryPageMetadata(category, page, totalPages, siteConfig)
831
+ }
832
+
833
+ export default async function Page({ params }: CategoryPageProps) {
834
+ const { category, page: pageParam } = await params
835
+ const page = parsePageParam(pageParam)
836
+ const allArticles = await getArticlesByCategory(category, siteConfig)
837
+ if (allArticles.length === 0) notFound()
838
+ const { articles, totalPages } = paginateArticles(allArticles, page, siteConfig.pageSize ?? 6)
839
+ if (isPageOutOfRange(page, totalPages)) notFound()
840
+ return (
841
+ <CategoryArticlesPage
842
+ category={category}
843
+ articles={articles}
844
+ config={siteConfig}
845
+ page={page}
846
+ totalPages={totalPages}
847
+ totalCount={allArticles.length}
848
+ />
849
+ )
850
+ }
851
+
852
+ // app/articles/authors/[author]/page/[page]/page.tsx
853
+ import { notFound } from 'next/navigation'
854
+ import { AuthorArticlesPage } from '@fullstackdatasolutions/articles'
855
+ import {
856
+ generateAuthorPageMetadata,
857
+ generateListingPageStaticParams,
858
+ getArticlesByAuthor,
859
+ getAuthorBySlug,
860
+ getTotalPages,
861
+ isPageOutOfRange,
862
+ paginateArticles,
863
+ parsePageParam,
864
+ } from '@fullstackdatasolutions/articles/server'
865
+ import { siteConfig } from '@/config/articles'
866
+
867
+ type AuthorPageProps = Readonly<{ params: Promise<{ author: string; page: string }> }>
868
+
869
+ export async function generateMetadata({ params }: AuthorPageProps) {
870
+ const { author: authorSlug, page: pageParam } = await params
871
+ const page = parsePageParam(pageParam)
872
+ const totalPages = getTotalPages(
873
+ (await getArticlesByAuthor(authorSlug, siteConfig)).length,
874
+ siteConfig.pageSize ?? 6
875
+ )
876
+ return generateAuthorPageMetadata(authorSlug, page, totalPages, siteConfig)
877
+ }
878
+
879
+ export default async function Page({ params }: AuthorPageProps) {
880
+ const { author: authorSlug, page: pageParam } = await params
881
+ const author = getAuthorBySlug(authorSlug, siteConfig)
882
+ if (!author) notFound()
883
+ const page = parsePageParam(pageParam)
884
+ const allArticles = await getArticlesByAuthor(authorSlug, siteConfig)
885
+ const { articles, totalPages } = paginateArticles(allArticles, page, siteConfig.pageSize ?? 6)
886
+ if (isPageOutOfRange(page, totalPages)) notFound()
887
+ return (
888
+ <AuthorArticlesPage
889
+ author={author}
890
+ articles={articles}
891
+ config={siteConfig}
892
+ page={page}
893
+ totalPages={totalPages}
894
+ totalCount={allArticles.length}
895
+ />
896
+ )
897
+ }
898
+ ```
899
+
900
+ `ArticlesPage`, `CategoryArticlesPage`, and `AuthorArticlesPage` all accept the same three optional props - `page`, `totalPages`, `totalCount` - which are only read when `config.listingPagination === 'pages'`; passing them with any other (or unset) `listingPagination` value is a no-op, so the default behavior can never regress from adding this wiring. `totalCount` (the true total across every page, not just the current slice) feeds `CollectionPageSchema.articleCount`/`numberOfItems`; omit it and it falls back to the length of the `articles`/`initialArticles` array you pass, matching prior behavior.
901
+
902
+ **Search stays unpaginated in both modes.** `ArticlesPage`'s search bar (`ArticleSearchBar`/`useArticles`) fetches from `/api/articles?q=...` and shows every match on one page regardless of `listingPagination` - search result URLs aren't meant to be indexed, so real pagination adds no SEO value there, and `LatestArticlesSection` explicitly drops the `pagination` prop while a search query is active.
903
+
904
+ **Sitemap:** paginated listing URLs (`/articles/page/2`, etc.) are intentionally **not** added to `getArticleSitemapEntries`. Every individual article, category, and author URL is already listed directly in the sitemap output, so a paginated listing page adds no new discoverable URLs to it - the reason `'pages'` mode exists is internal link equity (real `<a href>` links between pages) and crawlers/AI agents that weight on-page links over sitemaps, not sitemap coverage.
905
+
906
+ ---
907
+
908
+ ## Discovery & reader-journey layer (Phase 27F)
909
+
910
+ Search/social metadata overrides, series and curated paths, article-detail extension points, an app-owned primary action reference, reusable related-content selection, a vendor-neutral event contract, and a package validator. All additive - every legacy consumer with none of these fields/props set renders/behaves identically to before this phase.
911
+
912
+ ### Discovery metadata overrides
913
+
914
+ Optional frontmatter fields let one article optimize its search-result snippet differently from its social-share card, without touching canonical URLs, JSON-LD, RSS, or `ArticleCard` (those always read `title`/`excerpt`/`featuredImage` directly):
915
+
916
+ ```yaml
917
+ ---
918
+ title: The Real Article Title
919
+ excerpt: The real, on-page excerpt.
920
+ searchTitle: 'A Punchier, SEO-Optimized Title' # <title>/meta description ONLY
921
+ searchDescription: 'A keyword-forward summary for search snippets.'
922
+ socialTitle: 'A Scroll-Stopping Social Headline' # Open Graph/Twitter Card ONLY
923
+ socialDescription: 'A more casual, share-friendly description.'
924
+ socialImage: social-card.png # falls back to featuredImage when unset
925
+ ---
926
+ ```
927
+
928
+ `generateArticleMetadata`'s `<title>`/meta description prefer `searchTitle`/`searchDescription`, falling back to `title`/`excerpt`. Its Open Graph/Twitter Card output prefers `socialTitle`/`socialDescription`/`socialImage`, falling back to `title`/`excerpt`/`featuredImage`. Both resolvers are also exported directly for custom metadata generation:
929
+
930
+ ```ts
931
+ import { resolveSearchMetadata, resolveSocialMetadata } from '@fullstackdatasolutions/articles/server'
932
+
933
+ const { title, description } = resolveSearchMetadata(article, config)
934
+ const { title: ogTitle, description: ogDescription, imageUrl } = resolveSocialMetadata(article, config.siteUrl)
935
+ ```
936
+
937
+ A blank string (`searchTitle: ""`) is treated as unset, same as omitting the key. Recommended length limits (checked by `validateArticles`, warnings only): `searchTitle` 60 chars, `searchDescription` 160 chars, `socialTitle` 95 chars, `socialDescription` 200 chars.
938
+
939
+ ### Series as a navigable reader journey
940
+
941
+ `series` (a display label) keeps working exactly as before. Two new optional fields turn it into a real, orderable journey:
942
+
943
+ ```yaml
944
+ ---
945
+ series: New GM Starter Path # label-only, unchanged
946
+ seriesSlug: new-gm-path # machine-safe id
947
+ seriesOrder: 2 # position within the series
948
+ ---
949
+ ```
950
+
951
+ ```ts
952
+ import {
953
+ getArticlesBySeries,
954
+ getAdjacentArticlesInSeries,
955
+ generateSeriesStaticParams,
956
+ generateSeriesMetadata,
957
+ } from '@fullstackdatasolutions/articles/server'
958
+ import { SeriesArticlesPage } from '@fullstackdatasolutions/articles'
959
+
960
+ // app/articles/series/[series]/page.tsx
961
+ export async function generateStaticParams() {
962
+ return generateSeriesStaticParams(siteConfig)
963
+ }
964
+
965
+ export async function generateMetadata({ params }: { params: Promise<{ series: string }> }) {
966
+ const { series } = await params
967
+ return generateSeriesMetadata(series, siteConfig)
968
+ }
969
+
970
+ export default async function SeriesPage({ params }: { params: Promise<{ series: string }> }) {
971
+ const { series } = await params
972
+ const articles = await getArticlesBySeries(series, siteConfig)
973
+ return <SeriesArticlesPage seriesSlug={series} articles={articles} config={siteConfig} />
974
+ }
975
+ ```
976
+
977
+ `getArticlesBySeries(seriesSlug, config?)` sorts by `seriesOrder` ascending, falling back to `getAllArticles`' date order (stable sort) for articles missing `seriesOrder`. `getAdjacentArticlesInSeries(currentSlug, seriesSlug, config?)` is a series-aware sibling of `getAdjacentArticles` - it walks `seriesOrder` within the series instead of global date order. It returns the same `{ previous, next }` shape, so the existing `ArticleNavigation` component works unchanged for series navigation:
978
+
979
+ ```tsx
980
+ const { previous, next } = await getAdjacentArticlesInSeries(article.slug, article.seriesSlug!, siteConfig)
981
+ <ArticleNavigation previous={previous} next={next} basePath="/articles" />
982
+ ```
983
+
984
+ ### `Path`: curated "start here" journeys
985
+
986
+ A `Path` is a distinct primitive from `series` - a curated, ordered journey that can cross series and categories, configured (not frontmatter-driven) via `ArticlesConfig.paths`:
987
+
988
+ ```ts
989
+ const siteConfig: ArticlesConfig = {
990
+ siteUrl: 'https://example.com',
991
+ siteName: 'Example',
992
+ paths: {
993
+ 'new-gm': {
994
+ name: 'New GM Starter Path',
995
+ promise: 'Run your first session with confidence.',
996
+ articles: ['intro-to-gming', 'prepping-your-first-session', 'running-combat'],
997
+ nextAction: { label: 'Get the GM checklist', href: '/lead-magnets/gm-checklist' },
998
+ },
999
+ },
1000
+ }
1001
+ ```
1002
+
1003
+ ```ts
1004
+ import { getPath, getPathArticles } from '@fullstackdatasolutions/articles/server'
1005
+
1006
+ const path = getPath('new-gm', siteConfig) // PathDefinition | null
1007
+ const orderedArticles = await getPathArticles('new-gm', siteConfig) // resolved Article[], path order
1008
+ ```
1009
+
1010
+ Missing or unpublished (`draft: true`) article references are **not** silently dropped into a dead step - they're hard failures caught by `validateArticles` (see below). `getPathArticles` itself resolves what it can (skipping unresolvable slugs) so a broken reference degrades gracefully at runtime while still failing your CI/build check.
1011
+
1012
+ ### Article-detail extension points (slots)
1013
+
1014
+ Four typed, optional slots on `ArticleContent` place consumer-rendered content **around** the article body (a different problem from `config.mdxComponents`, which places content *inside* MDX bodies):
1015
+
1016
+ ```tsx
1017
+ import { ArticleContent } from '@fullstackdatasolutions/articles/server'
1018
+
1019
+ <ArticleContent
1020
+ article={article}
1021
+ config={siteConfig}
1022
+ afterHero={<NewsletterBanner />}
1023
+ afterIntro={(ctx) => <InlineCta actionId={ctx.primaryActionId} />}
1024
+ midContent={<LeadMagnetCallout />}
1025
+ afterContent={<ArticleSocialShare title={article.title} url={articleUrl} articleSlug={article.slug} config={siteConfig} />}
1026
+ />
1027
+ ```
1028
+
1029
+ Each slot accepts either a plain `ReactNode` or a function receiving a sanitized `ArticleSlotContext` (`slug`, `title`, `category`, `tags`, `readTime`, `wordCount`, `authorSlug`, `seriesSlug`, `primaryActionId` - no raw body content, no author PII). `afterHero`/`afterContent` always work. `afterIntro`/`midContent` need a detectable paragraph structure in the raw markdown/MDX source - resolved deterministically from the parsed AST (`getContentSlotBoundaries`, also exported from `./server`), never by splitting rendered HTML strings. When the source has no detectable paragraphs (e.g., very JSX-heavy MDX), `afterIntro`/`midContent` are silently omitted for that article - `afterHero`/`afterContent` still render. Omitting all four slot props reproduces `ArticleContent`'s exact pre-27F output.
1030
+
1031
+ ### Primary action reference
1032
+
1033
+ `Article.primaryAction?: { actionId: string }` (frontmatter: `primaryAction: download-starter-kit` or `primaryAction: { actionId: download-starter-kit }`) references an app-owned CTA/offer by opaque ID. The package never interprets `actionId` - no coupling to a specific form, email provider, or analytics vendor. Look it up in your own registry inside a slot, and render nothing when there's no match:
1034
+
1035
+ ```tsx
1036
+ const CTA_REGISTRY: Record<string, ReactNode> = {
1037
+ 'download-starter-kit': <LeadMagnetCTA offer="gm-starter-kit" />,
1038
+ }
1039
+
1040
+ afterContent={(ctx) => (ctx.primaryActionId ? (CTA_REGISTRY[ctx.primaryActionId] ?? null) : null)}
1041
+ ```
1042
+
1043
+ ### Reusable related-content selection
1044
+
1045
+ `getRelatedContent(article, config, limit?)` prefers a configured `Path` containing the article first, then its `seriesSlug`, falling back to 27B's `getRelatedArticlesByCategory` (imported, not reimplemented) when neither applies:
1046
+
1047
+ ```ts
1048
+ import { getRelatedContent } from '@fullstackdatasolutions/articles/server'
1049
+ import { RelatedArticlesSection } from '@fullstackdatasolutions/articles'
1050
+
1051
+ const related = await getRelatedContent(article, siteConfig)
1052
+
1053
+ <RelatedArticlesSection
1054
+ articles={related.articles}
1055
+ category={article.category}
1056
+ heading={related.heading}
1057
+ source={related.source}
1058
+ fromSlug={article.slug}
1059
+ config={siteConfig}
1060
+ />
1061
+ ```
1062
+
1063
+ `RelatedArticlesSection` gained an optional `heading` override (defaults to `"More in {category}"` when omitted, unchanged) and, with `config`/`fromSlug` set, fires a `related_article_clicked` event on card click.
1064
+
1065
+ ### Event callbacks
1066
+
1067
+ `ArticlesConfig.onEvent?: (event: ArticleEvent) => void` receives a typed, vendor-neutral event for eight reader-journey moments - `article_viewed`, `meaningful_read`, `author_clicked`, `cta_viewed`, `cta_clicked`, `shared`, `related_article_clicked`, `path_step_advanced`. Payloads only ever contain slugs/IDs/enums - no PII (no emails, no names-as-identifiers). The package has no PostHog/Plunk dependency; translate events to your own analytics stack inside `onEvent`:
1068
+
1069
+ ```ts
1070
+ const siteConfig: ArticlesConfig = {
1071
+ siteUrl: 'https://example.com',
1072
+ siteName: 'Example',
1073
+ onEvent: (event) => {
1074
+ posthog.capture(event.name, event)
1075
+ },
1076
+ }
1077
+ ```
1078
+
1079
+ Components that fire events (all safely no-op when `config`/`config.onEvent` is omitted):
1080
+
1081
+ | Component/hook | Event(s) | Requires |
1082
+ | ------------------------- | -------------------------- | ---------------------------------- |
1083
+ | `ArticleViewTracker` | `article_viewed`, `meaningful_read` | mount on the article detail page |
1084
+ | `ArticleCard` | `author_clicked` | `config` prop |
1085
+ | `RelatedArticlesSection` | `related_article_clicked` | `config` + `fromSlug` props |
1086
+ | `ArticleNavigation` | `path_step_advanced` | `config` + `pathKey` + `fromSlug` props |
1087
+ | `ArticleSocialShare` | `shared` | `config` + `articleSlug` props |
1088
+ | `AuthorArticlesPage` (`'cta'` section) | `cta_viewed`, `cta_clicked` | `config` prop |
1089
+ | `CtaViewTracker` | `cta_viewed` | wrap any CTA block |
1090
+
1091
+ `ArticleViewTracker` (client component, renders nothing) fires `article_viewed` on mount and `meaningful_read` once, after roughly half the article's estimated read time (`article.wordCount`-based, 15s default when unset) - a deterministic timing approximation, not scroll-depth tracking:
1092
+
1093
+ ```tsx
1094
+ import { ArticleViewTracker } from '@fullstackdatasolutions/articles'
1095
+
1096
+ <ArticleViewTracker article={article} config={siteConfig} />
1097
+ <ArticleContent article={article} config={siteConfig} />
1098
+ ```
1099
+
1100
+ `CtaViewTracker` wraps any CTA block and fires `cta_viewed` once, the first time at least half of it scrolls into view (falls back to firing immediately when `IntersectionObserver` is unavailable):
1101
+
1102
+ ```tsx
1103
+ import { CtaViewTracker } from '@fullstackdatasolutions/articles'
1104
+
1105
+ <CtaViewTracker ctaId="download-starter-kit" articleSlug={article.slug} config={siteConfig}>
1106
+ {CTA_REGISTRY[actionId]}
1107
+ </CtaViewTracker>
1108
+ ```
1109
+
1110
+ ### Package validator
1111
+
1112
+ `validateArticles(articles, config)` (pure, no `fs`) and `validateAllArticles(config)` (loads via `getAllArticles` first) check required frontmatter, unique canonical URLs, valid author references, series order/slug collisions, `Path` article references (missing or draft), unsafe URL schemes (`javascript:`/`data:`/`vbscript:` in `Path.nextAction.href`/`AuthorProfile.primaryCta.href`), and discovery field length limits - returning `{ ok, errors, warnings }`. Errors are broken-reader-journey issues (fail your build/CI on them); warnings are optional-field gaps (missing excerpt/date, over-length search/social fields, category slug collisions).
1113
+
1114
+ ```ts
1115
+ // scripts/validate-articles.ts (your app)
1116
+ import { validateAllArticles } from '@fullstackdatasolutions/articles/server'
1117
+ import { siteConfig } from '../lib/articles-config'
1118
+
1119
+ const result = await validateAllArticles(siteConfig)
1120
+ for (const warning of result.warnings) console.warn(warning.code, warning.message, warning.articleSlug)
1121
+ for (const error of result.errors) console.error(error.code, error.message, error.articleSlug ?? error.pathKey)
1122
+ if (!result.ok) process.exit(1)
1123
+ ```
1124
+
1125
+ There's no standalone CLI binary shipped in this package - `validateAllArticles` needs your app's real `ArticlesConfig` (author registry, paths, site URL), which lives in app code the package can't import. The thin script above is the intended integration point; wire it into CI or a pre-build step.
1126
+
1127
+ ---
1128
+
1129
+ ## Rich author profiles & composable `AuthorArticlesPage` sections
1130
+
1131
+ `AuthorProfile` has optional, additive fields beyond the original `name`/`slug`/`bio`/`avatar`/`url`/`social` (Phase 27E). All are optional - a profile that doesn't set them renders exactly as before, everywhere `AuthorProfile` is used.
1132
+
1133
+ | Field | Type | Description |
1134
+ | ------------- | ------------------------------------ | ----------------------------------------------------------------------------------------------- |
1135
+ | `promise` | `string` | Short one-line audience promise, e.g. `"Helping new GMs run confident first sessions."` |
1136
+ | `originStory` | `RichText` (`RichTextSection[]`) | Structured long-form story: `{ heading?: string; paragraphs: string[] }[]` - not a single HTML blob. |
1137
+ | `servesWho` | `string[]` | Who the author's work is for, e.g. `["New game masters", "Streaming DMs"]`. |
1138
+ | `principles` | `string[]` | Core beliefs/approach statements. |
1139
+ | `credentials` | `string[]` | Experience/credential claims. Never included in Person JSON-LD (see below). |
1140
+ | `proof` | `ProofItem[]` | `{ claim: string; source?: string; url?: string }[]` - concrete, sourceable proof points. |
1141
+ | `primaryCta` | `{ label: string; href: string }` | Primary call-to-action for the author's page. |
1142
+
1143
+ ### Composable `AuthorArticlesPage` sections
1144
+
1145
+ `AuthorArticlesPage` accepts an optional `sections` prop - an ordered array of section keys - plus a `customSection` slot:
1146
+
1147
+ ```ts
1148
+ type AuthorPageSection =
1149
+ | 'hero'
1150
+ | 'promise'
1151
+ | 'servesWho'
1152
+ | 'originStory'
1153
+ | 'principles'
1154
+ | 'proof'
1155
+ | 'cta'
1156
+ | 'articles'
1157
+ | 'custom'
1158
+ ```
1159
+
1160
+ ```tsx
1161
+ <AuthorArticlesPage
1162
+ author={author}
1163
+ articles={articles}
1164
+ config={siteConfig}
1165
+ sections={['hero', 'promise', 'servesWho', 'originStory', 'principles', 'proof', 'cta', 'custom', 'articles']}
1166
+ customSection={<NewsletterSignup />}
1167
+ />
1168
+ ```
1169
+
1170
+ - **Omitting `sections` keeps current behavior**: `AuthorArticlesPage` renders only the Person/CollectionPage JSON-LD plus the article list - no hero, no rich-profile sections - identical to pre-27E output. This is true even if `author` has every new field populated; nothing renders until you opt in via `sections`.
1171
+ - Each section (other than `'hero'`, `'articles'`, and `'custom'`) renders nothing when its backing field is unset, so you can safely list a key for a profile that doesn't have that data yet.
1172
+ - `'hero'` renders `AuthorDetailHero` (the same component you'd otherwise compose manually next to `AuthorArticlesPage`) - if you include it in `sections`, stop also rendering `<AuthorDetailHero>` separately in your page to avoid a duplicate.
1173
+ - `'custom'` renders whatever `ReactNode` you pass as `customSection`; omitted (renders nothing) if `'custom'` isn't in `sections`, or if `customSection` isn't passed.
1174
+ - Every new section uses a semantic `<section aria-label="...">` landmark with a visible heading, matching this package's existing accessibility convention (e.g. `RelatedArticlesSection`'s `aria-label="Related articles"`, `ArticleNavigation`'s `aria-label="Article navigation"`).
1175
+
1176
+ ```tsx
1177
+ // app/articles/authors/[author]/page.tsx
1178
+ <AuthorArticlesPage
1179
+ author={author}
1180
+ articles={articles}
1181
+ config={siteConfig}
1182
+ sections={['hero', 'promise', 'originStory', 'articles']}
1183
+ />
1184
+ ```
1185
+
1186
+ ### Person JSON-LD
1187
+
1188
+ `getPersonSchema` (`AuthorArticlesPage`) and `getPersonSchemas` (`ArticleSchemas.tsx`, used by `ArticleSEO`) intentionally do **not** include `promise`, `servesWho`, `principles`, `credentials`, or `proof` - `promise`/`principles` are audience-facing marketing copy, `servesWho` is an audience segment rather than a `knowsAbout` topic, and `credentials`/`proof` are self-reported/unverifiable claims. None meet the bar for schema.org structured data. `description`, `image`, `sameAs`, and `knowsAbout` are sourced exactly as they were before this phase (`bio`, resolved avatar, approved social links, `config.siteName`).
1189
+
1190
+ ---
1191
+
670
1192
  ## ArticleTOC
671
1193
 
672
1194
  `ArticleTOC` renders the article's table of contents as an inline nav block above article content. Server component (no `'use client'`). Returns `null` when the `toc` array is empty, so it is safe to render unconditionally.
@@ -941,7 +1463,11 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
941
1463
 
942
1464
  Returns article entries (priority 0.8, changeFrequency 'weekly') and category entries (priority 0.7, changeFrequency 'weekly'). Alternative: pass a plain URL string instead of `siteConfig`: `getArticleSitemapEntries('https://yoursite.com')`.
943
1465
 
944
- JSON-LD structured data (`ArticleSchema`, `Breadcrumb`, `BreadcrumbSchema`, `CollectionPageSchema`) is rendered automatically inside the library components. Use `Breadcrumb` for visible navigation plus schema, or `BreadcrumbSchema` when you only need visually hidden breadcrumb schema output.
1466
+ JSON-LD structured data (`ArticleSEO`, `Breadcrumb`, `BreadcrumbSchema`, `CollectionPageSchema`) is rendered automatically inside the library components. Use `Breadcrumb` for visible navigation plus schema, or `BreadcrumbSchema` when you only need visually hidden breadcrumb schema output.
1467
+
1468
+ `ArticleSEO` accepts an optional `config?: ArticlesConfig` prop - pass it (as shown in the canonical `app/articles/[...slug]/page.tsx` example above) to populate each author's `image` in the `Person` schema via `getAuthorAvatar`. Without `config`, author schemas omit `image` unless the author's `avatar` is already an absolute URL. The `Article` schema also always includes `articleSection` (from `article.category`), `keywords` (from `article.tags`, comma-joined, omitted if empty), and `wordCount` (from `article.wordCount`, omitted if unset - computed automatically from the article body, no extra parsing cost).
1469
+
1470
+ `CollectionPageSchema` accepts an optional `items?: { position: number; url: string; name: string }[]` prop - when provided (non-empty), it adds a `mainEntity: { '@type': 'ItemList', itemListElement: [...] }` alongside the existing `numberOfItems`. `ArticlesPage`, `CategoryArticlesPage`, and `AuthorArticlesPage` all pass this automatically, built from the articles actually present in the page's initial HTML (the first `pageSize`, matching what a crawler sees before "Load more" is clicked) - `CategoryArticlesPage` previously emitted no JSON-LD at all; `AuthorArticlesPage` now emits both its existing `Person` schema and a `CollectionPage`/`ItemList`.
945
1471
 
946
1472
  ---
947
1473
 
@@ -962,8 +1488,16 @@ JSON-LD structured data (`ArticleSchema`, `Breadcrumb`, `BreadcrumbSchema`, `Col
962
1488
  | `howTo` | `{ name: string; text: string }[]` | no | How-to steps used by the article structured data. Invalid entries are ignored. |
963
1489
  | `canonicalUrl` | `string` | no | Canonical URL override for article metadata. |
964
1490
  | `articleType` | `string` | no | Optional article type value included in article metadata/structured data. |
965
- | `series` | `string` | no | Optional series label for grouping related articles. |
1491
+ | `series` | `string` | no | Optional series label for grouping related articles (display only). |
1492
+ | `seriesSlug` | `string` | no | Machine-safe series id. Enables `getArticlesBySeries`/`getAdjacentArticlesInSeries`. See [Discovery & reader-journey layer](#discovery--reader-journey-layer-phase-27f). |
1493
+ | `seriesOrder` | `number` | no | Position within `seriesSlug`, ascending. Omitted articles fall back to date order. |
966
1494
  | `aiCrawl` | `boolean` | no | Set to `true` to publish the article's markdown twin and add a `text/markdown` alternate link. Omitted or `false` keeps markdown private and opts the HTML page out of AI indexing via helper headers. |
1495
+ | `searchTitle` | `string` | no | Overrides `<title>`/meta description ONLY. Falls back to `title`. |
1496
+ | `searchDescription` | `string` | no | Overrides meta description ONLY. Falls back to `excerpt`. |
1497
+ | `socialTitle` | `string` | no | Overrides Open Graph/Twitter Card title ONLY. Falls back to `title`. |
1498
+ | `socialDescription` | `string` | no | Overrides Open Graph/Twitter Card description ONLY. Falls back to `excerpt`. |
1499
+ | `socialImage` | `string` | no | Overrides Open Graph/Twitter Card image ONLY. Falls back to `featuredImage`. |
1500
+ | `primaryAction` | `string \| { actionId: string }` | no | References an app-owned CTA/offer by opaque ID. See [Discovery & reader-journey layer](#discovery--reader-journey-layer-phase-27f). |
967
1501
 
968
1502
  Articles can be authored as either `article.md` or `article.mdx` inside `public/articles/{slug}/`. The `{slug}` may be a path-like slug such as `game-system/article-name`. Markdown files are converted to HTML; MDX files are returned as `mdxSource` for the consuming app to render. Reading time and the table of contents are generated from the article body automatically.
969
1503
 
@@ -993,23 +1527,36 @@ import {
993
1527
  getAllCategories,
994
1528
  getAvailableArticleSlugs,
995
1529
  getAdjacentArticles,
1530
+ getAdjacentArticlesInSeries,
1531
+ getArticlesBySeries,
1532
+ getRelatedArticlesByCategory,
1533
+ getRelatedContent,
1534
+ getPath,
1535
+ getPathArticles,
996
1536
  searchArticles,
997
1537
  categoryToSlug,
998
1538
  sanitizeImagePath,
999
1539
  markdownToHtml,
1000
1540
  extractToc,
1541
+ getContentSlotBoundaries,
1001
1542
  generateArticleStaticParams,
1002
1543
  generateCategoryStaticParams,
1544
+ generateSeriesStaticParams,
1003
1545
  generateAuthorStaticParams,
1004
1546
  getArticleSitemapEntries,
1005
1547
  generateRssFeed,
1006
1548
  generateArticlesIndexMetadata,
1007
1549
  generateArticleMetadata,
1008
1550
  generateCategoryMetadata,
1551
+ generateSeriesMetadata,
1009
1552
  generateAuthorMetadata,
1553
+ resolveSearchMetadata,
1554
+ resolveSocialMetadata,
1010
1555
  buildArticleBreadcrumbs,
1011
1556
  buildCategoryBreadcrumbs,
1012
1557
  buildAuthorBreadcrumbs,
1558
+ validateArticles,
1559
+ validateAllArticles,
1013
1560
  } from '@fullstackdatasolutions/articles/server'
1014
1561
  ```
1015
1562