@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
@@ -64,7 +64,13 @@ jest.mock('unist-util-visit', () => ({
64
64
  },
65
65
  }))
66
66
 
67
- import { customRenderer, markdownToHtml, extractToc, getContentSlotBoundaries } from '../markdown'
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 mockGetArticleMarkdownResponse = jest.fn()
6
+ const mockGetMarkdownTwinResponse = jest.fn()
7
7
 
8
8
  jest.mock('../server-articles', () => ({
9
- getArticleMarkdownResponse: (...args: unknown[]) => mockGetArticleMarkdownResponse(...args),
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
- mockGetArticleMarkdownResponse.mockResolvedValue(new Response('# Article', { status: 200 }))
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 getArticleMarkdownResponse with joined slug array and config', async () => {
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(mockGetArticleMarkdownResponse).toHaveBeenCalledWith('category/my-article', siteConfig)
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(mockGetArticleMarkdownResponse).toHaveBeenCalledWith('some-article', siteConfig)
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(mockGetArticleMarkdownResponse).toHaveBeenCalledWith('plain-slug', siteConfig)
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(mockGetArticleMarkdownResponse).toHaveBeenCalledWith('plain-slug', siteConfig)
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(mockGetArticleMarkdownResponse).toHaveBeenCalledWith('', siteConfig)
114
+ expect(mockGetMarkdownTwinResponse).toHaveBeenCalledWith('', siteConfig, {
115
+ headers: requestHeaders,
116
+ })
105
117
  })
106
118
 
107
- it('returns the response from getArticleMarkdownResponse', async () => {
119
+ it('returns the response from getMarkdownTwinResponse', async () => {
108
120
  const mockResponse = new Response('markdown content', { status: 200 })
109
- mockGetArticleMarkdownResponse.mockResolvedValue(mockResponse)
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 getArticleMarkdownResponse', async () => {
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(mockGetArticleMarkdownResponse).toHaveBeenCalledWith('test', customConfig)
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(mockGetArticleMarkdownResponse).toHaveBeenCalledWith('a/b/c/deep-article', siteConfig)
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
+ })