@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fullstackdatasolutions/articles",
3
- "version": "1.2.2",
3
+ "version": "1.2.3",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "funding": {
@@ -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
- const internalComponents = basePath ? { img: makeImgComponent(basePath) } : undefined
49
- const components =
50
- internalComponents || config?.mdxComponents
51
- ? { ...internalComponents, ...config?.mdxComponents }
52
- : undefined
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
  }