@fullstackdatasolutions/articles 0.6.0 → 0.7.1
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 +71 -12
- package/dist/index.cjs +68 -51
- 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 +66 -50
- package/dist/index.js.map +1 -1
- package/dist/server.cjs +70 -18
- 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 +69 -17
- package/dist/server.js.map +1 -1
- package/package.json +34 -20
- package/src/ArticleBackLink.tsx +32 -0
- package/src/ArticleContent.tsx +4 -7
- package/src/ArticleSocialShare.tsx +2 -7
- package/src/__tests__/ArticleBackLink.test.tsx +89 -0
- package/src/__tests__/ArticleContent.test.tsx +72 -28
- package/src/__tests__/ArticleSocialShare.test.tsx +7 -12
- 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 +310 -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 +1 -2
|
@@ -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,6 +1,16 @@
|
|
|
1
1
|
import fs from 'node:fs'
|
|
2
2
|
import path from 'node:path'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
categoryToSlug,
|
|
5
|
+
getAdjacentArticles,
|
|
6
|
+
getAllArticles,
|
|
7
|
+
getAllCategories,
|
|
8
|
+
getArticleMetadata,
|
|
9
|
+
getArticlesByCategory,
|
|
10
|
+
getAvailableArticleSlugs,
|
|
11
|
+
sanitizeImagePath,
|
|
12
|
+
searchArticles,
|
|
13
|
+
} from '../server-articles'
|
|
4
14
|
|
|
5
15
|
jest.mock('react', () => ({ cache: (fn: Function) => fn }))
|
|
6
16
|
jest.mock('node:fs')
|
|
@@ -28,6 +38,58 @@ function setupArticleMock(frontmatter: string, body = 'Article body content.'):
|
|
|
28
38
|
;(mockedFs.readdirSync as jest.Mock).mockReturnValue([])
|
|
29
39
|
}
|
|
30
40
|
|
|
41
|
+
interface MockArticle {
|
|
42
|
+
readonly slug: string
|
|
43
|
+
readonly frontmatter: string
|
|
44
|
+
readonly body?: string
|
|
45
|
+
readonly contentType?: 'md' | 'mdx'
|
|
46
|
+
readonly files?: readonly string[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function setupArticleTreeMock(articles: readonly MockArticle[]): void {
|
|
50
|
+
const articleMap = new Map(articles.map((article) => [article.slug, article]))
|
|
51
|
+
const directoryChildren = new Map<string, Set<string>>()
|
|
52
|
+
const articleFilePaths = new Map<string, MockArticle>()
|
|
53
|
+
|
|
54
|
+
directoryChildren.set(articlesDirectory, new Set())
|
|
55
|
+
|
|
56
|
+
for (const article of articles) {
|
|
57
|
+
const segments = article.slug.split('/')
|
|
58
|
+
let currentDir = articlesDirectory
|
|
59
|
+
segments.forEach((segment, index) => {
|
|
60
|
+
if (!directoryChildren.has(currentDir)) directoryChildren.set(currentDir, new Set())
|
|
61
|
+
directoryChildren.get(currentDir)!.add(segment)
|
|
62
|
+
currentDir = path.join(currentDir, segment)
|
|
63
|
+
if (index < segments.length - 1 && !directoryChildren.has(currentDir)) {
|
|
64
|
+
directoryChildren.set(currentDir, new Set())
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
const extension = article.contentType === 'mdx' ? 'article.mdx' : 'article.md'
|
|
69
|
+
articleFilePaths.set(path.join(articlesDirectory, article.slug, extension), article)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
mockedFs.existsSync.mockImplementation((target) => {
|
|
73
|
+
const targetPath = target.toString()
|
|
74
|
+
return targetPath === articlesDirectory || articleFilePaths.has(targetPath)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
mockedFs.readFileSync.mockImplementation((target) => {
|
|
78
|
+
const article = articleFilePaths.get(target.toString())
|
|
79
|
+
if (!article) throw new Error(`Unexpected file read: ${target.toString()}`)
|
|
80
|
+
return `---\ntitle: ${article.slug}\nexcerpt: Excerpt for ${article.slug}\nauthor: Test Author\n${article.frontmatter}---\n\n${article.body ?? 'Article body content.'}`
|
|
81
|
+
})
|
|
82
|
+
;(mockedFs.readdirSync as jest.Mock).mockImplementation((target: fs.PathLike) => {
|
|
83
|
+
const targetPath = target.toString()
|
|
84
|
+
const children = directoryChildren.get(targetPath)
|
|
85
|
+
if (!children) {
|
|
86
|
+
const slug = path.relative(articlesDirectory, targetPath)
|
|
87
|
+
return articleMap.get(slug)?.files ?? []
|
|
88
|
+
}
|
|
89
|
+
return Array.from(children).map(mockDirectory)
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
31
93
|
describe('getArticleMetadata - frontmatter parsing', () => {
|
|
32
94
|
beforeEach(() => {
|
|
33
95
|
jest.clearAllMocks()
|
|
@@ -130,4 +192,251 @@ describe('getAvailableArticleSlugs', () => {
|
|
|
130
192
|
|
|
131
193
|
expect(getAvailableArticleSlugs()).toEqual(['game-system/article-name'])
|
|
132
194
|
})
|
|
195
|
+
|
|
196
|
+
it('continues walking inside directories that are also articles', () => {
|
|
197
|
+
mockedFs.existsSync.mockImplementation((target) => {
|
|
198
|
+
const targetPath = target.toString()
|
|
199
|
+
return (
|
|
200
|
+
targetPath === articlesDirectory ||
|
|
201
|
+
targetPath === path.join(articlesDirectory, 'tov', 'article.md') ||
|
|
202
|
+
targetPath ===
|
|
203
|
+
path.join(articlesDirectory, 'tov', 'subdirectory', 'article-name', 'article.md')
|
|
204
|
+
)
|
|
205
|
+
})
|
|
206
|
+
;(mockedFs.readdirSync as jest.Mock).mockImplementation((target: fs.PathLike) => {
|
|
207
|
+
const targetPath = target.toString()
|
|
208
|
+
if (targetPath === articlesDirectory) return [mockDirectory('tov')]
|
|
209
|
+
if (targetPath === path.join(articlesDirectory, 'tov')) {
|
|
210
|
+
return [mockDirectory('subdirectory')]
|
|
211
|
+
}
|
|
212
|
+
if (targetPath === path.join(articlesDirectory, 'tov', 'subdirectory')) {
|
|
213
|
+
return [mockDirectory('article-name')]
|
|
214
|
+
}
|
|
215
|
+
return []
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
expect(getAvailableArticleSlugs()).toEqual(['tov', 'tov/subdirectory/article-name'])
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('returns an empty list when the articles directory is missing', () => {
|
|
222
|
+
mockedFs.existsSync.mockReturnValue(false)
|
|
223
|
+
|
|
224
|
+
expect(getAvailableArticleSlugs()).toEqual([])
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
describe('article collection utilities', () => {
|
|
229
|
+
const originalEnv = process.env.NODE_ENV
|
|
230
|
+
|
|
231
|
+
beforeEach(() => {
|
|
232
|
+
jest.clearAllMocks()
|
|
233
|
+
Object.defineProperty(process.env, 'NODE_ENV', {
|
|
234
|
+
value: 'test',
|
|
235
|
+
configurable: true,
|
|
236
|
+
})
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
afterAll(() => {
|
|
240
|
+
Object.defineProperty(process.env, 'NODE_ENV', {
|
|
241
|
+
value: originalEnv,
|
|
242
|
+
configurable: true,
|
|
243
|
+
})
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('loads articles, resolves metadata defaults, filters future articles, and sorts by date', async () => {
|
|
247
|
+
setupArticleTreeMock([
|
|
248
|
+
{
|
|
249
|
+
slug: 'first',
|
|
250
|
+
frontmatter: 'date: 2025-01-02\ntags:\n - Civic Tech\nfeaturedImage: hero.png\n',
|
|
251
|
+
files: ['hero.png'],
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
slug: 'second',
|
|
255
|
+
frontmatter: 'date: 2025-01-03\ntags:\n - Campaigns\n',
|
|
256
|
+
files: ['cover.webp'],
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
slug: 'future',
|
|
260
|
+
frontmatter: 'date: 2999-01-01\ntags:\n - Future\n',
|
|
261
|
+
},
|
|
262
|
+
])
|
|
263
|
+
|
|
264
|
+
const articles = await getAllArticles()
|
|
265
|
+
|
|
266
|
+
expect(articles.map((article) => article.slug)).toEqual(['second', 'first'])
|
|
267
|
+
expect(articles[0]).toEqual(
|
|
268
|
+
expect.objectContaining({
|
|
269
|
+
title: 'second',
|
|
270
|
+
date: '2025-01-03',
|
|
271
|
+
author: 'Test Author',
|
|
272
|
+
category: 'Campaigns',
|
|
273
|
+
categories: ['Campaigns'],
|
|
274
|
+
featuredImage: '/articles/second/cover.webp',
|
|
275
|
+
contentType: 'md',
|
|
276
|
+
draft: false,
|
|
277
|
+
})
|
|
278
|
+
)
|
|
279
|
+
expect(articles[1].featuredImage).toBe('/articles/first/hero.png')
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it('keeps draft articles outside production and filters them in production', async () => {
|
|
283
|
+
setupArticleTreeMock([
|
|
284
|
+
{
|
|
285
|
+
slug: 'draft',
|
|
286
|
+
frontmatter: 'date: 2025-01-01\ndraft: true\n',
|
|
287
|
+
},
|
|
288
|
+
])
|
|
289
|
+
|
|
290
|
+
await expect(getAllArticles()).resolves.toHaveLength(1)
|
|
291
|
+
|
|
292
|
+
Object.defineProperty(process.env, 'NODE_ENV', {
|
|
293
|
+
value: 'production',
|
|
294
|
+
configurable: true,
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
await expect(getAllArticles()).resolves.toEqual([])
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('returns adjacent articles based on sorted article order', async () => {
|
|
301
|
+
setupArticleTreeMock([
|
|
302
|
+
{ slug: 'oldest', frontmatter: 'date: 2025-01-01\n' },
|
|
303
|
+
{ slug: 'middle', frontmatter: 'date: 2025-01-02\n' },
|
|
304
|
+
{ slug: 'newest', frontmatter: 'date: 2025-01-03\n' },
|
|
305
|
+
])
|
|
306
|
+
|
|
307
|
+
await expect(getAdjacentArticles('middle')).resolves.toEqual({
|
|
308
|
+
previous: expect.objectContaining({ slug: 'oldest' }),
|
|
309
|
+
next: expect.objectContaining({ slug: 'newest' }),
|
|
310
|
+
})
|
|
311
|
+
await expect(getAdjacentArticles('missing')).resolves.toEqual({ previous: null, next: null })
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
it('searches articles by title, excerpt, author, category, and tag', async () => {
|
|
315
|
+
setupArticleTreeMock([
|
|
316
|
+
{
|
|
317
|
+
slug: 'civic-article',
|
|
318
|
+
frontmatter: 'date: 2025-01-01\ntags:\n - Civic Tech\n - voting-tools\n',
|
|
319
|
+
body: 'Civic body',
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
slug: 'organizing-article',
|
|
323
|
+
frontmatter: 'date: 2025-01-02\ntags:\n - Field Ops\n',
|
|
324
|
+
body: 'Organizing body',
|
|
325
|
+
},
|
|
326
|
+
])
|
|
327
|
+
|
|
328
|
+
await expect(searchArticles('')).resolves.toHaveLength(2)
|
|
329
|
+
await expect(searchArticles('civic')).resolves.toEqual([
|
|
330
|
+
expect.objectContaining({ slug: 'civic-article' }),
|
|
331
|
+
])
|
|
332
|
+
await expect(searchArticles('field ops')).resolves.toEqual([
|
|
333
|
+
expect.objectContaining({ slug: 'organizing-article' }),
|
|
334
|
+
])
|
|
335
|
+
await expect(searchArticles('voting-tools')).resolves.toEqual([
|
|
336
|
+
expect.objectContaining({ slug: 'civic-article' }),
|
|
337
|
+
])
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
it('groups categories and filters articles by category slug', async () => {
|
|
341
|
+
setupArticleTreeMock([
|
|
342
|
+
{
|
|
343
|
+
slug: 'one',
|
|
344
|
+
frontmatter: 'date: 2025-01-01\ntags:\n - Civic Tech\n',
|
|
345
|
+
files: ['one.jpg'],
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
slug: 'two',
|
|
349
|
+
frontmatter: 'date: 2025-01-02\ntags:\n - Civic Tech\n - Field Ops\n',
|
|
350
|
+
},
|
|
351
|
+
])
|
|
352
|
+
|
|
353
|
+
await expect(getAllCategories()).resolves.toEqual([
|
|
354
|
+
expect.objectContaining({ name: 'Civic Tech', slug: 'civic-tech', count: 2 }),
|
|
355
|
+
expect.objectContaining({ name: 'Field Ops', slug: 'field-ops', count: 1 }),
|
|
356
|
+
])
|
|
357
|
+
await expect(getArticlesByCategory('field-ops')).resolves.toEqual([
|
|
358
|
+
expect.objectContaining({ slug: 'two' }),
|
|
359
|
+
])
|
|
360
|
+
})
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
describe('getArticleMetadata - content handling', () => {
|
|
364
|
+
beforeEach(() => {
|
|
365
|
+
jest.clearAllMocks()
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
it('loads markdown articles with rendered html content and toc', async () => {
|
|
369
|
+
setupArticleTreeMock([
|
|
370
|
+
{
|
|
371
|
+
slug: 'markdown-article',
|
|
372
|
+
frontmatter:
|
|
373
|
+
'date: 2025-01-01\nlastmod: 2025-01-05\ntags:\n - Civic Tech\nfeaturedImage: nested/hero.png\n',
|
|
374
|
+
body: '## Heading\n\nMarkdown body',
|
|
375
|
+
},
|
|
376
|
+
])
|
|
377
|
+
|
|
378
|
+
const article = await getArticleMetadata('markdown-article')
|
|
379
|
+
|
|
380
|
+
expect(article).toEqual(
|
|
381
|
+
expect.objectContaining({
|
|
382
|
+
slug: 'markdown-article',
|
|
383
|
+
htmlContent: '<p>content</p>',
|
|
384
|
+
mdxSource: undefined,
|
|
385
|
+
date: '2025-01-01',
|
|
386
|
+
lastmod: '2025-01-05',
|
|
387
|
+
featuredImage: '/articles/markdown-article/nested/hero.png',
|
|
388
|
+
toc: [],
|
|
389
|
+
})
|
|
390
|
+
)
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
it('loads mdx articles as source instead of rendered html', async () => {
|
|
394
|
+
setupArticleTreeMock([
|
|
395
|
+
{
|
|
396
|
+
slug: 'mdx-article',
|
|
397
|
+
contentType: 'mdx',
|
|
398
|
+
frontmatter: 'date: 2025-01-01\n',
|
|
399
|
+
body: '<Component />',
|
|
400
|
+
},
|
|
401
|
+
])
|
|
402
|
+
|
|
403
|
+
const article = await getArticleMetadata('mdx-article')
|
|
404
|
+
|
|
405
|
+
expect(article).toEqual(
|
|
406
|
+
expect.objectContaining({
|
|
407
|
+
slug: 'mdx-article',
|
|
408
|
+
contentType: 'mdx',
|
|
409
|
+
htmlContent: undefined,
|
|
410
|
+
})
|
|
411
|
+
)
|
|
412
|
+
expect(article?.mdxSource?.trim()).toBe('<Component />')
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
it('returns null when article file loading fails', async () => {
|
|
416
|
+
mockedFs.existsSync.mockReturnValue(true)
|
|
417
|
+
mockedFs.readFileSync.mockImplementation(() => {
|
|
418
|
+
throw new Error('read failed')
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
await expect(getArticleMetadata('broken')).resolves.toBeNull()
|
|
422
|
+
})
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
describe('article path helpers', () => {
|
|
426
|
+
it('sanitizes image paths and rejects unsafe values', () => {
|
|
427
|
+
expect(sanitizeImagePath('image.png', 'article')).toBe('/articles/article/image.png')
|
|
428
|
+
expect(sanitizeImagePath('nested/image.png', 'article')).toBe(
|
|
429
|
+
'/articles/article/nested/image.png'
|
|
430
|
+
)
|
|
431
|
+
expect(sanitizeImagePath('https://example.com/image.png', 'article')).toBe(
|
|
432
|
+
'https://example.com/image.png'
|
|
433
|
+
)
|
|
434
|
+
expect(sanitizeImagePath('../secret.png', 'article')).toBeNull()
|
|
435
|
+
expect(sanitizeImagePath('/secret.png', 'article')).toBeNull()
|
|
436
|
+
expect(sanitizeImagePath('bad image.png', 'article')).toBeNull()
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
it('creates normalized category slugs', () => {
|
|
440
|
+
expect(categoryToSlug('Civic Tech & Field Ops')).toBe('civic-tech--field-ops')
|
|
441
|
+
})
|
|
133
442
|
})
|
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
|
|
package/src/markdown.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { Element, Root, ElementContent } from 'hast'
|
|
2
|
-
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
|
|
3
2
|
import rehypePrism from 'rehype-prism-plus'
|
|
4
3
|
import rehypeSanitize from 'rehype-sanitize'
|
|
5
4
|
import rehypeSlug from 'rehype-slug'
|
|
@@ -157,7 +156,7 @@ function processFootnotesSection(node: Element): void {
|
|
|
157
156
|
if (node.properties) node.properties.className = undefined
|
|
158
157
|
}
|
|
159
158
|
|
|
160
|
-
const customRenderer: Plugin<[], Root> = () => {
|
|
159
|
+
export const customRenderer: Plugin<[], Root> = () => {
|
|
161
160
|
return (tree: Root) => {
|
|
162
161
|
// First pass: Apply general styles
|
|
163
162
|
visit(tree, 'element', (node: Element) => {
|
|
@@ -180,11 +179,15 @@ const customRenderer: Plugin<[], Root> = () => {
|
|
|
180
179
|
case 'p':
|
|
181
180
|
props.className = 'text-muted-foreground leading-relaxed mb-4'
|
|
182
181
|
break
|
|
183
|
-
case 'a':
|
|
182
|
+
case 'a': {
|
|
184
183
|
props.className = 'text-primary hover:underline transition-colors duration-200'
|
|
185
|
-
props.
|
|
186
|
-
|
|
184
|
+
const href = typeof props.href === 'string' ? props.href : ''
|
|
185
|
+
if (!href.startsWith('#')) {
|
|
186
|
+
props.target = '_blank'
|
|
187
|
+
props.rel = 'noopener noreferrer'
|
|
188
|
+
}
|
|
187
189
|
break
|
|
190
|
+
}
|
|
188
191
|
case 'ul':
|
|
189
192
|
props.className = 'list-disc list-inside mb-4 space-y-2 ml-4'
|
|
190
193
|
break
|
|
@@ -268,7 +271,6 @@ export async function markdownToHtml(markdown: string, articleSlug?: string) {
|
|
|
268
271
|
.use(remarkRehype)
|
|
269
272
|
.use(customRenderer)
|
|
270
273
|
.use(rehypeSlug)
|
|
271
|
-
.use(rehypeAutolinkHeadings, { behavior: 'wrap' })
|
|
272
274
|
// @ts-ignore
|
|
273
275
|
.use(rehypePrism)
|
|
274
276
|
.use(rehypeSanitize, {
|