@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
package/src/seoUtils.ts CHANGED
@@ -8,20 +8,24 @@ import {
8
8
  getArticlesByCategory,
9
9
  getArticlesBySeries,
10
10
  getAuthorBySlug,
11
+ getArticleMarkdown,
11
12
  getArticleMarkdownUrl,
12
13
  getAvailableArticleSlugs,
14
+ buildMarkdownTwinHeader,
13
15
  categoryToSlug,
14
16
  } from './server-articles'
15
17
  import {
16
18
  breadcrumbsAreEnabled,
19
+ formatPageTitle,
17
20
  getBreadcrumbsConfig,
21
+ DEFAULT_PAGE_SIZE,
18
22
  type ArticlesConfig,
19
23
  type ArticleBreadcrumbEntry,
20
24
  type AuthorBreadcrumbEntry,
21
25
  type CategoryBreadcrumbEntry,
22
26
  type CustomBreadcrumbItem,
23
27
  } from './articlesConfig'
24
- import { buildPaginationLinks } from './pagination'
28
+ import { buildPageUrl, buildPaginationLinks, getTotalPages } from './pagination'
25
29
  import type { Article, AuthorProfile, BreadcrumbItem } from './articleTypes'
26
30
 
27
31
  function escapeXml(str: string): string {
@@ -33,9 +37,22 @@ function escapeXml(str: string): string {
33
37
  .replaceAll("'", ''')
34
38
  }
35
39
 
36
- export function generateRssFeed(articles: Article[], config: ArticlesConfig): string {
40
+ /**
41
+ * `options.fullContent` adds `<content:encoded>` with each article's rendered
42
+ * HTML, for feeds meant to be ingested rather than previewed - an
43
+ * excerpt-only feed gives a consumer nothing to work with. Off by default:
44
+ * it requires articles loaded with `htmlContent` (i.e. via
45
+ * `getArticleMetadata`, not `getAllArticles`' summaries), and articles
46
+ * without it are simply emitted without the element.
47
+ */
48
+ export function generateRssFeed(
49
+ articles: Article[],
50
+ config: ArticlesConfig,
51
+ options?: Readonly<{ fullContent?: boolean }>
52
+ ): string {
37
53
  const siteUrl = config.siteUrl.replace(/\/$/, '')
38
54
  const showAuthor = config.showAuthor !== false
55
+ const fullContent = options?.fullContent === true
39
56
 
40
57
  const items = articles
41
58
  .map((article) => {
@@ -50,6 +67,9 @@ export function generateRssFeed(articles: Article[], config: ArticlesConfig): st
50
67
  ` <guid isPermaLink="true">${url}</guid>`,
51
68
  pubDate ? ` <pubDate>${pubDate}</pubDate>` : '',
52
69
  article.excerpt ? ` <description><![CDATA[${article.excerpt}]]></description>` : '',
70
+ fullContent && article.htmlContent
71
+ ? ` <content:encoded><![CDATA[${article.htmlContent}]]></content:encoded>`
72
+ : '',
53
73
  showAuthor && article.author ? ` <author>${escapeXml(article.author)}</author>` : '',
54
74
  article.category ? ` <category><![CDATA[${article.category}]]></category>` : '',
55
75
  imageUrl
@@ -65,18 +85,116 @@ export function generateRssFeed(articles: Article[], config: ArticlesConfig): st
65
85
  const description = config.description ?? `${config.siteName} articles`
66
86
 
67
87
  return `<?xml version="1.0" encoding="UTF-8" ?>
68
- <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/">
88
+ <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
69
89
  <channel>
70
90
  <title><![CDATA[${config.siteName}]]></title>
71
91
  <link>${siteUrl}/articles</link>
72
92
  <description><![CDATA[${description}]]></description>
73
- <language>en</language>
93
+ <language>${config.language ?? 'en'}</language>
74
94
  <atom:link href="${siteUrl}/articles/feed.xml" rel="self" type="application/rss+xml" />
75
95
  ${items}
76
96
  </channel>
77
97
  </rss>`
78
98
  }
79
99
 
100
+ /** Shared `# name` / `> summary` preamble for both llms.txt variants. */
101
+ function buildLlmsHeader(config: ArticlesConfig): string[] {
102
+ const summary = config.description ?? `${config.siteName} articles`
103
+ return [`# ${config.siteName}`, '', `> ${summary}`, '']
104
+ }
105
+
106
+ /**
107
+ * `llms.txt` index - the emerging convention for pointing an LLM at a site's
108
+ * canonical, markdown-native content (https://llmstxt.org). Lists only
109
+ * articles opted in via `aiCrawl` (see `ArticlesConfig.aiCrawlDefault`),
110
+ * grouped by category, linking to each article's `.md` twin rather than its
111
+ * HTML page.
112
+ *
113
+ * Wire it up in the consuming app as `app/llms.txt/route.ts`:
114
+ * export async function GET() {
115
+ * return new Response(generateLlmsTxt(await getAllArticles(siteConfig), siteConfig), {
116
+ * headers: { 'Content-Type': 'text/plain; charset=utf-8' },
117
+ * })
118
+ * }
119
+ */
120
+ export function generateLlmsTxt(articles: Article[], config: ArticlesConfig): string {
121
+ const siteUrl = config.siteUrl.replace(/\/$/, '')
122
+ const crawlable = articles.filter((article) => article.aiCrawl === true)
123
+
124
+ const byCategory = new Map<string, Article[]>()
125
+ for (const article of crawlable) {
126
+ const category = article.category || 'Articles'
127
+ const existing = byCategory.get(category)
128
+ if (existing) existing.push(article)
129
+ else byCategory.set(category, [article])
130
+ }
131
+
132
+ const sections = [...byCategory.entries()].map(([category, categoryArticles]) => {
133
+ const lines = categoryArticles.map((article) => {
134
+ const url = `${siteUrl}/articles/${article.slug}.md`
135
+ const summary = article.excerpt ? `: ${article.excerpt}` : ''
136
+ return `- [${article.title}](${url})${summary}`
137
+ })
138
+ return [`## ${category}`, '', ...lines].join('\n')
139
+ })
140
+
141
+ return [
142
+ ...buildLlmsHeader(config),
143
+ ...(sections.length > 0 ? sections : ['## Articles', '', '_No articles available._']),
144
+ ...buildLlmsListingSection(crawlable, config),
145
+ '',
146
+ ].join('\n')
147
+ }
148
+
149
+ /**
150
+ * Links the category, author, and series twins alongside the article list.
151
+ * Without them a model gets a flat bag of pages; these are the surfaces that
152
+ * answer "what does this site cover, and who writes it".
153
+ */
154
+ function buildLlmsListingSection(articles: Article[], config: ArticlesConfig): string[] {
155
+ const siteUrl = config.siteUrl.replace(/\/$/, '')
156
+ const categories = [...new Set(articles.flatMap((a) => a.categories ?? []))].filter(Boolean)
157
+ const series = [...new Set(articles.map((a) => a.seriesSlug).filter(Boolean))] as string[]
158
+ const authors = Object.values(config.authors ?? {})
159
+
160
+ const lines = [
161
+ ...categories.map(
162
+ (name) => `- [${name}](${siteUrl}/articles/category/${categoryToSlug(name)}.md)`
163
+ ),
164
+ ...(config.showAuthorPage === false
165
+ ? []
166
+ : authors.map((a) => `- [${a.name}](${siteUrl}/articles/authors/${a.slug}.md)`)),
167
+ ...series.map((slug) => `- [${slug}](${siteUrl}/articles/series/${slug}.md)`),
168
+ ]
169
+ if (lines.length === 0) return []
170
+ return ['## Collections', '', ...lines]
171
+ }
172
+
173
+ /**
174
+ * `llms-full.txt` - every opted-in article's full markdown twin, headers
175
+ * included, concatenated into one document. Larger and slower to build than
176
+ * `generateLlmsTxt`; generate it in a route handler or at build time, not on
177
+ * every request.
178
+ */
179
+ export async function generateLlmsFullTxt(
180
+ articles: Article[],
181
+ config: ArticlesConfig
182
+ ): Promise<string> {
183
+ const crawlable = articles.filter((article) => article.aiCrawl === true)
184
+ const documents = await Promise.all(
185
+ crawlable.map(async (article) => {
186
+ const body = await getArticleMarkdown(article.slug, config)
187
+ if (body === null) return null
188
+ return `${buildMarkdownTwinHeader(article, config, body)}${body.trimStart()}`
189
+ })
190
+ )
191
+
192
+ return [
193
+ ...buildLlmsHeader(config),
194
+ ...documents.filter((doc): doc is string => doc !== null),
195
+ ].join('\n')
196
+ }
197
+
80
198
  export function generateArticleStaticParams(): { slug: string }[] {
81
199
  return getAvailableArticleSlugs().map((slug) => ({ slug }))
82
200
  }
@@ -169,7 +287,7 @@ export async function generateArticleMetadata(
169
287
  const authorNames = getArticleAuthors(article, config).map((author) => author.name)
170
288
 
171
289
  return {
172
- title: `${search.title} | ${config.siteName}`,
290
+ title: formatPageTitle(search.title, config),
173
291
  description,
174
292
  keywords: [...(article.tags ?? []).map((tag) => tag.toLowerCase())].join(', '),
175
293
  openGraph: {
@@ -228,7 +346,7 @@ export async function generateArticleMetadata(
228
346
  export function generateArticlesIndexMetadata(config: ArticlesConfig): Metadata {
229
347
  const siteUrl = config.siteUrl.replace(/\/$/, '')
230
348
  const indexUrl = `${siteUrl}/articles`
231
- const title = `Articles | ${config.siteName}`
349
+ const title = formatPageTitle('Articles', config)
232
350
  const description =
233
351
  config.hero?.description ?? `Expert analysis and insights from ${config.siteName}.`
234
352
  return {
@@ -282,7 +400,7 @@ export async function generateCategoryMetadata(
282
400
  const fallback = `Browse ${articles.length} article${articles.length === 1 ? '' : 's'} in the ${categoryName} category.`
283
401
  const description = typeof raw === 'string' ? raw : (raw?.short ?? fallback)
284
402
 
285
- const title = `${categoryName} Articles | ${config.siteName}`
403
+ const title = formatPageTitle(`${categoryName} Articles`, config)
286
404
  return {
287
405
  title,
288
406
  description,
@@ -330,7 +448,7 @@ export async function generateSeriesMetadata(
330
448
  const siteUrl = config.siteUrl.replace(/\/$/, '')
331
449
  const seriesUrl = `${siteUrl}/articles/series/${seriesSlug}`
332
450
  const description = `Follow the ${seriesName} series - ${articles.length} article${articles.length === 1 ? '' : 's'} on ${config.siteName}.`
333
- const title = `${seriesName} Series | ${config.siteName}`
451
+ const title = formatPageTitle(`${seriesName} Series`, config)
334
452
 
335
453
  return {
336
454
  title,
@@ -376,7 +494,7 @@ export async function generateAuthorMetadata(
376
494
 
377
495
  const siteUrl = config.siteUrl.replace(/\/$/, '')
378
496
  const authorUrl = author.url ?? `${siteUrl}/articles/authors/${author.slug}`
379
- const title = `${author.name} Articles | ${config.siteName}`
497
+ const title = formatPageTitle(`${author.name} Articles`, config)
380
498
 
381
499
  return {
382
500
  title,
@@ -646,18 +764,53 @@ export function resolveAuthorAvatar(author: AuthorProfile, config: ArticlesConfi
646
764
  return `${siteUrl}/articles/authors/${author.slug}/${author.avatar.replace(/^\/+/, '')}`
647
765
  }
648
766
 
767
+ /** Newest `lastmod`/`date` across a set of articles, or `undefined` if none carry one. */
768
+ function newestArticleDate(articles: readonly Article[]): Date | undefined {
769
+ let newest: Date | undefined
770
+ for (const article of articles) {
771
+ const stamp = article.lastmod ?? article.date
772
+ if (!stamp) continue
773
+ const parsed = new Date(stamp)
774
+ if (Number.isNaN(parsed.getTime())) continue
775
+ if (!newest || parsed > newest) newest = parsed
776
+ }
777
+ return newest
778
+ }
779
+
780
+ /**
781
+ * Paginated listing routes, emitted only in `listingPagination: 'pages'` mode.
782
+ * Page 1 is the listing URL itself, already emitted by the caller, so this
783
+ * starts at page 2. Without these, the paginated routes exist and carry
784
+ * correct canonical/prev/next metadata but appear in no sitemap.
785
+ */
786
+ function paginationEntries(
787
+ basePath: string,
788
+ itemCount: number,
789
+ pageSize: number,
790
+ lastModified: Date | undefined,
791
+ priority: number
792
+ ): MetadataRoute.Sitemap {
793
+ const totalPages = getTotalPages(itemCount, pageSize)
794
+ const entries: MetadataRoute.Sitemap = []
795
+ for (let page = 2; page <= totalPages; page++) {
796
+ entries.push({
797
+ url: buildPageUrl(basePath, page),
798
+ lastModified,
799
+ changeFrequency: 'weekly' as const,
800
+ priority,
801
+ })
802
+ }
803
+ return entries
804
+ }
805
+
649
806
  export async function getArticleSitemapEntries(
650
807
  baseUrlOrConfig: string | ArticlesConfig
651
808
  ): Promise<MetadataRoute.Sitemap> {
652
- const baseUrl = (
653
- typeof baseUrlOrConfig === 'string' ? baseUrlOrConfig : baseUrlOrConfig.siteUrl
654
- ).replace(/\/$/, '')
809
+ const config = typeof baseUrlOrConfig === 'string' ? undefined : baseUrlOrConfig
810
+ const baseUrl = (config?.siteUrl ?? (baseUrlOrConfig as string)).replace(/\/$/, '')
655
811
 
656
812
  try {
657
- const [articles, categories] = await Promise.all([
658
- getAllArticles(typeof baseUrlOrConfig === 'string' ? undefined : baseUrlOrConfig),
659
- getAllCategories(),
660
- ])
813
+ const [articles, categories] = await Promise.all([getAllArticles(config), getAllCategories()])
661
814
 
662
815
  const articleEntries: MetadataRoute.Sitemap = articles.map((article) => {
663
816
  const dateStr = article.lastmod ?? article.date
@@ -670,23 +823,91 @@ export async function getArticleSitemapEntries(
670
823
  }
671
824
  })
672
825
 
826
+ // Derived from the newest article in each category, not `new Date()` -
827
+ // stamping "now" told every crawl that every category changed today,
828
+ // which is exactly the freshness signal a sitemap exists to carry.
673
829
  const categoryEntries: MetadataRoute.Sitemap = categories.map((cat) => ({
674
830
  url: `${baseUrl}/articles/category/${cat.slug}`,
675
- lastModified: new Date(),
831
+ lastModified: newestArticleDate(
832
+ articles.filter((article) =>
833
+ (article.categories ?? []).some((name) => categoryToSlug(name) === cat.slug)
834
+ )
835
+ ),
676
836
  changeFrequency: 'weekly' as const,
677
837
  priority: 0.7,
678
838
  }))
679
839
 
680
- const authorEntries: MetadataRoute.Sitemap =
681
- typeof baseUrlOrConfig === 'string' || baseUrlOrConfig.showAuthorPage === false
682
- ? []
683
- : getAllAuthors(baseUrlOrConfig).map((author) => ({
684
- url: `${baseUrl}/articles/authors/${author.slug}`,
685
- changeFrequency: 'monthly' as const,
686
- priority: 0.6,
687
- }))
840
+ const authors = config && config.showAuthorPage !== false ? getAllAuthors(config) : []
841
+ const authorEntries: MetadataRoute.Sitemap = authors.map((author) => ({
842
+ url: `${baseUrl}/articles/authors/${author.slug}`,
843
+ lastModified: newestArticleDate(
844
+ articles.filter((article) =>
845
+ getArticleAuthors(article, config!).some((profile) => profile.slug === author.slug)
846
+ )
847
+ ),
848
+ changeFrequency: 'monthly' as const,
849
+ priority: 0.6,
850
+ }))
688
851
 
689
- return [...articleEntries, ...categoryEntries, ...authorEntries]
852
+ // Series routes (`/articles/series/[series]`) are real - they have static
853
+ // params, metadata, and a markdown twin - but were absent from the sitemap.
854
+ const seriesSlugs = [...new Set(articles.map((a) => a.seriesSlug).filter(Boolean))] as string[]
855
+ const seriesEntries: MetadataRoute.Sitemap = seriesSlugs.map((seriesSlug) => ({
856
+ url: `${baseUrl}/articles/series/${seriesSlug}`,
857
+ lastModified: newestArticleDate(articles.filter((a) => a.seriesSlug === seriesSlug)),
858
+ changeFrequency: 'weekly' as const,
859
+ priority: 0.6,
860
+ }))
861
+
862
+ const pageEntries: MetadataRoute.Sitemap = []
863
+ if (config?.listingPagination === 'pages') {
864
+ const pageSize = config.pageSize ?? DEFAULT_PAGE_SIZE
865
+ pageEntries.push(
866
+ ...paginationEntries(
867
+ `${baseUrl}/articles`,
868
+ articles.length,
869
+ pageSize,
870
+ newestArticleDate(articles),
871
+ 0.5
872
+ )
873
+ )
874
+ for (const cat of categories) {
875
+ const inCategory = articles.filter((article) =>
876
+ (article.categories ?? []).some((name) => categoryToSlug(name) === cat.slug)
877
+ )
878
+ pageEntries.push(
879
+ ...paginationEntries(
880
+ `${baseUrl}/articles/category/${cat.slug}`,
881
+ inCategory.length,
882
+ pageSize,
883
+ newestArticleDate(inCategory),
884
+ 0.4
885
+ )
886
+ )
887
+ }
888
+ for (const author of authors) {
889
+ const byAuthor = articles.filter((article) =>
890
+ getArticleAuthors(article, config).some((profile) => profile.slug === author.slug)
891
+ )
892
+ pageEntries.push(
893
+ ...paginationEntries(
894
+ `${baseUrl}/articles/authors/${author.slug}`,
895
+ byAuthor.length,
896
+ pageSize,
897
+ newestArticleDate(byAuthor),
898
+ 0.4
899
+ )
900
+ )
901
+ }
902
+ }
903
+
904
+ return [
905
+ ...articleEntries,
906
+ ...categoryEntries,
907
+ ...authorEntries,
908
+ ...seriesEntries,
909
+ ...pageEntries,
910
+ ]
690
911
  } catch {
691
912
  return []
692
913
  }