@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +313 -1
  3. package/dist/index.cjs +308 -79
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.cts +267 -16
  6. package/dist/index.d.ts +267 -16
  7. package/dist/index.js +300 -79
  8. package/dist/index.js.map +1 -1
  9. package/dist/nextjs.cjs +324 -13
  10. package/dist/nextjs.cjs.map +1 -1
  11. package/dist/nextjs.d.cts +179 -2
  12. package/dist/nextjs.d.ts +179 -2
  13. package/dist/nextjs.js +324 -13
  14. package/dist/nextjs.js.map +1 -1
  15. package/dist/server.cjs +677 -51
  16. package/dist/server.cjs.map +1 -1
  17. package/dist/server.d.cts +333 -12
  18. package/dist/server.d.ts +333 -12
  19. package/dist/server.js +662 -51
  20. package/dist/server.js.map +1 -1
  21. package/package.json +1 -1
  22. package/src/ArticleAnswer.tsx +35 -0
  23. package/src/ArticleSchemas.tsx +263 -23
  24. package/src/AuthorArticlesPage.tsx +38 -8
  25. package/src/__tests__/ArticleAnswer.test.tsx +25 -0
  26. package/src/__tests__/ArticleSchemas.test.tsx +516 -0
  27. package/src/__tests__/AuthorArticlesPage.test.tsx +76 -0
  28. package/src/__tests__/authorUtils.test.ts +50 -0
  29. package/src/__tests__/linkClassification.test.ts +55 -0
  30. package/src/__tests__/markdown.test.ts +77 -1
  31. package/src/__tests__/nextjs.test.ts +31 -15
  32. package/src/__tests__/renderMdx.test.tsx +162 -3
  33. package/src/__tests__/seoUtils.test.ts +279 -0
  34. package/src/__tests__/server-articles.test.ts +413 -1
  35. package/src/__tests__/validateArticles.test.ts +167 -6
  36. package/src/articleTypes.ts +57 -0
  37. package/src/articlesConfig.ts +176 -1
  38. package/src/authorUtils.ts +19 -1
  39. package/src/errorReporting.ts +1 -0
  40. package/src/index.ts +17 -1
  41. package/src/linkClassification.ts +30 -0
  42. package/src/markdown.ts +103 -25
  43. package/src/nextjs.ts +7 -4
  44. package/src/renderMdx.tsx +43 -6
  45. package/src/seoUtils.ts +247 -26
  46. package/src/server-articles.ts +375 -24
  47. package/src/server.ts +35 -4
  48. 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 { customRenderer, markdownToHtml, extractToc, getContentSlotBoundaries } from '../markdown'
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 mockGetArticleMarkdownResponse = jest.fn()
6
+ const mockGetMarkdownTwinResponse = jest.fn()
7
7
 
8
8
  jest.mock('../server-articles', () => ({
9
- getArticleMarkdownResponse: (...args: unknown[]) => mockGetArticleMarkdownResponse(...args),
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
- mockGetArticleMarkdownResponse.mockResolvedValue(new Response('# Article', { status: 200 }))
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 getArticleMarkdownResponse with joined slug array and config', async () => {
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(mockGetArticleMarkdownResponse).toHaveBeenCalledWith('category/my-article', siteConfig)
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(mockGetArticleMarkdownResponse).toHaveBeenCalledWith('some-article', siteConfig)
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(mockGetArticleMarkdownResponse).toHaveBeenCalledWith('plain-slug', siteConfig)
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(mockGetArticleMarkdownResponse).toHaveBeenCalledWith('plain-slug', siteConfig)
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(mockGetArticleMarkdownResponse).toHaveBeenCalledWith('', siteConfig)
114
+ expect(mockGetMarkdownTwinResponse).toHaveBeenCalledWith('', siteConfig, {
115
+ headers: requestHeaders,
116
+ })
105
117
  })
106
118
 
107
- it('returns the response from getArticleMarkdownResponse', async () => {
119
+ it('returns the response from getMarkdownTwinResponse', async () => {
108
120
  const mockResponse = new Response('markdown content', { status: 200 })
109
- mockGetArticleMarkdownResponse.mockResolvedValue(mockResponse)
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 getArticleMarkdownResponse', async () => {
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(mockGetArticleMarkdownResponse).toHaveBeenCalledWith('test', customConfig)
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(mockGetArticleMarkdownResponse).toHaveBeenCalledWith('a/b/c/deep-article', siteConfig)
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