@fullstackdatasolutions/articles 1.2.2 → 1.2.3
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 +6 -0
- package/dist/nextjs.cjs +4 -2
- package/dist/nextjs.cjs.map +1 -1
- package/dist/nextjs.js +4 -2
- package/dist/nextjs.js.map +1 -1
- package/dist/server.cjs +22 -4
- package/dist/server.cjs.map +1 -1
- package/dist/server.js +22 -4
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/linkClassification.test.ts +55 -0
- package/src/__tests__/renderMdx.test.tsx +162 -3
- package/src/linkClassification.ts +30 -0
- package/src/markdown.ts +3 -24
- package/src/renderMdx.tsx +43 -6
package/package.json
CHANGED
|
@@ -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
|
+
})
|
|
@@ -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
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Pure href classification helpers with no dependency on the rehype/hast
|
|
2
|
+
// pipeline - kept separate from markdown.ts (which pulls in ESM-only
|
|
3
|
+
// rehype/remark plugins Jest can't transform without extra config) so
|
|
4
|
+
// consumers that only need "is this link internal/external/navigable"
|
|
5
|
+
// (like renderMdx.tsx's next/link routing decision) don't have to import
|
|
6
|
+
// that whole transitive dependency chain, in production or in tests.
|
|
7
|
+
|
|
8
|
+
export function isNonBrowserNavigationLink(href: string): boolean {
|
|
9
|
+
return (
|
|
10
|
+
/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(href) &&
|
|
11
|
+
!href.startsWith('http://') &&
|
|
12
|
+
!href.startsWith('https://')
|
|
13
|
+
)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function getOrigin(url: string | undefined): string | null {
|
|
17
|
+
if (!url) return null
|
|
18
|
+
try {
|
|
19
|
+
return new URL(url).origin
|
|
20
|
+
} catch {
|
|
21
|
+
return null
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function isExternalHttpLink(href: string, siteUrl?: string): boolean {
|
|
26
|
+
if (!href.startsWith('http://') && !href.startsWith('https://')) return false
|
|
27
|
+
const siteOrigin = getOrigin(siteUrl)
|
|
28
|
+
if (!siteOrigin) return true
|
|
29
|
+
return getOrigin(href) !== siteOrigin
|
|
30
|
+
}
|
package/src/markdown.ts
CHANGED
|
@@ -13,6 +13,9 @@ import { visit } from 'unist-util-visit'
|
|
|
13
13
|
import type { TocItem } from './articleTypes'
|
|
14
14
|
import type { ArticlesConfig, LinkTargetStrategy } from './articlesConfig'
|
|
15
15
|
import { reportArticlesError } from './errorReporting'
|
|
16
|
+
import { isExternalHttpLink, isNonBrowserNavigationLink } from './linkClassification'
|
|
17
|
+
|
|
18
|
+
export { isExternalHttpLink, isNonBrowserNavigationLink }
|
|
16
19
|
|
|
17
20
|
type LinkTargetOptions = Readonly<{
|
|
18
21
|
strategy?: LinkTargetStrategy
|
|
@@ -21,30 +24,6 @@ type LinkTargetOptions = Readonly<{
|
|
|
21
24
|
|
|
22
25
|
const DEFAULT_LINK_TARGET_STRATEGY: LinkTargetStrategy = 'external-new-tab'
|
|
23
26
|
|
|
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
27
|
function shouldOpenInNewTab(href: string, options: LinkTargetOptions = {}): boolean {
|
|
49
28
|
if (!href || href.startsWith('#') || isNonBrowserNavigationLink(href)) return false
|
|
50
29
|
|
package/src/renderMdx.tsx
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
// Correct: className="..."
|
|
5
5
|
// Incorrect: class="..."
|
|
6
6
|
import React from 'react'
|
|
7
|
-
import type { ComponentType, ImgHTMLAttributes } from 'react'
|
|
7
|
+
import type { AnchorHTMLAttributes, ComponentType, ImgHTMLAttributes } from 'react'
|
|
8
|
+
import Link from 'next/link'
|
|
8
9
|
import * as devRuntime from 'react/jsx-dev-runtime'
|
|
9
10
|
import * as runtime from 'react/jsx-runtime'
|
|
10
11
|
import { evaluate } from '@mdx-js/mdx'
|
|
@@ -13,6 +14,7 @@ import rehypeSlug from 'rehype-slug'
|
|
|
13
14
|
import remarkGfm from 'remark-gfm'
|
|
14
15
|
import remarkGithubBlockquoteAlert from 'remark-github-blockquote-alert'
|
|
15
16
|
import { customRenderer } from './markdown'
|
|
17
|
+
import { isExternalHttpLink, isNonBrowserNavigationLink } from './linkClassification'
|
|
16
18
|
import type { ArticlesConfig } from './articlesConfig'
|
|
17
19
|
|
|
18
20
|
type MdxContent = ComponentType<{
|
|
@@ -29,6 +31,35 @@ function makeImgComponent(basePath: string) {
|
|
|
29
31
|
}
|
|
30
32
|
}
|
|
31
33
|
|
|
34
|
+
// Same internal/external classification `customRenderer`'s rehype pass
|
|
35
|
+
// already used to decide target="_blank" (see `applyLinkTarget` in
|
|
36
|
+
// markdown.ts) - reused rather than reimplemented so the two decisions
|
|
37
|
+
// can never drift apart. An in-page anchor (#section) or a non-browser
|
|
38
|
+
// scheme (mailto:, tel:) is never routed through next/link either; only a
|
|
39
|
+
// same-origin, browser-navigable href gets client-side routing.
|
|
40
|
+
function isInternalNavigableHref(href: string, siteUrl?: string): boolean {
|
|
41
|
+
if (!href || href.startsWith('#') || isNonBrowserNavigationLink(href)) return false
|
|
42
|
+
return !isExternalHttpLink(href, siteUrl)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function makeLinkComponent(siteUrl?: string) {
|
|
46
|
+
return function MdxLink({ href, children, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) {
|
|
47
|
+
if (typeof href === 'string' && isInternalNavigableHref(href, siteUrl)) {
|
|
48
|
+
// React.createElement, not JSX, for the same reason makeImgComponent
|
|
49
|
+
// above uses it: next/link's own (duplicate) @types/react copy in
|
|
50
|
+
// this monorepo's node_modules is structurally incompatible with
|
|
51
|
+
// this file's DOM attribute types, and JSX's prop-checking is
|
|
52
|
+
// stricter about that mismatch than createElement's is.
|
|
53
|
+
return React.createElement(Link, { href, ...props } as never, children)
|
|
54
|
+
}
|
|
55
|
+
return (
|
|
56
|
+
<a href={href} {...props}>
|
|
57
|
+
{children}
|
|
58
|
+
</a>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
32
63
|
export async function renderMdxSource(source: string, basePath?: string, config?: ArticlesConfig) {
|
|
33
64
|
const isDevelopment = process.env.NODE_ENV === 'development'
|
|
34
65
|
|
|
@@ -45,10 +76,16 @@ export async function renderMdxSource(source: string, basePath?: string, config?
|
|
|
45
76
|
})
|
|
46
77
|
|
|
47
78
|
const Content = mdxModule.default as MdxContent
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
79
|
+
// `a` is always overridden - unlike `img`, internal-link routing isn't
|
|
80
|
+
// conditional on basePath being provided. Every article's markdown
|
|
81
|
+
// links otherwise compile to a plain `<a>` (a full page reload on
|
|
82
|
+
// click, since @mdx-js/mdx's evaluate() has no knowledge of Next's
|
|
83
|
+
// router), which was silently forcing a hard navigation - and a fresh
|
|
84
|
+
// same-site document.referrer - on every single in-article link click.
|
|
85
|
+
const internalComponents = {
|
|
86
|
+
a: makeLinkComponent(config?.siteUrl),
|
|
87
|
+
...(basePath ? { img: makeImgComponent(basePath) } : {}),
|
|
88
|
+
}
|
|
89
|
+
const components = { ...internalComponents, ...config?.mdxComponents }
|
|
53
90
|
return <Content components={components as Record<string, ComponentType<unknown>>} />
|
|
54
91
|
}
|