@fullstackdatasolutions/articles 0.8.2 → 0.9.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.
@@ -9,11 +9,16 @@ jest.mock('remark-github-blockquote-alert', () => () => () => {})
9
9
  jest.mock('remark-parse', () => () => () => {})
10
10
  jest.mock('remark-rehype', () => () => () => {})
11
11
 
12
+ const mockRemarkUseCalls: unknown[][] = []
13
+
12
14
  // Mock remark() to return a chainable processor whose .process() resolves to ''
13
15
  jest.mock('remark', () => {
14
16
  const makeChain = (): Record<string, unknown> => {
15
17
  const chain: Record<string, unknown> = {
16
- use: () => chain,
18
+ use: (...args: unknown[]) => {
19
+ mockRemarkUseCalls.push(args)
20
+ return chain
21
+ },
17
22
  process: async () => ({ toString: () => '' }),
18
23
  }
19
24
  return chain
@@ -80,8 +85,15 @@ function root(...nodes: HastElement[]): HastRoot {
80
85
  return { type: 'root', children: nodes }
81
86
  }
82
87
 
83
- function runRenderer(tree: HastRoot) {
84
- const transform = (customRenderer as unknown as () => (tree: HastRoot) => void)()
88
+ type LinkTargetOptions = Readonly<{
89
+ strategy?: 'external-new-tab' | 'all-new-tab' | 'same-tab'
90
+ siteUrl?: string
91
+ }>
92
+
93
+ function runRenderer(tree: HastRoot, options?: LinkTargetOptions) {
94
+ const transform = (
95
+ customRenderer as unknown as (options?: LinkTargetOptions) => (tree: HastRoot) => void
96
+ )(options)
85
97
  transform(tree)
86
98
  }
87
99
 
@@ -212,6 +224,13 @@ describe('customRenderer', () => {
212
224
  expect(node.properties.rel).toBe('noopener noreferrer')
213
225
  })
214
226
 
227
+ it('does NOT add target="_blank" to root-relative internal links by default', () => {
228
+ const node = el('a', { href: '/internal' })
229
+ runRenderer(root(node))
230
+ expect(node.properties.target).toBeUndefined()
231
+ expect(node.properties.rel).toBeUndefined()
232
+ })
233
+
215
234
  it('does NOT add target="_blank" to internal anchor links (#)', () => {
216
235
  const node = el('a', { href: '#section-id' })
217
236
  runRenderer(root(node))
@@ -225,6 +244,46 @@ describe('customRenderer', () => {
225
244
  expect(node.properties.target).toBeUndefined()
226
245
  })
227
246
 
247
+ it('does NOT add target="_blank" to same-origin absolute links', () => {
248
+ const node = el('a', { href: 'https://example.com/articles/internal' })
249
+ runRenderer(root(node), { siteUrl: 'https://example.com' })
250
+ expect(node.properties.target).toBeUndefined()
251
+ expect(node.properties.rel).toBeUndefined()
252
+ })
253
+
254
+ it('adds target="_blank" and rel to different-origin absolute links', () => {
255
+ const node = el('a', { href: 'https://different-site.com' })
256
+ runRenderer(root(node), { siteUrl: 'https://example.com' })
257
+ expect(node.properties.target).toBe('_blank')
258
+ expect(node.properties.rel).toBe('noopener noreferrer')
259
+ })
260
+
261
+ it('opens browser navigation links in a new tab when strategy is all-new-tab', () => {
262
+ const node = el('a', { href: '/internal' })
263
+ runRenderer(root(node), { strategy: 'all-new-tab', siteUrl: 'https://example.com' })
264
+ expect(node.properties.target).toBe('_blank')
265
+ expect(node.properties.rel).toBe('noopener noreferrer')
266
+ })
267
+
268
+ it('does not open links in a new tab when strategy is same-tab', () => {
269
+ const node = el('a', { href: 'https://different-site.com' })
270
+ runRenderer(root(node), { strategy: 'same-tab', siteUrl: 'https://example.com' })
271
+ expect(node.properties.target).toBeUndefined()
272
+ expect(node.properties.rel).toBeUndefined()
273
+ })
274
+
275
+ it('leaves mailto and tel links in the same tab', () => {
276
+ const nodes = [
277
+ el('a', { href: 'mailto:test@example.com' }),
278
+ el('a', { href: 'tel:+15555555555' }),
279
+ ]
280
+ runRenderer(root(...nodes), { strategy: 'all-new-tab', siteUrl: 'https://example.com' })
281
+ nodes.forEach((node) => {
282
+ expect(node.properties.target).toBeUndefined()
283
+ expect(node.properties.rel).toBeUndefined()
284
+ })
285
+ })
286
+
228
287
  it('applies link className to external anchors', () => {
229
288
  const node = el('a', { href: 'https://external.com' })
230
289
  runRenderer(root(node))
@@ -248,6 +307,10 @@ describe('customRenderer', () => {
248
307
  // ---------------------------------------------------------------------------
249
308
 
250
309
  describe('markdownToHtml', () => {
310
+ beforeEach(() => {
311
+ mockRemarkUseCalls.length = 0
312
+ })
313
+
251
314
  it('returns a string', async () => {
252
315
  const result = await markdownToHtml('# Hello')
253
316
  expect(typeof result).toBe('string')
@@ -260,6 +323,19 @@ describe('markdownToHtml', () => {
260
323
  it('resolves without throwing when articleSlug is provided', async () => {
261
324
  await expect(markdownToHtml('![alt](photo.png)', 'my-article')).resolves.toBeDefined()
262
325
  })
326
+
327
+ it('passes link target config to customRenderer', async () => {
328
+ await markdownToHtml('# Hello', 'my-article', {
329
+ siteUrl: 'https://example.com',
330
+ siteName: 'Example',
331
+ linkTargetStrategy: 'external-new-tab',
332
+ })
333
+
334
+ expect(mockRemarkUseCalls).toContainEqual([
335
+ customRenderer,
336
+ { strategy: 'external-new-tab', siteUrl: 'https://example.com' },
337
+ ])
338
+ })
263
339
  })
264
340
 
265
341
  // ---------------------------------------------------------------------------
@@ -179,6 +179,28 @@ describe('renderMdxSource', () => {
179
179
  expect(container.querySelector('a[href="#section-one"]')).not.toBeNull()
180
180
  expect(container.querySelector('a[href="#section-two"]')).not.toBeNull()
181
181
  })
182
+
183
+ it('passes link target config into the markdown renderer plugin', async () => {
184
+ mockEvaluate.mockResolvedValue({
185
+ default: () => React.createElement('a', { href: 'https://external.com' }, 'External'),
186
+ })
187
+ Object.defineProperty(process.env, 'NODE_ENV', { value: 'production', writable: true })
188
+
189
+ const config = {
190
+ siteUrl: 'https://example.com',
191
+ siteName: 'Example',
192
+ linkTargetStrategy: 'external-new-tab' as const,
193
+ }
194
+ const { renderMdxSource } = await import('../renderMdx')
195
+ await renderMdxSource('[External](https://external.com)', '/articles/my-article', config)
196
+
197
+ const callArgs = mockEvaluate.mock.calls[0][1]
198
+ expect(callArgs.rehypePlugins).toEqual(
199
+ expect.arrayContaining([
200
+ [expect.any(Function), { strategy: 'external-new-tab', siteUrl: 'https://example.com' }],
201
+ ])
202
+ )
203
+ })
182
204
  })
183
205
 
184
206
  describe('basePath image resolution', () => {
@@ -6,6 +6,7 @@ import {
6
6
  generateArticleMetadata,
7
7
  generateCategoryMetadata,
8
8
  getArticleSitemapEntries,
9
+ generateRssFeed,
9
10
  } from '../seoUtils'
10
11
 
11
12
  jest.mock('../server-articles', () => ({
@@ -579,3 +580,104 @@ describe('getArticleSitemapEntries', () => {
579
580
  expect(entries[0].lastModified).toEqual(new Date('2025-03-10'))
580
581
  })
581
582
  })
583
+
584
+ describe('generateRssFeed', () => {
585
+ const baseConfig: ArticlesConfig = {
586
+ siteUrl: 'https://example.com',
587
+ siteName: 'Example Site',
588
+ description: 'Test description',
589
+ }
590
+
591
+ const baseArticle = {
592
+ slug: 'my-article',
593
+ title: 'My Article',
594
+ excerpt: 'A short excerpt',
595
+ date: '2024-01-15',
596
+ author: 'Jane Doe',
597
+ category: 'Tales of the Valiant',
598
+ categories: ['Tales of the Valiant'],
599
+ readTime: '3 min read',
600
+ featuredImage: '/articles/my-article/hero.jpg',
601
+ tags: [],
602
+ }
603
+
604
+ it('returns a string starting with the XML declaration', () => {
605
+ const xml = generateRssFeed([], baseConfig)
606
+ expect(xml).toContain('<?xml version="1.0" encoding="UTF-8" ?>')
607
+ })
608
+
609
+ it('includes xmlns:media namespace in the rss element', () => {
610
+ const xml = generateRssFeed([], baseConfig)
611
+ expect(xml).toContain('xmlns:media="http://search.yahoo.com/mrss/"')
612
+ })
613
+
614
+ it('renders <category> from article.category', () => {
615
+ const xml = generateRssFeed([baseArticle], baseConfig)
616
+ expect(xml).toContain('<category><![CDATA[Tales of the Valiant]]></category>')
617
+ })
618
+
619
+ it('renders <media:content> with resolved absolute image URL', () => {
620
+ const xml = generateRssFeed([baseArticle], baseConfig)
621
+ expect(xml).toContain(
622
+ '<media:content url="https://example.com/articles/my-article/hero.jpg" medium="image" width="1200" height="630"/>'
623
+ )
624
+ })
625
+
626
+ it('passes through absolute featuredImage URLs unchanged in <media:content>', () => {
627
+ const article = { ...baseArticle, featuredImage: 'https://cdn.example.com/image.jpg' }
628
+ const xml = generateRssFeed([article], baseConfig)
629
+ expect(xml).toContain(
630
+ '<media:content url="https://cdn.example.com/image.jpg" medium="image" width="1200" height="630"/>'
631
+ )
632
+ })
633
+
634
+ it('omits <category> when article.category is empty', () => {
635
+ const article = { ...baseArticle, category: '' }
636
+ const xml = generateRssFeed([article], baseConfig)
637
+ expect(xml).not.toContain('<category>')
638
+ })
639
+
640
+ it('omits <media:content> when article.featuredImage is empty', () => {
641
+ const article = { ...baseArticle, featuredImage: '' }
642
+ const xml = generateRssFeed([article], baseConfig)
643
+ expect(xml).not.toContain('<media:content')
644
+ })
645
+
646
+ it('strips trailing slash from siteUrl when resolving image URL', () => {
647
+ const article = { ...baseArticle }
648
+ const xml = generateRssFeed([article], { ...baseConfig, siteUrl: 'https://example.com/' })
649
+ expect(xml).not.toContain('//articles')
650
+ expect(xml).toContain('https://example.com/articles/my-article/hero.jpg')
651
+ })
652
+
653
+ it('renders <title>, <link>, <guid>, <pubDate>, <description>, and <author>', () => {
654
+ const xml = generateRssFeed([baseArticle], baseConfig)
655
+ expect(xml).toContain('<![CDATA[My Article]]>')
656
+ expect(xml).toContain('https://example.com/articles/my-article')
657
+ expect(xml).toContain('<pubDate>')
658
+ expect(xml).toContain('<![CDATA[A short excerpt]]>')
659
+ expect(xml).toContain('<author>Jane Doe</author>')
660
+ })
661
+
662
+ it('uses config.description in the channel description', () => {
663
+ const xml = generateRssFeed([], baseConfig)
664
+ expect(xml).toContain('<![CDATA[Test description]]>')
665
+ })
666
+
667
+ it('falls back to siteName articles when description is absent', () => {
668
+ const xml = generateRssFeed([], { siteUrl: 'https://example.com', siteName: 'My Site' })
669
+ expect(xml).toContain('<![CDATA[My Site articles]]>')
670
+ })
671
+
672
+ it('omits author when showAuthor is false', () => {
673
+ const xml = generateRssFeed([baseArticle], { ...baseConfig, showAuthor: false })
674
+ expect(xml).not.toContain('<author>')
675
+ })
676
+
677
+ it('renders multiple items', () => {
678
+ const second = { ...baseArticle, slug: 'second', title: 'Second Article' }
679
+ const xml = generateRssFeed([baseArticle, second], baseConfig)
680
+ expect(xml).toContain('<![CDATA[My Article]]>')
681
+ expect(xml).toContain('<![CDATA[Second Article]]>')
682
+ })
683
+ })
@@ -17,6 +17,7 @@ import {
17
17
  searchArticles,
18
18
  } from '../server-articles'
19
19
  import { setArticlesErrorHandler } from '../errorReporting'
20
+ import { markdownToHtml } from '../markdown'
20
21
 
21
22
  jest.mock('react', () => ({ cache: (fn: Function) => fn }))
22
23
  jest.mock('node:fs')
@@ -27,6 +28,7 @@ jest.mock('../markdown', () => ({
27
28
  jest.mock('reading-time', () => () => ({ text: '2 min read' }))
28
29
 
29
30
  const mockedFs = fs as jest.Mocked<typeof fs>
31
+ const mockedMarkdownToHtml = markdownToHtml as jest.MockedFunction<typeof markdownToHtml>
30
32
  const articlesDirectory = path.join(process.cwd(), 'public/articles')
31
33
 
32
34
  beforeEach(() => {
@@ -480,7 +482,13 @@ describe('getArticleMetadata - content handling', () => {
480
482
  },
481
483
  ])
482
484
 
483
- const article = await getArticleMetadata('markdown-article')
485
+ const config = {
486
+ siteUrl: 'https://example.com',
487
+ siteName: 'Example',
488
+ linkTargetStrategy: 'external-new-tab' as const,
489
+ }
490
+
491
+ const article = await getArticleMetadata('markdown-article', config)
484
492
 
485
493
  expect(article).toEqual(
486
494
  expect.objectContaining({
@@ -493,6 +501,11 @@ describe('getArticleMetadata - content handling', () => {
493
501
  toc: [],
494
502
  })
495
503
  )
504
+ expect(mockedMarkdownToHtml).toHaveBeenCalledWith(
505
+ '\n## Heading\n\nMarkdown body',
506
+ 'markdown-article',
507
+ config
508
+ )
496
509
  })
497
510
 
498
511
  it('loads mdx articles as source instead of rendered html', async () => {
@@ -515,6 +528,7 @@ describe('getArticleMetadata - content handling', () => {
515
528
  })
516
529
  )
517
530
  expect(article?.mdxSource?.trim()).toBe('<Component />')
531
+ expect(mockedMarkdownToHtml).not.toHaveBeenCalled()
518
532
  })
519
533
 
520
534
  it('returns null when article file loading fails', async () => {
@@ -67,6 +67,9 @@ export interface HeroConfig {
67
67
  description?: string
68
68
  }
69
69
 
70
+ /** Controls how article body links set target/rel attributes. */
71
+ export type LinkTargetStrategy = 'external-new-tab' | 'all-new-tab' | 'same-tab'
72
+
70
73
  /** Top-level configuration object. Pass one instance to every library component. */
71
74
  export interface ArticlesConfig {
72
75
  /** Canonical base URL of the site, used in metadata and JSON-LD. Example: `'https://yoursite.com'` */
@@ -102,6 +105,8 @@ export interface ArticlesConfig {
102
105
  showBackToArticles?: boolean
103
106
  /** Set to false to hide author names from UI and metadata. Default: true. */
104
107
  showAuthor?: boolean
108
+ /** Article body link target behavior. Default: `'external-new-tab'`. */
109
+ linkTargetStrategy?: LinkTargetStrategy
105
110
  }
106
111
 
107
112
  export const DEFAULT_PAGE_SIZE = 6
package/src/index.ts CHANGED
@@ -42,6 +42,7 @@ export type {
42
42
  ArticlesSection,
43
43
  CategoryDescription,
44
44
  CommentsConfig,
45
+ LinkTargetStrategy,
45
46
  } from './articlesConfig'
46
47
  export { DEFAULT_LAYOUT, DEFAULT_PAGE_SIZE, DEFAULT_CATEGORIES_PAGE_SIZE } from './articlesConfig'
47
48
  export type { Article, CategoryInfo, FaqItem, HowToStep, TocItem } from './articleTypes'
package/src/markdown.ts CHANGED
@@ -11,8 +11,67 @@ import remarkRehype from 'remark-rehype'
11
11
  import { Plugin } from 'unified'
12
12
  import { visit } from 'unist-util-visit'
13
13
  import type { TocItem } from './articleTypes'
14
+ import type { ArticlesConfig, LinkTargetStrategy } from './articlesConfig'
14
15
  import { reportArticlesError } from './errorReporting'
15
16
 
17
+ type LinkTargetOptions = Readonly<{
18
+ strategy?: LinkTargetStrategy
19
+ siteUrl?: string
20
+ }>
21
+
22
+ const DEFAULT_LINK_TARGET_STRATEGY: LinkTargetStrategy = 'external-new-tab'
23
+
24
+ function isNonBrowserNavigationLink(href: string): boolean {
25
+ return (
26
+ /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(href) &&
27
+ !href.startsWith('http://') &&
28
+ !href.startsWith('https://')
29
+ )
30
+ }
31
+
32
+ function getOrigin(url: string | undefined): string | null {
33
+ if (!url) return null
34
+ try {
35
+ return new URL(url).origin
36
+ } catch {
37
+ return null
38
+ }
39
+ }
40
+
41
+ function isExternalHttpLink(href: string, siteUrl?: string): boolean {
42
+ if (!href.startsWith('http://') && !href.startsWith('https://')) return false
43
+ const siteOrigin = getOrigin(siteUrl)
44
+ if (!siteOrigin) return true
45
+ return getOrigin(href) !== siteOrigin
46
+ }
47
+
48
+ function shouldOpenInNewTab(href: string, options: LinkTargetOptions = {}): boolean {
49
+ if (!href || href.startsWith('#') || isNonBrowserNavigationLink(href)) return false
50
+
51
+ const strategy = options.strategy ?? DEFAULT_LINK_TARGET_STRATEGY
52
+ if (strategy === 'same-tab') return false
53
+ if (strategy === 'all-new-tab') return true
54
+ return isExternalHttpLink(href, options.siteUrl)
55
+ }
56
+
57
+ function applyLinkTarget(props: Record<string, unknown>, options?: LinkTargetOptions): void {
58
+ const href = typeof props.href === 'string' ? props.href : ''
59
+ if (shouldOpenInNewTab(href, options)) {
60
+ props.target = '_blank'
61
+ props.rel = 'noopener noreferrer'
62
+ return
63
+ }
64
+ delete props.target
65
+ delete props.rel
66
+ }
67
+
68
+ function getLinkTargetOptions(config?: ArticlesConfig): LinkTargetOptions {
69
+ return {
70
+ strategy: config?.linkTargetStrategy,
71
+ siteUrl: config?.siteUrl,
72
+ }
73
+ }
74
+
16
75
  // Import the sanitizeImagePath function
17
76
  function sanitizeImagePath(rawPath: string, articleSlug: string): string | null {
18
77
  if (!rawPath || typeof rawPath !== 'string') {
@@ -169,7 +228,7 @@ function processFootnotesSection(node: Element): void {
169
228
  if (node.properties) node.properties.className = undefined
170
229
  }
171
230
 
172
- export const customRenderer: Plugin<[], Root> = () => {
231
+ export const customRenderer: Plugin<[LinkTargetOptions?], Root> = (linkTargetOptions = {}) => {
173
232
  return (tree: Root) => {
174
233
  // First pass: Apply general styles
175
234
  visit(tree, 'element', (node: Element) => {
@@ -194,11 +253,7 @@ export const customRenderer: Plugin<[], Root> = () => {
194
253
  break
195
254
  case 'a': {
196
255
  props.className = 'text-primary hover:underline transition-colors duration-200'
197
- const href = typeof props.href === 'string' ? props.href : ''
198
- if (!href.startsWith('#')) {
199
- props.target = '_blank'
200
- props.rel = 'noopener noreferrer'
201
- }
256
+ applyLinkTarget(props, linkTargetOptions)
202
257
  break
203
258
  }
204
259
  case 'ul':
@@ -278,7 +333,11 @@ const rehypeProcessImages: Plugin<[{ articleSlug?: string }], Root> = (options =
278
333
  }
279
334
  }
280
335
 
281
- export async function markdownToHtml(markdown: string, articleSlug?: string) {
336
+ export async function markdownToHtml(
337
+ markdown: string,
338
+ articleSlug?: string,
339
+ config?: ArticlesConfig
340
+ ) {
282
341
  try {
283
342
  // Start building the remark processor
284
343
  let processor = remark()
@@ -286,7 +345,7 @@ export async function markdownToHtml(markdown: string, articleSlug?: string) {
286
345
  .use(remarkGfm)
287
346
  .use(remarkGithubBlockquoteAlert)
288
347
  .use(remarkRehype)
289
- .use(customRenderer)
348
+ .use(customRenderer, getLinkTargetOptions(config))
290
349
  .use(rehypeSlug)
291
350
  // @ts-ignore
292
351
  .use(rehypePrism)
package/src/renderMdx.tsx CHANGED
@@ -13,6 +13,7 @@ import rehypeSlug from 'rehype-slug'
13
13
  import remarkGfm from 'remark-gfm'
14
14
  import remarkGithubBlockquoteAlert from 'remark-github-blockquote-alert'
15
15
  import { customRenderer } from './markdown'
16
+ import type { ArticlesConfig } from './articlesConfig'
16
17
 
17
18
  type MdxContent = ComponentType<{
18
19
  components?: Record<string, ComponentType<unknown>>
@@ -26,7 +27,7 @@ function makeImgComponent(basePath: string) {
26
27
  }
27
28
  }
28
29
 
29
- export async function renderMdxSource(source: string, basePath?: string) {
30
+ export async function renderMdxSource(source: string, basePath?: string, config?: ArticlesConfig) {
30
31
  const isDevelopment = process.env.NODE_ENV === 'development'
31
32
 
32
33
  const mdxModule = await evaluate(source, {
@@ -34,7 +35,7 @@ export async function renderMdxSource(source: string, basePath?: string) {
34
35
  development: isDevelopment,
35
36
  remarkPlugins: [remarkGfm, remarkGithubBlockquoteAlert],
36
37
  rehypePlugins: [
37
- customRenderer,
38
+ [customRenderer, { strategy: config?.linkTargetStrategy, siteUrl: config?.siteUrl }],
38
39
  rehypeSlug,
39
40
  // @ts-ignore
40
41
  rehypePrism,
package/src/seoUtils.ts CHANGED
@@ -8,6 +8,60 @@ import {
8
8
  getAvailableArticleSlugs,
9
9
  } from './server-articles'
10
10
  import { ArticlesConfig } from './articlesConfig'
11
+ import type { Article } from './articleTypes'
12
+
13
+ function escapeXml(str: string): string {
14
+ return str
15
+ .replaceAll('&', '&amp;')
16
+ .replaceAll('<', '&lt;')
17
+ .replaceAll('>', '&gt;')
18
+ .replaceAll('"', '&quot;')
19
+ .replaceAll("'", '&apos;')
20
+ }
21
+
22
+ export function generateRssFeed(articles: Article[], config: ArticlesConfig): string {
23
+ const siteUrl = config.siteUrl.replace(/\/$/, '')
24
+ const showAuthor = config.showAuthor !== false
25
+
26
+ const items = articles
27
+ .map((article) => {
28
+ const url = `${siteUrl}/articles/${article.slug}`
29
+ const pubDate = article.date ? new Date(article.date).toUTCString() : ''
30
+ const imageUrl = article.featuredImage ? resolveImageUrl(article.featuredImage, siteUrl) : ''
31
+
32
+ return [
33
+ ' <item>',
34
+ ` <title><![CDATA[${article.title}]]></title>`,
35
+ ` <link>${url}</link>`,
36
+ ` <guid isPermaLink="true">${url}</guid>`,
37
+ pubDate ? ` <pubDate>${pubDate}</pubDate>` : '',
38
+ article.excerpt ? ` <description><![CDATA[${article.excerpt}]]></description>` : '',
39
+ showAuthor && article.author ? ` <author>${escapeXml(article.author)}</author>` : '',
40
+ article.category ? ` <category><![CDATA[${article.category}]]></category>` : '',
41
+ imageUrl
42
+ ? ` <media:content url="${imageUrl}" medium="image" width="1200" height="630"/>`
43
+ : '',
44
+ ' </item>',
45
+ ]
46
+ .filter(Boolean)
47
+ .join('\n')
48
+ })
49
+ .join('\n')
50
+
51
+ const description = config.description ?? `${config.siteName} articles`
52
+
53
+ return `<?xml version="1.0" encoding="UTF-8" ?>
54
+ <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/">
55
+ <channel>
56
+ <title><![CDATA[${config.siteName}]]></title>
57
+ <link>${siteUrl}/articles</link>
58
+ <description><![CDATA[${description}]]></description>
59
+ <language>en</language>
60
+ <atom:link href="${siteUrl}/articles/feed.xml" rel="self" type="application/rss+xml" />
61
+ ${items}
62
+ </channel>
63
+ </rss>`
64
+ }
11
65
 
12
66
  export function generateArticleStaticParams(): { slug: string }[] {
13
67
  return getAvailableArticleSlugs().map((slug) => ({ slug }))
@@ -197,33 +197,35 @@ async function getArticleSummary(slug: string): Promise<Article | null> {
197
197
  }
198
198
  }
199
199
 
200
- export const getArticleMetadata = cache(async (slug: string): Promise<Article | null> => {
201
- try {
202
- const summary = await getArticleSummary(slug)
203
- if (!summary) return null
204
- const found = findArticleFile(slug)
205
- if (!found) return null
206
- const fileContent = fs.readFileSync(found.filePath, 'utf8')
207
- const { content: markdownContent } = matter(fileContent)
208
- const toc = await extractToc(markdownContent)
209
- let htmlContent: string | undefined
210
- let mdxSource: string | undefined
211
- if (found.contentType === 'mdx') {
212
- mdxSource = markdownContent
213
- } else {
214
- htmlContent = await markdownToHtml(markdownContent, slug)
200
+ export const getArticleMetadata = cache(
201
+ async (slug: string, config?: ArticlesConfig): Promise<Article | null> => {
202
+ try {
203
+ const summary = await getArticleSummary(slug)
204
+ if (!summary) return null
205
+ const found = findArticleFile(slug)
206
+ if (!found) return null
207
+ const fileContent = fs.readFileSync(found.filePath, 'utf8')
208
+ const { content: markdownContent } = matter(fileContent)
209
+ const toc = await extractToc(markdownContent)
210
+ let htmlContent: string | undefined
211
+ let mdxSource: string | undefined
212
+ if (found.contentType === 'mdx') {
213
+ mdxSource = markdownContent
214
+ } else {
215
+ htmlContent = await markdownToHtml(markdownContent, slug, config)
216
+ }
217
+ return { ...summary, content: markdownContent, htmlContent, mdxSource, toc }
218
+ } catch (error) {
219
+ reportArticlesError({
220
+ code: 'article-load-failed',
221
+ message: 'Unable to load article metadata.',
222
+ error,
223
+ context: { slug },
224
+ })
225
+ return null
215
226
  }
216
- return { ...summary, content: markdownContent, htmlContent, mdxSource, toc }
217
- } catch (error) {
218
- reportArticlesError({
219
- code: 'article-load-failed',
220
- message: 'Unable to load article metadata.',
221
- error,
222
- context: { slug },
223
- })
224
- return null
225
227
  }
226
- })
228
+ )
227
229
 
228
230
  export const getAllArticles = cache(async (): Promise<Article[]> => {
229
231
  const slugs = getAvailableArticleSlugs()
package/src/server.ts CHANGED
@@ -17,6 +17,7 @@ export {
17
17
  } from './server-articles'
18
18
 
19
19
  export {
20
+ generateRssFeed,
20
21
  generateArticleStaticParams,
21
22
  generateCategoryStaticParams,
22
23
  generateArticlesIndexMetadata,
@@ -31,7 +32,7 @@ export { ArticleContent } from './ArticleContent'
31
32
  export { ArticleTOC } from './ArticleTOC'
32
33
 
33
34
  export type { Article, CategoryInfo, TocItem } from './articleTypes'
34
- export type { ArticlesConfig } from './articlesConfig'
35
+ export type { ArticlesConfig, LinkTargetStrategy } from './articlesConfig'
35
36
  export type {
36
37
  ArticlesErrorCode,
37
38
  ArticlesErrorContext,