@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.
- package/CHANGELOG.md +46 -0
- package/README.md +313 -1
- package/dist/index.cjs +308 -79
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +267 -16
- package/dist/index.d.ts +267 -16
- package/dist/index.js +300 -79
- package/dist/index.js.map +1 -1
- package/dist/nextjs.cjs +325 -31
- package/dist/nextjs.cjs.map +1 -1
- package/dist/nextjs.d.cts +179 -2
- package/dist/nextjs.d.ts +179 -2
- package/dist/nextjs.js +325 -31
- package/dist/nextjs.js.map +1 -1
- package/dist/server.cjs +660 -50
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +333 -12
- package/dist/server.d.ts +333 -12
- package/dist/server.js +645 -50
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
- package/src/ArticleAnswer.tsx +35 -0
- package/src/ArticleSchemas.tsx +263 -23
- package/src/AuthorArticlesPage.tsx +38 -8
- package/src/__tests__/ArticleAnswer.test.tsx +25 -0
- package/src/__tests__/ArticleSchemas.test.tsx +516 -0
- package/src/__tests__/AuthorArticlesPage.test.tsx +76 -0
- package/src/__tests__/authorUtils.test.ts +50 -0
- package/src/__tests__/markdown.test.ts +77 -1
- package/src/__tests__/nextjs.test.ts +31 -15
- package/src/__tests__/seoUtils.test.ts +279 -0
- package/src/__tests__/server-articles.test.ts +434 -1
- package/src/__tests__/validateArticles.test.ts +167 -6
- package/src/articleTypes.ts +57 -0
- package/src/articlesConfig.ts +176 -1
- package/src/authorUtils.ts +19 -1
- package/src/errorReporting.ts +1 -0
- package/src/index.ts +17 -1
- package/src/markdown.ts +100 -1
- package/src/nextjs.ts +7 -4
- package/src/seoUtils.ts +247 -26
- package/src/server-articles.ts +385 -25
- package/src/server.ts +35 -4
- package/src/validateArticles.ts +157 -12
package/src/validateArticles.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
categoryToSlug,
|
|
11
11
|
} from './server-articles'
|
|
12
12
|
import type { Article } from './articleTypes'
|
|
13
|
-
import type
|
|
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
|
-
|
|
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
|
-
[
|
|
188
|
-
[
|
|
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
|
|
236
|
-
* fields, category slug collisions)
|
|
237
|
-
* (
|
|
238
|
-
*
|
|
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(
|
|
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(
|
|
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
|
}
|