@fullstackdatasolutions/articles 0.10.0 → 0.12.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.
@@ -334,4 +334,82 @@ describe('renderMdxSource', () => {
334
334
  expect(capturedComponents).toBeUndefined()
335
335
  })
336
336
  })
337
+
338
+ describe('custom MDX components', () => {
339
+ it('renders a registered custom component with its MDX props', async () => {
340
+ type LeadMagnetCTAProps = Readonly<{
341
+ system: string
342
+ segment: string
343
+ }>
344
+ const LeadMagnetCTA = ({ system, segment }: LeadMagnetCTAProps) =>
345
+ React.createElement('div', { 'data-testid': 'lead-magnet' }, `${system}:${segment}`)
346
+
347
+ mockEvaluate.mockResolvedValue({
348
+ default: ({
349
+ components,
350
+ }: {
351
+ components?: Record<string, React.ComponentType<unknown>>
352
+ }) => {
353
+ const CustomCTA = components?.LeadMagnetCTA as React.ComponentType<LeadMagnetCTAProps>
354
+ return React.createElement(CustomCTA, { system: 'DND', segment: 'new-players' })
355
+ },
356
+ })
357
+ Object.defineProperty(process.env, 'NODE_ENV', { value: 'production', writable: true })
358
+
359
+ const { renderMdxSource } = await import('../renderMdx')
360
+ const source = 'Article intro.\n\n<LeadMagnetCTA system="DND" segment="new-players" />'
361
+ const el = await renderMdxSource(source, undefined, {
362
+ siteUrl: 'https://example.com',
363
+ siteName: 'Example',
364
+ mdxComponents: { LeadMagnetCTA },
365
+ })
366
+ const { getByTestId } = render(el as React.ReactElement)
367
+
368
+ expect(getByTestId('lead-magnet')).toHaveTextContent('DND:new-players')
369
+ expect(mockEvaluate).toHaveBeenCalledWith(source, expect.any(Object))
370
+ })
371
+
372
+ it('merges registered custom components with the relative image override', async () => {
373
+ type LeadMagnetCTAProps = Readonly<{ label: string }>
374
+ const LeadMagnetCTA = ({ label }: LeadMagnetCTAProps) =>
375
+ React.createElement('div', { 'data-testid': 'lead-magnet' }, label)
376
+
377
+ mockEvaluate.mockResolvedValue({
378
+ default: ({
379
+ components,
380
+ }: {
381
+ components?: Record<string, React.ComponentType<unknown>>
382
+ }) => {
383
+ const CustomCTA = components?.LeadMagnetCTA as React.ComponentType<LeadMagnetCTAProps>
384
+ const Img = components?.img as React.ComponentType<
385
+ React.ImgHTMLAttributes<HTMLImageElement>
386
+ >
387
+ return React.createElement(
388
+ 'div',
389
+ null,
390
+ React.createElement(CustomCTA, { label: 'Download now' }),
391
+ React.createElement(Img, { src: 'photo.png', alt: 'Article image' })
392
+ )
393
+ },
394
+ })
395
+ Object.defineProperty(process.env, 'NODE_ENV', { value: 'production', writable: true })
396
+
397
+ const { renderMdxSource } = await import('../renderMdx')
398
+ const el = await renderMdxSource(
399
+ '<LeadMagnetCTA label="Download now" />\n\n![Article image](photo.png)',
400
+ '/articles/my-article',
401
+ {
402
+ siteUrl: 'https://example.com',
403
+ siteName: 'Example',
404
+ mdxComponents: { LeadMagnetCTA },
405
+ }
406
+ )
407
+ const { container, getByTestId } = render(el as React.ReactElement)
408
+
409
+ expect(getByTestId('lead-magnet')).toHaveTextContent('Download now')
410
+ expect(container.querySelector('img')?.getAttribute('src')).toBe(
411
+ '/articles/my-article/photo.png'
412
+ )
413
+ })
414
+ })
337
415
  })
@@ -190,6 +190,34 @@ describe('useArticles', () => {
190
190
  })
191
191
  })
192
192
 
