@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
package/dist/server.d.ts CHANGED
@@ -15,6 +15,21 @@ interface HowToStep {
15
15
  name: string;
16
16
  text: string;
17
17
  }
18
+ /**
19
+ * A real-world thing an article is about, emitted as schema.org `about`.
20
+ * `sameAs` should point at an authoritative identifier for the entity
21
+ * (Wikipedia, Wikidata, an official site) - that URL is what lets a consumer
22
+ * resolve "Pathfinder" the game system rather than guessing from the string.
23
+ */
24
+ interface EntityReference {
25
+ name: string;
26
+ sameAs?: string;
27
+ }
28
+ /** An outbound source an article cites, emitted as schema.org `citation`. */
29
+ interface CitationReference {
30
+ name: string;
31
+ url?: string;
32
+ }
18
33
  interface Article {
19
34
  slug: string;
20
35
  title: string;
@@ -39,6 +54,18 @@ interface Article {
39
54
  toc?: TocItem[];
40
55
  faq?: FaqItem[];
41
56
  howTo?: HowToStep[];
57
+ /**
58
+ * A direct, self-contained answer to the article's core question, 40-60
59
+ * words. Rendered by `ArticleAnswer`, emitted as the Article schema's
60
+ * `abstract`, used as the `acceptedAnswer` when `articleType` is
61
+ * `'QAPage'`, and placed at the top of the article's markdown twin - the
62
+ * one passage most likely to be lifted verbatim by an answer engine.
63
+ */
64
+ answer?: string;
65
+ /** Entities this article is about. Emitted as schema.org `about`. */
66
+ about?: EntityReference[];
67
+ /** Outbound sources. Emitted as schema.org `citation`. */
68
+ citation?: CitationReference[];
42
69
  canonicalUrl?: string;
43
70
  articleType?: string;
44
71
  series?: string;
@@ -153,6 +180,13 @@ interface AuthorProfile {
153
180
  promise?: string;
154
181
  /** Structured long-form origin story - see `RichText`/`RichTextSection`. */
155
182
  originStory?: RichText;
183
+ /**
184
+ * Topics this author writes about, emitted as Person `knowsAbout`. Omit to
185
+ * derive it from the categories of their own published articles - unlike
186
+ * `credentials`/`proof`, subject matter is verifiable from the corpus
187
+ * itself, so it does belong in structured data.
188
+ */
189
+ knowsAbout?: string[];
156
190
  /** Who this author's content/work is for, e.g. "New game masters", "Streaming DMs". */
157
191
  servesWho?: string[];
158
192
  /** Core beliefs/approach statements. */
@@ -165,6 +199,27 @@ interface AuthorProfile {
165
199
  credentials?: string[];
166
200
  /** Concrete, sourceable proof points. */
167
201
  proof?: ProofItem[];
202
+ /**
203
+ * The one canonical URL identifying this person across every site they
204
+ * publish on. Used *only* to derive the Person `@id`, so the same author
205
+ * resolves to a single entity everywhere instead of one entity per site.
206
+ *
207
+ * Deliberately separate from `url`: that field also drives byline link
208
+ * targets, the author page's Open Graph URL, and its `CollectionPage` URL,
209
+ * so pointing it at another domain would send readers off-site and hand
210
+ * this site's author page a canonical belonging to a different one.
211
+ * `identityUrl` changes nothing a reader sees.
212
+ *
213
+ * Set the same value on every site. Falls back to `url`, then to this
214
+ * site's own author page.
215
+ */
216
+ identityUrl?: string;
217
+ /**
218
+ * Additional profile URLs identifying this same person elsewhere, merged
219
+ * into the Person schema's `sameAs` alongside the derived social links.
220
+ * List the author's pages on the other sites here.
221
+ */
222
+ sameAs?: string[];
168
223
  /** Primary call-to-action rendered on the author's page. */
169
224
  primaryCta?: {
170
225
  label: string;
@@ -346,6 +401,54 @@ interface BreadcrumbsConfig {
346
401
  /** Optional label overrides for built-in breadcrumb items. */
347
402
  labels?: BreadcrumbLabels;
348
403
  }
404
+ /** Payload passed to `ArticlesConfig.onAiCrawl`. */
405
+ interface AiCrawlEvent {
406
+ /** Article slug whose markdown twin was fetched. */
407
+ slug: string;
408
+ /** Matched crawler name (e.g. `'GPTBot'`), or `'unknown'` when the agent is not recognized. */
409
+ crawler: string;
410
+ /** Raw `User-Agent` header, or an empty string when absent. */
411
+ userAgent: string;
412
+ }
413
+ /**
414
+ * The publishing entity behind the site. Drives `OrganizationSchema`,
415
+ * `WebSiteSchema`, and the `publisher` reference on every article - so all
416
+ * three point at one `@id` instead of repeating an inline, unlinked
417
+ * `Organization` stub per page.
418
+ */
419
+ interface OrganizationConfig {
420
+ /** schema.org type. Use `'Person'` for a personal brand. Default: `'Organization'`. */
421
+ type?: 'Organization' | 'Person';
422
+ /** Entity name. Falls back to `siteName`. */
423
+ name?: string;
424
+ /** Entity homepage. Falls back to `siteUrl`. */
425
+ url?: string;
426
+ /** Logo URL. Site-relative paths are resolved against `siteUrl`. */
427
+ logo?: string;
428
+ /** Entity description. Falls back to `ArticlesConfig.description`. */
429
+ description?: string;
430
+ /** Profile URLs that identify the same entity elsewhere (social, Crunchbase, Wikidata). */
431
+ sameAs?: string[];
432
+ /**
433
+ * The umbrella entity this site belongs to, for a network of sites run by
434
+ * one publisher. Emitted as `parentOrganization` - the link that lets
435
+ * authority earned by the network attach to each site in it, instead of
436
+ * each site standing alone.
437
+ */
438
+ parentOrganization?: {
439
+ name: string;
440
+ url: string;
441
+ };
442
+ /**
443
+ * Search URL template for the `WebSite` `SearchAction`, e.g.
444
+ * `'/search?q={search_term_string}'`. Omitted by default - the library's
445
+ * own search is client-side with no crawlable results URL, so declaring one
446
+ * that does not exist would be a false claim. Only set this if the app
447
+ * actually serves search results at that URL. Must contain the literal
448
+ * `{search_term_string}` placeholder.
449
+ */
450
+ searchUrlTemplate?: string;
451
+ }
349
452
  /** Top-level configuration object. Pass one instance to every library component. */
350
453
  interface ArticlesConfig {
351
454
  /** Canonical base URL of the site, used in metadata and JSON-LD. Example: `'https://yoursite.com'` */
@@ -408,6 +511,94 @@ interface ArticlesConfig {
408
511
  * step rather than a build-time failure otherwise.
409
512
  */
410
513
  paths?: Record<string, PathDefinition>;
514
+ /**
515
+ * Template for the `<title>` tag on article, category, series, and author
516
+ * pages. Supports `{title}` and `{siteName}` placeholders.
517
+ * Default: `'{title} | {siteName}'`.
518
+ *
519
+ * Google truncates a result title around 60 characters, and a site name
520
+ * suffix spends that budget on every page. Set `'{title}'` to drop it when
521
+ * your titles are already long and your brand draws little search volume -
522
+ * the suffix is only earning its characters if people search for the brand.
523
+ */
524
+ titleTemplate?: string;
525
+ /**
526
+ * BCP 47 language tag for the site's content. Emitted as the Article
527
+ * schema's `inLanguage` and the RSS channel `<language>`. Default: `'en'`.
528
+ */
529
+ language?: string;
530
+ /**
531
+ * Set to `false` when articles sit behind a paywall or registration wall.
532
+ * Default: `true`, emitted as the Article schema's `isAccessibleForFree` -
533
+ * an explicit "this is readable" signal, since consumers that cannot tell
534
+ * tend to skip suspected-paywalled sources.
535
+ */
536
+ isAccessibleForFree?: boolean;
537
+ /**
538
+ * CSS selectors marking the parts of an article suitable for text-to-speech,
539
+ * emitted as the Article schema's `speakable`. Omitted by default - the
540
+ * correct selectors depend on the consuming app's own markup, and guessing
541
+ * them would point at elements that may not exist.
542
+ */
543
+ speakableSelectors?: string[];
544
+ /**
545
+ * Set to `true` to derive `FAQPage` entries from question-shaped `##`
546
+ * headings and the paragraph that follows each one. Default: `false` -
547
+ * turning prose into structured data without the author's intent can
548
+ * promote a rhetorical heading into a published Q&A pair, so this is
549
+ * opt-in. Explicit `faq` frontmatter always wins over derived entries.
550
+ */
551
+ deriveFaqFromHeadings?: boolean;
552
+ /**
553
+ * Shared entity vocabulary, keyed by an app-chosen slug. Article `about`
554
+ * entries may reference a key here instead of repeating a name/`sameAs`
555
+ * pair - the point of `about` is that a consumer can resolve one entity
556
+ * across a corpus, which free-text names spelled three different ways
557
+ * defeat. `validateArticles` warns on `about` keys with no registry entry.
558
+ */
559
+ entities?: Record<string, EntityReference>;
560
+ /**
561
+ * Where `lastmod` comes from when frontmatter omits it.
562
+ * - `'published'` (default): reuse the publish date, as before 1.3.0.
563
+ * - `'none'`: leave `lastmod` unset, so `dateModified` is omitted rather
564
+ * than repeating a stale publish date.
565
+ * - `'fileMtime'`: read the article file's modification time. Accurate
566
+ * locally; on a CI runner that clones fresh, every file's mtime is the
567
+ * checkout time, which would report the whole corpus as updated today.
568
+ * Only use it where the build preserves mtimes.
569
+ */
570
+ lastmodFallback?: 'published' | 'none' | 'fileMtime';
571
+ /**
572
+ * Called when an AI crawler fetches an article's markdown twin. The one
573
+ * choke point where those requests land, so it is the only place a site
574
+ * can measure whether any of its AI-readable content is being read, and
575
+ * by which bot. No PII: the payload carries the slug, the matched crawler
576
+ * name, and the raw user agent string only.
577
+ */
578
+ onAiCrawl?: (event: AiCrawlEvent) => void;
579
+ /**
580
+ * The publishing entity behind the site. Omit to keep the pre-1.3.0 inline
581
+ * `{'@type':'Organization', name: siteName}` publisher stub on articles.
582
+ * Set it to emit `OrganizationSchema`/`WebSiteSchema` in the root layout and
583
+ * have every article, author, and collection page reference the same `@id`.
584
+ */
585
+ organization?: OrganizationConfig;
586
+ /**
587
+ * Default `aiCrawl` value for articles whose frontmatter omits the key.
588
+ * Default: `false` (every article stays opted out unless it sets
589
+ * `aiCrawl: true`). Set to `true` on a site whose goal is being cited by
590
+ * answer engines to opt the whole corpus in at once; per-article
591
+ * `aiCrawl: false` still wins and keeps that article blocked.
592
+ */
593
+ aiCrawlDefault?: boolean;
594
+ /**
595
+ * Set to `false` to serve article markdown twins as the bare body, with no
596
+ * attribution header. Default: `true` - the twin is prefixed with the
597
+ * title, excerpt, canonical source URL, dates, author, and site name so a
598
+ * model reading `/articles/[slug].md` can attribute it. Only applies when
599
+ * a config is available (i.e. via `getArticleMarkdownResponse`).
600
+ */
601
+ markdownTwinHeader?: boolean;
411
602
  /**
412
603
  * Vendor-neutral event callback (Phase 27F). Fired by components/hooks at
413
604
  * meaningful reader-journey moments (see `ArticleEvent` in `events.ts`).
@@ -417,6 +608,18 @@ interface ArticlesConfig {
417
608
  onEvent?: ArticleEventHandler;
418
609
  }
419
610
  declare function getBreadcrumbsConfig(config: ArticlesConfig): BreadcrumbsConfig;
611
+ /** Stable `@id` for the site's publishing entity. */
612
+ declare function getOrganizationId(config: Pick<ArticlesConfig, 'siteUrl'>): string;
613
+ /** Stable `@id` for the site itself. */
614
+ declare function getWebSiteId(config: Pick<ArticlesConfig, 'siteUrl'>): string;
615
+ /** Stable `@id` for an author, so every article by them resolves to one Person. */
616
+ declare function getPersonId(authorUrl: string): string;
617
+ /**
618
+ * Applies `ArticlesConfig.titleTemplate` to a page title. Kept here rather
619
+ * than inlined at each call site so article, category, series, and author
620
+ * pages can never drift apart on how the site name is appended.
621
+ */
622
+ declare function formatPageTitle(title: string, config: Pick<ArticlesConfig, 'siteName' | 'titleTemplate'>): string;
420
623
 
421
624
  declare function sanitizeImagePath(rawPath: string, articleSlug: string): string | null;
422
625
  declare function getAvailableArticleSlugs(): string[];
@@ -429,11 +632,64 @@ declare function getAdjacentArticles(currentSlug: string): Promise<{
429
632
  previous: Article | null;
430
633
  next: Article | null;
431
634
  }>;
432
- declare function getArticleMarkdown(slug: string): Promise<string | null>;
433
- declare function getArticleMarkdownResponse(slug: string, config: ArticlesConfig): Promise<Response>;
635
+ declare function getArticleMarkdown(slug: string, config?: ArticlesConfig): Promise<string | null>;
636
+ /**
637
+ * Crawlers that read content on behalf of an answer engine. Used both to
638
+ * write robots.txt rules and to classify markdown-twin fetches for
639
+ * `ArticlesConfig.onAiCrawl` - one list, so the two can never disagree about
640
+ * what counts as an AI crawler.
641
+ */
642
+ declare const AI_CRAWLERS: readonly ["GPTBot", "ChatGPT-User", "OAI-SearchBot", "CCBot", "ClaudeBot", "Claude-User", "Claude-SearchBot", "anthropic-ai", "PerplexityBot", "Perplexity-User", "Google-Extended", "Applebot-Extended", "Bytespider", "Amazonbot", "meta-externalagent", "cohere-ai", "DuckAssistBot", "MistralAI-User"];
643
+ /** Matches a `User-Agent` against `AI_CRAWLERS`, returning the crawler name or `null`. */
644
+ declare function matchAiCrawler(userAgent: string): string | null;
645
+ /**
646
+ * Attribution header prepended to an article's markdown twin.
647
+ *
648
+ * The twin is what an AI crawler actually reads, and `matter()` strips every
649
+ * frontmatter field before it is served - so without this the model gets an
650
+ * anonymous body with no title, date, author, or link back to the canonical
651
+ * page. Skips its own `# {title}` line when the body already opens with the
652
+ * same H1, so the common "body repeats the title" layout doesn't end up with
653
+ * two.
654
+ */
655
+ declare function buildMarkdownTwinHeader(article: Article, config: ArticlesConfig, body: string): string;
656
+ /** Minimal shape of a request's headers - avoids depending on `next/server` here. */
657
+ type RequestHeaders = Readonly<{
658
+ get(name: string): string | null;
659
+ }>;
660
+ /** Markdown twin for `/articles/category/[category]`. `null` when the category has no articles. */
661
+ declare function getCategoryMarkdown(categorySlug: string, config: ArticlesConfig): Promise<string | null>;
662
+ /**
663
+ * Markdown twin for `/articles/authors/[author]`.
664
+ *
665
+ * Carries the author's bio, promise, principles, and sourced proof alongside
666
+ * their article list - the "who is this and why trust them" context that
667
+ * otherwise exists only inside React components, and the exact question asked
668
+ * before anything they wrote gets cited. `credentials` are included as the
669
+ * author's own stated claims; unlike JSON-LD, prose can attribute a claim
670
+ * without asserting it as a verified fact.
671
+ */
672
+ declare function getAuthorMarkdown(authorSlug: string, config: ArticlesConfig): Promise<string | null>;
673
+ /** Markdown twin for `/articles/series/[series]`. `null` when the series has no articles. */
674
+ declare function getSeriesMarkdown(seriesSlug: string, config: ArticlesConfig): Promise<string | null>;
675
+ /**
676
+ * Serves the markdown twin for any `/articles/...` path - an article, or a
677
+ * category/author/series listing.
678
+ *
679
+ * Dispatching on the slug prefix here rather than adding three more app
680
+ * routes keeps the existing single rewrite (`/articles/:path*.md`) working
681
+ * unchanged: without it, `/articles/category/campaigns.md` matches that
682
+ * rewrite, reaches the article handler, and 404s.
683
+ */
684
+ declare function getMarkdownTwinResponse(slug: string, config: ArticlesConfig, options?: Readonly<{
685
+ headers?: RequestHeaders;
686
+ }>): Promise<Response>;
687
+ declare function getArticleMarkdownResponse(slug: string, config: ArticlesConfig, options?: Readonly<{
688
+ headers?: RequestHeaders;
689
+ }>): Promise<Response>;
434
690
  declare function getArticleMarkdownUrl(article: Pick<Article, 'slug' | 'aiCrawl'>, config?: Pick<ArticlesConfig, 'siteUrl'>): string | undefined;
435
691
  declare function getArticleAiHeaders(article: Pick<Article, 'slug' | 'aiCrawl'>, config?: Pick<ArticlesConfig, 'siteUrl'>): Record<string, string>;
436
- declare function getAiRobotsTxtRules(): Promise<string>;
692
+ declare function getAiRobotsTxtRules(config?: ArticlesConfig): Promise<string>;
437
693
  declare function searchArticles(query: string, config?: ArticlesConfig): Promise<Article[]>;
438
694
  declare function categoryToSlug(category: string): string;
439
695
  declare function getAllCategories(): Promise<CategoryInfo[]>;
@@ -478,7 +734,39 @@ interface RelatedContentResult {
478
734
  */
479
735
  declare function getRelatedContent(article: Article, config: ArticlesConfig, limit?: number): Promise<RelatedContentResult>;
480
736
 
481
- declare function generateRssFeed(articles: Article[], config: ArticlesConfig): string;
737
+ /**
738
+ * `options.fullContent` adds `<content:encoded>` with each article's rendered
739
+ * HTML, for feeds meant to be ingested rather than previewed - an
740
+ * excerpt-only feed gives a consumer nothing to work with. Off by default:
741
+ * it requires articles loaded with `htmlContent` (i.e. via
742
+ * `getArticleMetadata`, not `getAllArticles`' summaries), and articles
743
+ * without it are simply emitted without the element.
744
+ */
745
+ declare function generateRssFeed(articles: Article[], config: ArticlesConfig, options?: Readonly<{
746
+ fullContent?: boolean;
747
+ }>): string;
748
+ /**
749
+ * `llms.txt` index - the emerging convention for pointing an LLM at a site's
750
+ * canonical, markdown-native content (https://llmstxt.org). Lists only
751
+ * articles opted in via `aiCrawl` (see `ArticlesConfig.aiCrawlDefault`),
752
+ * grouped by category, linking to each article's `.md` twin rather than its
753
+ * HTML page.
754
+ *
755
+ * Wire it up in the consuming app as `app/llms.txt/route.ts`:
756
+ * export async function GET() {
757
+ * return new Response(generateLlmsTxt(await getAllArticles(siteConfig), siteConfig), {
758
+ * headers: { 'Content-Type': 'text/plain; charset=utf-8' },
759
+ * })
760
+ * }
761
+ */
762
+ declare function generateLlmsTxt(articles: Article[], config: ArticlesConfig): string;
763
+ /**
764
+ * `llms-full.txt` - every opted-in article's full markdown twin, headers
765
+ * included, concatenated into one document. Larger and slower to build than
766
+ * `generateLlmsTxt`; generate it in a route handler or at build time, not on
767
+ * every request.
768
+ */
769
+ declare function generateLlmsFullTxt(articles: Article[], config: ArticlesConfig): Promise<string>;
482
770
  declare function generateArticleStaticParams(): {
483
771
  slug: string;
484
772
  }[];
@@ -570,8 +858,17 @@ interface ContentSlotBoundaries {
570
858
  }
571
859
  declare function getContentSlotBoundaries(markdown: string): ContentSlotBoundaries | null;
572
860
  declare function extractToc(markdown: string): Promise<TocItem[]>;
861
+ /**
862
+ * Derives `FaqItem`s from question-shaped `##` headings and the prose that
863
+ * follows each one, for `ArticlesConfig.deriveFaqFromHeadings`.
864
+ *
865
+ * Line-based on purpose: it runs for every article at summary time, and the
866
+ * shapes it must reject (fenced code, a heading with no prose under it) are
867
+ * cheaper to detect by scanning than by walking a parsed AST.
868
+ */
869
+ declare function deriveFaqFromHeadings(markdown: string): FaqItem[];
573
870
 
574
- type ArticlesErrorCode = 'article-directory-read-failed' | 'article-load-failed' | 'article-markdown-load-failed' | 'markdown-conversion-failed' | 'unsafe-image-path';
871
+ type ArticlesErrorCode = 'article-directory-read-failed' | 'article-load-failed' | 'article-markdown-load-failed' | 'ai-crawl-handler-failed' | 'markdown-conversion-failed' | 'unsafe-image-path';
575
872
  type ArticlesErrorContext = Readonly<Record<string, string | number | boolean | undefined>>;
576
873
  type ArticlesErrorReport = Readonly<{
577
874
  code: ArticlesErrorCode;
@@ -620,6 +917,22 @@ type ArticleTOCProps = Readonly<{
620
917
  }>;
621
918
  declare function ArticleTOC({ toc, className }: ArticleTOCProps): react_jsx_runtime.JSX.Element | null;
622
919
 
920
+ type ArticleAnswerProps = Readonly<{
921
+ article: Pick<Article, 'answer'>;
922
+ /** Heading shown above the answer. Default: `'The short answer'`. */
923
+ label?: string;
924
+ className?: string;
925
+ }>;
926
+ /**
927
+ * Renders `article.answer` as a callout above the article body.
928
+ *
929
+ * The same text is emitted as the Article schema's `abstract` and placed at
930
+ * the top of the article's markdown twin, so the passage an answer engine is
931
+ * most likely to lift is also the one a reader sees first. Returns `null`
932
+ * when the article has no `answer`, so it is safe to render unconditionally.
933
+ */
934
+ declare function ArticleAnswer({ article, label, className, }: ArticleAnswerProps): react_jsx_runtime.JSX.Element | null;
935
+
623
936
  type ValidationSeverity = 'error' | 'warning';
624
937
  interface ValidationIssue {
625
938
  severity: ValidationSeverity;
@@ -636,13 +949,21 @@ interface ValidationResult {
636
949
  }
637
950
  /**
638
951
  * Validates a loaded article set + config. Warnings cover optional
639
- * discovery-field issues (missing excerpt/date, over-length search/social
640
- * fields, category slug collisions); errors cover broken reader journeys
641
- * (duplicate canonical URLs, unknown author references, series order
642
- * collisions, missing/draft path references, unsafe URL schemes).
952
+ * discovery-field issues (missing excerpt/date, over-length rendered title
953
+ * and meta description, over-length social fields, category slug collisions) and answer-engine readiness
954
+ * (`no-answer`, `thin-content`, `missing-about`, `unknown-entity`,
955
+ * `stale-content`, `orphan-article`); errors cover broken reader journeys (duplicate canonical
956
+ * URLs, unknown author references, series order collisions, missing/draft
957
+ * path references, unsafe URL schemes).
958
+ *
959
+ * `options.now` overrides the clock used by the `stale-content` check.
643
960
  */
644
- declare function validateArticles(articles: Article[], config: ArticlesConfig): ValidationResult;
961
+ declare function validateArticles(articles: Article[], config: ArticlesConfig, options?: Readonly<{
962
+ now?: Date;
963
+ }>): ValidationResult;
645
964
  /** 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. */
646
- declare function validateAllArticles(config: ArticlesConfig): Promise<ValidationResult>;
965
+ declare function validateAllArticles(config: ArticlesConfig, options?: Readonly<{
966
+ now?: Date;
967
+ }>): Promise<ValidationResult>;
647
968
 
648
- export { type Article, ArticleContent, type ArticleEvent, type ArticleEventHandler, type ArticleEventName, type ArticleSlotContent, type ArticleSlotContext, ArticleTOC, type ArticleViewedEvent, type ArticlesConfig, type ArticlesErrorCode, type ArticlesErrorContext, type ArticlesErrorHandler, type ArticlesErrorReport, type AuthorClickedEvent, type AuthorProfile, type AuthorSocial, type BreadcrumbItem, type CategoryInfo, type ContentSlotBoundaries, type CtaClickedEvent, type CtaViewedEvent, type LinkTargetStrategy, type ListingPagination, type ListingPaginationContext, type MeaningfulReadEvent, type PaginatedArticles, type PaginationLinks, type PathDefinition, type PathStepAdvancedEvent, type RelatedArticleClickedEvent, type RelatedContentResult, type RelatedContentSource, type SharedEvent, type TocItem, type ValidationIssue, type ValidationResult, type ValidationSeverity, buildArticleBreadcrumbs, buildAuthorBreadcrumbs, buildCategoryBreadcrumbs, buildPageUrl, buildPaginationLinks, categoryToSlug, emitArticleEvent, extractToc, generateArticleMetadata, generateArticleStaticParams, generateArticlesIndexMetadata, generateArticlesIndexPageMetadata, generateAuthorMetadata, generateAuthorPageMetadata, generateAuthorStaticParams, generateCategoryMetadata, generateCategoryPageMetadata, generateCategoryStaticParams, generateListingPageStaticParams, generateRssFeed, generateSeriesMetadata, generateSeriesStaticParams, getAdjacentArticles, getAdjacentArticlesInSeries, getAiRobotsTxtRules, getAllArticles, getAllAuthors, getAllCategories, getArticleAiHeaders, getArticleAuthors, getArticleMarkdown, getArticleMarkdownResponse, getArticleMarkdownUrl, getArticleMetadata, getArticleSitemapEntries, getArticlesByAuthor, getArticlesByCategory, getArticlesBySeries, getAuthorBySlug, getAvailableArticleSlugs, getBreadcrumbsConfig, getContentSlotBoundaries, getPath, getPathArticles, getRelatedArticlesByCategory, getRelatedContent, getTotalPages, isPageOutOfRange, markdownToHtml, paginateArticles, parsePageParam, resolveAuthorAvatar, resolveSearchMetadata, resolveSocialMetadata, sanitizeImagePath, searchArticles, setArticlesErrorHandler, validateAllArticles, validateArticles };
969
+ export { AI_CRAWLERS, type AiCrawlEvent, type Article, ArticleAnswer, ArticleContent, type ArticleEvent, type ArticleEventHandler, type ArticleEventName, type ArticleSlotContent, type ArticleSlotContext, ArticleTOC, type ArticleViewedEvent, type ArticlesConfig, type ArticlesErrorCode, type ArticlesErrorContext, type ArticlesErrorHandler, type ArticlesErrorReport, type AuthorClickedEvent, type AuthorProfile, type AuthorSocial, type BreadcrumbItem, type CategoryInfo, type CitationReference, type ContentSlotBoundaries, type CtaClickedEvent, type CtaViewedEvent, type EntityReference, type FaqItem, type HowToStep, type LinkTargetStrategy, type ListingPagination, type ListingPaginationContext, type MeaningfulReadEvent, type OrganizationConfig, type PaginatedArticles, type PaginationLinks, type PathDefinition, type PathStepAdvancedEvent, type RelatedArticleClickedEvent, type RelatedContentResult, type RelatedContentSource, type RequestHeaders, type SharedEvent, type TocItem, type ValidationIssue, type ValidationResult, type ValidationSeverity, buildArticleBreadcrumbs, buildAuthorBreadcrumbs, buildCategoryBreadcrumbs, buildMarkdownTwinHeader, buildPageUrl, buildPaginationLinks, categoryToSlug, deriveFaqFromHeadings, emitArticleEvent, extractToc, formatPageTitle, generateArticleMetadata, generateArticleStaticParams, generateArticlesIndexMetadata, generateArticlesIndexPageMetadata, generateAuthorMetadata, generateAuthorPageMetadata, generateAuthorStaticParams, generateCategoryMetadata, generateCategoryPageMetadata, generateCategoryStaticParams, generateListingPageStaticParams, generateLlmsFullTxt, generateLlmsTxt, generateRssFeed, generateSeriesMetadata, generateSeriesStaticParams, getAdjacentArticles, getAdjacentArticlesInSeries, getAiRobotsTxtRules, getAllArticles, getAllAuthors, getAllCategories, getArticleAiHeaders, getArticleAuthors, getArticleMarkdown, getArticleMarkdownResponse, getArticleMarkdownUrl, getArticleMetadata, getArticleSitemapEntries, getArticlesByAuthor, getArticlesByCategory, getArticlesBySeries, getAuthorBySlug, getAuthorMarkdown, getAvailableArticleSlugs, getBreadcrumbsConfig, getCategoryMarkdown, getContentSlotBoundaries, getMarkdownTwinResponse, getOrganizationId, getPath, getPathArticles, getPersonId, getRelatedArticlesByCategory, getRelatedContent, getSeriesMarkdown, getTotalPages, getWebSiteId, isPageOutOfRange, markdownToHtml, matchAiCrawler, paginateArticles, parsePageParam, resolveAuthorAvatar, resolveSearchMetadata, resolveSocialMetadata, sanitizeImagePath, searchArticles, setArticlesErrorHandler, validateAllArticles, validateArticles };