@fullstackdatasolutions/articles 1.2.2 → 1.3.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 (48) 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 +324 -13
  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 +324 -13
  14. package/dist/nextjs.js.map +1 -1
  15. package/dist/server.cjs +677 -51
  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 +662 -51
  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__/linkClassification.test.ts +55 -0
  30. package/src/__tests__/markdown.test.ts +77 -1
  31. package/src/__tests__/nextjs.test.ts +31 -15
  32. package/src/__tests__/renderMdx.test.tsx +162 -3
  33. package/src/__tests__/seoUtils.test.ts +279 -0
  34. package/src/__tests__/server-articles.test.ts +413 -1
  35. package/src/__tests__/validateArticles.test.ts +167 -6
  36. package/src/articleTypes.ts +57 -0
  37. package/src/articlesConfig.ts +176 -1
  38. package/src/authorUtils.ts +19 -1
  39. package/src/errorReporting.ts +1 -0
  40. package/src/index.ts +17 -1
  41. package/src/linkClassification.ts +30 -0
  42. package/src/markdown.ts +103 -25
  43. package/src/nextjs.ts +7 -4
  44. package/src/renderMdx.tsx +43 -6
  45. package/src/seoUtils.ts +247 -26
  46. package/src/server-articles.ts +375 -24
  47. package/src/server.ts +35 -4
  48. package/src/validateArticles.ts +157 -12
@@ -4,11 +4,13 @@ import fs from 'node:fs'
4
4
  import path from 'node:path'
5
5
  import readingTime from 'reading-time'
6
6
  import { getAuthorAvatar } from './authorUtils'
