/** 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(/