@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/package.json CHANGED
@@ -1,15 +1,33 @@
1
1
  {
2
2
  "name": "@fullstackdatasolutions/articles",
3
- "version": "0.7.0",
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
- "react",
12
- "markdown"
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.0.0",
61
- "@testing-library/react": "^16.0.0",
62
- "@testing-library/user-event": "^14.0.0",
63
- "@types/hast": "^3.0.0",
64
- "@types/jest": "^29.0.0",
65
- "@types/node": "^20.0.0",
66
- "@types/react": "^19.0.0",
67
- "@types/react-dom": "^19.0.0",
68
- "@typescript-eslint/eslint-plugin": "^8.0.0",
69
- "@typescript-eslint/parser": "^8.0.0",
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.0.0",
90
+ "eslint-plugin-react": "^7.37.5",
72
91
  "eslint-plugin-react-hooks": "^5.0.0",
73
92
  "globals": "^15.0.0",
74
- "jest": "^29.0.0",
75
- "jest-environment-jsdom": "^29.0.0",
93
+ "jest": "^30.4.2",
94
+ "jest-environment-jsdom": "^30.4.1",
76
95
  "prettier": "^3.0.0",
77
- "ts-jest": "^29.0.0",
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",
@@ -0,0 +1,9 @@
1
+ const message = [
2
+ '',
3
+ 'Thanks for installing @fullstackdatasolutions/articles.',
4
+ 'If this package saves you time, consider buying the developer a coffee:',
5
+ 'https://buymeacoffee.com/andrewblase',
6
+ '',
7
+ ].join('\n')
8
+
9
+ console.log(message)
@@ -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
- <button
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
- </button>
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 8 share buttons', () => {
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('button', { name: /email/i })).toBeInTheDocument()
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('clicking Email sets location.href to a mailto link', () => {
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
- expect((globalThis.location as { href: string }).href).toContain('mailto:')
161
- expect((globalThis.location as { href: string }).href).toContain(
162
- encodeURIComponent(defaultProps.title)
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 { getArticleMetadata, getAvailableArticleSlugs } from '../server-articles'
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
  })
@@ -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