7
- import { markdownToHtml, extractToc } from './markdown'
7
+ import { markdownToHtml, extractToc, deriveFaqFromHeadings } from './markdown'
8
8
  import type {
9
9
  Article,
10
10
  AuthorProfile,
11
11
  CategoryInfo,
12
+ CitationReference,
13
+ EntityReference,
12
14
  FaqItem,
13
15
  HowToStep,
14
16
  PathDefinition,
@@ -171,6 +173,97 @@ function parseOptionalString(raw: unknown): string | undefined {
171
173
  return typeof raw === 'string' && raw.trim().length > 0 ? raw.trim() : undefined
172
174
  }
173
175
 
176
+ // `aiCrawl` is the one frontmatter flag with a config-level default
177
+ // (`ArticlesConfig.aiCrawlDefault`). Explicit `true`/`false` in frontmatter
178
+ // always wins; only an omitted/non-boolean key falls through to the config.
179
+ // Omitting both reproduces the original "blocked unless opted in" behavior.
180
+ // Explicit `faq` frontmatter always wins - derivation only fills the gap
181
+ // for articles that never declared one, and only when the site opted in.
182
+ function deriveFaq(markdownContent: string, config?: ArticlesConfig): FaqItem[] | undefined {
183
+ if (config?.deriveFaqFromHeadings !== true) return undefined
184
+ const derived = deriveFaqFromHeadings(markdownContent)
185
+ return derived.length ? derived : undefined
186
+ }
187
+
188
+ // Frontmatter `lastmod` always wins. Only `'fileMtime'` produces a value
189
+ // here; `'published'` (the default) and `'none'` differ in what
190
+ // `ArticleSEO` does with an absent `lastmod` - reuse `datePublished`, or
191
+ // omit `dateModified` rather than repeat a stale date.
192
+ function resolveLastmod(
193
+ rawLastmod: unknown,
194
+ rawDate: unknown,
195
+ filePath: string,
196
+ config?: ArticlesConfig
197
+ ): string | undefined {
198
+ const declared = parseDateField(rawLastmod)
199
+ if (declared) return declared
200
+ if (config?.lastmodFallback !== 'fileMtime') return undefined
201
+ try {
202
+ return parseDateField(fs.statSync(filePath).mtime)
203
+ } catch {
204
+ return parseDateField(rawDate)
205
+ }
206
+ }
207
+
208
+ function resolveAiCrawl(raw: unknown, config?: ArticlesConfig): boolean {
209
+ if (typeof raw === 'boolean') return raw
210
+ return config?.aiCrawlDefault === true
211
+ }
212
+
213
+ // A bare string resolves through `config.entities` first, so a corpus can
214
+ // share one canonical name/`sameAs` pair per topic instead of each article
215
+ // spelling it out (and spelling it differently). Unregistered strings still
216
+ // work as plain names - `validateArticles` warns about them rather than
217
+ // dropping the entry.
218
+ function parseEntityReferences(
219
+ raw: unknown,
220
+ config?: ArticlesConfig
221
+ ): EntityReference[] | undefined {
222
+ if (!Array.isArray(raw)) return undefined
223
+ const items = raw
224
+ .map((item) => {
225
+ if (typeof item === 'string') {
226
+ const key = item.trim()
227
+ if (!key) return null
228
+ return config?.entities?.[key] ?? { name: key }
229
+ }
230
+ if (
231
+ typeof item === 'object' &&
232
+ item !== null &&
233
+ typeof (item as EntityReference).name === 'string'
234
+ ) {
235
+ const entity = item as EntityReference
236
+ const name = entity.name.trim()
237
+ if (!name) return null
238
+ return entity.sameAs ? { name, sameAs: entity.sameAs } : { name }
239
+ }
240
+ return null
241
+ })
242
+ .filter((item): item is EntityReference => item !== null)
243
+ return items.length ? items : undefined
244
+ }
245
+
246
+ function parseCitations(raw: unknown): CitationReference[] | undefined {
247
+ if (!Array.isArray(raw)) return undefined
248
+ const items = raw
249
+ .map((item) => {
250
+ if (typeof item === 'string') return item.trim() ? { name: item.trim() } : null
251
+ if (
252
+ typeof item === 'object' &&
253
+ item !== null &&
254
+ typeof (item as CitationReference).name === 'string'
255
+ ) {
256
+ const citation = item as CitationReference
257
+ const name = citation.name.trim()
258
+ if (!name) return null
259
+ return citation.url ? { name, url: citation.url } : { name }
260
+ }
261
+ return null
262
+ })
263
+ .filter((item): item is CitationReference => item !== null)
264
+ return items.length ? items : undefined
265
+ }
266
+
174
267
  function parseSeriesOrder(raw: unknown): number | undefined {
175
268
  return typeof raw === 'number' && Number.isFinite(raw) ? raw : undefined
176
269
  }
@@ -292,7 +385,7 @@ async function getArticleSummary(slug: string, config?: ArticlesConfig): Promise
292
385
  title: data.title || slug.replaceAll('-', ' '),
293
386
  excerpt: data.excerpt || '',
294
387
  date: parseDateField(data.date),
295
- lastmod: parseDateField(data.lastmod),
388
+ lastmod: resolveLastmod(data.lastmod, data.date, found.filePath, config),
296
389
  author,
297
390
  authors,
298
391
  authorSlug: primaryAuthorProfile?.slug,
@@ -305,14 +398,17 @@ async function getArticleSummary(slug: string, config?: ArticlesConfig): Promise
305
398
  tags: data.tags || [],
306
399
  contentType: found.contentType,
307
400
  draft: data.draft === true,
308
- faq: parseFaqItems(data.faq),
401
+ faq: parseFaqItems(data.faq) ?? deriveFaq(markdownContent, config),
309
402
  howTo: parseHowToSteps(data.howTo),
403
+ answer: parseOptionalString(data.answer),
404
+ about: parseEntityReferences(data.about, config),
405
+ citation: parseCitations(data.citation),
310
406
  canonicalUrl: typeof data.canonicalUrl === 'string' ? data.canonicalUrl : undefined,
311
407
  articleType: typeof data.articleType === 'string' ? data.articleType : undefined,
312
408
  series: typeof data.series === 'string' ? data.series : undefined,
313
409
  seriesSlug: parseOptionalString(data.seriesSlug),
314
410
  seriesOrder: parseSeriesOrder(data.seriesOrder),
315
- aiCrawl: data.aiCrawl === true,
411
+ aiCrawl: resolveAiCrawl(data.aiCrawl, config),
316
412
  searchTitle: parseOptionalString(data.searchTitle),
317
413
  searchDescription: parseOptionalString(data.searchDescription),
318
414
  socialTitle: parseOptionalString(data.socialTitle),
@@ -388,9 +484,12 @@ export async function getAdjacentArticles(
388
484
  return { previous, next }
389
485
  }
390
486
 
391
- export async function getArticleMarkdown(slug: string): Promise<string | null> {
487
+ export async function getArticleMarkdown(
488
+ slug: string,
489
+ config?: ArticlesConfig
490
+ ): Promise<string | null> {
392
491
  try {
393
- const summary = await getArticleSummary(slug)
492
+ const summary = await getArticleSummary(slug, config)
394
493
  if (!summary?.aiCrawl) return null
395
494
  const found = findArticleFile(slug)
396
495
  if (!found) return null
@@ -408,14 +507,275 @@ export async function getArticleMarkdown(slug: string): Promise<string | null> {
408
507
  }
409
508
  }
410
509
 
411
- export async function getArticleMarkdownResponse(
510
+ /**
511
+ * Crawlers that read content on behalf of an answer engine. Used both to
512
+ * write robots.txt rules and to classify markdown-twin fetches for
513
+ * `ArticlesConfig.onAiCrawl` - one list, so the two can never disagree about
514
+ * what counts as an AI crawler.
515
+ */
516
+ export const AI_CRAWLERS = [
517
+ 'GPTBot',
518
+ 'ChatGPT-User',
519
+ 'OAI-SearchBot',
520
+ 'CCBot',
521
+ 'ClaudeBot',
522
+ 'Claude-User',
523
+ 'Claude-SearchBot',
524
+ 'anthropic-ai',
525
+ 'PerplexityBot',
526
+ 'Perplexity-User',
527
+ 'Google-Extended',
528
+ 'Applebot-Extended',
529
+ 'Bytespider',
530
+ 'Amazonbot',
531
+ 'meta-externalagent',
532
+ 'cohere-ai',
533
+ 'DuckAssistBot',
534
+ 'MistralAI-User',
535
+ ] as const
536
+
537
+ /** Matches a `User-Agent` against `AI_CRAWLERS`, returning the crawler name or `null`. */
538
+ export function matchAiCrawler(userAgent: string): string | null {
539
+ if (!userAgent) return null
540
+ const normalized = userAgent.toLowerCase()
541
+ return AI_CRAWLERS.find((crawler) => normalized.includes(crawler.toLowerCase())) ?? null
542
+ }
543
+
544
+ /**
545
+ * Attribution header prepended to an article's markdown twin.
546
+ *
547
+ * The twin is what an AI crawler actually reads, and `matter()` strips every
548
+ * frontmatter field before it is served - so without this the model gets an
549
+ * anonymous body with no title, date, author, or link back to the canonical
550
+ * page. Skips its own `# {title}` line when the body already opens with the
551
+ * same H1, so the common "body repeats the title" layout doesn't end up with
552
+ * two.
553
+ */
554
+ export function buildMarkdownTwinHeader(
555
+ article: Article,
556
+ config: ArticlesConfig,
557
+ body: string
558
+ ): string {
559
+ const siteUrl = config.siteUrl.replace(/\/$/, '')
560
+ const firstLine = body.trimStart().split('\n', 1)[0]?.trim() ?? ''
561
+ const bodyRepeatsTitle = firstLine.toLowerCase() === `# ${article.title}`.toLowerCase()
562
+
563
+ const facts = [
564
+ `Source: ${siteUrl}/articles/${article.slug}`,
565
+ article.date ? `Published: ${article.date}` : '',
566
+ article.lastmod ? `Updated: ${article.lastmod}` : '',
567
+ config.showAuthor !== false && article.author ? `Author: ${article.author}` : '',
568
+ `Site: ${config.siteName}`,
569
+ ].filter(Boolean)
570
+
571
+ const blocks = [
572
+ bodyRepeatsTitle ? '' : `# ${article.title}`,
573
+ article.excerpt ? `> ${article.excerpt}` : '',
574
+ facts.join('\n'),
575
+ article.answer ? `**Short answer:** ${article.answer}` : '',
576
+ '---',
577
+ ].filter((block) => block !== '')
578
+
579
+ return `${blocks.join('\n\n')}\n\n`
580
+ }
581
+
582
+ /**
583
+ * Reports a markdown-twin fetch to `config.onAiCrawl`. Never lets a consumer
584
+ * callback break the response - a telemetry handler throwing must not turn a
585
+ * served article into a 500.
586
+ */
587
+ function reportAiCrawl(slug: string, config: ArticlesConfig, headers?: RequestHeaders): void {
588
+ if (!config.onAiCrawl) return
589
+ const userAgent = headers?.get('user-agent') ?? ''
590
+ try {
591
+ config.onAiCrawl({ slug, crawler: matchAiCrawler(userAgent) ?? 'unknown', userAgent })
592
+ } catch (error) {
593
+ reportArticlesError({
594
+ code: 'ai-crawl-handler-failed',
595
+ message: 'onAiCrawl handler threw.',
596
+ error,
597
+ context: { slug },
598
+ })
599
+ }
600
+ }
601
+
602
+ /** Minimal shape of a request's headers - avoids depending on `next/server` here. */
603
+ export type RequestHeaders = Readonly<{ get(name: string): string | null }>
604
+
605
+ /**
606
+ * Markdown twin for a listing surface - a category, author, or series.
607
+ *
608
+ * Article twins alone leave a model with a bag of pages and no map: the
609
+ * listing surfaces are what answer "what does this site cover, and who
610
+ * writes it". Only articles resolved to `aiCrawl: true` are listed, so a
611
+ * blocked article stays invisible here too.
612
+ */
613
+ function buildListingMarkdown(
614
+ heading: string,
615
+ intro: string[],
616
+ articles: readonly Article[],
617
+ config: ArticlesConfig
618
+ ): string {
619
+ const siteUrl = config.siteUrl.replace(/\/$/, '')
620
+ const crawlable = articles.filter((article) => article.aiCrawl === true)
621
+ const entries = crawlable.map((article) => {
622
+ const summary = article.answer ?? article.excerpt
623
+ const line = `- [${article.title}](${siteUrl}/articles/${article.slug}.md)`
624
+ return summary ? `${line}: ${summary}` : line
625
+ })
626
+
627
+ return [
628
+ `# ${heading}`,
629
+ '',
630
+ ...intro.flatMap((line) => [line, '']),
631
+ `Source: ${siteUrl}`,
632
+ `Site: ${config.siteName}`,
633
+ '',
634
+ '---',
635
+ '',
636
+ ...(entries.length > 0 ? entries : ['_No articles available._']),
637
+ '',
638
+ ].join('\n')
639
+ }
640
+
641
+ /** Markdown twin for `/articles/category/[category]`. `null` when the category has no articles. */
642
+ export async function getCategoryMarkdown(
643
+ categorySlug: string,
644
+ config: ArticlesConfig
645
+ ): Promise<string | null> {
646
+ const articles = await getArticlesByCategory(categorySlug, config)
647
+ if (articles.length === 0) return null
648
+ const name =
649
+ articles[0].categories.find((c) => categoryToSlug(c) === categorySlug) ?? categorySlug
650
+ const description = resolveCategoryDescription(categorySlug, config)
651
+ return buildListingMarkdown(name, description ? [description] : [], articles, config)
652
+ }
653
+
654
+ function resolveCategoryDescription(
655
+ categorySlug: string,
656
+ config: ArticlesConfig
657
+ ): string | undefined {
658
+ const entry = config.categoryDescriptions?.[categorySlug]
659
+ if (!entry) return undefined
660
+ return typeof entry === 'string' ? entry : (entry.long ?? entry.short)
661
+ }
662
+
663
+ /**
664
+ * Markdown twin for `/articles/authors/[author]`.
665
+ *
666
+ * Carries the author's bio, promise, principles, and sourced proof alongside
667
+ * their article list - the "who is this and why trust them" context that
668
+ * otherwise exists only inside React components, and the exact question asked
669
+ * before anything they wrote gets cited. `credentials` are included as the
670
+ * author's own stated claims; unlike JSON-LD, prose can attribute a claim
671
+ * without asserting it as a verified fact.
672
+ */
673
+ export async function getAuthorMarkdown(
674
+ authorSlug: string,
675
+ config: ArticlesConfig
676
+ ): Promise<string | null> {
677
+ const author = getAuthorBySlug(authorSlug, config)
678
+ if (!author) return null
679
+ const articles = await getArticlesByAuthor(authorSlug, config)
680
+
681
+ const intro = [
682
+ author.promise ?? '',
683
+ author.bio ?? '',
684
+ ...(author.servesWho?.length ? [`Writes for: ${author.servesWho.join(', ')}`] : []),
685
+ ...(author.knowsAbout?.length ? [`Writes about: ${author.knowsAbout.join(', ')}`] : []),
686
+ ...(author.credentials?.length
687
+ ? ['## Stated experience', ...author.credentials.map((item) => `- ${item}`)]
688
+ : []),
689
+ ...(author.proof?.length
690
+ ? [
691
+ '## Proof points',
692
+ ...author.proof.map((item) =>
693
+ item.url ? `- [${item.claim}](${item.url})` : `- ${item.claim}`
694
+ ),
695
+ ]
696
+ : []),
697
+ ...(author.originStory?.length
698
+ ? [
699
+ '## Background',
700
+ ...author.originStory.flatMap((section) => [
701
+ ...(section.heading ? [`### ${section.heading}`] : []),
702
+ ...section.paragraphs,
703
+ ]),
704
+ ]
705
+ : []),
706
+ ].filter((line) => line.trim() !== '')
707
+
708
+ return buildListingMarkdown(author.name, intro, articles, config)
709
+ }
710
+
711
+ /** Markdown twin for `/articles/series/[series]`. `null` when the series has no articles. */
712
+ export async function getSeriesMarkdown(
713
+ seriesSlug: string,
714
+ config: ArticlesConfig
715
+ ): Promise<string | null> {
716
+ const articles = await getArticlesBySeries(seriesSlug, config)
717
+ if (articles.length === 0) return null
718
+ const name = articles[0].series ?? seriesSlug
719
+ return buildListingMarkdown(name, [], articles, config)
720
+ }
721
+
722
+ /**
723
+ * Serves the markdown twin for any `/articles/...` path - an article, or a
724
+ * category/author/series listing.
725
+ *
726
+ * Dispatching on the slug prefix here rather than adding three more app
727
+ * routes keeps the existing single rewrite (`/articles/:path*.md`) working
728
+ * unchanged: without it, `/articles/category/campaigns.md` matches that
729
+ * rewrite, reaches the article handler, and 404s.
730
+ */
731
+ export async function getMarkdownTwinResponse(
732
+ slug: string,
733
+ config: ArticlesConfig,
734
+ options?: Readonly<{ headers?: RequestHeaders }>
735
+ ): Promise<Response> {
736
+ const listing = await resolveListingMarkdown(slug, config)
737
+ if (listing !== undefined) {
738
+ if (listing === null) return new Response('Not Found', { status: 404 })
739
+ reportAiCrawl(slug, config, options?.headers)
740
+ return new Response(listing, { headers: LISTING_MARKDOWN_HEADERS })
741
+ }
742
+ return getArticleMarkdownResponse(slug, config, options)
743
+ }
744
+
745
+ const LISTING_MARKDOWN_HEADERS = {
746
+ 'Content-Type': 'text/markdown; charset=utf-8',
747
+ 'Cache-Control': 'public, max-age=3600, s-maxage=3600',
748
+ }
749
+
750
+ // `undefined` means "not a listing path" (fall through to the article
751
+ // handler); `null` means "a listing path that resolved to nothing" (404).
752
+ async function resolveListingMarkdown(
412
753
  slug: string,
413
754
  config: ArticlesConfig
755
+ ): Promise<string | null | undefined> {
756
+ const [prefix, ...rest] = slug.split('/')
757
+ const key = rest.join('/')
758
+ if (!key) return undefined
759
+ if (prefix === 'category') return getCategoryMarkdown(key, config)
760
+ if (prefix === 'authors') return getAuthorMarkdown(key, config)
761
+ if (prefix === 'series') return getSeriesMarkdown(key, config)
762
+ return undefined
763
+ }
764
+
765
+ export async function getArticleMarkdownResponse(
766
+ slug: string,
767
+ config: ArticlesConfig,
768
+ options?: Readonly<{ headers?: RequestHeaders }>
414
769
  ): Promise<Response> {
415
- const markdown = await getArticleMarkdown(slug)
770
+ const markdown = await getArticleMarkdown(slug, config)
416
771
  if (markdown === null) return new Response('Not Found', { status: 404 })
417
- const article = await getArticleMetadata(slug)
418
- return new Response(markdown, {
772
+ const article = await getArticleMetadata(slug, config)
773
+ reportAiCrawl(slug, config, options?.headers)
774
+ const body =
775
+ article && config.markdownTwinHeader !== false
776
+ ? `${buildMarkdownTwinHeader(article, config, markdown)}${markdown.trimStart()}`
777
+ : markdown
778
+ return new Response(body, {
419
779
  headers: {
420
780
  'Content-Type': 'text/markdown; charset=utf-8',
421
781
  'Cache-Control': 'public, max-age=3600, s-maxage=3600',
@@ -449,27 +809,18 @@ export function getArticleAiHeaders(
449
809
  }
450
810
  }
451
811
 
452
- export async function getAiRobotsTxtRules(): Promise<string> {
453
- const articles = await getAllArticles()
812
+ export async function getAiRobotsTxtRules(config?: ArticlesConfig): Promise<string> {
813
+ const articles = await getAllArticles(config)
454
814
  const blockedArticles = articles.filter((article) => article.aiCrawl !== true)
455
815
  if (blockedArticles.length === 0) return ''
456
816
 
457
- const aiCrawlers = [
458
- 'GPTBot',
459
- 'ChatGPT-User',
460
- 'CCBot',
461
- 'ClaudeBot',
462
- 'Claude-User',
463
- 'PerplexityBot',
464
- 'Google-Extended',
465
- ]
466
817
  const disallowRules = blockedArticles
467
818
  .map((article) => `Disallow: /articles/${article.slug}`)
468
819
  .join('\n')
469
820
 
470
- return aiCrawlers
471
- .map((crawler) => [`User-agent: ${crawler}`, disallowRules].join('\n'))
472
- .join('\n\n')
821
+ return AI_CRAWLERS.map((crawler) => [`User-agent: ${crawler}`, disallowRules].join('\n')).join(
822
+ '\n\n'
823
+ )
473
824
  }
474
825
 
475
826
  export async function searchArticles(query: string, config?: ArticlesConfig): Promise<Article[]> {
package/src/server.ts CHANGED
@@ -2,6 +2,13 @@
2
2
  export {
3
3
  getAllArticles,
4
4
  getAiRobotsTxtRules,
5
+ buildMarkdownTwinHeader,
6
+ getMarkdownTwinResponse,
7
+ getCategoryMarkdown,
8
+ getAuthorMarkdown,
9
+ getSeriesMarkdown,
10
+ matchAiCrawler,
11
+ AI_CRAWLERS,
5
12
  getArticleAiHeaders,
6
13
  getArticleMarkdown,
7
14
  getArticleMarkdownResponse,
@@ -28,6 +35,8 @@ export {
28
35
 
29
36
  export {
30
37
  generateRssFeed,
38
+ generateLlmsTxt,
39
+ generateLlmsFullTxt,
31
40
  generateArticleStaticParams,
32
41
  generateCategoryStaticParams,
33
42
  generateSeriesStaticParams,
@@ -59,11 +68,23 @@ export {
59
68
  isPageOutOfRange,
60
69
  } from './pagination'
61
70
 
62
- export { markdownToHtml, extractToc, getContentSlotBoundaries } from './markdown'
71
+ export {
72
+ markdownToHtml,
73
+ extractToc,
74
+ getContentSlotBoundaries,
75
+ deriveFaqFromHeadings,
76
+ } from './markdown'
63
77
  export { setArticlesErrorHandler } from './errorReporting'
64
- export { getBreadcrumbsConfig } from './articlesConfig'
78
+ export {
79
+ formatPageTitle,
80
+ getBreadcrumbsConfig,
81
+ getOrganizationId,
82
+ getPersonId,
83
+ getWebSiteId,
84
+ } from './articlesConfig'
65
85
  export { ArticleContent } from './ArticleContent'
66
86
  export { ArticleTOC } from './ArticleTOC'
87
+ export { ArticleAnswer } from './ArticleAnswer'
67
88
  export { validateArticles, validateAllArticles } from './validateArticles'
68
89
  export { emitArticleEvent } from './events'
69
90
 
@@ -73,14 +94,24 @@ export type {
73
94
  AuthorSocial,
74
95
  BreadcrumbItem,
75
96
  CategoryInfo,
97
+ CitationReference,
98
+ EntityReference,
99
+ FaqItem,
100
+ HowToStep,
76
101
  PathDefinition,
77
102
  TocItem,
78
103
  } from './articleTypes'
79
- export type { ArticlesConfig, LinkTargetStrategy, ListingPagination } from './articlesConfig'
104
+ export type {
105
+ AiCrawlEvent,
106
+ ArticlesConfig,
107
+ LinkTargetStrategy,
108
+ ListingPagination,
109
+ OrganizationConfig,
110
+ } from './articlesConfig'
80
111
  export type { PaginatedArticles, PaginationLinks, ListingPaginationContext } from './pagination'
81
112
  export type { ContentSlotBoundaries } from './markdown'
82
113
  export type { ArticleSlotContext, ArticleSlotContent } from './ArticleContent'
83
- export type { RelatedContentResult, RelatedContentSource } from './server-articles'
114
+ export type { RelatedContentResult, RelatedContentSource, RequestHeaders } from './server-articles'
84
115
  export type { ValidationIssue, ValidationResult, ValidationSeverity } from './validateArticles'
85
116
  export type {
86
117
  ArticleEvent,