@fullstackdatasolutions/articles 0.7.0 → 0.7.2
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 +26 -10
- package/dist/index.cjs +4 -13
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +4 -13
- package/dist/index.js.map +1 -1
- package/dist/server.cjs +1 -2
- package/dist/server.cjs.map +1 -1
- package/dist/server.js +1 -2
- package/dist/server.js.map +1 -1
- package/package.json +37 -17
- package/scripts/postinstall.cjs +9 -0
- package/src/ArticleSocialShare.tsx +2 -7
- package/src/__tests__/ArticleSocialShare.test.tsx +7 -12
- package/src/__tests__/server-articles.test.ts +310 -1
- package/src/server-articles.ts +1 -2
package/package.json
CHANGED
|
@@ -1,15 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fullstackdatasolutions/articles",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"license": "MIT",
|
|
6
|
+
"funding": {
|
|
7
|
+
"type": "buymeacoffee",
|
|
8
|
+
"url": "https://buymeacoffee.com/andrewblase"
|
|
9
|
+
},
|
|
6
10
|
"keywords": [
|
|
7
11
|
"nextjs",
|
|
12
|
+
"next",
|
|
13
|
+
"nextjs-blog",
|
|
14
|
+
"nextjs-articles",
|
|
15
|
+
"next-app-router",
|
|
16
|
+
"react",
|
|
8
17
|
"articles",
|
|
9
18
|
"blog",
|
|
19
|
+
"blog-engine",
|
|
10
20
|
"cms",
|
|
11
|
-
"
|
|
12
|
-
"
|
|
21
|
+
"markdown",
|
|
22
|
+
"mdx",
|
|
23
|
+
"rss",
|
|
24
|
+
"sitemap",
|
|
25
|
+
"seo",
|
|
26
|
+
"metadata",
|
|
27
|
+
"json-ld",
|
|
28
|
+
"tailwindcss",
|
|
29
|
+
"content",
|
|
30
|
+
"static-site"
|
|
13
31
|
],
|
|
14
32
|
"type": "module",
|
|
15
33
|
"exports": {
|
|
@@ -27,6 +45,7 @@
|
|
|
27
45
|
"files": [
|
|
28
46
|
"dist",
|
|
29
47
|
"src",
|
|
48
|
+
"scripts",
|
|
30
49
|
"README.md"
|
|
31
50
|
],
|
|
32
51
|
"peerDependencies": {
|
|
@@ -57,24 +76,24 @@
|
|
|
57
76
|
"unist-util-visit": "^5.1.0"
|
|
58
77
|
},
|
|
59
78
|
"devDependencies": {
|
|
60
|
-
"@testing-library/jest-dom": "^6.
|
|
61
|
-
"@testing-library/react": "^16.
|
|
62
|
-
"@testing-library/user-event": "^14.
|
|
63
|
-
"@types/hast": "^3.0.
|
|
64
|
-
"@types/jest": "^
|
|
65
|
-
"@types/node": "^
|
|
66
|
-
"@types/react": "^19.
|
|
67
|
-
"@types/react-dom": "^19.
|
|
68
|
-
"@typescript-eslint/eslint-plugin": "^8.
|
|
69
|
-
"@typescript-eslint/parser": "^8.
|
|
79
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
80
|
+
"@testing-library/react": "^16.3.2",
|
|
81
|
+
"@testing-library/user-event": "^14.6.1",
|
|
82
|
+
"@types/hast": "^3.0.4",
|
|
83
|
+
"@types/jest": "^30.0.0",
|
|
84
|
+
"@types/node": "^22.x",
|
|
85
|
+
"@types/react": "^19.2.15",
|
|
86
|
+
"@types/react-dom": "^19.2.3",
|
|
87
|
+
"@typescript-eslint/eslint-plugin": "^8.59.2",
|
|
88
|
+
"@typescript-eslint/parser": "^8.59.2",
|
|
70
89
|
"eslint": "^9.0.0",
|
|
71
|
-
"eslint-plugin-react": "^7.
|
|
90
|
+
"eslint-plugin-react": "^7.37.5",
|
|
72
91
|
"eslint-plugin-react-hooks": "^5.0.0",
|
|
73
92
|
"globals": "^15.0.0",
|
|
74
|
-
"jest": "^
|
|
75
|
-
"jest-environment-jsdom": "^
|
|
93
|
+
"jest": "^30.4.2",
|
|
94
|
+
"jest-environment-jsdom": "^30.4.1",
|
|
76
95
|
"prettier": "^3.0.0",
|
|
77
|
-
"ts-jest": "^29.
|
|
96
|
+
"ts-jest": "^29.4.10",
|
|
78
97
|
"tsup": "^8.3.5",
|
|
79
98
|
"typescript": "^5.0.0"
|
|
80
99
|
},
|
|
@@ -85,6 +104,7 @@
|
|
|
85
104
|
"scripts": {
|
|
86
105
|
"build": "tsup",
|
|
87
106
|
"dev": "tsup --watch",
|
|
107
|
+
"postinstall": "node ./scripts/postinstall.cjs",
|
|
88
108
|
"typecheck": "tsc --noEmit",
|
|
89
109
|
"lint": "eslint src",
|
|
90
110
|
"lint:fix": "eslint src --fix",
|
|
@@ -75,15 +75,10 @@ export function ArticleSocialShare({ title, url, excerpt, shareMessage }: Articl
|
|
|
75
75
|
<MessageCircle className="h-4 w-4" />
|
|
76
76
|
Telegram
|
|
77
77
|
</button>
|
|
78
|
-
<
|
|
79
|
-
className={btnClass}
|
|
80
|
-
onClick={() => {
|
|
81
|
-
globalThis.location.href = shareLinks.email
|
|
82
|
-
}}
|
|
83
|
-
>
|
|
78
|
+
<a className={btnClass} href={shareLinks.email}>
|
|
84
79
|
<Mail className="h-4 w-4" />
|
|
85
80
|
Email
|
|
86
|
-
</
|
|
81
|
+
</a>
|
|
87
82
|
<button className={btnClass} onClick={copyToClipboard}>
|
|
88
83
|
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
|
89
84
|
{copied ? 'Copied!' : 'Copy Link'}
|
|
@@ -32,7 +32,7 @@ describe('ArticleSocialShare', () => {
|
|
|
32
32
|
expect(screen.getByText('Share this article')).toBeInTheDocument()
|
|
33
33
|
})
|
|
34
34
|
|
|
35
|
-
it('renders all
|
|
35
|
+
it('renders all share controls', () => {
|
|
36
36
|
render(<ArticleSocialShare {...defaultProps} />)
|
|
37
37
|
expect(screen.getByRole('button', { name: /linkedin/i })).toBeInTheDocument()
|
|
38
38
|
expect(screen.getByRole('button', { name: /facebook/i })).toBeInTheDocument()
|
|
@@ -40,7 +40,7 @@ describe('ArticleSocialShare', () => {
|
|
|
40
40
|
expect(screen.getByRole('button', { name: /reddit/i })).toBeInTheDocument()
|
|
41
41
|
expect(screen.getByRole('button', { name: /whatsapp/i })).toBeInTheDocument()
|
|
42
42
|
expect(screen.getByRole('button', { name: /telegram/i })).toBeInTheDocument()
|
|
43
|
-
expect(screen.getByRole('
|
|
43
|
+
expect(screen.getByRole('link', { name: /email/i })).toBeInTheDocument()
|
|
44
44
|
expect(screen.getByRole('button', { name: /copy link/i })).toBeInTheDocument()
|
|
45
45
|
})
|
|
46
46
|
})
|
|
@@ -149,19 +149,14 @@ describe('ArticleSocialShare', () => {
|
|
|
149
149
|
})
|
|
150
150
|
|
|
151
151
|
describe('Email button', () => {
|
|
152
|
-
it('
|
|
153
|
-
const originalLocation = globalThis.location
|
|
154
|
-
delete (globalThis as Record<string, unknown>).location
|
|
155
|
-
;(globalThis as Record<string, unknown>).location = { href: '' }
|
|
156
|
-
|
|
152
|
+
it('renders Email as a mailto link', () => {
|
|
157
153
|
render(<ArticleSocialShare {...defaultProps} />)
|
|
158
|
-
fireEvent.click(screen.getByRole('button', { name: /email/i }))
|
|
159
154
|
|
|
160
|
-
|
|
161
|
-
expect(
|
|
162
|
-
|
|
155
|
+
const emailLink = screen.getByRole('link', { name: /email/i })
|
|
156
|
+
expect(emailLink).toHaveAttribute(
|
|
157
|
+
'href',
|
|
158
|
+
expect.stringContaining(`mailto:?subject=${encodeURIComponent(defaultProps.title)}`)
|
|
163
159
|
)
|
|
164
|
-
;(globalThis as Record<string, unknown>).location = originalLocation
|
|
165
160
|
})
|
|
166
161
|
})
|
|
167
162
|
|
|
@@ -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/server-articles.ts
CHANGED
|
@@ -83,9 +83,8 @@ function walkArticleDir(dir: string, baseSlug: string): string[] {
|
|
|
83
83
|
const slug = baseSlug ? `${baseSlug}/${item.name}` : item.name
|
|
84
84
|
if (findArticleFile(slug) !== null) {
|
|
85
85
|
slugs.push(slug)
|
|
86
|
-
} else {
|
|
87
|
-
slugs.push(...walkArticleDir(path.join(dir, item.name), slug))
|
|
88
86
|
}
|
|
87
|
+
slugs.push(...walkArticleDir(path.join(dir, item.name), slug))
|
|
89
88
|
}
|
|
90
89
|
} catch {
|
|
91
90
|
// ignore unreadable directories
|