Files
AgentHub/client/src/lib/htmlInline.ts
T
nntrivi2001 7b5abc6ad9 feat(files): an HTML file opens as the page — Files panel, Chat preview, File Explorer
Clicking an .html/.htm file now renders it instead of showing its source or
downloading it:
- Changes/Files panel: a whole HTML file opens on Preview (Raw = the editor).
- Chat file preview: a workspace page brings its relative files along.
- File Explorer: double-click / Enter opens a viewer; Download stays on its
  header and right-click menu.

The page runs in the chat artifact's sandbox (iframe srcdoc, scripts, never
allow-same-origin). Its relative stylesheets (and their url()s), scripts,
images, media and icons are read by the dashboard through the existing
download routes and inlined as <style>/data: URLs (lib/htmlInline.ts: never
above the folder's root, ≤ 60 files / 30 MB) — the login cookie is
SameSite=Strict, so a frame could not fetch them itself, and no new route
serves files without it.
2026-10-28 16:46:07 +07:00

231 lines
7.6 KiB
TypeScript

/**
* @file htmlInline.ts
* @description An HTML file made whole for its sandboxed preview. The page
* runs in an `<iframe srcdoc>` with no same-origin access, so it can fetch
* nothing from the dashboard — and the dashboard's login cookie is
* SameSite=Strict, so even a URL it could reach would answer 401. Instead the
* DASHBOARD reads the page's relative stylesheets, scripts, images, media and
* the `url(...)`s inside its CSS through a read route that already guards
* them (the caller's `fetchFile`: a workspace's or a home's download), and
* writes them into the page as `<style>` and `data:` URLs before it renders.
* Only paths inside the page's own place are followed (`..` above its root is
* skipped), and a page can pull at most `MAX_ASSETS` files / `MAX_BYTES` in
* all, so a big site never stalls the viewer. A reference that cannot be read
* is left as it was. `fetch()`/XHR from the page's own script, ES module
* imports and links to other pages still do not work.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/** Reads one file by its path relative to the place's root; throws when it cannot. */
export type FetchFile = (relPath: string) => Promise<Blob>;
const MAX_ASSETS = 60;
const MAX_BYTES = 30 * 1024 * 1024;
const MIME: Record<string, string> = {
css: "text/css",
js: "text/javascript",
mjs: "text/javascript",
json: "application/json",
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
avif: "image/avif",
bmp: "image/bmp",
ico: "image/x-icon",
svg: "image/svg+xml",
woff: "font/woff",
woff2: "font/woff2",
ttf: "font/ttf",
otf: "font/otf",
mp4: "video/mp4",
webm: "video/webm",
mp3: "audio/mpeg",
wav: "audio/wav",
ogg: "audio/ogg",
};
const mimeOf = (p: string) =>
MIME[(/\.([A-Za-z0-9]+)$/.exec(p)?.[1] || "").toLowerCase()] ?? "application/octet-stream";
/** The folder part of a relative path ("" at the root). */
export const dirOf = (p: string) => (p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : "");
/**
* A page's reference as a path from the place's root, or null when it is not
* a local file (a URL, `//host`, `data:`, an anchor) or climbs above the root.
* A leading `/` counts from the root, as a site served from it would.
*/
export function resolveRef(dir: string, ref: string): string | null {
const r = ref.trim();
if (!r || r.startsWith("#") || r.startsWith("//") || /^[a-z][a-z0-9+.-]*:/i.test(r)) return null;
let p = r.replace(/[?#].*$/, "");
try {
p = decodeURIComponent(p);
} catch {
// A stray % is a literal one.
}
const out: string[] = [];
for (const seg of (p.startsWith("/") ? p : `${dir}/${p}`).split("/")) {
if (!seg || seg === ".") continue;
if (seg === "..") {
if (!out.length) return null;
out.pop();
} else out.push(seg);
}
return out.length ? out.join("/") : null;
}
/** A Blob's text / bytes, through FileReader where Blob lacks the methods (jsdom). */
export function blobText(b: Blob): Promise<string> {
if (typeof b.text === "function") return b.text();
return new Promise((ok, fail) => {
const r = new FileReader();
r.onload = () => ok(String(r.result));
r.onerror = () => fail(r.error);
r.readAsText(b);
});
}
function blobBytes(b: Blob): Promise<ArrayBuffer> {
if (typeof b.arrayBuffer === "function") return b.arrayBuffer();
return new Promise((ok, fail) => {
const r = new FileReader();
r.onload = () => ok(r.result as ArrayBuffer);
r.onerror = () => fail(r.error);
r.readAsArrayBuffer(b);
});
}
function base64(buf: ArrayBuffer): string {
const bytes = new Uint8Array(buf);
let s = "";
for (let i = 0; i < bytes.length; i += 0x8000) {
s += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
}
return btoa(s);
}
const URL_RE = /url\(\s*(['"]?)([^'")]+?)\1\s*\)/gi;
/** Inline the relative resources of `html` (the file at `htmlPath`). */
export async function inlineHtml(
html: string,
htmlPath: string,
fetchFile: FetchFile
): Promise<string> {
const doc = new DOMParser().parseFromString(html, "text/html");
const cache = new Map<string, Promise<Blob | null>>();
let count = 0;
let bytes = 0;
let changed = false;
const read = (rel: string): Promise<Blob | null> => {
let p = cache.get(rel);
if (!p) {
if (count >= MAX_ASSETS) return Promise.resolve(null);
count++;
p = fetchFile(rel)
.then((b) => {
if (bytes + b.size > MAX_BYTES) return null;
bytes += b.size;
return b;
})
.catch(() => null);
cache.set(rel, p);
}
return p;
};
const dataUrl = async (rel: string): Promise<string | null> => {
const b = await read(rel);
return b ? `data:${mimeOf(rel)};base64,${base64(await blobBytes(b))}` : null;
};
/** CSS with its own relative `url(...)`s made data URLs (from the CSS's folder). */
const inlineCss = async (css: string, dir: string): Promise<string> => {
const refs = new Map<string, string | null>();
for (const m of css.matchAll(URL_RE)) {
const rel = resolveRef(dir, m[2]!);
if (rel && !refs.has(rel)) refs.set(rel, null);
}
await Promise.all([...refs.keys()].map(async (rel) => refs.set(rel, await dataUrl(rel))));
return css.replace(URL_RE, (all, _q, ref: string) => {
const rel = resolveRef(dir, ref);
const url = rel ? refs.get(rel) : null;
return url ? `url("${url}")` : all;
});
};
const dir = dirOf(htmlPath);
const jobs: Promise<void>[] = [];
for (const link of doc.querySelectorAll<HTMLLinkElement>("link[rel~='stylesheet' i][href]")) {
const rel = resolveRef(dir, link.getAttribute("href")!);
if (!rel) continue;
jobs.push(
(async () => {
const b = await read(rel);
if (!b) return;
const style = doc.createElement("style");
const media = link.getAttribute("media");
if (media) style.setAttribute("media", media);
style.textContent = await inlineCss(await blobText(b), dirOf(rel));
link.replaceWith(style);
changed = true;
})()
);
}
for (const style of doc.querySelectorAll("style")) {
const css = style.textContent || "";
if (!/url\(/i.test(css)) continue;
jobs.push(
inlineCss(css, dir).then((out) => {
if (out !== css) {
style.textContent = out;
changed = true;
}
})
);
}
for (const el of doc.querySelectorAll<HTMLElement>("[style*='url(' i]")) {
const css = el.getAttribute("style")!;
jobs.push(
inlineCss(css, dir).then((out) => {
if (out !== css) {
el.setAttribute("style", out);
changed = true;
}
})
);
}
const attrs: [string, string][] = [
["script[src]", "src"],
["img[src]", "src"],
["source[src]", "src"],
["video[src]", "src"],
["audio[src]", "src"],
["video[poster]", "poster"],
["input[type='image' i][src]", "src"],
["link[rel~='icon' i][href]", "href"],
];
for (const [sel, attr] of attrs) {
for (const el of doc.querySelectorAll(sel)) {
const rel = resolveRef(dir, el.getAttribute(attr)!);
if (!rel) continue;
jobs.push(
dataUrl(rel).then((url) => {
if (!url) return;
el.setAttribute(attr, url);
// A srcset beside it would win over the inlined src and still miss.
if (el.hasAttribute("srcset")) el.removeAttribute("srcset");
changed = true;
})
);
}
}
await Promise.all(jobs);
if (!changed) return html;
const doctype = /^\s*(?:<!--[\s\S]*?-->\s*)*<!doctype/i.test(html) ? "<!doctype html>\n" : "";
return doctype + doc.documentElement.outerHTML;
}