Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110/** 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<HTMLImageElement> { 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<string | null> { 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<void> { const urls = [ ...new Set( result.posts .map((p) => p.author.avatar) .filter((u): u is string => !!u && !u.startsWith("data:")), ), ]; const map = new Map<string, string>(); 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 "</script>" can appear inside the embedded JSON. const json = JSON.stringify({ ...result, posts, exported_at: new Date().toISOString(), }).replace(/</g, "\\u003c"); const html = template.replace('"__FRAY_DATA__"', () => 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(/<script id="fray-data" type="application\/json">([\s\S]*?)<\/script>/); if (m) data = JSON.parse(m[1]); } if ( !data || typeof data !== "object" || !Array.isArray((data as AnalyzeResult).posts) || !(data as AnalyzeResult).posts.length ) { throw new Error("No fray analysis data found in this file"); } return data as AnalyzeResult;}