@fullstackdatasolutions/articles 0.8.1 → 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.
Files changed (41) hide show
  1. package/README.md +233 -72
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +5 -1
  4. package/dist/index.d.ts +5 -1
  5. package/dist/index.js.map +1 -1
  6. package/dist/nextjs-middleware.cjs +61 -0
  7. package/dist/nextjs-middleware.cjs.map +1 -0
  8. package/dist/nextjs-middleware.d.cts +25 -0
  9. package/dist/nextjs-middleware.d.ts +25 -0
  10. package/dist/nextjs-middleware.js +33 -0
  11. package/dist/nextjs-middleware.js.map +1 -0
  12. package/dist/nextjs.cjs +692 -0
  13. package/dist/nextjs.cjs.map +1 -0
  14. package/dist/nextjs.d.cts +116 -0
  15. package/dist/nextjs.d.ts +116 -0
  16. package/dist/nextjs.js +658 -0
  17. package/dist/nextjs.js.map +1 -0
  18. package/dist/server.cjs +117 -38
  19. package/dist/server.cjs.map +1 -1
  20. package/dist/server.d.cts +10 -4
  21. package/dist/server.d.ts +10 -4
  22. package/dist/server.js +116 -38
  23. package/dist/server.js.map +1 -1
  24. package/package.json +11 -1
  25. package/src/ArticleContent.tsx +8 -3
  26. package/src/__tests__/ArticleContent.test.tsx +18 -2
  27. package/src/__tests__/markdown.test.ts +79 -3
  28. package/src/__tests__/nextjs-middleware.test.ts +183 -0
  29. package/src/__tests__/nextjs.test.ts +137 -0
  30. package/src/__tests__/renderMdx.test.tsx +22 -0
  31. package/src/__tests__/seoUtils.test.ts +102 -0
  32. package/src/__tests__/server-articles.test.ts +15 -1
  33. package/src/articlesConfig.ts +5 -0
  34. package/src/index.ts +1 -0
  35. package/src/markdown.ts +67 -8
  36. package/src/nextjs-middleware.ts +48 -0
  37. package/src/nextjs.ts +27 -0
  38. package/src/renderMdx.tsx +3 -2
  39. package/src/seoUtils.ts +54 -0
  40. package/src/server-articles.ts +27 -25
  41. package/src/server.ts +2 -1
