@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.
@@ -6,8 +6,12 @@ import { render, screen } from '@testing-library/react'
6
6
  import { ArticleContent } from '../ArticleContent'
7
7
  import type { Article } from '../articleTypes'
8
8
 
9
- jest.mock('next-mdx-remote/rsc', () => ({
10
- MDXRemote: ({ source }: { source: string }) => <div data-testid="mdx-remote">{source}</div>,
9
+ const mockRenderMdxSource = jest
10
+ .fn()
11
+ .mockResolvedValue(React.createElement('div', { 'data-testid': 'mdx-content' }, 'MDX rendered'))
12
+
13
+ jest.mock('../renderMdx', () => ({
14
+ renderMdxSource: (...args: unknown[]) => mockRenderMdxSource(...args),
11
15
  }))
12
16
 
13
17
  const baseArticle: Article = {
@@ -22,70 +26,110 @@ const baseArticle: Article = {
22
26
  }
23
27
 
24
28
  describe('ArticleContent', () => {
29
+ beforeEach(() => {
30
+ mockRenderMdxSource.mockResolvedValue(
31
+ React.createElement('div', { 'data-testid': 'mdx-content' }, 'MDX rendered')
32
+ )
33
+ })
34
+
25
35
  describe('HTML path', () => {
26
- it('renders a div with dangerouslySetInnerHTML when contentType is not mdx', () => {
36
+ it('renders a div with dangerouslySetInnerHTML when contentType is not mdx', async () => {
27
37
  const article: Article = { ...baseArticle, contentType: 'md', htmlContent: '<p>Hello</p>' }
28
- const { container } = render(<ArticleContent article={article} />)
38
+ const el = await ArticleContent({ article })
39
+ const { container } = render(el as React.ReactElement)
29
40
  expect(container.firstChild).toBeInTheDocument()
30
- expect(container.querySelector('[data-testid="mdx-remote"]')).toBeNull()
41
+ expect(container.querySelector('[data-testid="mdx-content"]')).toBeNull()
31
42
  expect(container.firstElementChild?.innerHTML).toBe('<p>Hello</p>')
32
43
  })
33
44
 
34
- it('renders HTML path when contentType is mdx but mdxSource is undefined', () => {
45
+ it('renders HTML path when contentType is mdx but mdxSource is undefined', async () => {
35
46
  const article: Article = {
36
47
  ...baseArticle,
37
48
  contentType: 'mdx',
38
49
  htmlContent: '<p>Fallback</p>',
39
50
  }
40
- const { container } = render(<ArticleContent article={article} />)
41
- expect(container.querySelector('[data-testid="mdx-remote"]')).toBeNull()
51
+ const el = await ArticleContent({ article })
52
+ const { container } = render(el as React.ReactElement)
53
+ expect(container.querySelector('[data-testid="mdx-content"]')).toBeNull()
42
54
  expect(container.firstElementChild?.innerHTML).toBe('<p>Fallback</p>')
43
55
  })
44
56
 
45
- it('renders an empty div without crashing when htmlContent is undefined', () => {
57
+ it('renders an empty div without crashing when htmlContent is undefined', async () => {
46
58
  const article: Article = { ...baseArticle, contentType: 'md' }
47
- const { container } = render(<ArticleContent article={article} />)
59
+ const el = await ArticleContent({ article })
60
+ const { container } = render(el as React.ReactElement)
48
61
  expect(container.firstElementChild?.innerHTML).toBe('')
49
62
  })
50
63
 
51
- it('renders an empty div without crashing when htmlContent is empty string', () => {
64
+ it('renders an empty div without crashing when htmlContent is empty string', async () => {
52
65
  const article: Article = { ...baseArticle, contentType: 'md', htmlContent: '' }
53
- const { container } = render(<ArticleContent article={article} />)
66
+ const el = await ArticleContent({ article })
67
+ const { container } = render(el as React.ReactElement)
54
68
  expect(container.firstElementChild?.innerHTML).toBe('')
55
69
  })
56
70
 
57
- it('applies className to the outer div on the HTML path', () => {
71
+ it('applies className to the outer div on the HTML path', async () => {
58
72
  const article: Article = { ...baseArticle, contentType: 'md', htmlContent: '<p>Hi</p>' }
59
- const { container } = render(<ArticleContent article={article} className="custom-class" />)
73
+ const el = await ArticleContent({ article, className: 'custom-class' })
74
+ const { container } = render(el as React.ReactElement)
60
75
  expect(container.firstElementChild).toHaveClass('custom-class')
61
76
  })
62
77
  })
63
78
 
64
79
  describe('MDX path', () => {
65
- it('renders MDXRemote when contentType is mdx and mdxSource is set', () => {
66
- const article: Article = { ...baseArticle, contentType: 'mdx', mdxSource: '# Hello MDX' }
67
- render(<ArticleContent article={article} />)
68
- expect(screen.getByTestId('mdx-remote')).toBeInTheDocument()
69
- })
70
-
71
- it('passes mdxSource as source prop to MDXRemote', () => {
80
+ it('renders MDX content when contentType is mdx and mdxSource is set', async () => {
72
81
  const article: Article = { ...baseArticle, contentType: 'mdx', mdxSource: '# Hello MDX' }
73
- render(<ArticleContent article={article} />)
74
- expect(screen.getByTestId('mdx-remote')).toHaveTextContent('# Hello MDX')
82
+ const el = await ArticleContent({ article })
83
+ render(el as React.ReactElement)
84
+ expect(screen.getByTestId('mdx-content')).toBeInTheDocument()
75
85
  })
76
86
 
77
- it('wraps MDXRemote in a div on the MDX path', () => {
87
+ it('wraps MDX content in a div on the MDX path', async () => {
78
88
  const article: Article = { ...baseArticle, contentType: 'mdx', mdxSource: '# Hello MDX' }
79
- const { container } = render(<ArticleContent article={article} />)
89
+ const el = await ArticleContent({ article })
90
+ const { container } = render(el as React.ReactElement)
80
91
  const wrapper = container.firstElementChild
81
92
  expect(wrapper?.tagName).toBe('DIV')
82
- expect(wrapper?.querySelector('[data-testid="mdx-remote"]')).toBeInTheDocument()
93
+ expect(wrapper?.querySelector('[data-testid="mdx-content"]')).toBeInTheDocument()
83
94
  })
84
95
 
85
- it('applies className to the outer div on the MDX path', () => {
96
+ it('applies className to the outer div on the MDX path', async () => {
86
97
  const article: Article = { ...baseArticle, contentType: 'mdx', mdxSource: '# Hello MDX' }
87
- const { container } = render(<ArticleContent article={article} className="mdx-wrapper" />)
98
+ const el = await ArticleContent({ article, className: 'mdx-wrapper' })
99
+ const { container } = render(el as React.ReactElement)
88
100
  expect(container.firstElementChild).toHaveClass('mdx-wrapper')
89
101
  })
102
+
103
+ it('renders MDX for a nested article slug', async () => {
104
+ const article: Article = {
105
+ ...baseArticle,
106
+ slug: 'tales-of-the-valiant/tov-barbarian-guide',
107
+ contentType: 'mdx',
108
+ mdxSource: '# Barbarian Guide',
109
+ }
110
+ const el = await ArticleContent({ article })
111
+ render(el as React.ReactElement)
112
+ expect(screen.getByTestId('mdx-content')).toBeInTheDocument()
113
+ })
114
+
115
+ it('passes basePath derived from article slug to renderMdxSource', async () => {
116
+ const article: Article = { ...baseArticle, contentType: 'mdx', mdxSource: '# Hello MDX' }
117
+ await ArticleContent({ article })
118
+ expect(mockRenderMdxSource).toHaveBeenCalledWith('# Hello MDX', '/articles/test-slug')
119
+ })
120
+
121
+ it('passes basePath with nested slug to renderMdxSource', async () => {
122
+ const article: Article = {
123
+ ...baseArticle,
124
+ slug: 'tales-of-the-valiant/tov-barbarian-guide',
125
+ contentType: 'mdx',
126
+ mdxSource: '# Barbarian Guide',
127
+ }
128
+ await ArticleContent({ article })
129
+ expect(mockRenderMdxSource).toHaveBeenCalledWith(
130
+ '# Barbarian Guide',
131
+ '/articles/tales-of-the-valiant/tov-barbarian-guide'
132
+ )
133
+ })
90
134
  })
91
135
  })
@@ -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
 
@@ -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('![alt](photo.png)', '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
+ })