@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.
- package/README.md +233 -72
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.js.map +1 -1
- package/dist/nextjs-middleware.cjs +61 -0
- package/dist/nextjs-middleware.cjs.map +1 -0
- package/dist/nextjs-middleware.d.cts +25 -0
- package/dist/nextjs-middleware.d.ts +25 -0
- package/dist/nextjs-middleware.js +33 -0
- package/dist/nextjs-middleware.js.map +1 -0
- package/dist/nextjs.cjs +692 -0
- package/dist/nextjs.cjs.map +1 -0
- package/dist/nextjs.d.cts +116 -0
- package/dist/nextjs.d.ts +116 -0
- package/dist/nextjs.js +658 -0
- package/dist/nextjs.js.map +1 -0
- package/dist/server.cjs +117 -38
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +10 -4
- package/dist/server.d.ts +10 -4
- package/dist/server.js +116 -38
- package/dist/server.js.map +1 -1
- package/package.json +11 -1
- package/src/ArticleContent.tsx +8 -3
- package/src/__tests__/ArticleContent.test.tsx +18 -2
- package/src/__tests__/markdown.test.ts +79 -3
- package/src/__tests__/nextjs-middleware.test.ts +183 -0
- package/src/__tests__/nextjs.test.ts +137 -0
- package/src/__tests__/renderMdx.test.tsx +22 -0
- package/src/__tests__/seoUtils.test.ts +102 -0
- package/src/__tests__/server-articles.test.ts +15 -1
- package/src/articlesConfig.ts +5 -0
- package/src/index.ts +1 -0
- package/src/markdown.ts +67 -8
- package/src/nextjs-middleware.ts +48 -0
- package/src/nextjs.ts +27 -0
- package/src/renderMdx.tsx +3 -2
- package/src/seoUtils.ts +54 -0
- package/src/server-articles.ts +27 -25
- 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: () =>
|
|
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
|
-
|
|
84
|
-
|
|
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('', '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
|
|
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 () => {
|
package/src/articlesConfig.ts
CHANGED
|
@@ -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'
|