@fullstackdatasolutions/articles 1.2.3 → 1.3.1

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 (44) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +313 -1
  3. package/dist/index.cjs +308 -79
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.cts +267 -16
  6. package/dist/index.d.ts +267 -16
  7. package/dist/index.js +300 -79
  8. package/dist/index.js.map +1 -1
  9. package/dist/nextjs.cjs +325 -31
  10. package/dist/nextjs.cjs.map +1 -1
  11. package/dist/nextjs.d.cts +179 -2
  12. package/dist/nextjs.d.ts +179 -2
  13. package/dist/nextjs.js +325 -31
  14. package/dist/nextjs.js.map +1 -1
  15. package/dist/server.cjs +660 -50
  16. package/dist/server.cjs.map +1 -1
  17. package/dist/server.d.cts +333 -12
  18. package/dist/server.d.ts +333 -12
  19. package/dist/server.js +645 -50
  20. package/dist/server.js.map +1 -1
  21. package/package.json +1 -1
  22. package/src/ArticleAnswer.tsx +35 -0
  23. package/src/ArticleSchemas.tsx +263 -23
  24. package/src/AuthorArticlesPage.tsx +38 -8
  25. package/src/__tests__/ArticleAnswer.test.tsx +25 -0
  26. package/src/__tests__/ArticleSchemas.test.tsx +516 -0
  27. package/src/__tests__/AuthorArticlesPage.test.tsx +76 -0
  28. package/src/__tests__/authorUtils.test.ts +50 -0
  29. package/src/__tests__/markdown.test.ts +77 -1
  30. package/src/__tests__/nextjs.test.ts +31 -15
  31. package/src/__tests__/seoUtils.test.ts +279 -0
  32. package/src/__tests__/server-articles.test.ts +434 -1
  33. package/src/__tests__/validateArticles.test.ts +167 -6
  34. package/src/articleTypes.ts +57 -0
  35. package/src/articlesConfig.ts +176 -1
  36. package/src/authorUtils.ts +19 -1
  37. package/src/errorReporting.ts +1 -0
  38. package/src/index.ts +17 -1
  39. package/src/markdown.ts +100 -1
  40. package/src/nextjs.ts +7 -4
  41. package/src/seoUtils.ts +247 -26
  42. package/src/server-articles.ts +385 -25
  43. package/src/server.ts +35 -4
  44. package/src/validateArticles.ts +157 -12
@@ -10,7 +10,7 @@ import {
10
10
  categoryToSlug,
11
11
  } from './server-articles'
12
12
  import type { Article } from './articleTypes'
13
- import type { ArticlesConfig } from './articlesConfig'
13
+ import { formatPageTitle, type ArticlesConfig } from './articlesConfig'
14
14
 
15
15
  export type ValidationSeverity = 'error' | 'warning'
16
16
 