193
+ describe('with server-seeded initial data', () => {
194
+ it('starts populated and not loading, and skips the mount fetch', () => {
195
+ ;(globalThis.fetch as jest.Mock).mockReturnValue(new Promise(() => {}))
196
+ const mockCategories = [
197
+ { name: 'Campaigns', slug: 'campaigns', count: 2, featuredImage: '/x.jpg' },
198
+ ]
199
+
200
+ const { result } = renderHook(() => useArticles(mockArticles, mockCategories))
201
+
202
+ expect(result.current.loading).toBe(false)
203
+ expect(result.current.articles).toHaveLength(3)
204
+ expect(result.current.categories).toEqual(mockCategories)
205
+ expect(globalThis.fetch).not.toHaveBeenCalled()
206
+ })
207
+
208
+ it('still fetches when a search is performed after seeding', async () => {
209
+ ;(globalThis.fetch as jest.Mock).mockResolvedValue({
210
+ ok: true,
211
+ json: async () => ({ articles: [mockArticles[0]] }),
212
+ })
213
+
214
+ const { result } = renderHook(() => useArticles(mockArticles, []))
215
+ act(() => result.current.handleSearch(''))
216
+
217
+ await waitFor(() => expect(globalThis.fetch).toHaveBeenCalledWith('/api/articles'))
218
+ })
219
+ })
220
+
193
221
  it('refetches all articles when handleSearch is called with empty string', async () => {
194
222
  ;(globalThis.fetch as jest.Mock).mockResolvedValue({
195
223
  ok: true,
@@ -1,3 +1,4 @@
1
+ import type { ComponentType } from 'react'
1
2
  import type { AuthorProfile } from './articleTypes'
2
3
 
3
4
  /** Keys for each renderable section of the articles listing page. */
@@ -72,6 +73,9 @@ export interface HeroConfig {
72
73
  /** Controls how article body links set target/rel attributes. */
73
74
  export type LinkTargetStrategy = 'external-new-tab' | 'all-new-tab' | 'same-tab'
74
75
 
76
+ /** React components that article MDX bodies can reference by JSX tag name. */
77
+ export type MdxComponents = Record<string, ComponentType<never>>
78
+
75
79
  export type ArticleBreadcrumbToken =
76
80
  | 'home'
77
81
  | 'articles'
@@ -160,6 +164,8 @@ export interface ArticlesConfig {
160
164
  breadcrumbs?: false | BreadcrumbsConfig
161
165
  /** Article body link target behavior. Default: `'external-new-tab'`. */
162
166
  linkTargetStrategy?: LinkTargetStrategy
167
+ /** Extra components exposed to article MDX bodies by JSX tag name. */
168
+ mdxComponents?: MdxComponents
163
169
  }
164
170
 
165
171
  export const DEFAULT_PAGE_SIZE = 6
package/src/index.ts CHANGED
@@ -41,6 +41,7 @@ export type {
41
41
  CategoryDescription,
42
42
  CommentsConfig,
43
43
  LinkTargetStrategy,
44
+ MdxComponents,
44
45
  BreadcrumbsConfig,
45
46
  BreadcrumbLabels,
46
47
  ArticleBreadcrumbToken,
package/src/renderMdx.tsx CHANGED
@@ -45,6 +45,10 @@ export async function renderMdxSource(source: string, basePath?: string, config?
45
45
  })
46
46
 
47
47
  const Content = mdxModule.default as MdxContent
48
- const components = basePath ? { img: makeImgComponent(basePath) } : undefined
48
+ const internalComponents = basePath ? { img: makeImgComponent(basePath) } : undefined
49
+ const components =
50
+ internalComponents || config?.mdxComponents
51
+ ? { ...internalComponents, ...config?.mdxComponents }
52
+ : undefined
49
53
  return <Content components={components as Record<string, ComponentType<unknown>>} />
50
54
  }
@@ -12,12 +12,15 @@ export interface UseArticlesReturn {
12
12
  handleSearch: (query: string) => void
13
13
  }
14
14
 
15
- export function useArticles(): UseArticlesReturn {
15
+ export function useArticles(
16
+ initialArticles?: Article[],
17
+ initialCategories?: CategoryInfo[]
18
+ ): UseArticlesReturn {
16
19
  const [searchQuery, setSearchQuery] = useState('')
17
20
  const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
18
- const [articles, setArticles] = useState<Article[]>([])
19
- const [categories, setCategories] = useState<CategoryInfo[]>([])
20
- const [loading, setLoading] = useState(true)
21
+ const [articles, setArticles] = useState<Article[]>(initialArticles ?? [])
22
+ const [categories, setCategories] = useState<CategoryInfo[]>(initialCategories ?? [])
23
+ const [loading, setLoading] = useState(initialArticles === undefined)
21
24
  const [error, setError] = useState<string | null>(null)
22
25
 
23
26
  const fetchArticles = useCallback(async (query: string) => {
@@ -66,7 +69,9 @@ export function useArticles(): UseArticlesReturn {
66
69
  }, [])
67
70
 
68
71
  useEffect(() => {
69
- fetchArticles('')
72
+ if (initialArticles === undefined) fetchArticles('')
73
+ // Seeded data came from the server render; skip the redundant client fetch on mount.
74
+ // eslint-disable-next-line react-hooks/exhaustive-deps
70
75
  }, [fetchArticles])
71
76
 
72
77
  const handleSearch = useCallback(