Skip to content

Commit c54b585

Browse files
feat(cli): support converting HTML examples to MDX (#94)
1 parent 4bffd11 commit c54b585

File tree

4 files changed

+288
-33
lines changed

4 files changed

+288
-33
lines changed

cli/__tests__/convertToMDX.test.ts

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import { readFile, writeFile, unlink } from 'fs/promises'
2+
import { glob } from 'glob'
3+
import { convertToMDX } from '../convertToMDX.ts'
4+
5+
jest.mock('fs/promises', () => ({
6+
readFile: jest.fn(),
7+
writeFile: jest.fn(),
8+
unlink: jest.fn(),
9+
}))
10+
11+
jest.mock('glob', () => ({
12+
glob: jest.fn(),
13+
}))
14+
15+
beforeEach(() => {
16+
jest.clearAllMocks()
17+
})
18+
19+
it('should convert a file with no examples', async () => {
20+
const mockContent = '# Test Content\nSome text here'
21+
;(glob as unknown as jest.Mock).mockResolvedValue(['test.md'])
22+
;(readFile as jest.Mock).mockResolvedValue(mockContent)
23+
24+
await convertToMDX('test.md')
25+
26+
expect(writeFile).toHaveBeenCalledWith('test.mdx', mockContent)
27+
})
28+
29+
it('should convert a file with JS/TS examples', async () => {
30+
const mockContent = "# Test Content\n```ts file='./Example.ts'\n```"
31+
const expectedContent =
32+
'# Test Content\n\nimport Example from "./Example.ts?raw"\n\n<LiveExample src={Example} />'
33+
;(glob as unknown as jest.Mock).mockResolvedValue(['test.md'])
34+
;(readFile as jest.Mock).mockResolvedValue(mockContent)
35+
36+
await convertToMDX('test.md')
37+
38+
expect(writeFile).toHaveBeenCalledWith('test.mdx', expectedContent)
39+
})
40+
41+
it('should convert a file with HTML examples', async () => {
42+
const mockContent = '# Test Content\n```html\n<div>Test HTML</div>\n```'
43+
const expectedContent =
44+
"# Test Content\n\nimport Example1 from './Example1.html?raw'\n\n<LiveExample html={Example1} />"
45+
;(glob as unknown as jest.Mock).mockResolvedValue(['test.md'])
46+
;(readFile as jest.Mock).mockResolvedValue(mockContent)
47+
48+
await convertToMDX('test.md')
49+
50+
expect(writeFile).toHaveBeenCalledWith('test.mdx', expectedContent)
51+
expect(writeFile).toHaveBeenCalledWith(
52+
expect.stringContaining('Example1.html'),
53+
'<div>Test HTML</div>',
54+
)
55+
})
56+
57+
it('should handle multiple HTML examples in the same file', async () => {
58+
const mockContent =
59+
'# Test Content\n```html\n<div>First HTML</div>\n```\n```html\n<div>Second HTML</div>\n```'
60+
const expectedContent =
61+
"# Test Content\n\nimport Example1 from './Example1.html?raw'\n\n<LiveExample html={Example1} />\n\nimport Example2 from './Example2.html?raw'\n\n<LiveExample html={Example2} />"
62+
;(glob as unknown as jest.Mock).mockResolvedValue(['test.md'])
63+
;(readFile as jest.Mock).mockResolvedValue(mockContent)
64+
65+
await convertToMDX('test.md')
66+
67+
expect(writeFile).toHaveBeenCalledWith('test.mdx', expectedContent)
68+
expect(writeFile).toHaveBeenCalledWith(
69+
expect.stringContaining('Example1.html'),
70+
'<div>First HTML</div>',
71+
)
72+
expect(writeFile).toHaveBeenCalledWith(
73+
expect.stringContaining('Example2.html'),
74+
'<div>Second HTML</div>',
75+
)
76+
})
77+
78+
it('should handle multiple files', async () => {
79+
const mockContent1 = '# Test Content 1\n```html\n<div>Test HTML 1</div>\n```'
80+
const mockContent2 = '# Test Content 2\n```html\n<div>Test HTML 2</div>\n```'
81+
;(glob as unknown as jest.Mock).mockResolvedValue(['test1.md', 'test2.md'])
82+
;(readFile as jest.Mock)
83+
.mockResolvedValueOnce(mockContent1)
84+
.mockResolvedValueOnce(mockContent2)
85+
86+
await convertToMDX('*.md')
87+
88+
expect(writeFile).toHaveBeenCalledTimes(4) // 2 MDX files + 2 HTML files
89+
expect(writeFile).toHaveBeenCalledWith(
90+
'test1.mdx',
91+
expect.stringContaining('Example1'),
92+
)
93+
expect(writeFile).toHaveBeenCalledWith(
94+
'test2.mdx',
95+
expect.stringContaining('Example1'),
96+
)
97+
})
98+
99+
it('should remove noLive tags from code blocks', async () => {
100+
const mockContent = "# Test Content\n```noLive\nconst test = 'test';\n```"
101+
const expectedContent = "# Test Content\n```\nconst test = 'test';\n```"
102+
;(glob as unknown as jest.Mock).mockResolvedValue(['test.md'])
103+
;(readFile as jest.Mock).mockResolvedValue(mockContent)
104+
105+
await convertToMDX('test.md')
106+
107+
expect(writeFile).toHaveBeenCalledWith('test.mdx', expectedContent)
108+
})
109+
110+
it('should remove existing imports but preserve CSS imports', async () => {
111+
const mockContent = `import { something } from 'somewhere'
112+
import './styles.css'
113+
import { other } from 'other-package'
114+
import './other-styles.css'
115+
# Test Content
116+
\`\`\`html
117+
<div>Test HTML</div>
118+
\`\`\``
119+
const expectedContent = `import './styles.css'
120+
import './other-styles.css'
121+
# Test Content
122+
123+
import Example1 from './Example1.html?raw'
124+
125+
<LiveExample html={Example1} />`
126+
;(glob as unknown as jest.Mock).mockResolvedValue(['test.md'])
127+
;(readFile as jest.Mock).mockResolvedValue(mockContent)
128+
129+
await convertToMDX('test.md')
130+
131+
expect(writeFile).toHaveBeenCalledWith('test.mdx', expectedContent)
132+
})
133+
134+
it('should delete the original file after conversion', async () => {
135+
const mockContent = '# Test Content\nSome text here'
136+
;(glob as unknown as jest.Mock).mockResolvedValue(['test.md'])
137+
;(readFile as jest.Mock).mockResolvedValue(mockContent)
138+
139+
await convertToMDX('test.md')
140+
141+
expect(writeFile).toHaveBeenCalledWith('test.mdx', mockContent)
142+
expect(unlink).toHaveBeenCalledWith('test.md')
143+
})
144+
145+
it('should convert HTML comments in MD content to MDX format', async () => {
146+
const mockContent = '# Test Content\n<!-- This is a comment in the MD content -->\nSome text here\n<!-- Another comment\nspanning multiple lines -->'
147+
const expectedContent = '# Test Content\n{/* This is a comment in the MD content */}\nSome text here\n{/* Another comment\nspanning multiple lines */}'
148+
;(glob as unknown as jest.Mock).mockResolvedValue(['test.md'])
149+
;(readFile as jest.Mock).mockResolvedValue(mockContent)
150+
151+
await convertToMDX('test.md')
152+
153+
expect(writeFile).toHaveBeenCalledWith('test.mdx', expectedContent)
154+
})
155+
156+
it('should preserve HTML comments in HTML files', async () => {
157+
const mockContent = '# Test Content\n```html\n<!-- This is a comment -->\n<div>Test HTML</div>\n<!-- Another comment\nspanning multiple lines -->\n```'
158+
const expectedMDXContent = '# Test Content\n\nimport Example1 from \'./Example1.html?raw\'\n\n<LiveExample html={Example1} />'
159+
const expectedHTMLContent = '<!-- This is a comment -->\n<div>Test HTML</div>\n<!-- Another comment\nspanning multiple lines -->'
160+
;(glob as unknown as jest.Mock).mockResolvedValue(['test.md'])
161+
;(readFile as jest.Mock).mockResolvedValue(mockContent)
162+
163+
await convertToMDX('test.md')
164+
165+
expect(writeFile).toHaveBeenCalledWith('test.mdx', expectedMDXContent)
166+
expect(writeFile).toHaveBeenCalledWith(
167+
expect.stringContaining('Example1.html'),
168+
expectedHTMLContent,
169+
)
170+
})
171+

cli/convertToMDX.ts

Lines changed: 77 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,87 @@
1-
import { readFile, writeFile } from 'fs/promises'
1+
import { readFile, writeFile, unlink } from 'fs/promises'
22
import { glob } from 'glob'
3+
import path from 'path'
34

4-
export async function convertToMDX(globPath: string) {
5-
const files = await glob(globPath)
5+
function handleTsExamples(content: string): string {
6+
//regex link: https://regexr.com/8f0bu
7+
const ExampleBlockRegex = /```[tj]s file=['"]\.?\/?(\w*)\.(\w*)['"]\s*\n```/g
8+
9+
//the first capture group is the example file name without the extension or path, the second is the extension
10+
const replacementString = `\nimport $1 from "./$1.$2?raw"\n\n<LiveExample src={$1} />`
11+
return content.replace(ExampleBlockRegex, replacementString)
12+
}
13+
14+
async function handleHTMLExamples(
15+
content: string,
16+
fileDir: string,
17+
): Promise<string> {
18+
const htmlCodeFenceRegex = /```html\n([\s\S]*?)\n```/g
19+
const matches = Array.from(content.matchAll(htmlCodeFenceRegex))
620

7-
files.forEach(async (file) => {
8-
const fileContent = await readFile(file, 'utf-8')
21+
const replacements = await Promise.all(
22+
matches.map(async (match, index) => {
23+
const htmlContent = match[1]
24+
const exampleName = `Example${index + 1}`
25+
const htmlFilePath = path.join(fileDir, `${exampleName}.html`)
926

10-
//regex link: https://regexr.com/8f0er
11-
const importRegex = /(?<!```no[lL]ive\n)import {?[\w\s,\n]*}?.*\n/g
27+
await writeFile(htmlFilePath, htmlContent)
1228

13-
//removes all top level imports from the md file that the old docs framework used to determine what imports are needed
14-
const withoutImports = fileContent.replace(importRegex, '')
29+
return {
30+
original: match[0],
31+
replacement: `\nimport ${exampleName} from './${exampleName}.html?raw'\n\n<LiveExample html={${exampleName}} />`,
32+
}
33+
}),
34+
)
1535

16-
//regex link: https://regexr.com/8f0bu
17-
const ExampleBlockRegex =
18-
/```[tj]s file=['"]\.?\/?(\w*)\.(\w*)['"]\s*\n```/g
36+
return replacements.reduce(
37+
(result, { original, replacement }) =>
38+
result.replace(original, replacement),
39+
content,
40+
)
41+
}
1942

20-
//the first capture group is the example file name without the extension or path, the second is the extension
21-
const replacementString = `\nimport $1 from "./$1.$2?raw"\n\n<LiveExample src={$1} />`
22-
const examplesConvertedToMDX = withoutImports.replace(
23-
ExampleBlockRegex,
24-
replacementString,
25-
)
43+
function removeNoLiveTags(content: string): string {
44+
return content.replace(/```no[lL]ive/g, '```')
45+
}
2646

27-
//we want to strip the nolive/noLive tags from codeblocks as that was custom to the old docs framework
28-
const noLiveRemoved = examplesConvertedToMDX.replace(/```no[lL]ive/g, '```')
47+
function removeExistingImports(content: string): string {
48+
// Remove imports that don't end in .css
49+
const importRegex = /^import {?[\w\s,\n]*}? from ['"](?!.*\.css['"])[^'"]*['"]\n/gm
50+
return content.replace(importRegex, '')
51+
}
2952

30-
await writeFile(file + 'x', noLiveRemoved)
31-
})
53+
function convertCommentsToMDX(content: string): string {
54+
return content.replace(
55+
/<!--([\s\S]*?)-->/g,
56+
(_, comment) => `{/*${comment}*/}`,
57+
)
58+
}
59+
60+
async function processFile(file: string): Promise<void> {
61+
const fileContent = await readFile(file, 'utf-8')
62+
const fileDir = path.dirname(file)
63+
64+
const transformations = [
65+
removeNoLiveTags,
66+
removeExistingImports,
67+
(content: string) => handleHTMLExamples(content, fileDir),
68+
handleTsExamples,
69+
convertCommentsToMDX,
70+
]
71+
72+
const processedContent = await transformations.reduce(
73+
async (contentPromise, transform) => {
74+
const content = await contentPromise
75+
return transform(content)
76+
},
77+
Promise.resolve(fileContent),
78+
)
79+
80+
await writeFile(file + 'x', processedContent)
81+
await unlink(file)
82+
}
83+
84+
export async function convertToMDX(globPath: string): Promise<void> {
85+
const files = await glob(globPath)
86+
await Promise.all(files.map(processFile))
3287
}

src/components/LiveExample.astro

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
import { LiveExample as LiveExampleBase } from './LiveExample'
33
4-
const { src } = Astro.props
4+
const { src, html } = Astro.props
55
---
66

7-
<LiveExampleBase src={src} client:only="react" />
7+
<LiveExampleBase src={src} html={html} client:only="react" />

src/components/LiveExample.tsx

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,23 @@
1-
import React, { useState, Fragment, useRef, useEffect, createRef, useReducer } from 'react'
1+
import React, {
2+
useState,
3+
Fragment,
4+
useRef,
5+
useEffect,
6+
createRef,
7+
useReducer,
8+
} from 'react'
29
import { convertToReactComponent } from '@patternfly/ast-helpers'
310
import { ErrorBoundary } from 'react-error-boundary'
411
import * as reactCoreModule from '@patternfly/react-core'
512
import * as reactIconsModule from '@patternfly/react-icons'
6-
import styles from "@patternfly/react-styles/css/components/_index"
7-
import * as reactTokensModule from "@patternfly/react-tokens"
13+
import styles from '@patternfly/react-styles/css/components/_index'
14+
import * as reactTokensModule from '@patternfly/react-tokens'
815
import { ExampleToolbar } from './ExampleToolbar'
16+
917
interface LiveExampleProps {
10-
src: string
18+
src?: string
19+
html?: string
20+
isFullscreenPreview?: boolean
1121
}
1222

1323
function fallbackRender({ error }: any) {
@@ -42,18 +52,37 @@ function getLivePreview(editorCode: string) {
4252
return <PreviewComponent />
4353
}
4454

45-
export const LiveExample = ({ src }: LiveExampleProps) => {
46-
const [code, setCode] = useState(src)
47-
const livePreview = getLivePreview(code)
55+
export const LiveExample = ({
56+
src,
57+
html,
58+
isFullscreenPreview,
59+
}: LiveExampleProps) => {
60+
const inputCode = src || html || ''
61+
const [code, setCode] = useState(inputCode)
62+
63+
let livePreview = null
64+
let lang = 'ts'
65+
if (html) {
66+
livePreview = (
67+
<div
68+
className={`ws-preview-html ${isFullscreenPreview && 'pf-v6-u-h-100'}`}
69+
dangerouslySetInnerHTML={{ __html: code }}
70+
/>
71+
)
72+
lang = 'html'
73+
} else {
74+
livePreview = getLivePreview(code)
75+
lang = 'ts'
76+
}
4877

4978
return (
5079
<ErrorBoundary fallbackRender={fallbackRender}>
5180
{livePreview}
5281
<ExampleToolbar
53-
originalCode={src}
82+
originalCode={inputCode}
5483
code={code}
5584
setCode={setCode}
56-
lang="ts"
85+
lang={lang}
5786
isFullscreen={false}
5887
/>
5988
</ErrorBoundary>

0 commit comments

Comments
 (0)