@fullstackdatasolutions/articles 0.12.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/README.md +550 -3
- package/dist/index.cjs +960 -383
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +372 -20
- package/dist/index.d.ts +372 -20
- package/dist/index.js +944 -371
- package/dist/index.js.map +1 -1
- package/dist/nextjs.cjs +74 -6
- package/dist/nextjs.cjs.map +1 -1
- package/dist/nextjs.d.cts +141 -0
- package/dist/nextjs.d.ts +141 -0
- package/dist/nextjs.js +74 -6
- package/dist/nextjs.js.map +1 -1
- package/dist/server.cjs +665 -27
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +349 -3
- package/dist/server.d.ts +349 -3
- package/dist/server.js +643 -29
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
- package/src/ArticleCard.tsx +37 -1
- package/src/ArticleContent.tsx +144 -5
- package/src/ArticleDetailHero.tsx +23 -0
- package/src/ArticleNavigation.tsx +32 -1
- package/src/ArticleSchemas.tsx +43 -39
- package/src/ArticleSocialShare.tsx +54 -10
- package/src/ArticlesPage.tsx +55 -5
- package/src/AuthorArticlesPage.tsx +308 -14
- package/src/AuthorCard.tsx +1 -1
- package/src/CategoryArticlesPage.tsx +34 -2
- package/src/LatestArticles.tsx +28 -1
- package/src/LatestArticlesSection.tsx +15 -1
- package/src/PaginationNav.tsx +78 -0
- package/src/RelatedArticlesSection.tsx +55 -0
- package/src/SeriesArticlesPage.tsx +66 -0
- package/src/__tests__/ArticleCard.test.tsx +63 -3
- package/src/__tests__/ArticleContent.test.tsx +143 -0
- package/src/__tests__/ArticleDetailHero.test.tsx +30 -0
- package/src/__tests__/ArticleNavigation.test.tsx +81 -3
- package/src/__tests__/ArticleSchemas.test.tsx +155 -81
- package/src/__tests__/ArticleSocialShare.test.tsx +54 -0
- package/src/__tests__/ArticlesPage.test.tsx +131 -0
- package/src/__tests__/AuthorArticlesPage.test.tsx +304 -3
- package/src/__tests__/CategoryArticlesPage.test.tsx +116 -1
- package/src/__tests__/LatestArticles.test.tsx +52 -0
- package/src/__tests__/LatestArticlesSection.test.tsx +28 -0
- package/src/__tests__/PaginationNav.test.tsx +73 -0
- package/src/__tests__/RelatedArticlesSection.test.tsx +132 -0
- package/src/__tests__/SeriesArticlesPage.test.tsx +121 -0
- package/src/__tests__/eventTracking.test.tsx +145 -0
- package/src/__tests__/events.test.ts +82 -0
- package/src/__tests__/markdown.test.ts +78 -1
- package/src/__tests__/pagination.test.ts +178 -0
- package/src/__tests__/seoUtils-authors.test.ts +37 -0
- package/src/__tests__/seoUtils.test.ts +246 -0
- package/src/__tests__/server-articles.test.ts +356 -1
- package/src/__tests__/validateArticles.test.ts +312 -0
- package/src/articleTypes.ts +109 -0
- package/src/articlesConfig.ts +37 -1
- package/src/eventTracking.tsx +97 -0
- package/src/events.ts +105 -0
- package/src/index.ts +26 -1
- package/src/markdown.ts +41 -0
- package/src/pagination.ts +93 -0
- package/src/seoUtils.ts +198 -11
- package/src/server-articles.ts +199 -6
- package/src/server.ts +46 -2
- package/src/validateArticles.ts +260 -0
package/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 (`
|
|
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
|
|