1 // Utilities for rendering parsed session content as HTML strings.
2-// Used by the export engine to produce static/dynamic HTML output.
3-// The live SPA uses Solid.js components (Block.jsx) instead.
2+// Used by the export engine. The live SPA uses Solid.js components instead.
3+//
4+// CSS is imported via ?raw so it is always available as a string regardless
5+// of whether we are running under the Vite dev server or the production build.
6
5-import { THEMES, DEFAULT_THEME, themeToCSS, allThemesToCSS } from './themes.js'
7+import rawCSS from './style.css?raw'
8+import { THEMES, themeToCSS, allThemesToCSS } from './themes.js'
9
10 // ---------------------------------------------------------------------------
8-// Inline markup: backtick code spans and line-wrapped text
11+// Minifiers — simple but effective for our generated output
12 // ---------------------------------------------------------------------------
13
14+function minifyCSS(css) {
15+ return css
16+ .replace(/\/\*[\s\S]*?\*\//g, '') // strip block comments
17+ .replace(/[ \t]*\n[ \t]*/g, '\n') // strip per-line indentation
18+ .replace(/\n{2,}/g, '\n') // collapse blank lines
19+ .replace(/\n?([{};:,>~+])\n?/g, '$1') // strip newlines around punctuation
20+ .replace(/;}/g, '}') // drop trailing semicolons in blocks
21+ .trim()
22+}
23+
24+function minifyHtml(html) {
25+ return html
26+ .replace(/[ \t]+/g, ' ') // collapse horizontal whitespace
27+ .replace(/\n\s*/g, '\n') // strip leading whitespace from lines
28+ .replace(/\n{2,}/g, '\n') // collapse blank lines
29+ .trim()
30+}
31+
32+function maybeMinifyCSS(css, minify) { return minify ? minifyCSS(css) : css }
33+function maybeMinifyHtml(html, minify) { return minify ? minifyHtml(html) : html }
34+
35+// ---------------------------------------------------------------------------
36+// Inline markup: backtick code spans and fenced code blocks
37+// ---------------------------------------------------------------------------
38+
39 export function escapeHtml(s) {
40 return s
41 .replace(/&/g, '&')...
44 .replace(/"/g, '"')
45 }
46
19-// Render inline content: turns `code` spans into <code> elements
47 export function renderInline(text) {
21- const escaped = escapeHtml(text)
22- return escaped.replace(/`([^`]+)`/g, '<code>$1</code>')
48+ return escapeHtml(text).replace(/`([^`]+)`/g, '<code>$1</code>')
49 }
50
25-// Render a block of text that may contain fenced code blocks
51 export function renderText(text) {
52 const parts = text.split(/(```[\s\S]*?```)/g)
53 return parts.map((part, i) => {
54 if (i % 2 === 1) {
30- // fenced block
55 const inner = escapeHtml(part.replace(/^```[^\n]*\n?/, '').replace(/\n?```$/, ''))
56 return `<pre class="fenced-block">${inner}</pre>`
57 }...
60 }
61
62 // ---------------------------------------------------------------------------
39-// Diff renderer (HTML)
63+// Diff renderer
64 // ---------------------------------------------------------------------------
65
66 function renderDiffLine(line) {
43- const numStr = line.lineNum ? `<span class="diff-line-num">${escapeHtml(line.lineNum)}</span>` : '<span class="diff-line-num"></span>'
67+ const num = `<span class="diff-line-num">${line.lineNum ? escapeHtml(line.lineNum) : ''}</span>`
68 const prefix = line.type === 'added' ? '+' : line.type === 'removed' ? '-' : ' '
45- const content = `<span class="diff-line-content">${prefix}${escapeHtml(line.content)}</span>`
46- return `<div class="diff-line ${line.type}">${numStr}${content}</div>`
69+ const cls = line.type === 'raw' ? 'wrap' : line.type
70+ const content = `<span class="diff-line-content"><span class="diff-type-char">${prefix}</span>${escapeHtml(line.content)}</span>`
71+ return `<div class="diff-line ${cls}">${num}${content}</div>`
72 }
73
74 function renderDiff(diffLines) {...
76 }
77
78 // ---------------------------------------------------------------------------
54-// Tool result renderer (HTML)
79+// Tool result & block renderers
80 // ---------------------------------------------------------------------------
81
82 function renderToolResult(result) {...
85 return `<div class="tool-result">${label}${diff}</div>`
86 }
87
63-// ---------------------------------------------------------------------------
64-// Block renderer (HTML)
65-// ---------------------------------------------------------------------------
88+function parseToolHeader(header) {
89+ const m = header.match(/^([A-Za-z_][\w.:]*)\((.*)/)
90+ if (m) return [m[1], '(' + (m[2] || '')]
91+ return [header, '']
92+}
93
94 function renderBlock(block) {
68- const [toolName, toolArgs] = parseToolHeader(block.header)
95 const isToolCall = block.blockType === 'tool-call'
70- const iconColor = isToolCall ? 'tool' : 'assistant'
71- const icon = isToolCall ? '●' : '●'
96+ const [toolName, toolArgs] = parseToolHeader(block.header)
97
98 const headerHtml = isToolCall
99 ? `<span class="tool-name">${escapeHtml(toolName)}</span><span class="tool-args">${escapeHtml(toolArgs)}</span>`...
105
106 const resultsHtml = block.toolResults.map(renderToolResult).join('')
107
83- return `
84-<div class="assistant-block block-${isToolCall ? 'tool' : 'text'}">
85- <div class="turn-gutter"><span class="turn-icon${isToolCall ? ' tool-icon' : ''}">●</span></div>
86- <div class="turn-body">
87- <div class="${isToolCall ? 'tool-header' : ''}">${headerHtml}</div>
88- ${bodyHtml}
89- ${resultsHtml}
90- </div>
91-</div>`.trim()
108+ return (
109+ `<div class="assistant-block block-${isToolCall ? 'tool' : 'text'}">` +
110+ `<div class="turn-gutter"><span class="turn-icon${isToolCall ? ' tool-icon' : ''}">●</span></div>` +
111+ `<div class="turn-body">` +
112+ `<div class="${isToolCall ? 'tool-header' : ''}">${headerHtml}</div>` +
113+ bodyHtml + resultsHtml +
114+ `</div></div>`
115+ )
116 }
117
118 // ---------------------------------------------------------------------------
95-// Full event renderer (HTML)
119+// Event renderer
120 // ---------------------------------------------------------------------------
121
122 function renderEvent(event) {...
124 case 'header': {
125 const m = event.meta
126 const items = [
103- m.version && `<span class="meta-item"><span class="meta-label">version</span><span class="meta-value">v${escapeHtml(m.version)}</span></span>`,
104- m.model && `<span class="meta-item"><span class="meta-label">model</span><span class="meta-value">${escapeHtml(m.model)}</span></span>`,
105- m.plan && `<span class="meta-item"><span class="meta-label">plan</span><span class="meta-value">${escapeHtml(m.plan)}</span></span>`,
106- m.project && `<span class="meta-item"><span class="meta-label">project</span><span class="meta-value">${escapeHtml(m.project)}</span></span>`,
107- m.user && `<span class="meta-item"><span class="meta-label">user</span><span class="meta-value">${escapeHtml(m.user)}</span></span>`,
108- ].filter(Boolean).join('\n')
127+ m.version && `<span class="meta-item"><span class="meta-label">version</span><span class="meta-value">v${escapeHtml(m.version)}</span></span>`,
128+ m.model && `<span class="meta-item"><span class="meta-label">model</span><span class="meta-value">${escapeHtml(m.model)}</span></span>`,
129+ m.plan && `<span class="meta-item"><span class="meta-label">plan</span><span class="meta-value">${escapeHtml(m.plan)}</span></span>`,
130+ m.project && `<span class="meta-item"><span class="meta-label">project</span><span class="meta-value">${escapeHtml(m.project)}</span></span>`,
131+ m.user && `<span class="meta-item"><span class="meta-label">user</span><span class="meta-value">${escapeHtml(m.user)}</span></span>`,
132+ ].filter(Boolean).join('')
133 return `<div class="block-header-meta">${items}</div>`
134 }
135
136 case 'user': {
113- const fileRefsHtml = event.fileRefs.length
137+ const refs = event.fileRefs.length
138 ? `<div class="user-filerefs">${event.fileRefs.map(r => `<span class="user-fileref">${escapeHtml(r)}</span>`).join('')}</div>`
139 : ''
116- return `
117-<div class="turn turn-user">
118- <div class="turn-gutter"><span class="turn-icon">❯</span></div>
119- <div class="turn-body">
120- <div class="user-content">${renderText(event.content)}</div>
121- ${fileRefsHtml}
122- </div>
123-</div>`.trim()
140+ return (
141+ `<div class="turn turn-user">` +
142+ `<div class="turn-gutter"><span class="turn-icon">❯</span></div>` +
143+ `<div class="turn-body"><div class="user-content">${renderText(event.content)}</div>${refs}</div>` +
144+ `</div>`
145+ )
146 }
147
126- case 'assistant': {
127- return `<div class="turn turn-assistant">${event.blocks.map(renderBlock).join('\n')}</div>`
128- }
148+ case 'assistant':
149+ return `<div class="turn turn-assistant">${event.blocks.map(renderBlock).join('')}</div>`
150
130- case 'thinking': {
131- return `
132-<div class="turn turn-thinking">
133- <div class="turn-gutter"></div>
134- <div class="turn-body"><span class="thinking-content">✻ ${escapeHtml(event.content)}</span></div>
135-</div>`.trim()
136- }
151+ case 'thinking':
152+ return (
153+ `<div class="turn turn-thinking">` +
154+ `<div class="turn-gutter"></div>` +
155+ `<div class="turn-body"><span class="thinking-content">✻ ${escapeHtml(event.content)}</span></div>` +
156+ `</div>`
157+ )
158
159 default:
160 return ''...
166 }
167
168 // ---------------------------------------------------------------------------
148-// Tool header parser (shared with Block.jsx logic)
169+// Shared shell fragments
170 // ---------------------------------------------------------------------------
171
151-function parseToolHeader(header) {
152- const m = header.match(/^([A-Za-z_][\w.:]*)\((.*)/)
153- if (m) return [m[1], '(' + (m[2] || '')]
154- return [header, '']
172+function htmlHead(title, styleContent) {
173+ return (
174+ `<!DOCTYPE html>\n<html lang="en">\n<head>\n` +
175+ `<meta charset="UTF-8">\n` +
176+ `<meta name="viewport" content="width=device-width,initial-scale=1">\n` +
177+ `<title>${escapeHtml(title)}</title>\n` +
178+ `<style>\n${styleContent}\n</style>\n` +
179+ `</head>\n<body>\n`
180+ )
181 }
182
183 // ---------------------------------------------------------------------------
158-// Export builders
184+// Static HTML export
185+// Fully rendered, zero JS, chosen theme baked in as :root vars.
186 // ---------------------------------------------------------------------------
187
161-// Shared CSS (structure only, no colors)
162-async function fetchCSS() {
163- const link = document.querySelector('link[rel=stylesheet]')
164- if (!link) return ''
165- const resp = await fetch(link.href)
166- return resp.text()
167-}
188+export function buildStaticHtml(events, themeKey, title, { minify = false } = {}) {
189+ // Structural CSS first, then theme vars — equal specificity means last wins.
190+ const css = maybeMinifyCSS(rawCSS + '\n' + themeToCSS(themeKey), minify)
191+ const body = maybeMinifyHtml(renderSession(events), minify)
192
169-async function fetchJS() {
170- const scripts = [...document.querySelectorAll('script[type=module][src]')]
171- const bodies = await Promise.all(scripts.map(s => fetch(s.src).then(r => r.text())))
172- return bodies.join('\n')
193+ return maybeMinifyHtml(
194+ htmlHead(title, css) +
195+ `<div class="session-wrap"><div class="session">\n${body}\n</div></div>\n` +
196+ `</body>\n</html>`,
197+ minify
198+ )
199 }
200
175-// ---- Static HTML export ----
176-// Fully rendered, zero JS, chosen theme baked in.
177-export async function buildStaticHtml(events, themeKey, title) {
178- const css = await fetchCSS()
179- const themeCss = themeToCSS(themeKey)
180- const body = renderSession(events)
201+// ---------------------------------------------------------------------------
202+// Dynamic HTML export
203+// All 8 themes switchable via a minimal self-contained JS snippet.
204+// No Vite artefacts, no source maps — just the CSS and a 600-byte script.
205+// ---------------------------------------------------------------------------
206
182- return `<!DOCTYPE html>
183-<html lang="en" data-theme="${themeKey}">
184-<head>
185-<meta charset="UTF-8">
186-<meta name="viewport" content="width=device-width,initial-scale=1">
187-<title>${escapeHtml(title || 'Claude Session')}</title>
188-<style>
189-${themeCss}
190-${css}
191-</style>
192-</head>
193-<body>
194-<div id="root">
195- <div class="session-wrap">
196- <div class="session">
197- ${body}
198- </div>
199- </div>
200-</div>
201-</body>
202-</html>`
203-}
207+export function buildDynamicHtml(events, themeKey, title, { minify = false } = {}) {
208+ // Structural CSS + all theme variable blocks ([data-theme="X"] { ... }).
209+ // [data-theme] comes after :root — equal specificity, last one wins.
210+ const topbarCSS = `
211+.exported-topbar{position:sticky;top:0;z-index:100;display:flex;align-items:center;gap:12px;padding:0 16px;height:48px;background:var(--bg-2);border-bottom:1px solid var(--border);font-family:system-ui,sans-serif;font-size:13px}
212+.exported-topbar .brand{font-weight:600;color:var(--fg)}
213+.exported-topbar .brand span{color:var(--user-accent)}
214+.theme-toggle-wrap{position:relative;margin-left:auto}
215+#theme-toggle{padding:5px 10px;border-radius:3px;border:1px solid var(--border-2);background:transparent;color:var(--fg-2);cursor:pointer;font:inherit;font-size:12px}
216+#theme-menu{display:none;position:absolute;right:0;top:calc(100% + 6px);z-index:200;background:var(--bg-2);border:1px solid var(--border-2);border-radius:6px;min-width:180px;overflow:hidden;box-shadow:0 8px 24px rgba(0,0,0,.4)}
217+.theme-btn{display:block;width:100%;text-align:left;padding:8px 14px;cursor:pointer;border:none;background:transparent;font-family:system-ui,sans-serif;font-size:13px;color:var(--fg-2)}
218+.theme-btn:hover,.theme-btn.active{background:var(--bg-hover);color:var(--fg)}
219+.theme-btn.active{color:var(--user-accent)}`
220
205-// ---- Dynamic HTML export ----
206-// All themes available via data-theme; a minimal theme picker is included.
207-export async function buildDynamicHtml(events, themeKey, title) {
208- const css = await fetchCSS()
209- const allThemes = allThemesToCSS()
210- const body = renderSession(events)
221+ const css = maybeMinifyCSS(rawCSS + '\n' + allThemesToCSS() + '\n' + topbarCSS, minify)
222
212- const themeOptions = Object.entries(THEMES).map(([key, t]) =>
213- `<button class="theme-option${key === themeKey ? ' active' : ''}" data-theme="${key}" onclick="setTheme('${key}')">${escapeHtml(t.label)}</button>`
214- ).join('\n')
223+ const themeButtons = Object.entries(THEMES).map(([key, t]) =>
224+ `<button class="theme-btn${key === themeKey ? ' active' : ''}" onclick="setTheme('${key}')">${escapeHtml(t.label)}</button>`
225+ ).join('')
226
216- const miniJs = `
217-(function() {
218- var html = document.documentElement
219- function setTheme(k) {
220- html.dataset.theme = k
221- document.querySelectorAll('.theme-option').forEach(function(b) {
222- b.classList.toggle('active', b.dataset.theme === k)
223- })
224- try { localStorage.setItem('cc-theme', k) } catch(e) {}
225- }
226- window.setTheme = setTheme
227- var saved = (function() { try { return localStorage.getItem('cc-theme') } catch(e) {} })()
228- if (saved) setTheme(saved)
229- document.getElementById('theme-toggle').addEventListener('click', function(e) {
230- var m = document.getElementById('theme-menu')
231- m.style.display = m.style.display === 'block' ? 'none' : 'block'
232- e.stopPropagation()
233- })
234- document.addEventListener('click', function() {
235- var m = document.getElementById('theme-menu')
236- if (m) m.style.display = 'none'
237- })
238-})()
239-`
227+ // Minimal self-contained theme switcher — no framework, no build step.
228+ const js = `(function(){var h=document.documentElement;function setTheme(k){h.dataset.theme=k;document.querySelectorAll('.theme-btn').forEach(function(b){b.classList.toggle('active',b.textContent===document.querySelector('.theme-btn[onclick*=\\''+k+'\\']').textContent)});try{localStorage.setItem('cc-theme',k)}catch(e){}}window.setTheme=setTheme;var saved;try{saved=localStorage.getItem('cc-theme')}catch(e){}if(saved)setTheme(saved);var btn=document.getElementById('theme-toggle'),menu=document.getElementById('theme-menu');btn.onclick=function(e){menu.style.display=menu.style.display==='block'?'none':'block';e.stopPropagation()};document.addEventListener('click',function(){menu.style.display='none'})})();`
229
241- return `<!DOCTYPE html>
242-<html lang="en" data-theme="${themeKey}">
243-<head>
244-<meta charset="UTF-8">
245-<meta name="viewport" content="width=device-width,initial-scale=1">
246-<title>${escapeHtml(title || 'Claude Session')}</title>
247-<style>
248-${allThemes}
249-${css}
250-.exported-topbar {
251- position: sticky; top: 0; z-index: 100; display: flex; align-items: center;
252- gap: 12px; padding: 0 16px; height: 48px;
253- background: var(--bg-2); border-bottom: 1px solid var(--border);
254- font-family: system-ui, sans-serif; font-size: 13px;
230+ const body = maybeMinifyHtml(renderSession(events), minify)
231+
232+ return maybeMinifyHtml(
233+ `<!DOCTYPE html>\n<html lang="en" data-theme="${themeKey}">\n<head>\n` +
234+ `<meta charset="UTF-8">\n<meta name="viewport" content="width=device-width,initial-scale=1">\n` +
235+ `<title>${escapeHtml(title)}</title>\n` +
236+ `<style>\n${css}\n</style>\n</head>\n<body>\n` +
237+ `<div class="exported-topbar">` +
238+ `<span class="brand">claude <span>session</span></span>` +
239+ `<div class="theme-toggle-wrap"><button id="theme-toggle">theme ▾</button>` +
240+ `<div id="theme-menu">${themeButtons}</div></div></div>\n` +
241+ `<div class="session-wrap"><div class="session">\n${body}\n</div></div>\n` +
242+ `<script>${js}</script>\n</body>\n</html>`,
243+ minify
244+ )
245 }
256-.exported-topbar .brand { font-weight: 600; color: var(--fg); }
257-.exported-topbar .brand span { color: var(--user-accent); }
258-.theme-toggle-wrap { position: relative; margin-left: auto; }
259-#theme-toggle {
260- padding: 5px 10px; border-radius: 3px; border: 1px solid var(--border-2);
261- background: transparent; color: var(--fg-2); cursor: pointer;
262- font: inherit; font-size: 12px;
263-}
264-#theme-menu {
265- display: none; position: absolute; right: 0; top: calc(100% + 6px);
266- z-index: 200; background: var(--bg-2); border: 1px solid var(--border-2);
267- border-radius: 6px; min-width: 180px; overflow: hidden;
268- box-shadow: 0 8px 24px rgba(0,0,0,.4);
269-}
270-.theme-option {
271- display: block; width: 100%; text-align: left;
272- padding: 8px 14px; cursor: pointer; border: none; background: transparent;
273- font-family: system-ui, sans-serif; font-size: 13px; color: var(--fg-2);
274-}
275-.theme-option:hover, .theme-option.active { background: var(--bg-hover); color: var(--fg); }
276-.theme-option.active { color: var(--user-accent); }
277-</style>
278-</head>
279-<body>
280-<div id="root">
281- <div class="exported-topbar">
282- <span class="brand">claude <span>session</span></span>
283- <div class="theme-toggle-wrap">
284- <button id="theme-toggle">theme ▾</button>
285- <div id="theme-menu">
286- ${themeOptions}
287- </div>
288- </div>
289- </div>
290- <div class="session-wrap">
291- <div class="session">
292- ${body}
293- </div>
294- </div>
295-</div>
296-<script>${miniJs}</script>
297-</body>
298-</html>`
299-}
246
301-// ---- Full SPA export ----
302-// Inlines the complete app bundle + session data. Self-replicating.
303-export async function buildFullSpa(events, rawText, themeKey, title) {
304- const [css, js] = await Promise.all([fetchCSS(), fetchJS()])
305- const allThemes = allThemesToCSS()
247+// ---------------------------------------------------------------------------
248+// Full-featured export
249+// Inlines the production JS bundle + session data. Self-replicating.
250+// Requires the production build (dist/assets/index.js). In dev, throws so
251+// App.jsx can surface a clear error to the user.
252+// ---------------------------------------------------------------------------
253
307- return `<!DOCTYPE html>
308-<html lang="en" data-theme="${themeKey}">
309-<head>
310-<meta charset="UTF-8">
311-<meta name="viewport" content="width=device-width,initial-scale=1">
312-<title>${escapeHtml(title || 'Claude Session')}</title>
313-<style>${allThemes}\n${css}</style>
314-</head>
315-<body>
316-<div id="root"></div>
317-<script>window.__PRELOADED_SESSION__=${JSON.stringify(rawText)};window.__PRELOADED_THEME__=${JSON.stringify(themeKey)};</script>
318-<script type="module">${js}</script>
319-</body>
320-</html>`
254+export async function buildFullFeatured(events, rawText, themeKey, title, { minify = false } = {}) {
255+ // Fetch the self-contained production bundle. This will 404 under the Vite
256+ // dev server (which serves unbundled modules) — the error is intentional.
257+ let js
258+ try {
259+ const resp = await fetch('./assets/index.js')
260+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
261+ js = await resp.text()
262+ } catch {
263+ throw new Error(
264+ 'Full-featured export requires the production build.\n' +
265+ 'Run: bun run build && bun run preview\n' +
266+ 'Then open http://localhost:4173 and export from there.'
267+ )
268+ }
269+
270+ // The JS app calls applyTheme() at runtime to set CSS vars, so we only need
271+ // the structural CSS here — no allThemesToCSS() required.
272+ const css = maybeMinifyCSS(rawCSS, minify)
273+
274+ const preload =
275+ `window.__PRELOADED_SESSION__=${JSON.stringify(rawText)};` +
276+ `window.__PRELOADED_THEME__=${JSON.stringify(themeKey)};`
277+
278+ return maybeMinifyHtml(
279+ `<!DOCTYPE html>\n<html lang="en" data-theme="${themeKey}">\n<head>\n` +
280+ `<meta charset="UTF-8">\n<meta name="viewport" content="width=device-width,initial-scale=1">\n` +
281+ `<title>${escapeHtml(title)}</title>\n` +
282+ `<style>\n${css}\n</style>\n</head>\n<body>\n` +
283+ `<div id="root"></div>\n` +
284+ `<script>${preload}</script>\n` +
285+ `<script type="module">${js}</script>\n` +
286+ `</body>\n</html>`,
287+ minify
288+ )
289 }
290
291 // ---------------------------------------------------------------------------