/** Standalone-HTML export: the built viewer template with the analysis JSON * embedded, avatars converted to small data URLs so the file works offline * (and so WebGL/canvas never need CORS). The embedded JSON doubles as the * app's importable format. */ import type { AnalyzeResult } from "./types"; const AVATAR_SIZE = 64; const CONCURRENCY = 8; function loadImage(url: string): Promise { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve(img); img.onerror = () => reject(new Error("avatar load failed")); img.src = url; }); } async function avatarDataUrl(url: string): Promise { try { const img = await loadImage(`/api/avatar?url=${encodeURIComponent(url)}&s=${AVATAR_SIZE}`); const canvas = document.createElement("canvas"); canvas.width = canvas.height = AVATAR_SIZE; const ctx = canvas.getContext("2d")!; ctx.fillStyle = "#9a9993"; // matte behind transparent avatars (jpeg has no alpha) ctx.fillRect(0, 0, AVATAR_SIZE, AVATAR_SIZE); const scale = Math.max(AVATAR_SIZE / img.width, AVATAR_SIZE / img.height); const w = img.width * scale; const h = img.height * scale; ctx.drawImage(img, (AVATAR_SIZE - w) / 2, (AVATAR_SIZE - h) / 2, w, h); return canvas.toDataURL("image/jpeg", 0.8); } catch { return null; } } export async function exportHtml( result: AnalyzeResult, onProgress?: (done: number, total: number) => void, ): Promise { const urls = [ ...new Set( result.posts .map((p) => p.author.avatar) .filter((u): u is string => !!u && !u.startsWith("data:")), ), ]; const map = new Map(); let next = 0; let done = 0; await Promise.all( Array.from({ length: CONCURRENCY }, async () => { while (next < urls.length) { const u = urls[next++]; const d = await avatarDataUrl(u); if (d) map.set(u, d); onProgress?.(++done, urls.length); } }), ); const posts = result.posts.map((p) => ({ ...p, author: { ...p.author, avatar: p.author.avatar ? map.get(p.author.avatar) ?? p.author.avatar : p.author.avatar, }, })); const tplRes = await fetch("/viewer.html"); if (!tplRes.ok) throw new Error("viewer template missing (was the viewer build run?)"); const template = await tplRes.text(); // <-escape so no "" can appear inside the embedded JSON. const json = JSON.stringify({ ...result, posts, exported_at: new Date().toISOString(), }).replace(/ json); if (html === template) throw new Error("viewer template has no data placeholder"); const rkey = result.root_uri.split("/").pop(); const blob = new Blob([html], { type: "text/html" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = `fray-${posts[0].author.handle}-${rkey}.html`; a.click(); URL.revokeObjectURL(a.href); } /** Parse an exported .html (or bare .json) back into a result. */ export function parseImport(text: string): AnalyzeResult { let data: unknown = null; if (text.trimStart().startsWith("{")) { data = JSON.parse(text); } else { const m = text.match(/