@fullstackdatasolutions/articles 0.5.1 → 0.7.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 +51 -6
- package/dist/index.cjs +64 -38
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +11 -1
- package/dist/index.d.ts +11 -1
- package/dist/index.js +62 -37
- package/dist/index.js.map +1 -1
- package/dist/server.cjs +87 -24
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +3 -1
- package/dist/server.d.ts +3 -1
- package/dist/server.js +86 -23
- package/dist/server.js.map +1 -1
- package/package.json +11 -11
- package/src/ArticleBackLink.tsx +32 -0
- package/src/ArticleContent.tsx +4 -7
- package/src/__tests__/ArticleBackLink.test.tsx +89 -0
- package/src/__tests__/ArticleContent.test.tsx +72 -28
- package/src/__tests__/articlesConfig.test.ts +57 -0
- package/src/__tests__/markdown.test.ts +278 -0
- package/src/__tests__/renderMdx.test.tsx +280 -0
- package/src/__tests__/server-articles.test.ts +36 -1
- package/src/articlesConfig.ts +2 -0
- package/src/index.ts +1 -0
- package/src/markdown.ts +8 -6
- package/src/renderMdx.tsx +47 -0
- package/src/server-articles.ts +20 -11
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { DEFAULT_PAGE_SIZE, DEFAULT_CATEGORIES_PAGE_SIZE, DEFAULT_LAYOUT } from '../articlesConfig'
|
|
2
|
+
|
|
3
|
+
describe('articlesConfig constants', () => {
|
|
4
|
+
describe('DEFAULT_PAGE_SIZE', () => {
|
|
5
|
+
it('exports DEFAULT_PAGE_SIZE as 6', () => {
|
|
6
|
+
expect(DEFAULT_PAGE_SIZE).toBe(6)
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('is a number', () => {
|
|
10
|
+
expect(typeof DEFAULT_PAGE_SIZE).toBe('number')
|
|
11
|
+
})
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
describe('DEFAULT_CATEGORIES_PAGE_SIZE', () => {
|
|
15
|
+
it('exports DEFAULT_CATEGORIES_PAGE_SIZE as 8', () => {
|
|
16
|
+
expect(DEFAULT_CATEGORIES_PAGE_SIZE).toBe(8)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('is a number', () => {
|
|
20
|
+
expect(typeof DEFAULT_CATEGORIES_PAGE_SIZE).toBe('number')
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe('DEFAULT_LAYOUT', () => {
|
|
25
|
+
it('is an array', () => {
|
|
26
|
+
expect(Array.isArray(DEFAULT_LAYOUT)).toBe(true)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('contains hero, search, featured, latest, categories in order', () => {
|
|
30
|
+
expect(DEFAULT_LAYOUT).toEqual(['hero', 'search', 'featured', 'latest', 'categories'])
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('has 5 entries', () => {
|
|
34
|
+
expect(DEFAULT_LAYOUT).toHaveLength(5)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('includes hero', () => {
|
|
38
|
+
expect(DEFAULT_LAYOUT).toContain('hero')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('includes search', () => {
|
|
42
|
+
expect(DEFAULT_LAYOUT).toContain('search')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('includes featured', () => {
|
|
46
|
+
expect(DEFAULT_LAYOUT).toContain('featured')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('includes latest', () => {
|
|
50
|
+
expect(DEFAULT_LAYOUT).toContain('latest')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('includes categories', () => {
|
|
54
|
+
expect(DEFAULT_LAYOUT).toContain('categories')
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
})
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// All unified-ecosystem packages are ESM-only and cannot be loaded by ts-jest's
|
|
2
|
+
// CommonJS transform. Mock them before importing markdown.ts.
|
|
3
|
+
jest.mock('rehype-prism-plus', () => () => () => {})
|
|
4
|
+
jest.mock('rehype-sanitize', () => () => () => {})
|
|
5
|
+
jest.mock('rehype-slug', () => () => () => {})
|
|
6
|
+
jest.mock('rehype-stringify', () => () => () => {})
|
|
7
|
+
jest.mock('remark-gfm', () => () => () => {})
|
|
8
|
+
jest.mock('remark-github-blockquote-alert', () => () => () => {})
|
|
9
|
+
jest.mock('remark-parse', () => () => () => {})
|
|
10
|
+
jest.mock('remark-rehype', () => () => () => {})
|
|
11
|
+
|
|
12
|
+
// Mock remark() to return a chainable processor whose .process() resolves to ''
|
|
13
|
+
jest.mock('remark', () => {
|
|
14
|
+
const makeChain = (): Record<string, unknown> => {
|
|
15
|
+
const chain: Record<string, unknown> = {
|
|
16
|
+
use: () => chain,
|
|
17
|
+
process: async () => ({ toString: () => '' }),
|
|
18
|
+
}
|
|
19
|
+
return chain
|
|
20
|
+
}
|
|
21
|
+
return { remark: makeChain }
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
// Provide a real depth-first visit implementation so customRenderer works.
|
|
25
|
+
jest.mock('unist-util-visit', () => ({
|
|
26
|
+
visit(
|
|
27
|
+
tree: { children?: unknown[] },
|
|
28
|
+
type: string,
|
|
29
|
+
visitor: (node: {
|
|
30
|
+
type: string
|
|
31
|
+
tagName?: string
|
|
32
|
+
properties?: Record<string, unknown>
|
|
33
|
+
children?: unknown[]
|
|
34
|
+
}) => void
|
|
35
|
+
) {
|
|
36
|
+
function walk(node: {
|
|
37
|
+
type: string
|
|
38
|
+
tagName?: string
|
|
39
|
+
properties?: Record<string, unknown>
|
|
40
|
+
children?: unknown[]
|
|
41
|
+
}) {
|
|
42
|
+
if (node.type === type) visitor(node)
|
|
43
|
+
if (Array.isArray(node.children)) {
|
|
44
|
+
for (const child of node.children) {
|
|
45
|
+
walk(child as typeof node)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
walk(tree as { type: string; children: unknown[] })
|
|
50
|
+
},
|
|
51
|
+
}))
|
|
52
|
+
|
|
53
|
+
import { customRenderer, markdownToHtml, extractToc } from '../markdown'
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// customRenderer
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
type HastElement = {
|
|
60
|
+
type: 'element'
|
|
61
|
+
tagName: string
|
|
62
|
+
properties: Record<string, unknown>
|
|
63
|
+
children: HastElement[]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type HastRoot = {
|
|
67
|
+
type: 'root'
|
|
68
|
+
children: HastElement[]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function el(
|
|
72
|
+
tagName: string,
|
|
73
|
+
properties: Record<string, unknown> = {},
|
|
74
|
+
children: HastElement[] = []
|
|
75
|
+
): HastElement {
|
|
76
|
+
return { type: 'element', tagName, properties, children }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function root(...nodes: HastElement[]): HastRoot {
|
|
80
|
+
return { type: 'root', children: nodes }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function runRenderer(tree: HastRoot) {
|
|
84
|
+
const transform = (customRenderer as unknown as () => (tree: HastRoot) => void)()
|
|
85
|
+
transform(tree)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
describe('customRenderer', () => {
|
|
89
|
+
describe('heading styles', () => {
|
|
90
|
+
it('applies h1 className', () => {
|
|
91
|
+
const node = el('h1')
|
|
92
|
+
runRenderer(root(node))
|
|
93
|
+
expect(node.properties.className).toBe(
|
|
94
|
+
'text-3xl font-bold text-foreground mt-8 mb-4 scroll-mt-20'
|
|
95
|
+
)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('applies h2 className', () => {
|
|
99
|
+
const node = el('h2')
|
|
100
|
+
runRenderer(root(node))
|
|
101
|
+
expect(node.properties.className).toBe(
|
|
102
|
+
'text-2xl font-semibold text-foreground mt-6 mb-3 scroll-mt-20'
|
|
103
|
+
)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('applies h3 className', () => {
|
|
107
|
+
const node = el('h3')
|
|
108
|
+
runRenderer(root(node))
|
|
109
|
+
expect(node.properties.className).toBe(
|
|
110
|
+
'text-xl font-semibold text-foreground mt-4 mb-2 scroll-mt-20'
|
|
111
|
+
)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('applies h4 className', () => {
|
|
115
|
+
const node = el('h4')
|
|
116
|
+
runRenderer(root(node))
|
|
117
|
+
expect(node.properties.className).toBe(
|
|
118
|
+
'text-lg font-semibold text-foreground mt-3 mb-2 scroll-mt-20'
|
|
119
|
+
)
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('applies p className', () => {
|
|
124
|
+
const node = el('p')
|
|
125
|
+
runRenderer(root(node))
|
|
126
|
+
expect(node.properties.className).toBe('text-muted-foreground leading-relaxed mb-4')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('applies ul className', () => {
|
|
130
|
+
const node = el('ul')
|
|
131
|
+
runRenderer(root(node))
|
|
132
|
+
expect(node.properties.className).toBe('list-disc list-inside mb-4 space-y-2 ml-4')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('applies ol className', () => {
|
|
136
|
+
const node = el('ol')
|
|
137
|
+
runRenderer(root(node))
|
|
138
|
+
expect(node.properties.className).toBe('list-decimal list-inside mb-4 space-y-2 ml-4')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('applies li className', () => {
|
|
142
|
+
const node = el('li')
|
|
143
|
+
runRenderer(root(node))
|
|
144
|
+
expect(node.properties.className).toBe('mb-1')
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('applies blockquote className', () => {
|
|
148
|
+
const node = el('blockquote')
|
|
149
|
+
runRenderer(root(node))
|
|
150
|
+
expect(node.properties.className).toBe(
|
|
151
|
+
'border-l-4 border-primary pl-4 italic my-4 text-muted-foreground'
|
|
152
|
+
)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('applies pre className', () => {
|
|
156
|
+
const node = el('pre')
|
|
157
|
+
runRenderer(root(node))
|
|
158
|
+
expect(node.properties.className).toBe('bg-muted rounded-lg p-4 overflow-x-auto my-4')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('applies img className', () => {
|
|
162
|
+
const node = el('img')
|
|
163
|
+
runRenderer(root(node))
|
|
164
|
+
expect(node.properties.className).toBe('rounded-lg my-6 w-full max-w-2xl mx-auto')
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('applies table className', () => {
|
|
168
|
+
const node = el('table')
|
|
169
|
+
runRenderer(root(node))
|
|
170
|
+
expect(node.properties.className).toBe('border-collapse border border-border my-4 w-full')
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('applies th className', () => {
|
|
174
|
+
const node = el('th')
|
|
175
|
+
runRenderer(root(node))
|
|
176
|
+
expect(node.properties.className).toBe('border border-border px-2 py-1 bg-muted font-semibold')
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('applies td className', () => {
|
|
180
|
+
const node = el('td')
|
|
181
|
+
runRenderer(root(node))
|
|
182
|
+
expect(node.properties.className).toBe('border border-border px-2 py-1')
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('applies hr className', () => {
|
|
186
|
+
const node = el('hr')
|
|
187
|
+
runRenderer(root(node))
|
|
188
|
+
expect(node.properties.className).toBe('my-8 border-border')
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
describe('code element', () => {
|
|
192
|
+
it('appends to existing className', () => {
|
|
193
|
+
const node = el('code', { className: 'language-js' })
|
|
194
|
+
runRenderer(root(node))
|
|
195
|
+
expect(node.properties.className).toBe(
|
|
196
|
+
'language-js bg-muted px-1 py-0.5 rounded text-sm font-mono'
|
|
197
|
+
)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('sets className when none exists', () => {
|
|
201
|
+
const node = el('code')
|
|
202
|
+
runRenderer(root(node))
|
|
203
|
+
expect(node.properties.className).toBe('bg-muted px-1 py-0.5 rounded text-sm font-mono')
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
describe('anchor links', () => {
|
|
208
|
+
it('adds target="_blank" and rel to external https links', () => {
|
|
209
|
+
const node = el('a', { href: 'https://example.com' })
|
|
210
|
+
runRenderer(root(node))
|
|
211
|
+
expect(node.properties.target).toBe('_blank')
|
|
212
|
+
expect(node.properties.rel).toBe('noopener noreferrer')
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('does NOT add target="_blank" to internal anchor links (#)', () => {
|
|
216
|
+
const node = el('a', { href: '#section-id' })
|
|
217
|
+
runRenderer(root(node))
|
|
218
|
+
expect(node.properties.target).toBeUndefined()
|
|
219
|
+
expect(node.properties.rel).toBeUndefined()
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('does NOT add target="_blank" to any href starting with #', () => {
|
|
223
|
+
const node = el('a', { href: '#my-heading' })
|
|
224
|
+
runRenderer(root(node))
|
|
225
|
+
expect(node.properties.target).toBeUndefined()
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('applies link className to external anchors', () => {
|
|
229
|
+
const node = el('a', { href: 'https://external.com' })
|
|
230
|
+
runRenderer(root(node))
|
|
231
|
+
expect(node.properties.className).toBe(
|
|
232
|
+
'text-primary hover:underline transition-colors duration-200'
|
|
233
|
+
)
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('applies link className to internal anchors', () => {
|
|
237
|
+
const node = el('a', { href: '#internal' })
|
|
238
|
+
runRenderer(root(node))
|
|
239
|
+
expect(node.properties.className).toBe(
|
|
240
|
+
'text-primary hover:underline transition-colors duration-200'
|
|
241
|
+
)
|
|
242
|
+
})
|
|
243
|
+
})
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
// markdownToHtml - pipeline is mocked so we only verify the function resolves
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
describe('markdownToHtml', () => {
|
|
251
|
+
it('returns a string', async () => {
|
|
252
|
+
const result = await markdownToHtml('# Hello')
|
|
253
|
+
expect(typeof result).toBe('string')
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('resolves without throwing for empty input', async () => {
|
|
257
|
+
await expect(markdownToHtml('')).resolves.toBeDefined()
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('resolves without throwing when articleSlug is provided', async () => {
|
|
261
|
+
await expect(markdownToHtml('', 'my-article')).resolves.toBeDefined()
|
|
262
|
+
})
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
// extractToc - pipeline is mocked so we verify it returns an array
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
describe('extractToc', () => {
|
|
270
|
+
it('returns an array', async () => {
|
|
271
|
+
const result = await extractToc('## Hello')
|
|
272
|
+
expect(Array.isArray(result)).toBe(true)
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('resolves without throwing for empty input', async () => {
|
|
276
|
+
await expect(extractToc('')).resolves.toBeDefined()
|
|
277
|
+
})
|
|
278
|
+
})
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import React from 'react'
|
|
5
|
+
import { render } from '@testing-library/react'
|
|
6
|
+
|
|
7
|
+
const mockEvaluate = jest.fn()
|
|
8
|
+
|
|
9
|
+
jest.mock('@mdx-js/mdx', () => ({
|
|
10
|
+
evaluate: mockEvaluate,
|
|
11
|
+
}))
|
|
12
|
+
|
|
13
|
+
jest.mock('rehype-slug', () => () => () => {})
|
|
14
|
+
jest.mock('rehype-prism-plus', () => () => () => {})
|
|
15
|
+
jest.mock('remark-gfm', () => () => () => {})
|
|
16
|
+
jest.mock('remark-github-blockquote-alert', () => () => () => {})
|
|
17
|
+
jest.mock('../markdown', () => ({ customRenderer: () => () => {} }))
|
|
18
|
+
|
|
19
|
+
const originalNodeEnv = process.env.NODE_ENV
|
|
20
|
+
|
|
21
|
+
describe('renderMdxSource', () => {
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
jest.resetModules()
|
|
24
|
+
mockEvaluate.mockReset()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
Object.defineProperty(process.env, 'NODE_ENV', { value: originalNodeEnv, writable: true })
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('renders an MDX article in production mode', async () => {
|
|
32
|
+
mockEvaluate.mockResolvedValue({
|
|
33
|
+
default: () => React.createElement('p', null, 'Hello MDX'),
|
|
34
|
+
})
|
|
35
|
+
Object.defineProperty(process.env, 'NODE_ENV', { value: 'production', writable: true })
|
|
36
|
+
|
|
37
|
+
const { renderMdxSource } = await import('../renderMdx')
|
|
38
|
+
const el = await renderMdxSource('# Hello')
|
|
39
|
+
const { container } = render(el as React.ReactElement)
|
|
40
|
+
|
|
41
|
+
expect(container.querySelector('p')).not.toBeNull()
|
|
42
|
+
expect(container.querySelector('p')?.textContent).toBe('Hello MDX')
|
|
43
|
+
expect(mockEvaluate).toHaveBeenCalledWith(
|
|
44
|
+
'# Hello',
|
|
45
|
+
expect.objectContaining({ development: false })
|
|
46
|
+
)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('renders an MDX article in development mode without React dev runtime errors', async () => {
|
|
50
|
+
mockEvaluate.mockResolvedValue({
|
|
51
|
+
default: () => React.createElement('p', null, 'Hello Dev MDX'),
|
|
52
|
+
})
|
|
53
|
+
Object.defineProperty(process.env, 'NODE_ENV', { value: 'development', writable: true })
|
|
54
|
+
|
|
55
|
+
const { renderMdxSource } = await import('../renderMdx')
|
|
56
|
+
const el = await renderMdxSource('# Hello Dev')
|
|
57
|
+
const { container } = render(el as React.ReactElement)
|
|
58
|
+
|
|
59
|
+
expect(container.querySelector('p')).not.toBeNull()
|
|
60
|
+
expect(container.querySelector('p')?.textContent).toBe('Hello Dev MDX')
|
|
61
|
+
expect(mockEvaluate).toHaveBeenCalledWith(
|
|
62
|
+
'# Hello Dev',
|
|
63
|
+
expect.objectContaining({ development: true })
|
|
64
|
+
)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('passes production runtime (jsx/jsxs) when NODE_ENV is production', async () => {
|
|
68
|
+
mockEvaluate.mockResolvedValue({ default: () => React.createElement('span', null) })
|
|
69
|
+
Object.defineProperty(process.env, 'NODE_ENV', { value: 'production', writable: true })
|
|
70
|
+
|
|
71
|
+
const { renderMdxSource } = await import('../renderMdx')
|
|
72
|
+
await renderMdxSource('# Test')
|
|
73
|
+
|
|
74
|
+
const callArgs = mockEvaluate.mock.calls[0][1]
|
|
75
|
+
expect(callArgs).not.toHaveProperty('jsxDEV')
|
|
76
|
+
expect(callArgs).toHaveProperty('jsx')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('passes dev runtime (jsxDEV) when NODE_ENV is development', async () => {
|
|
80
|
+
mockEvaluate.mockResolvedValue({ default: () => React.createElement('span', null) })
|
|
81
|
+
Object.defineProperty(process.env, 'NODE_ENV', { value: 'development', writable: true })
|
|
82
|
+
|
|
83
|
+
const { renderMdxSource } = await import('../renderMdx')
|
|
84
|
+
await renderMdxSource('# Test')
|
|
85
|
+
|
|
86
|
+
const callArgs = mockEvaluate.mock.calls[0][1]
|
|
87
|
+
expect(callArgs).toHaveProperty('jsxDEV')
|
|
88
|
+
expect(callArgs).not.toHaveProperty('jsx')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe('inline JSX style rendering', () => {
|
|
92
|
+
it('renders inline color style on a JSX heading', async () => {
|
|
93
|
+
mockEvaluate.mockResolvedValue({
|
|
94
|
+
default: () => React.createElement('h2', { style: { color: 'red' } }, 'Colored Heading'),
|
|
95
|
+
})
|
|
96
|
+
Object.defineProperty(process.env, 'NODE_ENV', { value: 'production', writable: true })
|
|
97
|
+
|
|
98
|
+
const { renderMdxSource } = await import('../renderMdx')
|
|
99
|
+
const el = await renderMdxSource('<h2 style={{ color: "red" }}>Colored Heading</h2>')
|
|
100
|
+
const { container } = render(el as React.ReactElement)
|
|
101
|
+
|
|
102
|
+
const h2 = container.querySelector('h2')
|
|
103
|
+
expect(h2).not.toBeNull()
|
|
104
|
+
expect(h2?.style.color).toBe('red')
|
|
105
|
+
expect(h2?.textContent).toBe('Colored Heading')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('preserves multiple inline style properties', async () => {
|
|
109
|
+
mockEvaluate.mockResolvedValue({
|
|
110
|
+
default: () =>
|
|
111
|
+
React.createElement(
|
|
112
|
+
'h3',
|
|
113
|
+
{ style: { color: 'blue', fontWeight: 'bold' } },
|
|
114
|
+
'Styled Heading'
|
|
115
|
+
),
|
|
116
|
+
})
|
|
117
|
+
Object.defineProperty(process.env, 'NODE_ENV', { value: 'production', writable: true })
|
|
118
|
+
|
|
119
|
+
const { renderMdxSource } = await import('../renderMdx')
|
|
120
|
+
const el = await renderMdxSource(
|
|
121
|
+
'<h3 style={{ color: "blue", fontWeight: "bold" }}>Styled Heading</h3>'
|
|
122
|
+
)
|
|
123
|
+
const { container } = render(el as React.ReactElement)
|
|
124
|
+
|
|
125
|
+
const h3 = container.querySelector('h3')
|
|
126
|
+
expect(h3?.style.color).toBe('blue')
|
|
127
|
+
expect(h3?.style.fontWeight).toBe('bold')
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
describe('anchor link rendering', () => {
|
|
132
|
+
it('renders heading with id for internal anchor navigation', async () => {
|
|
133
|
+
mockEvaluate.mockResolvedValue({
|
|
134
|
+
default: () =>
|
|
135
|
+
React.createElement(
|
|
136
|
+
'div',
|
|
137
|
+
null,
|
|
138
|
+
React.createElement('h2', { id: 'my-heading' }, 'My Heading'),
|
|
139
|
+
React.createElement('a', { href: '#my-heading' }, 'Jump to heading')
|
|
140
|
+
),
|
|
141
|
+
})
|
|
142
|
+
Object.defineProperty(process.env, 'NODE_ENV', { value: 'production', writable: true })
|
|
143
|
+
|
|
144
|
+
const { renderMdxSource } = await import('../renderMdx')
|
|
145
|
+
const el = await renderMdxSource('## My Heading\n\n[Jump to heading](#my-heading)')
|
|
146
|
+
const { container } = render(el as React.ReactElement)
|
|
147
|
+
|
|
148
|
+
const heading = container.querySelector('h2#my-heading')
|
|
149
|
+
expect(heading).not.toBeNull()
|
|
150
|
+
expect(heading?.textContent).toBe('My Heading')
|
|
151
|
+
|
|
152
|
+
const link = container.querySelector('a[href="#my-heading"]')
|
|
153
|
+
expect(link).not.toBeNull()
|
|
154
|
+
expect(link?.textContent).toBe('Jump to heading')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('supports multiple headings with unique ids for in-page navigation', async () => {
|
|
158
|
+
mockEvaluate.mockResolvedValue({
|
|
159
|
+
default: () =>
|
|
160
|
+
React.createElement(
|
|
161
|
+
'div',
|
|
162
|
+
null,
|
|
163
|
+
React.createElement('h2', { id: 'section-one' }, 'Section One'),
|
|
164
|
+
React.createElement('h2', { id: 'section-two' }, 'Section Two'),
|
|
165
|
+
React.createElement('a', { href: '#section-one' }, 'Go to one'),
|
|
166
|
+
React.createElement('a', { href: '#section-two' }, 'Go to two')
|
|
167
|
+
),
|
|
168
|
+
})
|
|
169
|
+
Object.defineProperty(process.env, 'NODE_ENV', { value: 'production', writable: true })
|
|
170
|
+
|
|
171
|
+
const { renderMdxSource } = await import('../renderMdx')
|
|
172
|
+
const el = await renderMdxSource(
|
|
173
|
+
'## Section One\n\n## Section Two\n\n[Go to one](#section-one) [Go to two](#section-two)'
|
|
174
|
+
)
|
|
175
|
+
const { container } = render(el as React.ReactElement)
|
|
176
|
+
|
|
177
|
+
expect(container.querySelector('h2#section-one')).not.toBeNull()
|
|
178
|
+
expect(container.querySelector('h2#section-two')).not.toBeNull()
|
|
179
|
+
expect(container.querySelector('a[href="#section-one"]')).not.toBeNull()
|
|
180
|
+
expect(container.querySelector('a[href="#section-two"]')).not.toBeNull()
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
describe('basePath image resolution', () => {
|
|
185
|
+
it('resolves relative img src to absolute path when basePath is provided', async () => {
|
|
186
|
+
mockEvaluate.mockResolvedValue({
|
|
187
|
+
default: ({
|
|
188
|
+
components,
|
|
189
|
+
}: {
|
|
190
|
+
components?: Record<string, React.ComponentType<unknown>>
|
|
191
|
+
}) => {
|
|
192
|
+
const Img = components?.img as React.ComponentType<
|
|
193
|
+
React.ImgHTMLAttributes<HTMLImageElement>
|
|
194
|
+
>
|
|
195
|
+
return Img
|
|
196
|
+
? React.createElement(Img, { src: 'photo.png', alt: 'test' })
|
|
197
|
+
: React.createElement('img', { src: 'photo.png', alt: 'test' })
|
|
198
|
+
},
|
|
199
|
+
})
|
|
200
|
+
Object.defineProperty(process.env, 'NODE_ENV', { value: 'production', writable: true })
|
|
201
|
+
|
|
202
|
+
const { renderMdxSource } = await import('../renderMdx')
|
|
203
|
+
const el = await renderMdxSource('', '/articles/my-article')
|
|
204
|
+
const { container } = render(el as React.ReactElement)
|
|
205
|
+
|
|
206
|
+
const img = container.querySelector('img')
|
|
207
|
+
expect(img).not.toBeNull()
|
|
208
|
+
expect(img?.getAttribute('src')).toBe('/articles/my-article/photo.png')
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('does not modify absolute src when basePath is provided', async () => {
|
|
212
|
+
mockEvaluate.mockResolvedValue({
|
|
213
|
+
default: ({
|
|
214
|
+
components,
|
|
215
|
+
}: {
|
|
216
|
+
components?: Record<string, React.ComponentType<unknown>>
|
|
217
|
+
}) => {
|
|
218
|
+
const Img = components?.img as React.ComponentType<
|
|
219
|
+
React.ImgHTMLAttributes<HTMLImageElement>
|
|
220
|
+
>
|
|
221
|
+
return Img
|
|
222
|
+
? React.createElement(Img, { src: '/already/absolute.png', alt: 'test' })
|
|
223
|
+
: React.createElement('img', { src: '/already/absolute.png', alt: 'test' })
|
|
224
|
+
},
|
|
225
|
+
})
|
|
226
|
+
Object.defineProperty(process.env, 'NODE_ENV', { value: 'production', writable: true })
|
|
227
|
+
|
|
228
|
+
const { renderMdxSource } = await import('../renderMdx')
|
|
229
|
+
const el = await renderMdxSource('', '/articles/my-article')
|
|
230
|
+
const { container } = render(el as React.ReactElement)
|
|
231
|
+
|
|
232
|
+
const img = container.querySelector('img')
|
|
233
|
+
expect(img?.getAttribute('src')).toBe('/already/absolute.png')
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('does not modify http src when basePath is provided', async () => {
|
|
237
|
+
mockEvaluate.mockResolvedValue({
|
|
238
|
+
default: ({
|
|
239
|
+
components,
|
|
240
|
+
}: {
|
|
241
|
+
components?: Record<string, React.ComponentType<unknown>>
|
|
242
|
+
}) => {
|
|
243
|
+
const Img = components?.img as React.ComponentType<
|
|
244
|
+
React.ImgHTMLAttributes<HTMLImageElement>
|
|
245
|
+
>
|
|
246
|
+
return Img
|
|
247
|
+
? React.createElement(Img, { src: 'https://example.com/img.png', alt: 'test' })
|
|
248
|
+
: React.createElement('img', { src: 'https://example.com/img.png', alt: 'test' })
|
|
249
|
+
},
|
|
250
|
+
})
|
|
251
|
+
Object.defineProperty(process.env, 'NODE_ENV', { value: 'production', writable: true })
|
|
252
|
+
|
|
253
|
+
const { renderMdxSource } = await import('../renderMdx')
|
|
254
|
+
const el = await renderMdxSource(
|
|
255
|
+
'',
|
|
256
|
+
'/articles/my-article'
|
|
257
|
+
)
|
|
258
|
+
const { container } = render(el as React.ReactElement)
|
|
259
|
+
|
|
260
|
+
const img = container.querySelector('img')
|
|
261
|
+
expect(img?.getAttribute('src')).toBe('https://example.com/img.png')
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('does not inject img component when basePath is not provided', async () => {
|
|
265
|
+
let capturedComponents: Record<string, unknown> | undefined
|
|
266
|
+
mockEvaluate.mockResolvedValue({
|
|
267
|
+
default: ({ components }: { components?: Record<string, unknown> }) => {
|
|
268
|
+
capturedComponents = components
|
|
269
|
+
return React.createElement('span', null)
|
|
270
|
+
},
|
|
271
|
+
})
|
|
272
|
+
Object.defineProperty(process.env, 'NODE_ENV', { value: 'production', writable: true })
|
|
273
|
+
|
|
274
|
+
const { renderMdxSource } = await import('../renderMdx')
|
|
275
|
+
await renderMdxSource('# Test')
|
|
276
|
+
|
|
277
|
+
expect(capturedComponents).toBeUndefined()
|
|
278
|
+
})
|
|
279
|
+
})
|
|
280
|
+
})
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs'
|
|
2
|
-
import
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { getArticleMetadata, getAvailableArticleSlugs } from '../server-articles'
|
|
3
4
|
|
|
4
5
|
jest.mock('react', () => ({ cache: (fn: Function) => fn }))
|
|
5
6
|
jest.mock('node:fs')
|
|
@@ -10,6 +11,14 @@ jest.mock('../markdown', () => ({
|
|
|
10
11
|
jest.mock('reading-time', () => () => ({ text: '2 min read' }))
|
|
11
12
|
|
|
12
13
|
const mockedFs = fs as jest.Mocked<typeof fs>
|
|
14
|
+
const articlesDirectory = path.join(process.cwd(), 'public/articles')
|
|
15
|
+
|
|
16
|
+
function mockDirectory(name: string): fs.Dirent {
|
|
17
|
+
return {
|
|
18
|
+
name,
|
|
19
|
+
isDirectory: () => true,
|
|
20
|
+
} as fs.Dirent
|
|
21
|
+
}
|
|
13
22
|
|
|
14
23
|
function setupArticleMock(frontmatter: string, body = 'Article body content.'): void {
|
|
15
24
|
mockedFs.existsSync.mockReturnValue(true)
|
|
@@ -96,3 +105,29 @@ describe('getArticleMetadata - frontmatter parsing', () => {
|
|
|
96
105
|
expect(article!.howTo).toBeUndefined()
|
|
97
106
|
})
|
|
98
107
|
})
|
|
108
|
+
|
|
109
|
+
describe('getAvailableArticleSlugs', () => {
|
|
110
|
+
beforeEach(() => {
|
|
111
|
+
jest.clearAllMocks()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('returns slugs for article directories nested below a grouping directory', () => {
|
|
115
|
+
mockedFs.existsSync.mockImplementation((target) => {
|
|
116
|
+
const targetPath = target.toString()
|
|
117
|
+
return (
|
|
118
|
+
targetPath === articlesDirectory ||
|
|
119
|
+
targetPath === path.join(articlesDirectory, 'game-system', 'article-name', 'article.mdx')
|
|
120
|
+
)
|
|
121
|
+
})
|
|
122
|
+
;(mockedFs.readdirSync as jest.Mock).mockImplementation((target: fs.PathLike) => {
|
|
123
|
+
const targetPath = target.toString()
|
|
124
|
+
if (targetPath === articlesDirectory) return [mockDirectory('game-system')]
|
|
125
|
+
if (targetPath === path.join(articlesDirectory, 'game-system')) {
|
|
126
|
+
return [mockDirectory('article-name')]
|
|
127
|
+
}
|
|
128
|
+
return []
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
expect(getAvailableArticleSlugs()).toEqual(['game-system/article-name'])
|
|
132
|
+
})
|
|
133
|
+
})
|
package/src/articlesConfig.ts
CHANGED
|
@@ -98,6 +98,8 @@ export interface ArticlesConfig {
|
|
|
98
98
|
description?: string
|
|
99
99
|
/** Set to false to hide the table of contents on article detail pages. Default: true. */
|
|
100
100
|
showToc?: boolean
|
|
101
|
+
/** Set to false to hide the "Back to Articles" navigation link on article detail pages. Default: true. */
|
|
102
|
+
showBackToArticles?: boolean
|
|
101
103
|
}
|
|
102
104
|
|
|
103
105
|
export const DEFAULT_PAGE_SIZE = 6
|
package/src/index.ts
CHANGED
|
@@ -21,6 +21,7 @@ export {
|
|
|
21
21
|
|
|
22
22
|
export { ArticleSocialShare } from './ArticleSocialShare'
|
|
23
23
|
export { ArticleNavigation } from './ArticleNavigation'
|
|
24
|
+
export { ArticleBackLink } from './ArticleBackLink'
|
|
24
25
|
export { ArticleTOC } from './ArticleTOC'
|
|
25
26
|
export { ScrollToTop } from './ScrollToTop'
|
|
26
27
|
|