@@ -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
  // ---------------------------------------------------------------------------
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Unit tests for nextjs-middleware.ts (Edge-safe URL rewrite helper).
3
+ * Uses manual mocks for next/server to avoid Edge runtime dependencies.
4
+ */
5
+
6
+ // Manual mock for next/server - must be before imports
7
+ const mockRewrite = jest.fn()
8
+
9
+ jest.mock('next/server', () => ({
10
+ NextResponse: {
11
+ rewrite: (url: URL) => mockRewrite(url),
12
+ },
13
+ }))
14
+
15
+ import {
16
+ rewriteArticleMarkdown,
17
+ middleware,
18
+ config,
19
+ createArticleMarkdownMiddleware,
20
+ } from '../nextjs-middleware'
21
+
22
+ type MiddlewareRequest = Parameters<typeof rewriteArticleMarkdown>[0]
23
+
24
+ function makeRequest(pathname: string, base = 'https://example.com'): MiddlewareRequest {
25
+ const request = {
26
+ nextUrl: { pathname },
27
+ url: `${base}${pathname}`,
28
+ }
29
+
30
+ return request as MiddlewareRequest
31
+ }
32
+
33
+ describe('rewriteArticleMarkdown', () => {
34
+ beforeEach(() => {
35
+ jest.clearAllMocks()
36
+ mockRewrite.mockImplementation((url: URL) => ({ type: 'rewrite', url }))
37
+ })
38
+
39
+ it('rewrites /articles/slug.md to /api/articles-markdown/slug.md', () => {
40
+ const req = makeRequest('/articles/some-article.md')
41
+ const result = rewriteArticleMarkdown(req)
42
+
43
+ expect(mockRewrite).toHaveBeenCalledTimes(1)
44
+ const calledUrl: URL = mockRewrite.mock.calls[0][0]
45
+ expect(calledUrl.pathname).toBe('/api/articles-markdown/some-article.md')
46
+ expect(result).toBeDefined()
47
+ })
48
+
49
+ it('rewrites nested slug path correctly', () => {
50
+ const req = makeRequest('/articles/category/nested-article.md')
51
+ rewriteArticleMarkdown(req)
52
+
53
+ const calledUrl: URL = mockRewrite.mock.calls[0][0]
54
+ expect(calledUrl.pathname).toBe('/api/articles-markdown/category/nested-article.md')
55
+ })
56
+
57
+ it('returns undefined for paths that do not start with /articles/', () => {
58
+ const req = makeRequest('/blog/some-article.md')
59
+ const result = rewriteArticleMarkdown(req)
60
+
61
+ expect(mockRewrite).not.toHaveBeenCalled()
62
+ expect(result).toBeUndefined()
63
+ })
64
+
65
+ it('returns undefined for paths that start with /articles/ but do not end with .md', () => {
66
+ const req = makeRequest('/articles/some-article')
67
+ const result = rewriteArticleMarkdown(req)
68
+
69
+ expect(mockRewrite).not.toHaveBeenCalled()
70
+ expect(result).toBeUndefined()
71
+ })
72
+
73
+ it('returns undefined for paths that end in .md but do not start with /articles/', () => {
74
+ const req = makeRequest('/docs/readme.md')
75
+ const result = rewriteArticleMarkdown(req)
76
+
77
+ expect(mockRewrite).not.toHaveBeenCalled()
78
+ expect(result).toBeUndefined()
79
+ })
80
+
81
+ it('returns undefined for the root path', () => {
82
+ const req = makeRequest('/')
83
+ const result = rewriteArticleMarkdown(req)
84
+
85
+ expect(mockRewrite).not.toHaveBeenCalled()
86
+ expect(result).toBeUndefined()
87
+ })
88
+
89
+ it('uses custom apiBase when provided', () => {
90
+ const req = makeRequest('/articles/my-article.md')
91
+ rewriteArticleMarkdown(req, '/custom/api/base')
92
+
93
+ const calledUrl: URL = mockRewrite.mock.calls[0][0]
94
+ expect(calledUrl.pathname).toBe('/custom/api/base/my-article.md')
95
+ })
96
+
97
+ it('defaults to /api/articles-markdown when apiBase is omitted', () => {
98
+ const req = makeRequest('/articles/test.md')
99
+ rewriteArticleMarkdown(req)
100
+
101
+ const calledUrl: URL = mockRewrite.mock.calls[0][0]
102
+ expect(calledUrl.pathname).toBe('/api/articles-markdown/test.md')
103
+ })
104
+ })
105
+
106
+ describe('middleware (legacy export)', () => {
107
+ beforeEach(() => {
108
+ jest.clearAllMocks()
109
+ mockRewrite.mockImplementation((url: URL) => ({ type: 'rewrite', url }))
110
+ })
111
+
112
+ it('is a function', () => {
113
+ expect(typeof middleware).toBe('function')
114
+ })
115
+
116
+ it('rewrites .md article paths', () => {
117
+ const req = makeRequest('/articles/legacy.md')
118
+ const result = middleware(req)
119
+
120
+ expect(mockRewrite).toHaveBeenCalledTimes(1)
121
+ expect(result).toBeDefined()
122
+ })
123
+
124
+ it('returns undefined for non-article paths', () => {
125
+ const req = makeRequest('/about')
126
+ const result = middleware(req)
127
+
128
+ expect(mockRewrite).not.toHaveBeenCalled()
129
+ expect(result).toBeUndefined()
130
+ })
131
+ })
132
+
133
+ describe('config export', () => {
134
+ it('exports a matcher for article .md paths', () => {
135
+ expect(config).toEqual({
136
+ matcher: '/articles/:path*.md',
137
+ })
138
+ })
139
+ })
140
+
141
+ describe('createArticleMarkdownMiddleware', () => {
142
+ beforeEach(() => {
143
+ jest.clearAllMocks()
144
+ mockRewrite.mockImplementation((url: URL) => ({ type: 'rewrite', url }))
145
+ })
146
+
147
+ it('returns an object with middleware and config', () => {
148
+ const result = createArticleMarkdownMiddleware()
149
+ expect(typeof result.middleware).toBe('function')
150
+ expect(result.config).toEqual({ matcher: '/articles/:path*.md' })
151
+ })
152
+
153
+ it('middleware uses the default apiBase when none provided', () => {
154
+ const { middleware: mw } = createArticleMarkdownMiddleware()
155
+ const req = makeRequest('/articles/test.md')
156
+ mw(req)
157
+
158
+ const calledUrl: URL = mockRewrite.mock.calls[0][0]
159
+ expect(calledUrl.pathname).toBe('/api/articles-markdown/test.md')
160
+ })
161
+
162
+ it('middleware uses the custom apiBase when provided', () => {
163
+ const { middleware: mw } = createArticleMarkdownMiddleware('/v2/articles-api')
164
+ const req = makeRequest('/articles/test.md')
165
+ mw(req)
166
+
167
+ const calledUrl: URL = mockRewrite.mock.calls[0][0]
168
+ expect(calledUrl.pathname).toBe('/v2/articles-api/test.md')
169
+ })
170
+
171
+ it('middleware returns undefined for non-matching paths', () => {
172
+ const { middleware: mw } = createArticleMarkdownMiddleware()
173
+ const req = makeRequest('/about')
174
+ const result = mw(req)
175
+
176
+ expect(result).toBeUndefined()
177
+ })
178
+
179
+ it('config always contains the standard matcher regardless of apiBase', () => {
180
+ const { config: cfg } = createArticleMarkdownMiddleware('/custom')
181
+ expect(cfg).toEqual({ matcher: '/articles/:path*.md' })
182
+ })
183
+ })
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Unit tests for nextjs.ts (createArticleMarkdownHandler factory).
3
+ * Mocks server-articles to isolate the handler logic.
4
+ */
5
+
6
+ const mockGetArticleMarkdownResponse = jest.fn()
7
+
8
+ jest.mock('../server-articles', () => ({
9
+ getArticleMarkdownResponse: (...args: unknown[]) => mockGetArticleMarkdownResponse(...args),
10
+ }))
11
+
12
+ import { createArticleMarkdownHandler } from '../nextjs'
13
+ import type { ArticlesConfig } from '../articlesConfig'
14
+ import type { NextRequest } from 'next/server'
15
+
16
+ // jsdom does not include Response - polyfill to match server-articles.test.ts pattern
17
+ class TestResponse {
18
+ readonly status: number
19
+ constructor(
20
+ private readonly body: string,
21
+ init?: { status?: number }
22
+ ) {
23
+ this.status = init?.status ?? 200
24
+ }
25
+ text(): Promise<string> {
26
+ return Promise.resolve(this.body)
27
+ }
28
+ }
29
+
30
+ if (globalThis.Response === undefined) {
31
+ Object.defineProperty(globalThis, 'Response', {
32
+ value: TestResponse,
33
+ configurable: true,
34
+ })
35
+ }
36
+
37
+ const siteConfig: ArticlesConfig = {
38
+ siteUrl: 'https://example.com',
39
+ siteName: 'Example Site',
40
+ }
41
+
42
+ function makeContext(slugValue: string | string[]): {
43
+ params: Promise<Record<string, string | string[]>>
44
+ } {
45
+ return {
46
+ params: Promise.resolve({ slug: slugValue }),
47
+ }
48
+ }
49
+
50
+ function makeRequest(): NextRequest {
51
+ return {} as NextRequest
52
+ }
53
+
54
+ describe('createArticleMarkdownHandler', () => {
55
+ beforeEach(() => {
56
+ jest.clearAllMocks()
57
+ mockGetArticleMarkdownResponse.mockResolvedValue(new Response('# Article', { status: 200 }))
58
+ })
59
+
60
+ it('returns an object with a GET handler', () => {
61
+ const { GET } = createArticleMarkdownHandler(siteConfig)
62
+ expect(typeof GET).toBe('function')
63
+ })
64
+
65
+ it('calls getArticleMarkdownResponse with joined slug array and config', async () => {
66
+ const { GET } = createArticleMarkdownHandler(siteConfig)
67
+ const ctx = makeContext(['category', 'my-article.md'])
68
+ await GET(makeRequest(), ctx)
69
+
70
+ expect(mockGetArticleMarkdownResponse).toHaveBeenCalledWith('category/my-article', siteConfig)
71
+ })
72
+
73
+ it('strips trailing .md from a slug array', async () => {
74
+ const { GET } = createArticleMarkdownHandler(siteConfig)
75
+ const ctx = makeContext(['some-article.md'])
76
+ await GET(makeRequest(), ctx)
77
+
78
+ expect(mockGetArticleMarkdownResponse).toHaveBeenCalledWith('some-article', siteConfig)
79
+ })
80
+
81
+ it('handles a plain string slug without .md extension', async () => {
82
+ const { GET } = createArticleMarkdownHandler(siteConfig)
83
+ const ctx = makeContext('plain-slug')
84
+ await GET(makeRequest(), ctx)
85
+
86
+ expect(mockGetArticleMarkdownResponse).toHaveBeenCalledWith('plain-slug', siteConfig)
87
+ })
88
+
89
+ it('handles a plain string slug with .md extension - strips it', async () => {
90
+ const { GET } = createArticleMarkdownHandler(siteConfig)
91
+ const ctx = makeContext('plain-slug.md')
92
+ await GET(makeRequest(), ctx)
93
+
94
+ expect(mockGetArticleMarkdownResponse).toHaveBeenCalledWith('plain-slug', siteConfig)
95
+ })
96
+
97
+ it('handles undefined slug (missing param) as empty string', async () => {
98
+ const { GET } = createArticleMarkdownHandler(siteConfig)
99
+ const ctx = {
100
+ params: Promise.resolve({} as Record<string, string | string[]>),
101
+ }
102
+ await GET(makeRequest(), ctx)
103
+
104
+ expect(mockGetArticleMarkdownResponse).toHaveBeenCalledWith('', siteConfig)
105
+ })
106
+
107
+ it('returns the response from getArticleMarkdownResponse', async () => {
108
+ const mockResponse = new Response('markdown content', { status: 200 })
109
+ mockGetArticleMarkdownResponse.mockResolvedValue(mockResponse)
110
+
111
+ const { GET } = createArticleMarkdownHandler(siteConfig)
112
+ const ctx = makeContext(['article.md'])
113
+ const result = await GET(makeRequest(), ctx)
114
+
115
+ expect(result).toBe(mockResponse)
116
+ })
117
+
118
+ it('passes the config object through to getArticleMarkdownResponse', async () => {
119
+ const customConfig: ArticlesConfig = {
120
+ siteUrl: 'https://custom.example.com',
121
+ siteName: 'Custom Site',
122
+ }
123
+ const { GET } = createArticleMarkdownHandler(customConfig)
124
+ const ctx = makeContext(['test.md'])
125
+ await GET(makeRequest(), ctx)
126
+
127
+ expect(mockGetArticleMarkdownResponse).toHaveBeenCalledWith('test', customConfig)
128
+ })
129
+
130
+ it('handles deeply nested slug array', async () => {
131
+ const { GET } = createArticleMarkdownHandler(siteConfig)
132
+ const ctx = makeContext(['a', 'b', 'c', 'deep-article.md'])
133
+ await GET(makeRequest(), ctx)
134
+
135
+ expect(mockGetArticleMarkdownResponse).toHaveBeenCalledWith('a/b/c/deep-article', siteConfig)
136
+ })
137
+ })
@@ -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'