@fullstackdatasolutions/articles 1.2.2 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/README.md +313 -1
- package/dist/index.cjs +308 -79
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +267 -16
- package/dist/index.d.ts +267 -16
- package/dist/index.js +300 -79
- package/dist/index.js.map +1 -1
- package/dist/nextjs.cjs +324 -13
- package/dist/nextjs.cjs.map +1 -1
- package/dist/nextjs.d.cts +179 -2
- package/dist/nextjs.d.ts +179 -2
- package/dist/nextjs.js +324 -13
- package/dist/nextjs.js.map +1 -1
- package/dist/server.cjs +677 -51
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +333 -12
- package/dist/server.d.ts +333 -12
- package/dist/server.js +662 -51
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
- package/src/ArticleAnswer.tsx +35 -0
- package/src/ArticleSchemas.tsx +263 -23
- package/src/AuthorArticlesPage.tsx +38 -8
- package/src/__tests__/ArticleAnswer.test.tsx +25 -0
- package/src/__tests__/ArticleSchemas.test.tsx +516 -0
- package/src/__tests__/AuthorArticlesPage.test.tsx +76 -0
- package/src/__tests__/authorUtils.test.ts +50 -0
- package/src/__tests__/linkClassification.test.ts +55 -0
- package/src/__tests__/markdown.test.ts +77 -1
- package/src/__tests__/nextjs.test.ts +31 -15
- package/src/__tests__/renderMdx.test.tsx +162 -3
- package/src/__tests__/seoUtils.test.ts +279 -0
- package/src/__tests__/server-articles.test.ts +413 -1
- package/src/__tests__/validateArticles.test.ts +167 -6
- package/src/articleTypes.ts +57 -0
- package/src/articlesConfig.ts +176 -1
- package/src/authorUtils.ts +19 -1
- package/src/errorReporting.ts +1 -0
- package/src/index.ts +17 -1
- package/src/linkClassification.ts +30 -0
- package/src/markdown.ts +103 -25
- package/src/nextjs.ts +7 -4
- package/src/renderMdx.tsx +43 -6
- package/src/seoUtils.ts +247 -26
- package/src/server-articles.ts +375 -24
- package/src/server.ts +35 -4
- package/src/validateArticles.ts +157 -12
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { isExternalHttpLink, isNonBrowserNavigationLink } from '../linkClassification'
|
|
2
|
+
|
|
3
|
+
describe('isNonBrowserNavigationLink', () => {
|
|
4
|
+
it('returns true for mailto: links', () => {
|
|
5
|
+
expect(isNonBrowserNavigationLink('mailto:hello@example.com')).toBe(true)
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
it('returns true for tel: links', () => {
|
|
9
|
+
expect(isNonBrowserNavigationLink('tel:+15551234567')).toBe(true)
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('returns false for http:// links', () => {
|
|
13
|
+
expect(isNonBrowserNavigationLink('http://example.com')).toBe(false)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('returns false for https:// links', () => {
|
|
17
|
+
expect(isNonBrowserNavigationLink('https://example.com')).toBe(false)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('returns false for root-relative links', () => {
|
|
21
|
+
expect(isNonBrowserNavigationLink('/articles/some-article')).toBe(false)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('returns false for in-page hash anchors', () => {
|
|
25
|
+
expect(isNonBrowserNavigationLink('#section')).toBe(false)
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe('isExternalHttpLink', () => {
|
|
30
|
+
it('returns false for a root-relative link regardless of siteUrl', () => {
|
|
31
|
+
expect(isExternalHttpLink('/articles/some-article', 'https://example.com')).toBe(false)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('returns false for an in-page hash anchor', () => {
|
|
35
|
+
expect(isExternalHttpLink('#section')).toBe(false)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('returns true for an http(s) link when no siteUrl is configured', () => {
|
|
39
|
+
expect(isExternalHttpLink('https://other-site.com')).toBe(true)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('returns false for a same-origin absolute link when siteUrl is configured', () => {
|
|
43
|
+
expect(isExternalHttpLink('https://example.com/articles/other', 'https://example.com')).toBe(
|
|
44
|
+
false
|
|
45
|
+
)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('returns true for a different-origin absolute link when siteUrl is configured', () => {
|
|
49
|
+
expect(isExternalHttpLink('https://other-site.com/page', 'https://example.com')).toBe(true)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('returns true when siteUrl is malformed and cannot be parsed', () => {
|
|
53
|
+
expect(isExternalHttpLink('https://example.com', 'not-a-valid-url')).toBe(true)
|
|
54
|
+
})
|
|
55
|
+
})
|
|
@@ -64,7 +64,13 @@ jest.mock('unist-util-visit', () => ({
|
|
|
64
64
|
},
|
|
65
65
|
}))
|
|
66
66
|
|
|
67
|
-
import {
|
|
67
|
+
import {
|
|
68
|
+
customRenderer,
|
|
69
|
+
deriveFaqFromHeadings,
|
|
70
|
+
markdownToHtml,
|
|
71
|
+
extractToc,
|
|
72
|
+
getContentSlotBoundaries,
|
|
73
|
+
} from '../markdown'
|
|
68
74
|
|
|
69
75
|
// ---------------------------------------------------------------------------
|
|
70
76
|
// customRenderer
|
|
@@ -429,3 +435,73 @@ describe('getContentSlotBoundaries', () => {
|
|
|
429
435
|
expect(boundaries?.paragraphCount).toBe(1)
|
|
430
436
|
})
|
|
431
437
|
})
|
|
438
|
+
|
|
439
|
+
describe('deriveFaqFromHeadings', () => {
|
|
440
|
+
it('pairs a question-shaped h2 with the paragraph that follows it', () => {
|
|
441
|
+
const markdown = [
|
|
442
|
+
'## What is initiative?',
|
|
443
|
+
'',
|
|
444
|
+
'It is the turn order for combat.',
|
|
445
|
+
'Everyone rolls once.',
|
|
446
|
+
'',
|
|
447
|
+
'## How do I roll it?',
|
|
448
|
+
'',
|
|
449
|
+
'Roll a d20 and add your modifier.',
|
|
450
|
+
].join('\n')
|
|
451
|
+
|
|
452
|
+
expect(deriveFaqFromHeadings(markdown)).toEqual([
|
|
453
|
+
{
|
|
454
|
+
question: 'What is initiative?',
|
|
455
|
+
answer: 'It is the turn order for combat. Everyone rolls once.',
|
|
456
|
+
},
|
|
457
|
+
{ question: 'How do I roll it?', answer: 'Roll a d20 and add your modifier.' },
|
|
458
|
+
])
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
it('ignores headings that are not questions, rhetorical questions, and other heading levels', () => {
|
|
462
|
+
const markdown = [
|
|
463
|
+
'## Setting the scene',
|
|
464
|
+
'',
|
|
465
|
+
'Some prose.',
|
|
466
|
+
'',
|
|
467
|
+
'## Sound familiar?',
|
|
468
|
+
'',
|
|
469
|
+
'More prose.',
|
|
470
|
+
'',
|
|
471
|
+
'### How do I nest?',
|
|
472
|
+
'',
|
|
473
|
+
'Nested prose.',
|
|
474
|
+
].join('\n')
|
|
475
|
+
|
|
476
|
+
expect(deriveFaqFromHeadings(markdown)).toEqual([])
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
it('skips a question heading with no prose under it', () => {
|
|
480
|
+
const markdown = ['## What is this?', '', '## Why does it matter?', '', 'Because.'].join('\n')
|
|
481
|
+
expect(deriveFaqFromHeadings(markdown)).toEqual([
|
|
482
|
+
{ question: 'Why does it matter?', answer: 'Because.' },
|
|
483
|
+
])
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
it('ignores question headings inside fenced code blocks', () => {
|
|
487
|
+
const markdown = ['```md', '## What is this?', '', 'Not real prose.', '```'].join('\n')
|
|
488
|
+
expect(deriveFaqFromHeadings(markdown)).toEqual([])
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
it('requires whitespace after ## and rejects deeper heading levels', () => {
|
|
492
|
+
expect(deriveFaqFromHeadings(['##What is this?', '', 'Prose.'].join('\n'))).toEqual([])
|
|
493
|
+
expect(deriveFaqFromHeadings(['### What is this?', '', 'Prose.'].join('\n'))).toEqual([])
|
|
494
|
+
expect(deriveFaqFromHeadings(['##\tWhat is this?', '', 'Prose.'].join('\n'))).toEqual([
|
|
495
|
+
{ question: 'What is this?', answer: 'Prose.' },
|
|
496
|
+
])
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
it('stops collecting at the next heading and strips closing hashes', () => {
|
|
500
|
+
const markdown = ['## Can I do this? ##', '', 'Yes.', '# New section', '', 'Ignored.'].join(
|
|
501
|
+
'\n'
|
|
502
|
+
)
|
|
503
|
+
expect(deriveFaqFromHeadings(markdown)).toEqual([
|
|
504
|
+
{ question: 'Can I do this?', answer: 'Yes.' },
|
|
505
|
+
])
|
|
506
|
+
})
|
|
507
|
+
})
|
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
* Mocks server-articles to isolate the handler logic.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
const
|
|
6
|
+
const mockGetMarkdownTwinResponse = jest.fn()
|
|
7
7
|
|
|
8
8
|
jest.mock('../server-articles', () => ({
|
|
9
|
-
|
|
9
|
+
getMarkdownTwinResponse: (...args: unknown[]) => mockGetMarkdownTwinResponse(...args),
|
|
10
10
|
}))
|
|
11
11
|
|
|
12
12
|
import { createArticleMarkdownHandler } from '../nextjs'
|
|
@@ -47,14 +47,16 @@ function makeContext(slugValue: string | string[]): {
|
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
const requestHeaders = { get: () => null }
|
|
51
|
+
|
|
50
52
|
function makeRequest(): NextRequest {
|
|
51
|
-
return {} as NextRequest
|
|
53
|
+
return { headers: requestHeaders } as unknown as NextRequest
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
describe('createArticleMarkdownHandler', () => {
|
|
55
57
|
beforeEach(() => {
|
|
56
58
|
jest.clearAllMocks()
|
|
57
|
-
|
|
59
|
+
mockGetMarkdownTwinResponse.mockResolvedValue(new Response('# Article', { status: 200 }))
|
|
58
60
|
})
|
|
59
61
|
|
|
60
62
|
it('returns an object with a GET handler', () => {
|
|
@@ -62,12 +64,14 @@ describe('createArticleMarkdownHandler', () => {
|
|
|
62
64
|
expect(typeof GET).toBe('function')
|
|
63
65
|
})
|
|
64
66
|
|
|
65
|
-
it('calls
|
|
67
|
+
it('calls getMarkdownTwinResponse with joined slug array and config', async () => {
|
|
66
68
|
const { GET } = createArticleMarkdownHandler(siteConfig)
|
|
67
69
|
const ctx = makeContext(['category', 'my-article.md'])
|
|
68
70
|
await GET(makeRequest(), ctx)
|
|
69
71
|
|
|
70
|
-
expect(
|
|
72
|
+
expect(mockGetMarkdownTwinResponse).toHaveBeenCalledWith('category/my-article', siteConfig, {
|
|
73
|
+
headers: requestHeaders,
|
|
74
|
+
})
|
|
71
75
|
})
|
|
72
76
|
|
|
73
77
|
it('strips trailing .md from a slug array', async () => {
|
|
@@ -75,7 +79,9 @@ describe('createArticleMarkdownHandler', () => {
|
|
|
75
79
|
const ctx = makeContext(['some-article.md'])
|
|
76
80
|
await GET(makeRequest(), ctx)
|
|
77
81
|
|
|
78
|
-
expect(
|
|
82
|
+
expect(mockGetMarkdownTwinResponse).toHaveBeenCalledWith('some-article', siteConfig, {
|
|
83
|
+
headers: requestHeaders,
|
|
84
|
+
})
|
|
79
85
|
})
|
|
80
86
|
|
|
81
87
|
it('handles a plain string slug without .md extension', async () => {
|
|
@@ -83,7 +89,9 @@ describe('createArticleMarkdownHandler', () => {
|
|
|
83
89
|
const ctx = makeContext('plain-slug')
|
|
84
90
|
await GET(makeRequest(), ctx)
|
|
85
91
|
|
|
86
|
-
expect(
|
|
92
|
+
expect(mockGetMarkdownTwinResponse).toHaveBeenCalledWith('plain-slug', siteConfig, {
|
|
93
|
+
headers: requestHeaders,
|
|
94
|
+
})
|
|
87
95
|
})
|
|
88
96
|
|
|
89
97
|
it('handles a plain string slug with .md extension - strips it', async () => {
|
|
@@ -91,7 +99,9 @@ describe('createArticleMarkdownHandler', () => {
|
|
|
91
99
|
const ctx = makeContext('plain-slug.md')
|
|
92
100
|
await GET(makeRequest(), ctx)
|
|
93
101
|
|
|
94
|
-
expect(
|
|
102
|
+
expect(mockGetMarkdownTwinResponse).toHaveBeenCalledWith('plain-slug', siteConfig, {
|
|
103
|
+
headers: requestHeaders,
|
|
104
|
+
})
|
|
95
105
|
})
|
|
96
106
|
|
|
97
107
|
it('handles undefined slug (missing param) as empty string', async () => {
|
|
@@ -101,12 +111,14 @@ describe('createArticleMarkdownHandler', () => {
|
|
|
101
111
|
}
|
|
102
112
|
await GET(makeRequest(), ctx)
|
|
103
113
|
|
|
104
|
-
expect(
|
|
114
|
+
expect(mockGetMarkdownTwinResponse).toHaveBeenCalledWith('', siteConfig, {
|
|
115
|
+
headers: requestHeaders,
|
|
116
|
+
})
|
|
105
117
|
})
|
|
106
118
|
|
|
107
|
-
it('returns the response from
|
|
119
|
+
it('returns the response from getMarkdownTwinResponse', async () => {
|
|
108
120
|
const mockResponse = new Response('markdown content', { status: 200 })
|
|
109
|
-
|
|
121
|
+
mockGetMarkdownTwinResponse.mockResolvedValue(mockResponse)
|
|
110
122
|
|
|
111
123
|
const { GET } = createArticleMarkdownHandler(siteConfig)
|
|
112
124
|
const ctx = makeContext(['article.md'])
|
|
@@ -115,7 +127,7 @@ describe('createArticleMarkdownHandler', () => {
|
|
|
115
127
|
expect(result).toBe(mockResponse)
|
|
116
128
|
})
|
|
117
129
|
|
|
118
|
-
it('passes the config object through to
|
|
130
|
+
it('passes the config object through to getMarkdownTwinResponse', async () => {
|
|
119
131
|
const customConfig: ArticlesConfig = {
|
|
120
132
|
siteUrl: 'https://custom.example.com',
|
|
121
133
|
siteName: 'Custom Site',
|
|
@@ -124,7 +136,9 @@ describe('createArticleMarkdownHandler', () => {
|
|
|
124
136
|
const ctx = makeContext(['test.md'])
|
|
125
137
|
await GET(makeRequest(), ctx)
|
|
126
138
|
|
|
127
|
-
expect(
|
|
139
|
+
expect(mockGetMarkdownTwinResponse).toHaveBeenCalledWith('test', customConfig, {
|
|
140
|
+
headers: requestHeaders,
|
|
141
|
+
})
|
|
128
142
|
})
|
|
129
143
|
|
|
130
144
|
it('handles deeply nested slug array', async () => {
|
|
@@ -132,6 +146,8 @@ describe('createArticleMarkdownHandler', () => {
|
|
|
132
146
|
const ctx = makeContext(['a', 'b', 'c', 'deep-article.md'])
|
|
133
147
|
await GET(makeRequest(), ctx)
|
|
134
148
|
|
|
135
|
-
expect(
|
|
149
|
+
expect(mockGetMarkdownTwinResponse).toHaveBeenCalledWith('a/b/c/deep-article', siteConfig, {
|
|
150
|
+
headers: requestHeaders,
|
|
151
|
+
})
|
|
136
152
|
})
|
|
137
153
|
})
|
|
@@ -14,7 +14,18 @@ jest.mock('rehype-slug', () => () => () => {})
|
|
|
14
14
|
jest.mock('rehype-prism-plus', () => () => () => {})
|
|
15
15
|
jest.mock('remark-gfm', () => () => () => {})
|
|
16
16
|
jest.mock('remark-github-blockquote-alert', () => () => () => {})
|
|
17
|
+
// customRenderer runs the real rehype/hast pipeline, not needed for these
|
|
18
|
+
// unit tests. linkClassification.ts is not mocked - it's dependency-free,
|
|
19
|
+
// and the internal-link-routing tests below rely on its real logic.
|
|
17
20
|
jest.mock('../markdown', () => ({ customRenderer: () => () => {} }))
|
|
21
|
+
jest.mock('next/link', () => ({
|
|
22
|
+
__esModule: true,
|
|
23
|
+
default: ({ href, children }: { href: string; children: React.ReactNode }) => (
|
|
24
|
+
<a href={href} data-testid="next-link">
|
|
25
|
+
{children}
|
|
26
|
+
</a>
|
|
27
|
+
),
|
|
28
|
+
}))
|
|
18
29
|
|
|
19
30
|
const originalNodeEnv = process.env.NODE_ENV
|
|
20
31
|
|
|
@@ -203,6 +214,152 @@ describe('renderMdxSource', () => {
|
|
|
203
214
|
})
|
|
204
215
|
})
|
|
205
216
|
|
|
217
|
+
describe('internal link routing', () => {
|
|
218
|
+
it('routes a root-relative internal link through next/link, not a plain page-reload anchor', async () => {
|
|
219
|
+
mockEvaluate.mockResolvedValue({
|
|
220
|
+
default: ({
|
|
221
|
+
components,
|
|
222
|
+
}: {
|
|
223
|
+
components?: Record<string, React.ComponentType<unknown>>
|
|
224
|
+
}) => {
|
|
225
|
+
const A = components?.a as React.ComponentType<
|
|
226
|
+
React.AnchorHTMLAttributes<HTMLAnchorElement>
|
|
227
|
+
>
|
|
228
|
+
return A
|
|
229
|
+
? React.createElement(A, { href: '/articles/other-article' }, 'Other article')
|
|
230
|
+
: React.createElement('a', { href: '/articles/other-article' }, 'Other article')
|
|
231
|
+
},
|
|
232
|
+
})
|
|
233
|
+
Object.defineProperty(process.env, 'NODE_ENV', { value: 'production', writable: true })
|
|
234
|
+
|
|
235
|
+
const { renderMdxSource } = await import('../renderMdx')
|
|
236
|
+
const el = await renderMdxSource('[Other article](/articles/other-article)')
|
|
237
|
+
const { container } = render(el as React.ReactElement)
|
|
238
|
+
|
|
239
|
+
const link = container.querySelector('a[href="/articles/other-article"]')
|
|
240
|
+
expect(link).not.toBeNull()
|
|
241
|
+
expect(link?.textContent).toBe('Other article')
|
|
242
|
+
// Confirms next/link (mocked above) actually handled this render,
|
|
243
|
+
// not a bare intrinsic <a> - this is the assertion that would have
|
|
244
|
+
// caught the original bug (every in-article link forcing a full
|
|
245
|
+
// page reload instead of client-side navigation).
|
|
246
|
+
expect(link?.getAttribute('data-testid')).toBe('next-link')
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('leaves an external link as a plain anchor, not routed through next/link', async () => {
|
|
250
|
+
mockEvaluate.mockResolvedValue({
|
|
251
|
+
default: ({
|
|
252
|
+
components,
|
|
253
|
+
}: {
|
|
254
|
+
components?: Record<string, React.ComponentType<unknown>>
|
|
255
|
+
}) => {
|
|
256
|
+
const A = components?.a as React.ComponentType<
|
|
257
|
+
React.AnchorHTMLAttributes<HTMLAnchorElement>
|
|
258
|
+
>
|
|
259
|
+
return A
|
|
260
|
+
? React.createElement(
|
|
261
|
+
A,
|
|
262
|
+
{
|
|
263
|
+
href: 'https://external.example.com',
|
|
264
|
+
target: '_blank',
|
|
265
|
+
rel: 'noopener noreferrer',
|
|
266
|
+
},
|
|
267
|
+
'External'
|
|
268
|
+
)
|
|
269
|
+
: null
|
|
270
|
+
},
|
|
271
|
+
})
|
|
272
|
+
Object.defineProperty(process.env, 'NODE_ENV', { value: 'production', writable: true })
|
|
273
|
+
|
|
274
|
+
const { renderMdxSource } = await import('../renderMdx')
|
|
275
|
+
const el = await renderMdxSource('[External](https://external.example.com)')
|
|
276
|
+
const { container } = render(el as React.ReactElement)
|
|
277
|
+
|
|
278
|
+
const link = container.querySelector('a[href="https://external.example.com"]')
|
|
279
|
+
expect(link).not.toBeNull()
|
|
280
|
+
expect(link?.getAttribute('data-testid')).toBeNull()
|
|
281
|
+
expect(link?.getAttribute('target')).toBe('_blank')
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it('routes a same-origin absolute link through next/link when siteUrl is configured', async () => {
|
|
285
|
+
mockEvaluate.mockResolvedValue({
|
|
286
|
+
default: ({
|
|
287
|
+
components,
|
|
288
|
+
}: {
|
|
289
|
+
components?: Record<string, React.ComponentType<unknown>>
|
|
290
|
+
}) => {
|
|
291
|
+
const A = components?.a as React.ComponentType<
|
|
292
|
+
React.AnchorHTMLAttributes<HTMLAnchorElement>
|
|
293
|
+
>
|
|
294
|
+
return A
|
|
295
|
+
? React.createElement(A, { href: 'https://example.com/articles/other' }, 'Same origin')
|
|
296
|
+
: null
|
|
297
|
+
},
|
|
298
|
+
})
|
|
299
|
+
Object.defineProperty(process.env, 'NODE_ENV', { value: 'production', writable: true })
|
|
300
|
+
|
|
301
|
+
const { renderMdxSource } = await import('../renderMdx')
|
|
302
|
+
const el = await renderMdxSource(
|
|
303
|
+
'[Same origin](https://example.com/articles/other)',
|
|
304
|
+
undefined,
|
|
305
|
+
{ siteUrl: 'https://example.com', siteName: 'Example' }
|
|
306
|
+
)
|
|
307
|
+
const { container } = render(el as React.ReactElement)
|
|
308
|
+
|
|
309
|
+
const link = container.querySelector('a[href="https://example.com/articles/other"]')
|
|
310
|
+
expect(link).not.toBeNull()
|
|
311
|
+
expect(link?.getAttribute('data-testid')).toBe('next-link')
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
it('leaves an in-page hash anchor as a plain anchor, not routed through next/link', async () => {
|
|
315
|
+
mockEvaluate.mockResolvedValue({
|
|
316
|
+
default: ({
|
|
317
|
+
components,
|
|
318
|
+
}: {
|
|
319
|
+
components?: Record<string, React.ComponentType<unknown>>
|
|
320
|
+
}) => {
|
|
321
|
+
const A = components?.a as React.ComponentType<
|
|
322
|
+
React.AnchorHTMLAttributes<HTMLAnchorElement>
|
|
323
|
+
>
|
|
324
|
+
return A ? React.createElement(A, { href: '#section' }, 'Jump') : null
|
|
325
|
+
},
|
|
326
|
+
})
|
|
327
|
+
Object.defineProperty(process.env, 'NODE_ENV', { value: 'production', writable: true })
|
|
328
|
+
|
|
329
|
+
const { renderMdxSource } = await import('../renderMdx')
|
|
330
|
+
const el = await renderMdxSource('[Jump](#section)')
|
|
331
|
+
const { container } = render(el as React.ReactElement)
|
|
332
|
+
|
|
333
|
+
const link = container.querySelector('a[href="#section"]')
|
|
334
|
+
expect(link).not.toBeNull()
|
|
335
|
+
expect(link?.getAttribute('data-testid')).toBeNull()
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
it('leaves a mailto: link as a plain anchor, not routed through next/link', async () => {
|
|
339
|
+
mockEvaluate.mockResolvedValue({
|
|
340
|
+
default: ({
|
|
341
|
+
components,
|
|
342
|
+
}: {
|
|
343
|
+
components?: Record<string, React.ComponentType<unknown>>
|
|
344
|
+
}) => {
|
|
345
|
+
const A = components?.a as React.ComponentType<
|
|
346
|
+
React.AnchorHTMLAttributes<HTMLAnchorElement>
|
|
347
|
+
>
|
|
348
|
+
return A ? React.createElement(A, { href: 'mailto:hello@example.com' }, 'Email us') : null
|
|
349
|
+
},
|
|
350
|
+
})
|
|
351
|
+
Object.defineProperty(process.env, 'NODE_ENV', { value: 'production', writable: true })
|
|
352
|
+
|
|
353
|
+
const { renderMdxSource } = await import('../renderMdx')
|
|
354
|
+
const el = await renderMdxSource('[Email us](mailto:hello@example.com)')
|
|
355
|
+
const { container } = render(el as React.ReactElement)
|
|
356
|
+
|
|
357
|
+
const link = container.querySelector('a[href="mailto:hello@example.com"]')
|
|
358
|
+
expect(link).not.toBeNull()
|
|
359
|
+
expect(link?.getAttribute('data-testid')).toBeNull()
|
|
360
|
+
})
|
|
361
|
+
})
|
|
362
|
+
|
|
206
363
|
describe('basePath image resolution', () => {
|
|
207
364
|
it('resolves relative img src to absolute path when basePath is provided', async () => {
|
|
208
365
|
mockEvaluate.mockResolvedValue({
|
|
@@ -318,7 +475,7 @@ describe('renderMdxSource', () => {
|
|
|
318
475
|
expect(capturedSrc).not.toBe('/articles/my-article/[object Blob]')
|
|
319
476
|
})
|
|
320
477
|
|
|
321
|
-
it('does not inject img component when basePath is not provided', async () => {
|
|
478
|
+
it('does not inject img component when basePath is not provided, but always injects the link override', async () => {
|
|
322
479
|
let capturedComponents: Record<string, unknown> | undefined
|
|
323
480
|
mockEvaluate.mockResolvedValue({
|
|
324
481
|
default: ({ components }: { components?: Record<string, unknown> }) => {
|
|
@@ -329,9 +486,11 @@ describe('renderMdxSource', () => {
|
|
|
329
486
|
Object.defineProperty(process.env, 'NODE_ENV', { value: 'production', writable: true })
|
|
330
487
|
|
|
331
488
|
const { renderMdxSource } = await import('../renderMdx')
|
|
332
|
-
await renderMdxSource('# Test')
|
|
489
|
+
const el = await renderMdxSource('# Test')
|
|
490
|
+
render(el as React.ReactElement)
|
|
333
491
|
|
|
334
|
-
expect(capturedComponents).toBeUndefined()
|
|
492
|
+
expect(capturedComponents?.img).toBeUndefined()
|
|
493
|
+
expect(capturedComponents?.a).toBeInstanceOf(Function)
|
|
335
494
|
})
|
|
336
495
|
})
|
|
337
496
|
|