import { createElement } from 'react'; import { createRoot } from 'react-dom/client'; import { designToCssVars } from './design'; import { SlidePageProvider } from './page-context'; import type { SlideModule } from './sdk'; type AssetEntry = { name: string; bytes: Uint8Array }; const ASSET_EXT_RE = /\.(?:png|jpe?g|gif|svg|webp|avif|mp4|webm|mov|woff2?|ttf|otf|mp3|wav|ogg)(?:\?[^#]*)?(?:#.*)?$/i; export async function exportSlideAsHtml(slide: SlideModule, slideId: string): Promise { const pages = slide.default ?? []; if (pages.length === 0) return; const title = slide.meta?.title ?? slideId; const pagesHtml = await renderPagesToHtml(pages); const bundledCss = collectCss(); const externalLinks = collectExternalStylesheetLinks(); const assets = new Map(); const usedNames = new Set(); const urls = new Set([ ...findHtmlAssetUrls(pagesHtml.join('\n')), ...findCssAssetUrls(bundledCss), ]); for (const url of urls) { const absolute = toAbsolute(url); if (!absolute) continue; try { const res = await fetch(absolute); if (!res.ok) continue; const buf = new Uint8Array(await res.arrayBuffer()); const name = uniqueAssetName(absolute, usedNames); assets.set(url, { name, bytes: buf }); } catch {} } const rewrittenPages = pagesHtml.map((html) => rewriteUrls(html, assets, 'html')); const rewrittenCss = rewriteUrls(bundledCss, assets, 'css'); const html = buildHtml({ title, pagesHtml: rewrittenPages, bundledCss: rewrittenCss, externalLinks, design: slide.design, }); const htmlBytes = new TextEncoder().encode(html); if (assets.size === 0) { downloadBlob(new Blob([htmlBytes as BlobPart], { type: 'text/html' }), `${slideId}.html`); return; } const { zipSync } = await import('fflate'); const zipTree: Record> = { [`${slideId}.html`]: htmlBytes, assets: {}, }; for (const { name, bytes } of assets.values()) { (zipTree.assets as Record)[name] = bytes; } const zipped = zipSync(zipTree as Parameters[0]); downloadBlob(new Blob([zipped as BlobPart], { type: 'application/zip' }), `${slideId}.zip`); } async function renderPagesToHtml(pages: NonNullable): Promise { const container = document.createElement('div'); container.setAttribute('aria-hidden', 'true'); Object.assign(container.style, { position: 'fixed', left: '-99999px', top: '0', width: '1920px', height: '1080px', pointerEvents: 'none', }); document.body.appendChild(container); const result: string[] = []; try { for (let i = 0; i < pages.length; i++) { const Page = pages[i]; if (!Page) continue; const host = document.createElement('div'); host.style.width = '1920px'; host.style.height = '1080px'; container.appendChild(host); const root = createRoot(host); root.render( createElement(SlidePageProvider, { index: i, total: pages.length }, createElement(Page)), ); await nextPaint(); await nextPaint(); result.push(host.innerHTML); root.unmount(); container.removeChild(host); } } finally { container.remove(); } return result; } function nextPaint(): Promise { return new Promise((resolve) => requestAnimationFrame(() => resolve())); } function collectCss(): string { const chunks: string[] = []; for (const sheet of Array.from(document.styleSheets)) { let rules: CSSRuleList | null = null; try { rules = sheet.cssRules; } catch { continue; } if (!rules) continue; for (const rule of Array.from(rules)) { chunks.push(rule.cssText); } } return chunks.join('\n'); } function collectExternalStylesheetLinks(): string { const links: string[] = []; for (const sheet of Array.from(document.styleSheets)) { try { void sheet.cssRules; } catch { if (sheet.href) { links.push(``); } } } return links.join('\n'); } function findHtmlAssetUrls(html: string): string[] { const out: string[] = []; const attrRe = /\s(?:src|href)="([^"]+)"/g; for (const m of html.matchAll(attrRe)) { if (looksLikeAsset(m[1])) out.push(m[1]); } const srcsetRe = /\ssrcset="([^"]+)"/g; for (const m of html.matchAll(srcsetRe)) { for (const part of m[1].split(',')) { const url = part.trim().split(/\s+/)[0]; if (url && looksLikeAsset(url)) out.push(url); } } return out; } function findCssAssetUrls(css: string): string[] { const out: string[] = []; const re = /url\(\s*(['"]?)([^)'"]+)\1\s*\)/g; for (const m of css.matchAll(re)) { const url = m[2].trim(); if (looksLikeAsset(url)) out.push(url); } return out; } function looksLikeAsset(url: string): boolean { if (!url) return false; if (url.startsWith('data:') || url.startsWith('blob:') || url.startsWith('#')) return false; if (url.startsWith('mailto:') || url.startsWith('javascript:')) return false; const abs = toAbsolute(url); if (!abs) return false; try { const u = new URL(abs); if (u.origin !== window.location.origin) return false; } catch { return false; } return ASSET_EXT_RE.test(url); } function toAbsolute(url: string): string | null { try { return new URL(url, window.location.href).toString(); } catch { return null; } } function uniqueAssetName(absoluteUrl: string, used: Set): string { let base: string; try { const u = new URL(absoluteUrl); base = u.pathname.split('/').pop() || 'asset'; } catch { base = 'asset'; } if (!used.has(base)) { used.add(base); return base; } const hash = shortHash(absoluteUrl); const dot = base.lastIndexOf('.'); const name = dot > 0 ? `${base.slice(0, dot)}-${hash}${base.slice(dot)}` : `${base}-${hash}`; used.add(name); return name; } function shortHash(input: string): string { let h = 2166136261; for (let i = 0; i < input.length; i++) { h ^= input.charCodeAt(i); h = Math.imul(h, 16777619); } return (h >>> 0).toString(36).slice(0, 6); } function rewriteUrls( source: string, assets: Map, kind: 'html' | 'css', ): string { let out = source; for (const [orig, { name }] of assets) { const replacement = kind === 'css' ? `./assets/${name}` : `assets/${name}`; out = out.split(orig).join(replacement); } return out; } function buildHtml(opts: { title: string; pagesHtml: string[]; bundledCss: string; externalLinks: string; design: SlideModule['design']; }): string { const pagesMarkup = opts.pagesHtml .map( (page, i) => `
${page}
`, ) .join(''); const frameStyle = opts.design ? Object.entries(designToCssVars(opts.design)) .map(([k, v]) => `${k}: ${v};`) .join(' ') : ''; return ` ${escapeHtml(opts.title)} ${opts.externalLinks}
${pagesMarkup}
1 / ${opts.pagesHtml.length}
`; } function escapeHtml(s: string): string { return s .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function escapeAttr(s: string): string { return s.replace(/&/g, '&').replace(/"/g, '"'); } function downloadBlob(blob: Blob, filename: string): void { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.rel = 'noopener'; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 0); }