@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
|
@@ -64,7 +64,13 @@ jest.mock('unist-util-visit', () => ({
|
|
|
64
64
|
},
|
|
65
65
|
}))
|
|
66
66
|
|
|
67
|
-
import {
|
|
67
|
+
import {
|
|
68
|
+
customRenderer,
|
|
69
|
+
deriveFaqFromHeadings,
|
|
70
|
+
markdownToHtml,
|
|
71
|
+
extractToc,
|
|
72
|
+
getContentSlotBoundaries,
|
|
73
|
+
} from '../markdown'
|
|
68
74
|
|
|
69
75
|
// ---------------------------------------------------------------------------
|
|
70
76
|
// customRenderer
|
|
@@ -429,3 +435,73 @@ describe('getContentSlotBoundaries', () => {
|
|
|
429
435
|
expect(boundaries?.paragraphCount).toBe(1)
|
|
430
436
|
})
|
|
431
437
|
})
|
|
438
|
+
|
|
439
|
+
describe('deriveFaqFromHeadings', () => {
|
|
440
|
+
it('pairs a question-shaped h2 with the paragraph that follows it', () => {
|
|
441
|
+
const markdown = [
|
|
442
|
+
'## What is initiative?',
|
|
443
|
+
'',
|
|
444
|
+
'It is the turn order for combat.',
|
|
445
|
+
'Everyone rolls once.',
|
|
446
|
+
'',
|
|
447
|
+
'## How do I roll it?',
|
|
448
|
+
'',
|
|
449
|
+
'Roll a d20 and add your modifier.',
|
|
450
|
+
].join('\n')
|
|
451
|
+
|
|
452
|
+
expect(deriveFaqFromHeadings(markdown)).toEqual([
|
|
453
|
+
{
|
|
454
|
+
question: 'What is initiative?',
|
|
455
|
+
answer: 'It is the turn order for combat. Everyone rolls once.',
|
|
456
|
+
},
|
|
457
|
+
{ question: 'How do I roll it?', answer: 'Roll a d20 and add your modifier.' },
|
|
458
|
+
])
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
it('ignores headings that are not questions, rhetorical questions, and other heading levels', () => {
|
|
462
|
+
const markdown = [
|
|
463
|
+
'## Setting the scene',
|
|
464
|
+
'',
|
|
465
|
+
'Some prose.',
|
|
466
|
+
'',
|
|
467
|
+
'## Sound familiar?',
|
|
468
|
+
'',
|
|
469
|
+
'More prose.',
|
|
470
|
+
'',
|
|
471
|
+
'### How do I nest?',
|
|
472
|
+
'',
|
|
473
|
+
'Nested prose.',
|
|
474
|
+
].join('\n')
|
|
475
|
+
|
|
476
|
+
expect(deriveFaqFromHeadings(markdown)).toEqual([])
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
it('skips a question heading with no prose under it', () => {
|
|
480
|
+
const markdown = ['## What is this?', '', '## Why does it matter?', '', 'Because.'].join('\n')
|
|
481
|
+
expect(deriveFaqFromHeadings(markdown)).toEqual([
|
|
482
|
+
{ question: 'Why does it matter?', answer: 'Because.' },
|
|
483
|
+
])
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
it('ignores question headings inside fenced code blocks', () => {
|
|
487
|
+
const markdown = ['```md', '## What is this?', '', 'Not real prose.', '```'].join('\n')
|
|
488
|
+
expect(deriveFaqFromHeadings(markdown)).toEqual([])
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
it('requires whitespace after ## and rejects deeper heading levels', () => {
|
|
492
|
+
expect(deriveFaqFromHeadings(['##What is this?', '', 'Prose.'].join('\n'))).toEqual([])
|
|
493
|
+
expect(deriveFaqFromHeadings(['### What is this?', '', 'Prose.'].join('\n'))).toEqual([])
|
|
494
|
+
expect(deriveFaqFromHeadings(['##\tWhat is this?', '', 'Prose.'].join('\n'))).toEqual([
|
|
495
|
+
{ question: 'What is this?', answer: 'Prose.' },
|
|
496
|
+
])
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
it('stops collecting at the next heading and strips closing hashes', () => {
|
|
500
|
+
const markdown = ['## Can I do this? ##', '', 'Yes.', '# New section', '', 'Ignored.'].join(
|
|
501
|
+
'\n'
|
|
502
|
+
)
|
|
503
|
+
expect(deriveFaqFromHeadings(markdown)).toEqual([
|
|
504
|
+
{ question: 'Can I do this?', answer: 'Yes.' },
|
|
505
|
+
])
|
|
506
|
+
})
|
|
507
|
+
})
|
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
* Mocks server-articles to isolate the handler logic.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
const
|
|
6
|
+
const mockGetMarkdownTwinResponse = jest.fn()
|
|
7
7
|
|
|
8
8
|
jest.mock('../server-articles', () => ({
|
|
9
|
-
|
|
9
|
+
getMarkdownTwinResponse: (...args: unknown[]) => mockGetMarkdownTwinResponse(...args),
|
|
10
10
|
}))
|
|
11
11
|
|
|
12
12
|
import { createArticleMarkdownHandler } from '../nextjs'
|
|
@@ -47,14 +47,16 @@ function makeContext(slugValue: string | string[]): {
|
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
const requestHeaders = { get: () => null }
|
|
51
|
+
|
|
50
52
|
function makeRequest(): NextRequest {
|
|
51
|
-
return {} as NextRequest
|
|
53
|
+
return { headers: requestHeaders } as unknown as NextRequest
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
describe('createArticleMarkdownHandler', () => {
|
|
55
57
|
beforeEach(() => {
|
|
56
58
|
jest.clearAllMocks()
|
|
57
|
-
|
|
59
|
+
mockGetMarkdownTwinResponse.mockResolvedValue(new Response('# Article', { status: 200 }))
|
|
58
60
|
})
|
|
59
61
|
|
|
60
62
|
it('returns an object with a GET handler', () => {
|
|
@@ -62,12 +64,14 @@ describe('createArticleMarkdownHandler', () => {
|
|
|
62
64
|
expect(typeof GET).toBe('function')
|
|
63
65
|
})
|
|
64
66
|
|
|
65
|
-
it('calls
|
|
67
|
+
it('calls getMarkdownTwinResponse with joined slug array and config', async () => {
|
|
66
68
|
const { GET } = createArticleMarkdownHandler(siteConfig)
|
|
67
69
|
const ctx = makeContext(['category', 'my-article.md'])
|
|
68
70
|
await GET(makeRequest(), ctx)
|
|
69
71
|
|
|
70
|
-
expect(
|
|
72
|
+
expect(mockGetMarkdownTwinResponse).toHaveBeenCalledWith('category/my-article', siteConfig, {
|
|
73
|
+
headers: requestHeaders,
|
|
74
|
+
})
|
|
71
75
|
})
|
|
72
76
|
|
|
73
77
|
it('strips trailing .md from a slug array', async () => {
|
|
@@ -75,7 +79,9 @@ describe('createArticleMarkdownHandler', () => {
|
|
|
75
79
|
const ctx = makeContext(['some-article.md'])
|
|
76
80
|
await GET(makeRequest(), ctx)
|
|
77
81
|
|
|
78
|
-
expect(
|
|
82
|
+
expect(mockGetMarkdownTwinResponse).toHaveBeenCalledWith('some-article', siteConfig, {
|
|
83
|
+
headers: requestHeaders,
|
|
84
|
+
})
|
|
79
85
|
})
|
|
80
86
|
|
|
81
87
|
it('handles a plain string slug without .md extension', async () => {
|
|
@@ -83,7 +89,9 @@ describe('createArticleMarkdownHandler', () => {
|
|
|
83
89
|
const ctx = makeContext('plain-slug')
|
|
84
90
|
await GET(makeRequest(), ctx)
|
|
85
91
|
|
|
86
|
-
expect(
|
|
92
|
+
expect(mockGetMarkdownTwinResponse).toHaveBeenCalledWith('plain-slug', siteConfig, {
|
|
93
|
+
headers: requestHeaders,
|
|
94
|
+
})
|
|
87
95
|
})
|
|
88
96
|
|
|
89
97
|
it('handles a plain string slug with .md extension - strips it', async () => {
|
|
@@ -91,7 +99,9 @@ describe('createArticleMarkdownHandler', () => {
|
|
|
91
99
|
const ctx = makeContext('plain-slug.md')
|
|
92
100
|
await GET(makeRequest(), ctx)
|
|
93
101
|
|
|
94
|
-
expect(
|
|
102
|
+
expect(mockGetMarkdownTwinResponse).toHaveBeenCalledWith('plain-slug', siteConfig, {
|
|
103
|
+
headers: requestHeaders,
|
|
104
|
+
})
|
|
95
105
|
})
|
|
96
106
|
|
|
97
107
|
it('handles undefined slug (missing param) as empty string', async () => {
|
|
@@ -101,12 +111,14 @@ describe('createArticleMarkdownHandler', () => {
|
|
|
101
111
|
}
|
|
102
112
|
await GET(makeRequest(), ctx)
|
|
103
113
|
|
|
104
|
-
expect(
|
|
114
|
+
expect(mockGetMarkdownTwinResponse).toHaveBeenCalledWith('', siteConfig, {
|
|
115
|
+
headers: requestHeaders,
|
|
116
|
+
})
|
|
105
117
|
})
|
|
106
118
|
|
|
107
|
-
it('returns the response from
|
|
119
|
+
it('returns the response from getMarkdownTwinResponse', async () => {
|
|
108
120
|
const mockResponse = new Response('markdown content', { status: 200 })
|
|
109
|
-
|
|
121
|
+
mockGetMarkdownTwinResponse.mockResolvedValue(mockResponse)
|
|
110
122
|
|
|
111
123
|
const { GET } = createArticleMarkdownHandler(siteConfig)
|
|
112
124
|
const ctx = makeContext(['article.md'])
|
|
@@ -115,7 +127,7 @@ describe('createArticleMarkdownHandler', () => {
|
|
|
115
127
|
expect(result).toBe(mockResponse)
|
|
116
128
|
})
|
|
117
129
|
|
|
118
|
-
it('passes the config object through to
|
|
130
|
+
it('passes the config object through to getMarkdownTwinResponse', async () => {
|
|
119
131
|
const customConfig: ArticlesConfig = {
|
|
120
132
|
siteUrl: 'https://custom.example.com',
|
|
121
133
|
siteName: 'Custom Site',
|
|
@@ -124,7 +136,9 @@ describe('createArticleMarkdownHandler', () => {
|
|
|
124
136
|
const ctx = makeContext(['test.md'])
|
|
125
137
|
await GET(makeRequest(), ctx)
|
|
126
138
|
|
|
127
|
-
expect(
|
|
139
|
+
expect(mockGetMarkdownTwinResponse).toHaveBeenCalledWith('test', customConfig, {
|
|
140
|
+
headers: requestHeaders,
|
|
141
|
+
})
|
|
128
142
|
})
|
|
129
143
|
|
|
130
144
|
it('handles deeply nested slug array', async () => {
|
|
@@ -132,6 +146,8 @@ describe('createArticleMarkdownHandler', () => {
|
|
|
132
146
|
const ctx = makeContext(['a', 'b', 'c', 'deep-article.md'])
|
|
133
147
|
await GET(makeRequest(), ctx)
|
|
134
148
|
|
|
135
|
-
expect(
|
|
149
|
+
expect(mockGetMarkdownTwinResponse).toHaveBeenCalledWith('a/b/c/deep-article', siteConfig, {
|
|
150
|
+
headers: requestHeaders,
|
|
151
|
+
})
|
|
136
152
|
})
|
|
137
153
|
})
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ArticlesConfig } from '../articlesConfig'
|
|
2
|
+
import type { Article } from '../articleTypes'
|
|
2
3
|
import {
|
|
3
4
|
generateArticleStaticParams,
|
|
4
5
|
generateArticlesIndexMetadata,
|
|
@@ -11,6 +12,8 @@ import {
|
|
|
11
12
|
generateSeriesMetadata,
|
|
12
13
|
getArticleSitemapEntries,
|
|
13
14
|
generateRssFeed,
|
|
15
|
+
generateLlmsTxt,
|
|
16
|
+
generateLlmsFullTxt,
|
|
14
17
|
resolveSearchMetadata,
|
|
15
18
|
resolveSocialMetadata,
|
|
16
19
|
} from '../seoUtils'
|
|
@@ -102,6 +105,15 @@ jest.mock('../server-articles', () => ({
|
|
|
102
105
|
return `${cfg.siteUrl.replace(/\/$/, '')}/articles/${article.slug}.md`
|
|
103
106
|
}
|
|
104
107
|
),
|
|
108
|
+
categoryToSlug: (category: string) =>
|
|
109
|
+
category
|
|
110
|
+
.toLowerCase()
|
|
111
|
+
.replaceAll(/\s+/g, '-')
|
|
112
|
+
.replaceAll(/[^a-z0-9-]/g, ''),
|
|
113
|
+
getArticleMarkdown: jest.fn(async (slug: string) =>
|
|
114
|
+
slug === 'missing-body' ? null : `Body of ${slug}.`
|
|
115
|
+
),
|
|
116
|
+
buildMarkdownTwinHeader: jest.fn((article: { title: string }) => `HEADER(${article.title})\n\n`),
|
|
105
117
|
getArticlesBySeries: jest.fn(async (seriesSlug: string) => {
|
|
106
118
|
if (seriesSlug === 'new-gm-path') {
|
|
107
119
|
return [
|
|
@@ -563,6 +575,71 @@ describe('getArticleSitemapEntries', () => {
|
|
|
563
575
|
expect(categoryEntry.changeFrequency).toBe('weekly')
|
|
564
576
|
})
|
|
565
577
|
|
|
578
|
+
it('derives category lastModified from the newest article in it, not now', async () => {
|
|
579
|
+
const entries = await getArticleSitemapEntries('https://example.com')
|
|
580
|
+
const campaigns = entries.find((e) => e.url.endsWith('/articles/category/campaigns'))
|
|
581
|
+
// getAllArticles mock returns article-one (2025-01-15) and article-two
|
|
582
|
+
// (2025-01-16), both uncategorized in the mock, so no date is derivable.
|
|
583
|
+
expect(campaigns).toBeDefined()
|
|
584
|
+
expect(campaigns!.lastModified).toBeUndefined()
|
|
585
|
+
})
|
|
586
|
+
|
|
587
|
+
it('emits series entries for every distinct seriesSlug', async () => {
|
|
588
|
+
const { getAllArticles } = jest.requireMock('../server-articles')
|
|
589
|
+
getAllArticles.mockResolvedValueOnce([
|
|
590
|
+
{
|
|
591
|
+
slug: 'one',
|
|
592
|
+
title: 'One',
|
|
593
|
+
excerpt: '',
|
|
594
|
+
author: '',
|
|
595
|
+
category: 'Campaigns',
|
|
596
|
+
categories: ['Campaigns'],
|
|
597
|
+
readTime: '',
|
|
598
|
+
featuredImage: '',
|
|
599
|
+
date: '2025-01-15',
|
|
600
|
+
seriesSlug: 'new-gm',
|
|
601
|
+
},
|
|
602
|
+
{
|
|
603
|
+
slug: 'two',
|
|
604
|
+
title: 'Two',
|
|
605
|
+
excerpt: '',
|
|
606
|
+
author: '',
|
|
607
|
+
category: 'Campaigns',
|
|
608
|
+
categories: ['Campaigns'],
|
|
609
|
+
readTime: '',
|
|
610
|
+
featuredImage: '',
|
|
611
|
+
date: '2025-03-01',
|
|
612
|
+
seriesSlug: 'new-gm',
|
|
613
|
+
},
|
|
614
|
+
])
|
|
615
|
+
|
|
616
|
+
const entries = await getArticleSitemapEntries('https://example.com')
|
|
617
|
+
const series = entries.filter((e) => e.url.includes('/articles/series/'))
|
|
618
|
+
expect(series).toHaveLength(1)
|
|
619
|
+
expect(series[0].url).toBe('https://example.com/articles/series/new-gm')
|
|
620
|
+
expect(series[0].lastModified).toEqual(new Date('2025-03-01'))
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
it('omits paginated entries unless listingPagination is pages', async () => {
|
|
624
|
+
const entries = await getArticleSitemapEntries({
|
|
625
|
+
siteUrl: 'https://example.com',
|
|
626
|
+
siteName: 'Example',
|
|
627
|
+
})
|
|
628
|
+
expect(entries.some((e) => e.url.includes('/page/'))).toBe(false)
|
|
629
|
+
})
|
|
630
|
+
|
|
631
|
+
it('emits paginated listing entries starting at page 2 in pages mode', async () => {
|
|
632
|
+
const entries = await getArticleSitemapEntries({
|
|
633
|
+
siteUrl: 'https://example.com',
|
|
634
|
+
siteName: 'Example',
|
|
635
|
+
listingPagination: 'pages',
|
|
636
|
+
pageSize: 1,
|
|
637
|
+
})
|
|
638
|
+
const indexPages = entries.filter((e) => e.url.startsWith('https://example.com/articles/page/'))
|
|
639
|
+
// Two articles at pageSize 1 -> pages 1 and 2; page 1 is the listing URL.
|
|
640
|
+
expect(indexPages.map((e) => e.url)).toEqual(['https://example.com/articles/page/2'])
|
|
641
|
+
})
|
|
642
|
+
|
|
566
643
|
it('returns empty array when fetching throws', async () => {
|
|
567
644
|
const { getAllArticles } = jest.requireMock('../server-articles')
|
|
568
645
|
getAllArticles.mockRejectedValueOnce(new Error('filesystem error'))
|
|
@@ -931,3 +1008,205 @@ describe('generateSeriesMetadata', () => {
|
|
|
931
1008
|
expect(meta.alternates?.canonical).toBe('https://example.com/articles/series/new-gm-path')
|
|
932
1009
|
})
|
|
933
1010
|
})
|
|
1011
|
+
|
|
1012
|
+
describe('generateRssFeed full content', () => {
|
|
1013
|
+
const baseConfig: ArticlesConfig = {
|
|
1014
|
+
siteUrl: 'https://example.com',
|
|
1015
|
+
siteName: 'Example Site',
|
|
1016
|
+
description: 'Test description',
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
const article = {
|
|
1020
|
+
slug: 'my-article',
|
|
1021
|
+
title: 'My Article',
|
|
1022
|
+
excerpt: 'A short excerpt',
|
|
1023
|
+
date: '2024-01-15',
|
|
1024
|
+
author: 'Jane Doe',
|
|
1025
|
+
category: 'Campaigns',
|
|
1026
|
+
categories: ['Campaigns'],
|
|
1027
|
+
readTime: '3 min read',
|
|
1028
|
+
featuredImage: '',
|
|
1029
|
+
htmlContent: '<p>Full body.</p>',
|
|
1030
|
+
} as Article
|
|
1031
|
+
|
|
1032
|
+
it('declares the content namespace', () => {
|
|
1033
|
+
expect(generateRssFeed([], baseConfig)).toContain(
|
|
1034
|
+
'xmlns:content="http://purl.org/rss/1.0/modules/content/"'
|
|
1035
|
+
)
|
|
1036
|
+
})
|
|
1037
|
+
|
|
1038
|
+
it('omits content:encoded by default', () => {
|
|
1039
|
+
expect(generateRssFeed([article], baseConfig)).not.toContain('<content:encoded>')
|
|
1040
|
+
})
|
|
1041
|
+
|
|
1042
|
+
it('emits content:encoded when fullContent is set', () => {
|
|
1043
|
+
expect(generateRssFeed([article], baseConfig, { fullContent: true })).toContain(
|
|
1044
|
+
'<content:encoded><![CDATA[<p>Full body.</p>]]></content:encoded>'
|
|
1045
|
+
)
|
|
1046
|
+
})
|
|
1047
|
+
|
|
1048
|
+
it('skips content:encoded for articles with no htmlContent', () => {
|
|
1049
|
+
const xml = generateRssFeed([{ ...article, htmlContent: undefined }], baseConfig, {
|
|
1050
|
+
fullContent: true,
|
|
1051
|
+
})
|
|
1052
|
+
expect(xml).not.toContain('<content:encoded>')
|
|
1053
|
+
})
|
|
1054
|
+
|
|
1055
|
+
it('uses the configured language for the channel', () => {
|
|
1056
|
+
expect(generateRssFeed([], baseConfig)).toContain('<language>en</language>')
|
|
1057
|
+
expect(generateRssFeed([], { ...baseConfig, language: 'es' })).toContain(
|
|
1058
|
+
'<language>es</language>'
|
|
1059
|
+
)
|
|
1060
|
+
})
|
|
1061
|
+
})
|
|
1062
|
+
|
|
1063
|
+
describe('generateLlmsTxt', () => {
|
|
1064
|
+
const baseConfig: ArticlesConfig = {
|
|
1065
|
+
siteUrl: 'https://example.com/',
|
|
1066
|
+
siteName: 'Example Site',
|
|
1067
|
+
description: 'Test description',
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
const article = (overrides: Partial<Article> & Pick<Article, 'slug' | 'title'>): Article =>
|
|
1071
|
+
({
|
|
1072
|
+
excerpt: `Excerpt for ${overrides.slug}.`,
|
|
1073
|
+
date: '2025-01-15',
|
|
1074
|
+
author: 'Jane Doe',
|
|
1075
|
+
category: 'Campaigns',
|
|
1076
|
+
categories: ['Campaigns'],
|
|
1077
|
+
readTime: '3 min read',
|
|
1078
|
+
featuredImage: '',
|
|
1079
|
+
aiCrawl: true,
|
|
1080
|
+
...overrides,
|
|
1081
|
+
}) as Article
|
|
1082
|
+
|
|
1083
|
+
it('lists only aiCrawl articles, grouped by category, linking to the .md twin', () => {
|
|
1084
|
+
const output = generateLlmsTxt(
|
|
1085
|
+
[
|
|
1086
|
+
article({ slug: 'one', title: 'One' }),
|
|
1087
|
+
article({ slug: 'two', title: 'Two', category: 'Volunteers', categories: ['Volunteers'] }),
|
|
1088
|
+
article({ slug: 'blocked', title: 'Blocked', aiCrawl: false }),
|
|
1089
|
+
],
|
|
1090
|
+
baseConfig
|
|
1091
|
+
)
|
|
1092
|
+
|
|
1093
|
+
expect(output).toBe(
|
|
1094
|
+
[
|
|
1095
|
+
'# Example Site',
|
|
1096
|
+
'',
|
|
1097
|
+
'> Test description',
|
|
1098
|
+
'',
|
|
1099
|
+
'## Campaigns',
|
|
1100
|
+
'',
|
|
1101
|
+
'- [One](https://example.com/articles/one.md): Excerpt for one.',
|
|
1102
|
+
'## Volunteers',
|
|
1103
|
+
'',
|
|
1104
|
+
'- [Two](https://example.com/articles/two.md): Excerpt for two.',
|
|
1105
|
+
'## Collections',
|
|
1106
|
+
'',
|
|
1107
|
+
'- [Campaigns](https://example.com/articles/category/campaigns.md)',
|
|
1108
|
+
'- [Volunteers](https://example.com/articles/category/volunteers.md)',
|
|
1109
|
+
'',
|
|
1110
|
+
].join('\n')
|
|
1111
|
+
)
|
|
1112
|
+
expect(output).not.toContain('Blocked')
|
|
1113
|
+
})
|
|
1114
|
+
|
|
1115
|
+
it('falls back to siteName when description is unset and handles an empty corpus', () => {
|
|
1116
|
+
const output = generateLlmsTxt([], { siteUrl: 'https://example.com', siteName: 'Example Site' })
|
|
1117
|
+
expect(output).toContain('> Example Site articles')
|
|
1118
|
+
expect(output).toContain('_No articles available._')
|
|
1119
|
+
})
|
|
1120
|
+
|
|
1121
|
+
it('groups uncategorized articles under Articles and omits an empty excerpt', () => {
|
|
1122
|
+
const output = generateLlmsTxt(
|
|
1123
|
+
[article({ slug: 'bare', title: 'Bare', category: '', excerpt: '' })],
|
|
1124
|
+
baseConfig
|
|
1125
|
+
)
|
|
1126
|
+
expect(output).toContain('## Articles')
|
|
1127
|
+
expect(output).toContain('- [Bare](https://example.com/articles/bare.md)\n')
|
|
1128
|
+
expect(output).toContain('## Collections')
|
|
1129
|
+
expect(output).toContain('- [Campaigns](https://example.com/articles/category/campaigns.md)')
|
|
1130
|
+
})
|
|
1131
|
+
})
|
|
1132
|
+
|
|
1133
|
+
describe('generateLlmsFullTxt', () => {
|
|
1134
|
+
const baseConfig: ArticlesConfig = {
|
|
1135
|
+
siteUrl: 'https://example.com',
|
|
1136
|
+
siteName: 'Example Site',
|
|
1137
|
+
description: 'Test description',
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
const article = (slug: string, aiCrawl = true): Article =>
|
|
1141
|
+
({
|
|
1142
|
+
slug,
|
|
1143
|
+
title: slug.toUpperCase(),
|
|
1144
|
+
excerpt: '',
|
|
1145
|
+
author: '',
|
|
1146
|
+
category: 'Campaigns',
|
|
1147
|
+
categories: ['Campaigns'],
|
|
1148
|
+
readTime: '',
|
|
1149
|
+
featuredImage: '',
|
|
1150
|
+
aiCrawl,
|
|
1151
|
+
}) as Article
|
|
1152
|
+
|
|
1153
|
+
it('concatenates header-prefixed bodies for opted-in articles only', async () => {
|
|
1154
|
+
const output = await generateLlmsFullTxt(
|
|
1155
|
+
[article('one'), article('blocked', false)],
|
|
1156
|
+
baseConfig
|
|
1157
|
+
)
|
|
1158
|
+
|
|
1159
|
+
expect(output).toBe(
|
|
1160
|
+
['# Example Site', '', '> Test description', '', 'HEADER(ONE)\n\nBody of one.'].join('\n')
|
|
1161
|
+
)
|
|
1162
|
+
})
|
|
1163
|
+
|
|
1164
|
+
it('skips articles whose markdown cannot be read', async () => {
|
|
1165
|
+
const output = await generateLlmsFullTxt([article('missing-body')], baseConfig)
|
|
1166
|
+
expect(output).toBe(['# Example Site', '', '> Test description', ''].join('\n'))
|
|
1167
|
+
})
|
|
1168
|
+
|
|
1169
|
+
it('falls back to siteName when description is unset', async () => {
|
|
1170
|
+
const output = await generateLlmsFullTxt([], {
|
|
1171
|
+
siteUrl: 'https://example.com',
|
|
1172
|
+
siteName: 'Example Site',
|
|
1173
|
+
})
|
|
1174
|
+
expect(output).toContain('> Example Site articles')
|
|
1175
|
+
})
|
|
1176
|
+
})
|
|
1177
|
+
|
|
1178
|
+
describe('titleTemplate', () => {
|
|
1179
|
+
const base: ArticlesConfig = { siteUrl: 'https://example.com', siteName: 'Example Site' }
|
|
1180
|
+
|
|
1181
|
+
it('appends the site name by default', async () => {
|
|
1182
|
+
const meta = await generateArticleMetadata('article-one', base)
|
|
1183
|
+
expect(meta.title).toBe('Article One | Example Site')
|
|
1184
|
+
})
|
|
1185
|
+
|
|
1186
|
+
it('drops the suffix with {title}', async () => {
|
|
1187
|
+
const meta = await generateArticleMetadata('article-one', { ...base, titleTemplate: '{title}' })
|
|
1188
|
+
expect(meta.title).toBe('Article One')
|
|
1189
|
+
})
|
|
1190
|
+
|
|
1191
|
+
it('supports a custom arrangement of both placeholders', async () => {
|
|
1192
|
+
const meta = await generateArticleMetadata('article-one', {
|
|
1193
|
+
...base,
|
|
1194
|
+
titleTemplate: '{siteName}: {title}',
|
|
1195
|
+
})
|
|
1196
|
+
expect(meta.title).toBe('Example Site: Article One')
|
|
1197
|
+
})
|
|
1198
|
+
|
|
1199
|
+
it('applies to index, category, series, and author titles too', async () => {
|
|
1200
|
+
const config = { ...base, titleTemplate: '{title}' }
|
|
1201
|
+
expect(generateArticlesIndexMetadata(config).title).toBe('Articles')
|
|
1202
|
+
expect((await generateCategoryMetadata('campaigns', config)).title).toBe('Campaigns Articles')
|
|
1203
|
+
expect((await generateSeriesMetadata('new-gm-path', config)).title).toBe('New GM Path Series')
|
|
1204
|
+
})
|
|
1205
|
+
|
|
1206
|
+
it('leaves those titles suffixed under the default template', async () => {
|
|
1207
|
+
expect(generateArticlesIndexMetadata(base).title).toBe('Articles | Example Site')
|
|
1208
|
+
expect((await generateCategoryMetadata('campaigns', base)).title).toBe(
|
|
1209
|
+
'Campaigns Articles | Example Site'
|
|
1210
|
+
)
|
|
1211
|
+
})
|
|
1212
|
+
})
|