@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
|
@@ -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
|
+
})
|