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