@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.
- package/README.md +71 -12
- package/dist/index.cjs +68 -51
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +11 -1
- package/dist/index.d.ts +11 -1
- package/dist/index.js +66 -50
- package/dist/index.js.map +1 -1
- package/dist/server.cjs +70 -18
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +3 -1
- package/dist/server.d.ts +3 -1
- package/dist/server.js +69 -17
- package/dist/server.js.map +1 -1
- package/package.json +34 -20
- package/src/ArticleBackLink.tsx +32 -0
- package/src/ArticleContent.tsx +4 -7
- package/src/ArticleSocialShare.tsx +2 -7
- package/src/__tests__/ArticleBackLink.test.tsx +89 -0
- package/src/__tests__/ArticleContent.test.tsx +72 -28
- package/src/__tests__/ArticleSocialShare.test.tsx +7 -12
- package/src/__tests__/articlesConfig.test.ts +57 -0
- package/src/__tests__/markdown.test.ts +278 -0
- package/src/__tests__/renderMdx.test.tsx +280 -0
- package/src/__tests__/server-articles.test.ts +310 -1
- package/src/articlesConfig.ts +2 -0
- package/src/index.ts +1 -0
- package/src/markdown.ts +8 -6
- package/src/renderMdx.tsx +47 -0
- package/src/server-articles.ts +1 -2
|
@@ -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
|
-
|
|
10
|
-
|
|
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
|
|
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-
|
|
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
|
|
41
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
74
|
-
|
|
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
|
|
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
|
|
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-
|
|
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
|
|
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
|
|
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
|
|
|
@@ -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('', '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
|
+
})
|