@@ -31,6 +31,9 @@ export interface ValidationResult {
31
31
 
32
32
  const UNSAFE_URL_SCHEME = /^\s*(javascript|data|vbscript):/i
33
33
 
34
+ const THIN_CONTENT_WORDS = 300
35
+ const STALE_CONTENT_MONTHS = 18
36
+
34
37
  const SEARCH_TITLE_MAX = 60
35
38
  const SEARCH_DESCRIPTION_MAX = 160
36
39
  const SOCIAL_TITLE_MAX = 95
@@ -180,12 +183,28 @@ function checkRequiredFrontmatter(articles: Article[]): ValidationIssue[] {
180
183
  return issues
181
184
  }
182
185
 
183
- function checkDiscoveryFieldLengths(articles: Article[]): ValidationIssue[] {
186
+ /**
187
+ * Length checks against what a search result actually renders, not just the
188
+ * optional overrides.
189
+ *
190
+ * Before 1.3.0 this only measured `searchTitle`/`searchDescription`, so a site
191
+ * that never set them (the common case) got no warnings at all while every
192
+ * one of its titles rendered over-length. The effective title is
193
+ * `titleTemplate` applied to `searchTitle ?? title`, and the effective
194
+ * description is `searchDescription ?? excerpt` - those are the strings a
195
+ * person sees, so those are what get measured.
196
+ */
197
+ function checkDiscoveryFieldLengths(
198
+ articles: Article[],
199
+ config: ArticlesConfig
200
+ ): ValidationIssue[] {
184
201
  const issues: ValidationIssue[] = []
185
202
  for (const article of articles) {
203
+ const effectiveTitle = formatPageTitle(article.searchTitle ?? article.title, config)
204
+ const effectiveDescription = article.searchDescription ?? article.excerpt
186
205
  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],
206
+ [effectiveTitle, 'effective-title-too-long', SEARCH_TITLE_MAX],
207
+ [effectiveDescription, 'effective-description-too-long', SEARCH_DESCRIPTION_MAX],
189
208
  [article.socialTitle, 'social-title-too-long', SOCIAL_TITLE_MAX],
190
209
  [article.socialDescription, 'social-description-too-long', SOCIAL_DESCRIPTION_MAX],
191
210
  ]
@@ -203,6 +222,117 @@ function checkDiscoveryFieldLengths(articles: Article[]): ValidationIssue[] {
203
222
  return issues
204
223
  }
205
224
 
225
+ // AEO checks. All warnings, never errors: each one flags content that will
226
+ // still build and render correctly but is unlikely to be quotable, resolvable,
227
+ // or trusted by an answer engine.
228
+ function checkAnswerability(articles: Article[]): ValidationIssue[] {
229
+ const issues: ValidationIssue[] = []
230
+ for (const article of articles) {
231
+ const hasQuestionHeading = (article.toc ?? []).some(
232
+ (item) => item.depth === 2 && item.text.trim().endsWith('?')
233
+ )
234
+ if (!article.answer && !article.faq?.length && !hasQuestionHeading) {
235
+ issues.push({
236
+ severity: 'warning',
237
+ code: 'no-answer',
238
+ message:
239
+ 'Article has no `answer`, no `faq`, and no question-shaped heading - nothing for an answer engine to lift.',
240
+ articleSlug: article.slug,
241
+ })
242
+ }
243
+ if (article.wordCount !== undefined && article.wordCount < THIN_CONTENT_WORDS) {
244
+ issues.push({
245
+ severity: 'warning',
246
+ code: 'thin-content',
247
+ message: `Article is ${article.wordCount} words (under ${THIN_CONTENT_WORDS}).`,
248
+ articleSlug: article.slug,
249
+ })
250
+ }
251
+ if (!article.about?.length) {
252
+ issues.push({
253
+ severity: 'warning',
254
+ code: 'missing-about',
255
+ message: 'Article has no `about` entity references.',
256
+ articleSlug: article.slug,
257
+ })
258
+ }
259
+ }
260
+ return issues
261
+ }
262
+
263
+ // `now` is injected rather than read from the clock so the check is
264
+ // deterministic in tests and reproducible in CI.
265
+ // `about` strings that hit no `config.entities` key still render as plain
266
+ // names - the warning exists because that is how a corpus ends up with
267
+ // "Pathfinder", "pathfinder", and "PF2e" as three separate entities.
268
+ function checkEntityReferences(articles: Article[], config: ArticlesConfig): ValidationIssue[] {
269
+ const registry = config.entities
270
+ if (!registry) return []
271
+ const known = new Set(Object.values(registry).map((entity) => entity.name))
272
+ const issues: ValidationIssue[] = []
273
+ for (const article of articles) {
274
+ for (const entity of article.about ?? []) {
275
+ if (!known.has(entity.name) && !entity.sameAs) {
276
+ issues.push({
277
+ severity: 'warning',
278
+ code: 'unknown-entity',
279
+ message: `about entry "${entity.name}" is not in config.entities and has no sameAs.`,
280
+ articleSlug: article.slug,
281
+ })
282
+ }
283
+ }
284
+ }
285
+ return issues
286
+ }
287
+
288
+ function checkStaleContent(articles: Article[], now: Date): ValidationIssue[] {
289
+ const cutoff = new Date(now)
290
+ cutoff.setMonth(cutoff.getMonth() - STALE_CONTENT_MONTHS)
291
+ const issues: ValidationIssue[] = []
292
+ for (const article of articles) {
293
+ const stamp = article.lastmod ?? article.date
294
+ if (!stamp) continue
295
+ const parsed = new Date(stamp)
296
+ if (Number.isNaN(parsed.getTime())) continue
297
+ if (parsed < cutoff) {
298
+ issues.push({
299
+ severity: 'warning',
300
+ code: 'stale-content',
301
+ message: `Last updated ${stamp}, over ${STALE_CONTENT_MONTHS} months ago.`,
302
+ articleSlug: article.slug,
303
+ })
304
+ }
305
+ }
306
+ return issues
307
+ }
308
+
309
+ // An article no other article links to is reachable only from listing pages,
310
+ // which is the weakest possible internal signal. Matches on the article's own
311
+ // `/articles/<slug>` path appearing in any other article's raw body, so it
312
+ // only runs for articles loaded with their `content` (i.e. via
313
+ // `getArticleMetadata`, not `getAllArticles`' summaries).
314
+ function checkOrphanArticles(articles: Article[]): ValidationIssue[] {
315
+ const bodies = articles.filter((article) => typeof article.content === 'string')
316
+ if (bodies.length === 0) return []
317
+
318
+ const issues: ValidationIssue[] = []
319
+ for (const article of articles) {
320
+ const needle = `/articles/${article.slug}`
321
+ const linked = bodies.some(
322
+ (other) => other.slug !== article.slug && other.content!.includes(needle)
323
+ )
324
+ if (!linked) {
325
+ issues.push({
326
+ severity: 'warning',
327
+ code: 'orphan-article',
328
+ message: 'No other article links to this one.',
329
+ articleSlug: article.slug,
330
+ })
331
+ }
332
+ }
333
+ return issues
334
+ }
335
+
206
336
  function checkCategorySlugs(articles: Article[]): ValidationIssue[] {
207
337
  // Two differently-cased/spaced category labels that collapse to the same
208
338
  // slug silently merge on `/articles/category/[slug]` - surfaced as a
@@ -232,12 +362,20 @@ function checkCategorySlugs(articles: Article[]): ValidationIssue[] {
232
362
 
233
363
  /**
234
364
  * 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).
365
+ * discovery-field issues (missing excerpt/date, over-length rendered title
366
+ * and meta description, over-length social fields, category slug collisions) and answer-engine readiness
367
+ * (`no-answer`, `thin-content`, `missing-about`, `unknown-entity`,
368
+ * `stale-content`, `orphan-article`); errors cover broken reader journeys (duplicate canonical
369
+ * URLs, unknown author references, series order collisions, missing/draft
370
+ * path references, unsafe URL schemes).
371
+ *
372
+ * `options.now` overrides the clock used by the `stale-content` check.
239
373
  */
240
- export function validateArticles(articles: Article[], config: ArticlesConfig): ValidationResult {
374
+ export function validateArticles(
375
+ articles: Article[],
376
+ config: ArticlesConfig,
377
+ options?: Readonly<{ now?: Date }>
378
+ ): ValidationResult {
241
379
  const errors = [
242
380
  ...checkDuplicateCanonicalUrls(articles),
243
381
  ...checkAuthorReferences(articles, config),
@@ -247,14 +385,21 @@ export function validateArticles(articles: Article[], config: ArticlesConfig): V
247
385
  ]
248
386
  const warnings = [
249
387
  ...checkRequiredFrontmatter(articles),
250
- ...checkDiscoveryFieldLengths(articles),
388
+ ...checkDiscoveryFieldLengths(articles, config),
389
+ ...checkAnswerability(articles),
390
+ ...checkEntityReferences(articles, config),
391
+ ...checkStaleContent(articles, options?.now ?? new Date()),
392
+ ...checkOrphanArticles(articles),
251
393
  ...checkCategorySlugs(articles),
252
394
  ]
253
395
  return { ok: errors.length === 0, errors, warnings }
254
396
  }
255
397
 
256
398
  /** 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> {
399
+ export async function validateAllArticles(
400
+ config: ArticlesConfig,
401
+ options?: Readonly<{ now?: Date }>
402
+ ): Promise<ValidationResult> {
258
403
  const articles = await getAllArticles(config)
259
- return validateArticles(articles, config)
404
+ return validateArticles(articles, config, options)
260
405
  }