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 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.0",
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 }