@fullstackdatasolutions/articles 0.11.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 +30 -0
- package/README.md +568 -6
- package/dist/index.cjs +970 -389
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +375 -21
- package/dist/index.d.ts +375 -21
- package/dist/index.js +954 -377
- 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 +65 -7
- 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 +148 -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__/useArticles.test.ts +28 -0
- 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/useArticles.ts +10 -5
- package/src/validateArticles.ts +260 -0
package/README.md
CHANGED
|
@@ -110,11 +110,26 @@ export const siteConfig: ArticlesConfig = {
|
|
|
110
110
|
// app/articles/page.tsx
|
|
111
111
|
import type { Metadata } from 'next'
|
|
112
112
|
import { ArticlesPage } from '@fullstackdatasolutions/articles'
|
|
113
|
-
import {
|
|
113
|
+
import {
|
|
114
|
+
generateArticlesIndexMetadata,
|
|
115
|
+
getAllArticles,
|
|
116
|
+
getAllCategories,
|
|
117
|
+
} from '@fullstackdatasolutions/articles/server'
|
|
114
118
|
import { siteConfig } from '@/config/articles'
|
|
115
119
|
export const metadata: Metadata = generateArticlesIndexMetadata(siteConfig)
|
|
116
|
-
export default function Page() {
|
|
117
|
-
|
|
120
|
+
export default async function Page() {
|
|
121
|
+
// Fetching server-side and passing as initialArticles/initialCategories means the
|
|
122
|
+
// first response already contains real <a href> article links — required for
|
|
123
|
+
// crawlers that don't execute client JS. Omitting these props still works, but
|
|
124
|
+
// falls back to a client-side fetch and a "Loading articles..." placeholder in
|
|
125
|
+
// the initial HTML.
|
|
126
|
+
const [articles, categories] = await Promise.all([
|
|
127
|
+
getAllArticles(siteConfig),
|
|
128
|
+
getAllCategories(),
|
|
129
|
+
])
|
|
130
|
+
return (
|
|
131
|
+
<ArticlesPage config={siteConfig} initialArticles={articles} initialCategories={categories} />
|
|
132
|
+
)
|
|
118
133
|
}
|
|
119
134
|
|
|
120
135
|
// app/articles/[...slug]/page.tsx
|
|
@@ -124,8 +139,10 @@ import {
|
|
|
124
139
|
ArticleNavigation,
|
|
125
140
|
ArticleSEO,
|
|
126
141
|
ArticleSocialShare,
|
|
142
|
+
ArticleViewTracker,
|
|
127
143
|
Breadcrumb,
|
|
128
144
|
CommentsSection,
|
|
145
|
+
RelatedArticlesSection,
|
|
129
146
|
ScrollToTop,
|
|
130
147
|
} from '@fullstackdatasolutions/articles'
|
|
131
148
|
import {
|
|
@@ -137,6 +154,7 @@ import {
|
|
|
137
154
|
getAdjacentArticles,
|
|
138
155
|
getArticleAuthors,
|
|
139
156
|
getArticleMetadata,
|
|
157
|
+
getRelatedArticlesByCategory,
|
|
140
158
|
} from '@fullstackdatasolutions/articles/server'
|
|
141
159
|
import { siteConfig } from '@/config/articles'
|
|
142
160
|
type ArticlePageProps = Readonly<{ params: Promise<{ slug: string[] }> }>
|
|
@@ -157,6 +175,7 @@ export default async function Page({ params }: ArticlePageProps) {
|
|
|
157
175
|
const article = await getArticleMetadata(slug, siteConfig)
|
|
158
176
|
if (!article) notFound()
|
|
159
177
|
const { previous, next } = await getAdjacentArticles(slug)
|
|
178
|
+
const related = await getRelatedArticlesByCategory(slug, article.category, 3, siteConfig)
|
|
160
179
|
const authors = getArticleAuthors(article, siteConfig)
|
|
161
180
|
const breadcrumbItems = buildArticleBreadcrumbs(article, siteConfig)
|
|
162
181
|
const siteUrl = siteConfig.siteUrl.replace(/\/$/, '')
|
|
@@ -168,6 +187,7 @@ export default async function Page({ params }: ArticlePageProps) {
|
|
|
168
187
|
articleUrl={articleUrl}
|
|
169
188
|
siteName={siteConfig.siteName}
|
|
170
189
|
authors={authors}
|
|
190
|
+
config={siteConfig}
|
|
171
191
|
/>
|
|
172
192
|
<Breadcrumb items={breadcrumbItems} />
|
|
173
193
|
<ArticleDetailHero
|
|
@@ -175,12 +195,15 @@ export default async function Page({ params }: ArticlePageProps) {
|
|
|
175
195
|
authors={authors}
|
|
176
196
|
categoryBasePath="/articles/category"
|
|
177
197
|
/>
|
|
198
|
+
<ArticleViewTracker article={article} config={siteConfig} />
|
|
178
199
|
{siteConfig.showToc !== false && article.toc && article.toc.length > 0 && (
|
|
179
200
|
<ArticleTOC toc={article.toc} />
|
|
180
201
|
)}
|
|
202
|
+
{/* See "Discovery & reader-journey layer" below for afterHero/afterIntro/midContent/afterContent slots */}
|
|
181
203
|
<ArticleContent article={article} config={siteConfig} />
|
|
182
|
-
<ArticleSocialShare title={article.title} url={articleUrl} excerpt={article.excerpt} />
|
|
204
|
+
<ArticleSocialShare title={article.title} url={articleUrl} excerpt={article.excerpt} articleSlug={slug} config={siteConfig} />
|
|
183
205
|
{siteConfig.comments && <CommentsSection articleSlug={slug} config={siteConfig.comments} />}
|
|
206
|
+
<RelatedArticlesSection articles={related} category={article.category} />
|
|
184
207
|
<ArticleNavigation previous={previous} next={next} basePath="/articles" />
|
|
185
208
|
<ScrollToTop />
|
|
186
209
|
</>
|
|
@@ -456,6 +479,7 @@ Use this if you don't need a custom API base path. For custom paths, use `create
|
|
|
456
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. |
|
|
457
480
|
| `mdxComponents` | `Record<string, ComponentType<never>>` | - | Components exposed to article `.mdx` bodies by tag name. Merged with the built-in MDX image override. |
|
|
458
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. |
|
|
459
483
|
|
|
460
484
|
**Default layout order:** `['hero', 'search', 'featured', 'latest', 'categories']`
|
|
461
485
|
|
|
@@ -602,6 +626,7 @@ import { ArticleDetailHero } from '@fullstackdatasolutions/articles'
|
|
|
602
626
|
- The date field is hidden by default. Pass `showDate={true}` to display it.
|
|
603
627
|
- All layout styles are inline so the component renders correctly regardless of whether `@source` is configured for the package.
|
|
604
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.
|
|
605
630
|
|
|
606
631
|
---
|
|
607
632
|
|
|
@@ -652,6 +677,518 @@ const { previous, next } = await getAdjacentArticles(slug)
|
|
|
652
677
|
|
|
653
678
|
---
|
|
654
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
|
+
|
|
655
1192
|
## ArticleTOC
|
|
656
1193
|
|
|
657
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.
|
|
@@ -926,7 +1463,11 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|
|
926
1463
|
|
|
927
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')`.
|
|
928
1465
|
|
|
929
|
-
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`.
|
|
930
1471
|
|
|
931
1472
|
---
|
|
932
1473
|
|
|
@@ -947,8 +1488,16 @@ JSON-LD structured data (`ArticleSchema`, `Breadcrumb`, `BreadcrumbSchema`, `Col
|
|
|
947
1488
|
| `howTo` | `{ name: string; text: string }[]` | no | How-to steps used by the article structured data. Invalid entries are ignored. |
|
|
948
1489
|
| `canonicalUrl` | `string` | no | Canonical URL override for article metadata. |
|
|
949
1490
|
| `articleType` | `string` | no | Optional article type value included in article metadata/structured data. |
|
|
950
|
-
| `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. |
|
|
951
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). |
|
|
952
1501
|
|
|
953
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.
|
|
954
1503
|
|
|
@@ -978,23 +1527,36 @@ import {
|
|
|
978
1527
|
getAllCategories,
|
|
979
1528
|
getAvailableArticleSlugs,
|
|
980
1529
|
getAdjacentArticles,
|
|
1530
|
+
getAdjacentArticlesInSeries,
|
|
1531
|
+
getArticlesBySeries,
|
|
1532
|
+
getRelatedArticlesByCategory,
|
|
1533
|
+
getRelatedContent,
|
|
1534
|
+
getPath,
|
|
1535
|
+
getPathArticles,
|
|
981
1536
|
searchArticles,
|
|
982
1537
|
categoryToSlug,
|
|
983
1538
|
sanitizeImagePath,
|
|
984
1539
|
markdownToHtml,
|
|
985
1540
|
extractToc,
|
|
1541
|
+
getContentSlotBoundaries,
|
|
986
1542
|
generateArticleStaticParams,
|
|
987
1543
|
generateCategoryStaticParams,
|
|
1544
|
+
generateSeriesStaticParams,
|
|
988
1545
|
generateAuthorStaticParams,
|
|
989
1546
|
getArticleSitemapEntries,
|
|
990
1547
|
generateRssFeed,
|
|
991
1548
|
generateArticlesIndexMetadata,
|
|
992
1549
|
generateArticleMetadata,
|
|
993
1550
|
generateCategoryMetadata,
|
|
1551
|
+
generateSeriesMetadata,
|
|
994
1552
|
generateAuthorMetadata,
|
|
1553
|
+
resolveSearchMetadata,
|
|
1554
|
+
resolveSocialMetadata,
|
|
995
1555
|
buildArticleBreadcrumbs,
|
|
996
1556
|
buildCategoryBreadcrumbs,
|
|
997
1557
|
buildAuthorBreadcrumbs,
|
|
1558
|
+
validateArticles,
|
|
1559
|
+
validateAllArticles,
|
|
998
1560
|
} from '@fullstackdatasolutions/articles/server'
|
|
999
1561
|
```
|
|
1000
1562
|
|