@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/src/server-articles.ts
CHANGED
|
@@ -3,15 +3,24 @@ import matter from 'gray-matter'
|
|
|
3
3
|
import fs from 'node:fs'
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
import readingTime from 'reading-time'
|
|
6
|
+
import { getAuthorAvatar } from './authorUtils'
|
|
6
7
|
import { markdownToHtml, extractToc } from './markdown'
|
|
7
|
-
import type {
|
|
8
|
+
import type {
|
|
9
|
+
Article,
|
|
10
|
+
AuthorProfile,
|
|
11
|
+
CategoryInfo,
|
|
12
|
+
FaqItem,
|
|
13
|
+
HowToStep,
|
|
14
|
+
PathDefinition,
|
|
15
|
+
} from './articleTypes'
|
|
8
16
|
import type { ArticlesConfig } from './articlesConfig'
|
|
9
17
|
import { reportArticlesError } from './errorReporting'
|
|
10
18
|
|
|
11
19
|
const articlesDirectory = path.join(/* turbopackIgnore: true */ process.cwd(), 'public/articles')
|
|
12
20
|
|
|
13
|
-
function
|
|
14
|
-
|
|
21
|
+
function getReadingStats(content: string): { readTime: string; wordCount: number } {
|
|
22
|
+
const stats = readingTime(content)
|
|
23
|
+
return { readTime: stats.text, wordCount: stats.words }
|
|
15
24
|
}
|
|
16
25
|
|
|
17
26
|
function findArticleImage(slug: string): string | null {
|
|
@@ -153,6 +162,35 @@ function parseHowToSteps(raw: unknown): HowToStep[] | undefined {
|
|
|
153
162
|
return steps.length ? steps : undefined
|
|
154
163
|
}
|
|
155
164
|
|
|
165
|
+
// Trims and drops empty strings - the same "unset if blank" rule applied to
|
|
166
|
+
// every other optional string frontmatter field in this file
|
|
167
|
+
// (canonicalUrl/articleType/series). Used for all five discovery overrides
|
|
168
|
+
// (Phase 27F) so a stray `searchTitle: ""` in frontmatter behaves exactly
|
|
169
|
+
// like omitting the key, rather than becoming an empty <title> override.
|
|
170
|
+
function parseOptionalString(raw: unknown): string | undefined {
|
|
171
|
+
return typeof raw === 'string' && raw.trim().length > 0 ? raw.trim() : undefined
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function parseSeriesOrder(raw: unknown): number | undefined {
|
|
175
|
+
return typeof raw === 'number' && Number.isFinite(raw) ? raw : undefined
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function parsePrimaryAction(raw: unknown): { actionId: string } | undefined {
|
|
179
|
+
if (typeof raw === 'string') {
|
|
180
|
+
const actionId = raw.trim()
|
|
181
|
+
return actionId ? { actionId } : undefined
|
|
182
|
+
}
|
|
183
|
+
if (
|
|
184
|
+
typeof raw === 'object' &&
|
|
185
|
+
raw !== null &&
|
|
186
|
+
typeof (raw as { actionId?: unknown }).actionId === 'string'
|
|
187
|
+
) {
|
|
188
|
+
const actionId = (raw as { actionId: string }).actionId.trim()
|
|
189
|
+
return actionId ? { actionId } : undefined
|
|
190
|
+
}
|
|
191
|
+
return undefined
|
|
192
|
+
}
|
|
193
|
+
|
|
156
194
|
function parseAuthors(raw: unknown): string[] | undefined {
|
|
157
195
|
if (!Array.isArray(raw)) return undefined
|
|
158
196
|
const authors = raw.filter(
|
|
@@ -233,23 +271,38 @@ async function getArticleSummary(slug: string, config?: ArticlesConfig): Promise
|
|
|
233
271
|
if (!found) return null
|
|
234
272
|
const fileContent = fs.readFileSync(found.filePath, 'utf8')
|
|
235
273
|
const { data, content: markdownContent } = matter(fileContent)
|
|
236
|
-
const readTime =
|
|
274
|
+
const { readTime, wordCount } = getReadingStats(markdownContent)
|
|
237
275
|
const allTags: string[] = Array.isArray(data.tags)
|
|
238
276
|
? data.tags.filter((t: unknown) => typeof t === 'string' && String(t).trim())
|
|
239
277
|
: []
|
|
240
278
|
const categories: string[] =
|
|
241
279
|
allTags.length > 0 ? allTags.map((t: string) => t.replaceAll('-', ' ').trim()) : ['Campaigns']
|
|
280
|
+
const author = resolveArticleAuthorName(data.author, data.authors, config)
|
|
281
|
+
const authors = parseAuthors(data.authors)
|
|
282
|
+
// Resolve the primary author's profile (if configured) so cards can
|
|
283
|
+
// render an avatar/link without needing `config` client-side. Reuses
|
|
284
|
+
// getArticleAuthors' existing slug/name resolution chain instead of
|
|
285
|
+
// duplicating it - only the two fields it reads (author/authors) exist
|
|
286
|
+
// on this partial yet.
|
|
287
|
+
const primaryAuthorProfile = config
|
|
288
|
+
? getArticleAuthors({ author, authors } as Article, config)[0]
|
|
289
|
+
: undefined
|
|
242
290
|
return {
|
|
243
291
|
slug,
|
|
244
292
|
title: data.title || slug.replaceAll('-', ' '),
|
|
245
293
|
excerpt: data.excerpt || '',
|
|
246
294
|
date: parseDateField(data.date),
|
|
247
295
|
lastmod: parseDateField(data.lastmod),
|
|
248
|
-
author
|
|
249
|
-
authors
|
|
296
|
+
author,
|
|
297
|
+
authors,
|
|
298
|
+
authorSlug: primaryAuthorProfile?.slug,
|
|
299
|
+
authorAvatar: primaryAuthorProfile
|
|
300
|
+
? getAuthorAvatar(primaryAuthorProfile, config)
|
|
301
|
+
: undefined,
|
|
250
302
|
category: categories[0],
|
|
251
303
|
categories,
|
|
252
304
|
readTime,
|
|
305
|
+
wordCount,
|
|
253
306
|
featuredImage: resolveFeaturedImage(data.featuredImage, slug),
|
|
254
307
|
tags: data.tags || [],
|
|
255
308
|
contentType: found.contentType,
|
|
@@ -259,7 +312,15 @@ async function getArticleSummary(slug: string, config?: ArticlesConfig): Promise
|
|
|
259
312
|
canonicalUrl: typeof data.canonicalUrl === 'string' ? data.canonicalUrl : undefined,
|
|
260
313
|
articleType: typeof data.articleType === 'string' ? data.articleType : undefined,
|
|
261
314
|
series: typeof data.series === 'string' ? data.series : undefined,
|
|
315
|
+
seriesSlug: parseOptionalString(data.seriesSlug),
|
|
316
|
+
seriesOrder: parseSeriesOrder(data.seriesOrder),
|
|
262
317
|
aiCrawl: data.aiCrawl === true,
|
|
318
|
+
searchTitle: parseOptionalString(data.searchTitle),
|
|
319
|
+
searchDescription: parseOptionalString(data.searchDescription),
|
|
320
|
+
socialTitle: parseOptionalString(data.socialTitle),
|
|
321
|
+
socialDescription: parseOptionalString(data.socialDescription),
|
|
322
|
+
socialImage: parseOptionalString(data.socialImage),
|
|
323
|
+
primaryAction: parsePrimaryAction(data.primaryAction),
|
|
263
324
|
}
|
|
264
325
|
} catch (error) {
|
|
265
326
|
reportArticlesError({
|
|
@@ -468,6 +529,20 @@ export async function getArticlesByCategory(
|
|
|
468
529
|
)
|
|
469
530
|
}
|
|
470
531
|
|
|
532
|
+
// Built on getArticlesByCategory (same slug-matching filter, no duplicated
|
|
533
|
+
// logic) rather than getAdjacentArticles' global date-order walk, so an
|
|
534
|
+
// article detail page can link to other articles in the same category
|
|
535
|
+
// instead of just the two chronologically-nearest articles overall.
|
|
536
|
+
export async function getRelatedArticlesByCategory(
|
|
537
|
+
currentSlug: string,
|
|
538
|
+
category: string,
|
|
539
|
+
limit = 3,
|
|
540
|
+
config?: ArticlesConfig
|
|
541
|
+
): Promise<Article[]> {
|
|
542
|
+
const articles = await getArticlesByCategory(categoryToSlug(category), config)
|
|
543
|
+
return articles.filter((article) => article.slug !== currentSlug).slice(0, limit)
|
|
544
|
+
}
|
|
545
|
+
|
|
471
546
|
export async function getArticlesByAuthor(
|
|
472
547
|
authorSlug: string,
|
|
473
548
|
config: ArticlesConfig
|
|
@@ -478,4 +553,122 @@ export async function getArticlesByAuthor(
|
|
|
478
553
|
)
|
|
479
554
|
}
|
|
480
555
|
|
|
556
|
+
// Sorted by `seriesOrder` ascending (undefined pushed to the end); ties fall
|
|
557
|
+
// back to the date-descending order `getAllArticles` already applies, since
|
|
558
|
+
// `Array.prototype.sort` is stable - matching `getArticlesByCategory`'s
|
|
559
|
+
// "build on the existing filter, don't duplicate `getAllArticles`" pattern.
|
|
560
|
+
// The label-only `series` string field is untouched by this function.
|
|
561
|
+
export async function getArticlesBySeries(
|
|
562
|
+
seriesSlug: string,
|
|
563
|
+
config?: ArticlesConfig
|
|
564
|
+
): Promise<Article[]> {
|
|
565
|
+
const articles = await getAllArticles(config)
|
|
566
|
+
return articles
|
|
567
|
+
.filter((article) => article.seriesSlug === seriesSlug)
|
|
568
|
+
.sort((a, b) => {
|
|
569
|
+
const orderA = a.seriesOrder ?? Number.POSITIVE_INFINITY
|
|
570
|
+
const orderB = b.seriesOrder ?? Number.POSITIVE_INFINITY
|
|
571
|
+
return orderA - orderB
|
|
572
|
+
})
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Series-aware sibling of `getAdjacentArticles`: walks `seriesOrder` within
|
|
577
|
+
* one series instead of global date order. `previous`/`next` follow series
|
|
578
|
+
* order (ascending), not chronology.
|
|
579
|
+
*/
|
|
580
|
+
export async function getAdjacentArticlesInSeries(
|
|
581
|
+
currentSlug: string,
|
|
582
|
+
seriesSlug: string,
|
|
583
|
+
config?: ArticlesConfig
|
|
584
|
+
): Promise<{ previous: Article | null; next: Article | null }> {
|
|
585
|
+
const seriesArticles = await getArticlesBySeries(seriesSlug, config)
|
|
586
|
+
const currentIndex = seriesArticles.findIndex((article) => article.slug === currentSlug)
|
|
587
|
+
if (currentIndex === -1) return { previous: null, next: null }
|
|
588
|
+
return {
|
|
589
|
+
previous: currentIndex > 0 ? seriesArticles[currentIndex - 1] : null,
|
|
590
|
+
next: currentIndex < seriesArticles.length - 1 ? seriesArticles[currentIndex + 1] : null,
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** Looks up one configured `PathDefinition` by its app-chosen key. */
|
|
595
|
+
export function getPath(pathKey: string, config: ArticlesConfig): PathDefinition | null {
|
|
596
|
+
return config.paths?.[pathKey] ?? null
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** Resolves a path's ordered slugs against the real article set, dropping any that don't resolve (e.g. a draft filtered out of `getAllArticles` in production) rather than throwing - use `validateArticles` to catch broken references before publishing. */
|
|
600
|
+
export async function getPathArticles(pathKey: string, config: ArticlesConfig): Promise<Article[]> {
|
|
601
|
+
const path = getPath(pathKey, config)
|
|
602
|
+
if (!path) return []
|
|
603
|
+
const articles = await getAllArticles(config)
|
|
604
|
+
const bySlug = new Map(articles.map((article) => [article.slug, article]))
|
|
605
|
+
return path.articles
|
|
606
|
+
.map((slug) => bySlug.get(slug))
|
|
607
|
+
.filter((article): article is Article => Boolean(article))
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function findPathForArticle(
|
|
611
|
+
slug: string,
|
|
612
|
+
config: ArticlesConfig
|
|
613
|
+
): { key: string; path: PathDefinition } | null {
|
|
614
|
+
for (const [key, path] of Object.entries(config.paths ?? {})) {
|
|
615
|
+
if (path.articles.includes(slug)) return { key, path }
|
|
616
|
+
}
|
|
617
|
+
return null
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
export type RelatedContentSource = 'path' | 'series' | 'category'
|
|
621
|
+
|
|
622
|
+
export interface RelatedContentResult {
|
|
623
|
+
source: RelatedContentSource
|
|
624
|
+
/** Heading for a related-content UI - the path's `name`, the article's `series` label, or "More in {category}". */
|
|
625
|
+
heading: string
|
|
626
|
+
articles: Article[]
|
|
627
|
+
/** Set only when `source === 'path'`. */
|
|
628
|
+
pathKey?: string
|
|
629
|
+
/** Set only when `source === 'path'` - the path's one configured next action. */
|
|
630
|
+
nextAction?: { label: string; href: string }
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Reusable related-content selection (Phase 27F): prefers a configured
|
|
635
|
+
* `Path` containing this article first, then the article's `seriesSlug`,
|
|
636
|
+
* falling back to 27B's `getRelatedArticlesByCategory` (imported, not
|
|
637
|
+
* reimplemented) when neither a path nor a series applies - the plain
|
|
638
|
+
* chronological-within-category behavior stays the fallback, not a full
|
|
639
|
+
* replacement.
|
|
640
|
+
*/
|
|
641
|
+
export async function getRelatedContent(
|
|
642
|
+
article: Article,
|
|
643
|
+
config: ArticlesConfig,
|
|
644
|
+
limit = 3
|
|
645
|
+
): Promise<RelatedContentResult> {
|
|
646
|
+
const matchedPath = findPathForArticle(article.slug, config)
|
|
647
|
+
if (matchedPath) {
|
|
648
|
+
const pathArticles = await getPathArticles(matchedPath.key, config)
|
|
649
|
+
return {
|
|
650
|
+
source: 'path',
|
|
651
|
+
heading: matchedPath.path.name,
|
|
652
|
+
articles: pathArticles.filter((a) => a.slug !== article.slug),
|
|
653
|
+
pathKey: matchedPath.key,
|
|
654
|
+
nextAction: matchedPath.path.nextAction,
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
if (article.seriesSlug) {
|
|
658
|
+
const seriesArticles = await getArticlesBySeries(article.seriesSlug, config)
|
|
659
|
+
return {
|
|
660
|
+
source: 'series',
|
|
661
|
+
heading: article.series ?? 'This series',
|
|
662
|
+
articles: seriesArticles.filter((a) => a.slug !== article.slug),
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
const categoryArticles = await getRelatedArticlesByCategory(
|
|
666
|
+
article.slug,
|
|
667
|
+
article.category,
|
|
668
|
+
limit,
|
|
669
|
+
config
|
|
670
|
+
)
|
|
671
|
+
return { source: 'category', heading: `More in ${article.category}`, articles: categoryArticles }
|
|
672
|
+
}
|
|
673
|
+
|
|
481
674
|
export { sanitizeImagePath }
|
package/src/server.ts
CHANGED
|
@@ -10,6 +10,12 @@ export {
|
|
|
10
10
|
getArticleAuthors,
|
|
11
11
|
getAvailableArticleSlugs,
|
|
12
12
|
getAdjacentArticles,
|
|
13
|
+
getAdjacentArticlesInSeries,
|
|
14
|
+
getArticlesBySeries,
|
|
15
|
+
getRelatedArticlesByCategory,
|
|
16
|
+
getRelatedContent,
|
|
17
|
+
getPath,
|
|
18
|
+
getPathArticles,
|
|
13
19
|
getAllAuthors,
|
|
14
20
|
searchArticles,
|
|
15
21
|
getAuthorBySlug,
|
|
@@ -24,23 +30,42 @@ export {
|
|
|
24
30
|
generateRssFeed,
|
|
25
31
|
generateArticleStaticParams,
|
|
26
32
|
generateCategoryStaticParams,
|
|
33
|
+
generateSeriesStaticParams,
|
|
27
34
|
generateAuthorStaticParams,
|
|
28
35
|
generateArticlesIndexMetadata,
|
|
36
|
+
generateArticlesIndexPageMetadata,
|
|
29
37
|
generateArticleMetadata,
|
|
30
38
|
generateCategoryMetadata,
|
|
39
|
+
generateCategoryPageMetadata,
|
|
40
|
+
generateSeriesMetadata,
|
|
31
41
|
generateAuthorMetadata,
|
|
42
|
+
generateAuthorPageMetadata,
|
|
32
43
|
buildArticleBreadcrumbs,
|
|
33
44
|
buildCategoryBreadcrumbs,
|
|
34
45
|
buildAuthorBreadcrumbs,
|
|
35
46
|
resolveAuthorAvatar,
|
|
47
|
+
resolveSearchMetadata,
|
|
48
|
+
resolveSocialMetadata,
|
|
36
49
|
getArticleSitemapEntries,
|
|
37
50
|
} from './seoUtils'
|
|
38
51
|
|
|
39
|
-
export {
|
|
52
|
+
export {
|
|
53
|
+
getTotalPages,
|
|
54
|
+
paginateArticles,
|
|
55
|
+
buildPageUrl,
|
|
56
|
+
buildPaginationLinks,
|
|
57
|
+
generateListingPageStaticParams,
|
|
58
|
+
parsePageParam,
|
|
59
|
+
isPageOutOfRange,
|
|
60
|
+
} from './pagination'
|
|
61
|
+
|
|
62
|
+
export { markdownToHtml, extractToc, getContentSlotBoundaries } from './markdown'
|
|
40
63
|
export { setArticlesErrorHandler } from './errorReporting'
|
|
41
64
|
export { getBreadcrumbsConfig } from './articlesConfig'
|
|
42
65
|
export { ArticleContent } from './ArticleContent'
|
|
43
66
|
export { ArticleTOC } from './ArticleTOC'
|
|
67
|
+
export { validateArticles, validateAllArticles } from './validateArticles'
|
|
68
|
+
export { emitArticleEvent } from './events'
|
|
44
69
|
|
|
45
70
|
export type {
|
|
46
71
|
Article,
|
|
@@ -48,9 +73,28 @@ export type {
|
|
|
48
73
|
AuthorSocial,
|
|
49
74
|
BreadcrumbItem,
|
|
50
75
|
CategoryInfo,
|
|
76
|
+
PathDefinition,
|
|
51
77
|
TocItem,
|
|
52
78
|
} from './articleTypes'
|
|
53
|
-
export type { ArticlesConfig, LinkTargetStrategy } from './articlesConfig'
|
|
79
|
+
export type { ArticlesConfig, LinkTargetStrategy, ListingPagination } from './articlesConfig'
|
|
80
|
+
export type { PaginatedArticles, PaginationLinks, ListingPaginationContext } from './pagination'
|
|
81
|
+
export type { ContentSlotBoundaries } from './markdown'
|
|
82
|
+
export type { ArticleSlotContext, ArticleSlotContent } from './ArticleContent'
|
|
83
|
+
export type { RelatedContentResult, RelatedContentSource } from './server-articles'
|
|
84
|
+
export type { ValidationIssue, ValidationResult, ValidationSeverity } from './validateArticles'
|
|
85
|
+
export type {
|
|
86
|
+
ArticleEvent,
|
|
87
|
+
ArticleEventHandler,
|
|
88
|
+
ArticleEventName,
|
|
89
|
+
ArticleViewedEvent,
|
|
90
|
+
MeaningfulReadEvent,
|
|
91
|
+
AuthorClickedEvent,
|
|
92
|
+
CtaViewedEvent,
|
|
93
|
+
CtaClickedEvent,
|
|
94
|
+
SharedEvent,
|
|
95
|
+
RelatedArticleClickedEvent,
|
|
96
|
+
PathStepAdvancedEvent,
|
|
97
|
+
} from './events'
|
|
54
98
|
export type {
|
|
55
99
|
ArticlesErrorCode,
|
|
56
100
|
ArticlesErrorContext,
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// Package validator (Phase 27F). A pure function operating on an already
|
|
2
|
+
// loaded `Article[]`/`ArticlesConfig` - no `fs` access here, so it's
|
|
3
|
+
// directly unit-testable with fixture data. `validateAllArticles` below is
|
|
4
|
+
// the thin, fs-dependent convenience wrapper (`server`-only, like the rest
|
|
5
|
+
// of this file) for a consuming app's own validation script.
|
|
6
|
+
import {
|
|
7
|
+
getAllArticles,
|
|
8
|
+
getArticleAuthors,
|
|
9
|
+
getAuthorBySlug,
|
|
10
|
+
categoryToSlug,
|
|
11
|
+
} from './server-articles'
|
|
12
|
+
import type { Article } from './articleTypes'
|
|
13
|
+
import type { ArticlesConfig } from './articlesConfig'
|
|
14
|
+
|
|
15
|
+
export type ValidationSeverity = 'error' | 'warning'
|
|
16
|
+
|
|
17
|
+
export interface ValidationIssue {
|
|
18
|
+
severity: ValidationSeverity
|
|
19
|
+
/** Stable machine-readable code, e.g. `'duplicate-canonical-url'`. */
|
|
20
|
+
code: string
|
|
21
|
+
message: string
|
|
22
|
+
articleSlug?: string
|
|
23
|
+
pathKey?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ValidationResult {
|
|
27
|
+
ok: boolean
|
|
28
|
+
errors: ValidationIssue[]
|
|
29
|
+
warnings: ValidationIssue[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const UNSAFE_URL_SCHEME = /^\s*(javascript|data|vbscript):/i
|
|
33
|
+
|
|
34
|
+
const SEARCH_TITLE_MAX = 60
|
|
35
|
+
const SEARCH_DESCRIPTION_MAX = 160
|
|
36
|
+
const SOCIAL_TITLE_MAX = 95
|
|
37
|
+
const SOCIAL_DESCRIPTION_MAX = 200
|
|
38
|
+
|
|
39
|
+
function isUnsafeUrl(href: string): boolean {
|
|
40
|
+
return UNSAFE_URL_SCHEME.test(href)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function checkDuplicateCanonicalUrls(articles: Article[]): ValidationIssue[] {
|
|
44
|
+
const seen = new Map<string, string>()
|
|
45
|
+
const issues: ValidationIssue[] = []
|
|
46
|
+
for (const article of articles) {
|
|
47
|
+
if (!article.canonicalUrl) continue
|
|
48
|
+
const owner = seen.get(article.canonicalUrl)
|
|
49
|
+
if (owner) {
|
|
50
|
+
issues.push({
|
|
51
|
+
severity: 'error',
|
|
52
|
+
code: 'duplicate-canonical-url',
|
|
53
|
+
message: `canonicalUrl "${article.canonicalUrl}" is also used by "${owner}".`,
|
|
54
|
+
articleSlug: article.slug,
|
|
55
|
+
})
|
|
56
|
+
} else {
|
|
57
|
+
seen.set(article.canonicalUrl, article.slug)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return issues
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function checkAuthorReferences(articles: Article[], config: ArticlesConfig): ValidationIssue[] {
|
|
64
|
+
if (!config.authors || Object.keys(config.authors).length === 0) return []
|
|
65
|
+
const issues: ValidationIssue[] = []
|
|
66
|
+
for (const article of articles) {
|
|
67
|
+
for (const resolved of getArticleAuthors(article, config)) {
|
|
68
|
+
if (!getAuthorBySlug(resolved.slug, config)) {
|
|
69
|
+
issues.push({
|
|
70
|
+
severity: 'error',
|
|
71
|
+
code: 'unknown-author-reference',
|
|
72
|
+
message: `Author "${resolved.name}" does not match any entry in config.authors.`,
|
|
73
|
+
articleSlug: article.slug,
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return issues
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function checkSeriesCollisions(articles: Article[]): ValidationIssue[] {
|
|
82
|
+
const issues: ValidationIssue[] = []
|
|
83
|
+
const seenSlugOrder = new Map<string, string>()
|
|
84
|
+
for (const article of articles) {
|
|
85
|
+
if (!article.seriesSlug || article.seriesOrder === undefined) continue
|
|
86
|
+
const key = `${article.seriesSlug}::${article.seriesOrder}`
|
|
87
|
+
const owner = seenSlugOrder.get(key)
|
|
88
|
+
if (owner) {
|
|
89
|
+
issues.push({
|
|
90
|
+
severity: 'error',
|
|
91
|
+
code: 'duplicate-series-order',
|
|
92
|
+
message: `seriesOrder ${article.seriesOrder} in series "${article.seriesSlug}" collides with "${owner}".`,
|
|
93
|
+
articleSlug: article.slug,
|
|
94
|
+
})
|
|
95
|
+
} else {
|
|
96
|
+
seenSlugOrder.set(key, article.slug)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return issues
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function checkPaths(articles: Article[], config: ArticlesConfig): ValidationIssue[] {
|
|
103
|
+
const issues: ValidationIssue[] = []
|
|
104
|
+
const bySlug = new Map(articles.map((article) => [article.slug, article]))
|
|
105
|
+
for (const [pathKey, path] of Object.entries(config.paths ?? {})) {
|
|
106
|
+
if (path.articles.length === 0) {
|
|
107
|
+
issues.push({
|
|
108
|
+
severity: 'error',
|
|
109
|
+
code: 'empty-path',
|
|
110
|
+
message: `Path "${pathKey}" has no articles.`,
|
|
111
|
+
pathKey,
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
for (const slug of path.articles) {
|
|
115
|
+
const referenced = bySlug.get(slug)
|
|
116
|
+
if (!referenced) {
|
|
117
|
+
issues.push({
|
|
118
|
+
severity: 'error',
|
|
119
|
+
code: 'path-missing-article',
|
|
120
|
+
message: `Path "${pathKey}" references missing article "${slug}".`,
|
|
121
|
+
pathKey,
|
|
122
|
+
articleSlug: slug,
|
|
123
|
+
})
|
|
124
|
+
} else if (referenced.draft) {
|
|
125
|
+
issues.push({
|
|
126
|
+
severity: 'error',
|
|
127
|
+
code: 'path-references-draft',
|
|
128
|
+
message: `Path "${pathKey}" references unpublished (draft) article "${slug}".`,
|
|
129
|
+
pathKey,
|
|
130
|
+
articleSlug: slug,
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (isUnsafeUrl(path.nextAction.href)) {
|
|
135
|
+
issues.push({
|
|
136
|
+
severity: 'error',
|
|
137
|
+
code: 'unsafe-url',
|
|
138
|
+
message: `Path "${pathKey}" nextAction.href uses an unsafe URL scheme.`,
|
|
139
|
+
pathKey,
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return issues
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function checkAuthorCtaUrls(config: ArticlesConfig): ValidationIssue[] {
|
|
147
|
+
const issues: ValidationIssue[] = []
|
|
148
|
+
for (const author of Object.values(config.authors ?? {})) {
|
|
149
|
+
if (author.primaryCta && isUnsafeUrl(author.primaryCta.href)) {
|
|
150
|
+
issues.push({
|
|
151
|
+
severity: 'error',
|
|
152
|
+
code: 'unsafe-url',
|
|
153
|
+
message: `Author "${author.slug}" primaryCta.href uses an unsafe URL scheme.`,
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return issues
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function checkRequiredFrontmatter(articles: Article[]): ValidationIssue[] {
|
|
161
|
+
const issues: ValidationIssue[] = []
|
|
162
|
+
for (const article of articles) {
|
|
163
|
+
if (!article.excerpt) {
|
|
164
|
+
issues.push({
|
|
165
|
+
severity: 'warning',
|
|
166
|
+
code: 'missing-excerpt',
|
|
167
|
+
message: 'Article has no excerpt.',
|
|
168
|
+
articleSlug: article.slug,
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
if (!article.date) {
|
|
172
|
+
issues.push({
|
|
173
|
+
severity: 'warning',
|
|
174
|
+
code: 'missing-date',
|
|
175
|
+
message: 'Article has no date.',
|
|
176
|
+
articleSlug: article.slug,
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return issues
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function checkDiscoveryFieldLengths(articles: Article[]): ValidationIssue[] {
|
|
184
|
+
const issues: ValidationIssue[] = []
|
|
185
|
+
for (const article of articles) {
|
|
186
|
+
const checks: [string | undefined, string, number][] = [
|
|
187
|
+
[article.searchTitle, 'search-title-too-long', SEARCH_TITLE_MAX],
|
|
188
|
+
[article.searchDescription, 'search-description-too-long', SEARCH_DESCRIPTION_MAX],
|
|
189
|
+
[article.socialTitle, 'social-title-too-long', SOCIAL_TITLE_MAX],
|
|
190
|
+
[article.socialDescription, 'social-description-too-long', SOCIAL_DESCRIPTION_MAX],
|
|
191
|
+
]
|
|
192
|
+
for (const [value, code, max] of checks) {
|
|
193
|
+
if (value && value.length > max) {
|
|
194
|
+
issues.push({
|
|
195
|
+
severity: 'warning',
|
|
196
|
+
code,
|
|
197
|
+
message: `${code.replaceAll('-', ' ')} (${value.length} > ${max} recommended chars).`,
|
|
198
|
+
articleSlug: article.slug,
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return issues
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function checkCategorySlugs(articles: Article[]): ValidationIssue[] {
|
|
207
|
+
// Two differently-cased/spaced category labels that collapse to the same
|
|
208
|
+
// slug silently merge on `/articles/category/[slug]` - surfaced as a
|
|
209
|
+
// warning (not an error) since it may be intentional (e.g. "Game
|
|
210
|
+
// Masters" and "game-masters" tags both meaning the same category).
|
|
211
|
+
const issues: ValidationIssue[] = []
|
|
212
|
+
const slugToNames = new Map<string, Set<string>>()
|
|
213
|
+
for (const article of articles) {
|
|
214
|
+
for (const category of article.categories) {
|
|
215
|
+
const slug = categoryToSlug(category)
|
|
216
|
+
const names = slugToNames.get(slug) ?? new Set<string>()
|
|
217
|
+
names.add(category)
|
|
218
|
+
slugToNames.set(slug, names)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
for (const [slug, names] of slugToNames) {
|
|
222
|
+
if (names.size > 1) {
|
|
223
|
+
issues.push({
|
|
224
|
+
severity: 'warning',
|
|
225
|
+
code: 'category-slug-collision',
|
|
226
|
+
message: `Categories [${[...names].join(', ')}] all collapse to slug "${slug}".`,
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return issues
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Validates a loaded article set + config. Warnings cover optional
|
|
235
|
+
* discovery-field issues (missing excerpt/date, over-length search/social
|
|
236
|
+
* fields, category slug collisions); errors cover broken reader journeys
|
|
237
|
+
* (duplicate canonical URLs, unknown author references, series order
|
|
238
|
+
* collisions, missing/draft path references, unsafe URL schemes).
|
|
239
|
+
*/
|
|
240
|
+
export function validateArticles(articles: Article[], config: ArticlesConfig): ValidationResult {
|
|
241
|
+
const errors = [
|
|
242
|
+
...checkDuplicateCanonicalUrls(articles),
|
|
243
|
+
...checkAuthorReferences(articles, config),
|
|
244
|
+
...checkSeriesCollisions(articles),
|
|
245
|
+
...checkPaths(articles, config),
|
|
246
|
+
...checkAuthorCtaUrls(config),
|
|
247
|
+
]
|
|
248
|
+
const warnings = [
|
|
249
|
+
...checkRequiredFrontmatter(articles),
|
|
250
|
+
...checkDiscoveryFieldLengths(articles),
|
|
251
|
+
...checkCategorySlugs(articles),
|
|
252
|
+
]
|
|
253
|
+
return { ok: errors.length === 0, errors, warnings }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Convenience wrapper: loads every article via `getAllArticles(config)` (fs-dependent) then validates. Suitable for a consuming app's own `scripts/validate-articles.ts` invoked in CI before publish. */
|
|
257
|
+
export async function validateAllArticles(config: ArticlesConfig): Promise<ValidationResult> {
|
|
258
|
+
const articles = await getAllArticles(config)
|
|
259
|
+
return validateArticles(articles, config)
|
|
260
|
+
}
|