@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.
- package/CHANGELOG.md +12 -0
- package/README.md +45 -3
- package/dist/index.cjs +11 -7
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +10 -3
- package/dist/index.d.ts +10 -3
- package/dist/index.js +11 -7
- package/dist/index.js.map +1 -1
- package/dist/nextjs.d.cts +5 -0
- package/dist/nextjs.d.ts +5 -0
- package/dist/server.cjs +2 -1
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +5 -0
- package/dist/server.d.ts +5 -0
- package/dist/server.js +2 -1
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
- package/src/ArticlesPage.tsx +10 -2
- package/src/__tests__/ArticlesPage.test.tsx +17 -0
- package/src/__tests__/articlesConfig.test.ts +20 -1
- package/src/__tests__/renderMdx.test.tsx +78 -0
- package/src/__tests__/useArticles.test.ts +28 -0
- package/src/articlesConfig.ts +6 -0
- package/src/index.ts +1 -0
- package/src/renderMdx.tsx +5 -1
- package/src/useArticles.ts +10 -5
|
@@ -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',
|
|
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,
|
package/src/articlesConfig.ts
CHANGED
|
@@ -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
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
|
|
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
|
}
|
package/src/useArticles.ts
CHANGED
|
@@ -12,12 +12,15 @@ export interface UseArticlesReturn {
|
|
|
12
12
|
handleSearch: (query: string) => void
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
export function useArticles(
|
|
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(
|
|
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(
|