@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.
- 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 +324 -13
- 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 +324 -13
- package/dist/nextjs.js.map +1 -1
- package/dist/server.cjs +677 -51
- 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 +662 -51
- 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__/linkClassification.test.ts +55 -0
- package/src/__tests__/markdown.test.ts +77 -1
- package/src/__tests__/nextjs.test.ts +31 -15
- package/src/__tests__/renderMdx.test.tsx +162 -3
- package/src/__tests__/seoUtils.test.ts +279 -0
- package/src/__tests__/server-articles.test.ts +413 -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/linkClassification.ts +30 -0
- package/src/markdown.ts +103 -25
- package/src/nextjs.ts +7 -4
- package/src/renderMdx.tsx +43 -6
- package/src/seoUtils.ts +247 -26
- package/src/server-articles.ts +375 -24
- package/src/server.ts +35 -4
- package/src/validateArticles.ts +157 -12
package/src/server-articles.ts
CHANGED
|
@@ -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:
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
471
|
-
|
|
472
|
-
|
|
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 {
|
|
71
|
+
export {
|
|
72
|
+
markdownToHtml,
|
|
73
|
+
extractToc,
|
|
74
|
+
getContentSlotBoundaries,
|
|
75
|
+
deriveFaqFromHeadings,
|
|
76
|
+
} from './markdown'
|
|
63
77
|
export { setArticlesErrorHandler } from './errorReporting'
|
|
64
|
-
export {
|
|
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 {
|
|
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,
|