@fullstackdatasolutions/articles 0.12.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +559 -11
  3. package/dist/index.cjs +984 -388
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.cts +383 -21
  6. package/dist/index.d.ts +383 -21
  7. package/dist/index.js +968 -376
  8. package/dist/index.js.map +1 -1
  9. package/dist/nextjs.cjs +74 -6
  10. package/dist/nextjs.cjs.map +1 -1
  11. package/dist/nextjs.d.cts +149 -0
  12. package/dist/nextjs.d.ts +149 -0
  13. package/dist/nextjs.js +74 -6
  14. package/dist/nextjs.js.map +1 -1
  15. package/dist/server.cjs +665 -27
  16. package/dist/server.cjs.map +1 -1
  17. package/dist/server.d.cts +357 -3
  18. package/dist/server.d.ts +357 -3
  19. package/dist/server.js +643 -29
  20. package/dist/server.js.map +1 -1
  21. package/package.json +1 -1
  22. package/src/ArticleCard.tsx +42 -6
  23. package/src/ArticleContent.tsx +144 -5
  24. package/src/ArticleDetailHero.tsx +33 -1
  25. package/src/ArticleNavigation.tsx +32 -1
  26. package/src/ArticleSchemas.tsx +43 -39
  27. package/src/ArticleSocialShare.tsx +54 -10
  28. package/src/ArticlesPage.tsx +56 -5
  29. package/src/AuthorArticlesPage.tsx +308 -14
  30. package/src/AuthorCard.tsx +1 -1
  31. package/src/CategoryArticlesPage.tsx +34 -2
  32. package/src/LatestArticles.tsx +28 -1
  33. package/src/LatestArticlesSection.tsx +15 -1
  34. package/src/PaginationNav.tsx +78 -0
  35. package/src/RelatedArticlesSection.tsx +58 -0
  36. package/src/SeriesArticlesPage.tsx +66 -0
  37. package/src/__tests__/ArticleCard.test.tsx +63 -3
  38. package/src/__tests__/ArticleContent.test.tsx +143 -0
  39. package/src/__tests__/ArticleDetailHero.test.tsx +30 -0
  40. package/src/__tests__/ArticleNavigation.test.tsx +81 -3
  41. package/src/__tests__/ArticleSchemas.test.tsx +155 -81
  42. package/src/__tests__/ArticleSocialShare.test.tsx +54 -0
  43. package/src/__tests__/ArticlesPage.test.tsx +131 -0
  44. package/src/__tests__/AuthorArticlesPage.test.tsx +304 -3
  45. package/src/__tests__/CategoryArticlesPage.test.tsx +116 -1
  46. package/src/__tests__/LatestArticles.test.tsx +52 -0
  47. package/src/__tests__/LatestArticlesSection.test.tsx +28 -0
  48. package/src/__tests__/PaginationNav.test.tsx +73 -0
  49. package/src/__tests__/RelatedArticlesSection.test.tsx +132 -0
  50. package/src/__tests__/SeriesArticlesPage.test.tsx +121 -0
  51. package/src/__tests__/eventTracking.test.tsx +145 -0
  52. package/src/__tests__/events.test.ts +82 -0
  53. package/src/__tests__/markdown.test.ts +78 -1
  54. package/src/__tests__/pagination.test.ts +178 -0
  55. package/src/__tests__/seoUtils-authors.test.ts +37 -0
  56. package/src/__tests__/seoUtils.test.ts +246 -0
  57. package/src/__tests__/server-articles.test.ts +356 -1
  58. package/src/__tests__/validateArticles.test.ts +312 -0
  59. package/src/articleTypes.ts +109 -0
  60. package/src/articlesConfig.ts +45 -1
  61. package/src/eventTracking.tsx +97 -0
  62. package/src/events.ts +105 -0
  63. package/src/index.ts +26 -1
  64. package/src/markdown.ts +41 -0
  65. package/src/pagination.ts +93 -0
  66. package/src/seoUtils.ts +198 -11
  67. package/src/server-articles.ts +199 -6
  68. package/src/server.ts +46 -2
  69. package/src/validateArticles.ts +260 -0
@@ -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 { Article, AuthorProfile, CategoryInfo, FaqItem, HowToStep } from './articleTypes'
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 getReadingTime(content: string): string {
14
- return readingTime(content).text
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 = getReadingTime(markdownContent)
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: resolveArticleAuthorName(data.author, data.authors, config),
249
- authors: parseAuthors(data.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 { markdownToHtml, extractToc } from './markdown'
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
+ }