prev-cli 0.22.0 → 0.22.2
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/dist/cli.js +1 -0
- package/package.json +3 -2
- package/src/preview-runtime/build.ts +147 -0
- package/src/preview-runtime/template.html +224 -0
- package/src/preview-runtime/types.ts +29 -0
package/dist/cli.js
CHANGED
|
@@ -979,6 +979,7 @@ async function createViteConfig(options) {
|
|
|
979
979
|
react: path7.join(cliNodeModules, "react"),
|
|
980
980
|
"react-dom": path7.join(cliNodeModules, "react-dom"),
|
|
981
981
|
"@tanstack/react-router": path7.join(cliNodeModules, "@tanstack/react-router"),
|
|
982
|
+
"@mdx-js/react": path7.join(cliNodeModules, "@mdx-js/react"),
|
|
982
983
|
mermaid: path7.join(cliNodeModules, "mermaid"),
|
|
983
984
|
dayjs: path7.join(cliNodeModules, "dayjs"),
|
|
984
985
|
"@terrastruct/d2": path7.join(cliNodeModules, "@terrastruct/d2")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "prev-cli",
|
|
3
|
-
"version": "0.22.
|
|
3
|
+
"version": "0.22.2",
|
|
4
4
|
"description": "Transform MDX directories into beautiful documentation websites",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
"dist",
|
|
12
12
|
"src/theme",
|
|
13
|
-
"src/ui"
|
|
13
|
+
"src/ui",
|
|
14
|
+
"src/preview-runtime"
|
|
14
15
|
],
|
|
15
16
|
"keywords": [
|
|
16
17
|
"documentation",
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// src/preview-runtime/build.ts
|
|
2
|
+
// Production build for previews - pre-bundles React/TSX at build time
|
|
3
|
+
|
|
4
|
+
import { build } from 'esbuild'
|
|
5
|
+
import type { PreviewConfig } from './types'
|
|
6
|
+
|
|
7
|
+
export interface PreviewBuildResult {
|
|
8
|
+
html: string
|
|
9
|
+
error?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Build a preview into a standalone HTML file for production
|
|
14
|
+
* Uses esbuild (native) to bundle at build time
|
|
15
|
+
*/
|
|
16
|
+
export async function buildPreviewHtml(config: PreviewConfig): Promise<PreviewBuildResult> {
|
|
17
|
+
try {
|
|
18
|
+
// Build virtual filesystem
|
|
19
|
+
const virtualFs: Record<string, { contents: string; loader: string }> = {}
|
|
20
|
+
for (const file of config.files) {
|
|
21
|
+
const ext = file.path.split('.').pop()?.toLowerCase()
|
|
22
|
+
const loader = ext === 'css' ? 'css' : ext === 'json' ? 'json' : ext || 'tsx'
|
|
23
|
+
virtualFs[file.path] = { contents: file.content, loader }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Find entry and check if it exports default
|
|
27
|
+
const entryFile = config.files.find(f => f.path === config.entry)
|
|
28
|
+
if (!entryFile) {
|
|
29
|
+
return { html: '', error: `Entry file not found: ${config.entry}` }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const hasDefaultExport = /export\s+default/.test(entryFile.content)
|
|
33
|
+
|
|
34
|
+
// Create entry wrapper
|
|
35
|
+
const entryCode = hasDefaultExport ? `
|
|
36
|
+
import React from 'react'
|
|
37
|
+
import { createRoot } from 'react-dom/client'
|
|
38
|
+
import App from './${config.entry}'
|
|
39
|
+
|
|
40
|
+
const root = createRoot(document.getElementById('root'))
|
|
41
|
+
root.render(React.createElement(App))
|
|
42
|
+
` : `
|
|
43
|
+
import './${config.entry}'
|
|
44
|
+
`
|
|
45
|
+
|
|
46
|
+
// Bundle with esbuild
|
|
47
|
+
const result = await build({
|
|
48
|
+
stdin: {
|
|
49
|
+
contents: entryCode,
|
|
50
|
+
loader: 'tsx',
|
|
51
|
+
resolveDir: '/',
|
|
52
|
+
},
|
|
53
|
+
bundle: true,
|
|
54
|
+
write: false,
|
|
55
|
+
format: 'esm',
|
|
56
|
+
jsx: 'automatic',
|
|
57
|
+
jsxImportSource: 'react',
|
|
58
|
+
target: 'es2020',
|
|
59
|
+
minify: true,
|
|
60
|
+
plugins: [{
|
|
61
|
+
name: 'virtual-fs',
|
|
62
|
+
setup(build) {
|
|
63
|
+
// External: React from CDN
|
|
64
|
+
build.onResolve({ filter: /^react(-dom)?(\/.*)?$/ }, args => {
|
|
65
|
+
const parts = args.path.split('/')
|
|
66
|
+
const pkg = parts[0]
|
|
67
|
+
const subpath = parts.slice(1).join('/')
|
|
68
|
+
const url = subpath
|
|
69
|
+
? `https://esm.sh/${pkg}@18/${subpath}`
|
|
70
|
+
: `https://esm.sh/${pkg}@18`
|
|
71
|
+
return { path: url, external: true }
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
// Auto-resolve npm packages via esm.sh
|
|
75
|
+
build.onResolve({ filter: /^[^./]/ }, args => {
|
|
76
|
+
if (args.path.startsWith('https://')) return
|
|
77
|
+
return { path: `https://esm.sh/${args.path}`, external: true }
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
// Resolve relative imports
|
|
81
|
+
build.onResolve({ filter: /^\./ }, args => {
|
|
82
|
+
let resolved = args.path.replace(/^\.\//, '')
|
|
83
|
+
if (!resolved.includes('.')) {
|
|
84
|
+
for (const ext of ['.tsx', '.ts', '.jsx', '.js', '.css']) {
|
|
85
|
+
if (virtualFs[resolved + ext]) {
|
|
86
|
+
resolved = resolved + ext
|
|
87
|
+
break
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { path: resolved, namespace: 'virtual' }
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
// Load from virtual filesystem
|
|
95
|
+
build.onLoad({ filter: /.*/, namespace: 'virtual' }, args => {
|
|
96
|
+
const file = virtualFs[args.path]
|
|
97
|
+
if (file) {
|
|
98
|
+
// CSS: convert to JS that injects styles
|
|
99
|
+
if (file.loader === 'css') {
|
|
100
|
+
const css = file.contents.replace(/`/g, '\\`').replace(/\$/g, '\\$')
|
|
101
|
+
return {
|
|
102
|
+
contents: `
|
|
103
|
+
const style = document.createElement('style');
|
|
104
|
+
style.textContent = \`${css}\`;
|
|
105
|
+
document.head.appendChild(style);
|
|
106
|
+
`,
|
|
107
|
+
loader: 'js',
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { contents: file.contents, loader: file.loader as any }
|
|
111
|
+
}
|
|
112
|
+
return { contents: '', loader: 'empty' }
|
|
113
|
+
})
|
|
114
|
+
},
|
|
115
|
+
}],
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
const jsFile = result.outputFiles.find(f => f.path.endsWith('.js')) || result.outputFiles[0]
|
|
119
|
+
const jsCode = jsFile?.text || ''
|
|
120
|
+
|
|
121
|
+
// Generate standalone HTML
|
|
122
|
+
const html = `<!DOCTYPE html>
|
|
123
|
+
<html lang="en">
|
|
124
|
+
<head>
|
|
125
|
+
<meta charset="UTF-8">
|
|
126
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
127
|
+
<title>Preview</title>
|
|
128
|
+
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
|
129
|
+
<style>
|
|
130
|
+
body { margin: 0; }
|
|
131
|
+
#root { min-height: 100vh; }
|
|
132
|
+
</style>
|
|
133
|
+
</head>
|
|
134
|
+
<body>
|
|
135
|
+
<div id="root"></div>
|
|
136
|
+
<script type="module">${jsCode}</script>
|
|
137
|
+
</body>
|
|
138
|
+
</html>`
|
|
139
|
+
|
|
140
|
+
return { html }
|
|
141
|
+
} catch (err) {
|
|
142
|
+
return {
|
|
143
|
+
html: '',
|
|
144
|
+
error: err instanceof Error ? err.message : String(err),
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Preview</title>
|
|
7
|
+
<!-- Tailwind CSS v4 Play CDN -->
|
|
8
|
+
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
|
9
|
+
<!-- esbuild-wasm -->
|
|
10
|
+
<script src="https://unpkg.com/esbuild-wasm@0.24.2/lib/browser.min.js"></script>
|
|
11
|
+
<style>
|
|
12
|
+
body { margin: 0; }
|
|
13
|
+
#root { min-height: 100vh; }
|
|
14
|
+
.preview-loading {
|
|
15
|
+
display: flex;
|
|
16
|
+
align-items: center;
|
|
17
|
+
justify-content: center;
|
|
18
|
+
min-height: 100vh;
|
|
19
|
+
font-family: system-ui, sans-serif;
|
|
20
|
+
color: #666;
|
|
21
|
+
}
|
|
22
|
+
.preview-error {
|
|
23
|
+
padding: 1rem;
|
|
24
|
+
background: #fef2f2;
|
|
25
|
+
color: #dc2626;
|
|
26
|
+
font-family: monospace;
|
|
27
|
+
font-size: 13px;
|
|
28
|
+
white-space: pre-wrap;
|
|
29
|
+
}
|
|
30
|
+
</style>
|
|
31
|
+
</head>
|
|
32
|
+
<body>
|
|
33
|
+
<div id="root"><div class="preview-loading">Loading preview...</div></div>
|
|
34
|
+
|
|
35
|
+
<script type="module">
|
|
36
|
+
// Preview runtime - bundles React/TSX in browser via esbuild-wasm
|
|
37
|
+
|
|
38
|
+
let initialized = false
|
|
39
|
+
let lastConfig = null
|
|
40
|
+
|
|
41
|
+
function getLoader(filePath) {
|
|
42
|
+
const ext = filePath.split('.').pop()?.toLowerCase()
|
|
43
|
+
const loaders = { tsx: 'tsx', ts: 'ts', jsx: 'jsx', js: 'js', css: 'css', json: 'json' }
|
|
44
|
+
return loaders[ext] || 'tsx'
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function initEsbuild() {
|
|
48
|
+
if (initialized) return
|
|
49
|
+
await esbuild.initialize({
|
|
50
|
+
wasmURL: 'https://unpkg.com/esbuild-wasm@0.24.2/esbuild.wasm',
|
|
51
|
+
})
|
|
52
|
+
initialized = true
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function bundle(config) {
|
|
56
|
+
const startTime = performance.now()
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
await initEsbuild()
|
|
60
|
+
|
|
61
|
+
// Build virtual filesystem
|
|
62
|
+
const virtualFs = {}
|
|
63
|
+
for (const file of config.files) {
|
|
64
|
+
virtualFs[file.path] = {
|
|
65
|
+
contents: file.content,
|
|
66
|
+
loader: getLoader(file.path),
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Find entry and create wrapper
|
|
71
|
+
const entryFile = config.files.find(f => f.path === config.entry)
|
|
72
|
+
if (!entryFile) {
|
|
73
|
+
return { success: false, error: `Entry file not found: ${config.entry}` }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Determine if entry exports default or needs wrapping
|
|
77
|
+
const hasDefaultExport = /export\s+default/.test(entryFile.content)
|
|
78
|
+
|
|
79
|
+
const entryCode = hasDefaultExport ? `
|
|
80
|
+
import React from 'react'
|
|
81
|
+
import { createRoot } from 'react-dom/client'
|
|
82
|
+
import App from './${config.entry}'
|
|
83
|
+
|
|
84
|
+
const root = createRoot(document.getElementById('root'))
|
|
85
|
+
root.render(React.createElement(App))
|
|
86
|
+
` : `
|
|
87
|
+
// Entry file doesn't export default, execute directly
|
|
88
|
+
import './${config.entry}'
|
|
89
|
+
`
|
|
90
|
+
|
|
91
|
+
const result = await esbuild.build({
|
|
92
|
+
stdin: {
|
|
93
|
+
contents: entryCode,
|
|
94
|
+
loader: 'tsx',
|
|
95
|
+
resolveDir: '/',
|
|
96
|
+
},
|
|
97
|
+
bundle: true,
|
|
98
|
+
write: false,
|
|
99
|
+
format: 'esm',
|
|
100
|
+
jsx: 'automatic',
|
|
101
|
+
jsxImportSource: 'react',
|
|
102
|
+
target: 'es2020',
|
|
103
|
+
plugins: [{
|
|
104
|
+
name: 'virtual-fs',
|
|
105
|
+
setup(build) {
|
|
106
|
+
// External: React from CDN
|
|
107
|
+
build.onResolve({ filter: /^react(-dom)?(\/.*)?$/ }, args => {
|
|
108
|
+
const parts = args.path.split('/')
|
|
109
|
+
const pkg = parts[0]
|
|
110
|
+
const subpath = parts.slice(1).join('/')
|
|
111
|
+
const url = subpath
|
|
112
|
+
? `https://esm.sh/${pkg}@18/${subpath}`
|
|
113
|
+
: `https://esm.sh/${pkg}@18`
|
|
114
|
+
return { path: url, external: true }
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
// Auto-resolve npm packages via esm.sh
|
|
118
|
+
build.onResolve({ filter: /^[^./]/ }, args => {
|
|
119
|
+
if (args.path.startsWith('https://')) return
|
|
120
|
+
return { path: `https://esm.sh/${args.path}`, external: true }
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
// Resolve relative imports
|
|
124
|
+
build.onResolve({ filter: /^\./ }, args => {
|
|
125
|
+
let resolved = args.path.replace(/^\.\//, '')
|
|
126
|
+
if (!resolved.includes('.')) {
|
|
127
|
+
for (const ext of ['.tsx', '.ts', '.jsx', '.js', '.css']) {
|
|
128
|
+
if (virtualFs[resolved + ext]) {
|
|
129
|
+
resolved = resolved + ext
|
|
130
|
+
break
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return { path: resolved, namespace: 'virtual' }
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
// Load from virtual filesystem
|
|
138
|
+
build.onLoad({ filter: /.*/, namespace: 'virtual' }, args => {
|
|
139
|
+
const file = virtualFs[args.path]
|
|
140
|
+
if (file) {
|
|
141
|
+
if (file.loader === 'css') {
|
|
142
|
+
const css = file.contents.replace(/`/g, '\\`').replace(/\$/g, '\\$')
|
|
143
|
+
return {
|
|
144
|
+
contents: `
|
|
145
|
+
const style = document.createElement('style');
|
|
146
|
+
style.textContent = \`${css}\`;
|
|
147
|
+
document.head.appendChild(style);
|
|
148
|
+
`,
|
|
149
|
+
loader: 'js',
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return { contents: file.contents, loader: file.loader }
|
|
153
|
+
}
|
|
154
|
+
console.warn('[preview] File not found:', args.path)
|
|
155
|
+
return { contents: '', loader: 'empty' }
|
|
156
|
+
})
|
|
157
|
+
},
|
|
158
|
+
}],
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
const jsFile = result.outputFiles.find(f => f.path.endsWith('.js')) || result.outputFiles[0]
|
|
162
|
+
return {
|
|
163
|
+
success: true,
|
|
164
|
+
code: jsFile?.text || '',
|
|
165
|
+
buildTime: Math.round(performance.now() - startTime),
|
|
166
|
+
}
|
|
167
|
+
} catch (err) {
|
|
168
|
+
return {
|
|
169
|
+
success: false,
|
|
170
|
+
error: err.message || String(err),
|
|
171
|
+
buildTime: Math.round(performance.now() - startTime),
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function renderCode(code) {
|
|
177
|
+
const root = document.getElementById('root')
|
|
178
|
+
root.innerHTML = ''
|
|
179
|
+
|
|
180
|
+
const script = document.createElement('script')
|
|
181
|
+
script.type = 'module'
|
|
182
|
+
script.textContent = code
|
|
183
|
+
document.body.appendChild(script)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function showError(error) {
|
|
187
|
+
const root = document.getElementById('root')
|
|
188
|
+
root.innerHTML = `<div class="preview-error">${error}</div>`
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Listen for config from parent
|
|
192
|
+
let receivedInit = false
|
|
193
|
+
|
|
194
|
+
window.addEventListener('message', async (event) => {
|
|
195
|
+
const msg = event.data
|
|
196
|
+
|
|
197
|
+
if (msg.type === 'init' || msg.type === 'update') {
|
|
198
|
+
receivedInit = true
|
|
199
|
+
const config = msg.type === 'init' ? msg.config : { ...lastConfig, files: msg.files }
|
|
200
|
+
lastConfig = config
|
|
201
|
+
|
|
202
|
+
const result = await bundle(config)
|
|
203
|
+
|
|
204
|
+
if (result.success && result.code) {
|
|
205
|
+
renderCode(result.code)
|
|
206
|
+
parent.postMessage({ type: 'built', result }, '*')
|
|
207
|
+
} else {
|
|
208
|
+
showError(result.error)
|
|
209
|
+
parent.postMessage({ type: 'error', error: result.error }, '*')
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
// Signal ready to parent - retry until we receive init
|
|
215
|
+
function sendReady() {
|
|
216
|
+
if (!receivedInit) {
|
|
217
|
+
parent.postMessage({ type: 'ready' }, '*')
|
|
218
|
+
setTimeout(sendReady, 100)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
sendReady()
|
|
222
|
+
</script>
|
|
223
|
+
</body>
|
|
224
|
+
</html>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// src/preview-runtime/types.ts
|
|
2
|
+
|
|
3
|
+
export interface PreviewFile {
|
|
4
|
+
path: string
|
|
5
|
+
content: string
|
|
6
|
+
type: 'tsx' | 'ts' | 'jsx' | 'js' | 'css' | 'html' | 'json'
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface PreviewConfig {
|
|
10
|
+
files: PreviewFile[]
|
|
11
|
+
entry: string // Entry file path (e.g., "index.tsx" or "App.tsx")
|
|
12
|
+
tailwind?: boolean // Enable Tailwind CSS v4 CDN
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface BuildResult {
|
|
16
|
+
success: boolean
|
|
17
|
+
code?: string
|
|
18
|
+
css?: string
|
|
19
|
+
error?: string
|
|
20
|
+
buildTime?: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Message protocol between parent and iframe
|
|
24
|
+
export type PreviewMessage =
|
|
25
|
+
| { type: 'init'; config: PreviewConfig }
|
|
26
|
+
| { type: 'update'; files: PreviewFile[] }
|
|
27
|
+
| { type: 'ready' }
|
|
28
|
+
| { type: 'built'; result: BuildResult }
|
|
29
|
+
| { type: 'error'; error: string }
|