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.
This commit is contained in:
2026-10-28 16:45:57 +07:00
parent ca55ff8ec5
commit 7b5abc6ad9
11 changed files with 633 additions and 25 deletions
@@ -0,0 +1,53 @@
/**
* @file htmlInline.test.ts
* @description An HTML preview's relative CSS, scripts and images are inlined
* from the place's own files, never from above its root or off a URL.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it, vi } from "vitest";
import { inlineHtml, resolveRef } from "../htmlInline";
const files: Record<string, string> = {
"site/css/app.css": "body{background:url(../img/bg.png)}",
"site/js/app.js": "window.ok=1",
"site/img/logo.png": "PNG",
"site/img/bg.png": "BG",
};
const fetchFile = vi.fn(async (p: string) => {
if (!(p in files)) throw new Error("404");
return new Blob([files[p]!]);
});
describe("resolveRef", () => {
it("resolves against the page's folder, from the root on a leading slash", () => {
expect(resolveRef("site", "css/a.css?v=2#x")).toBe("site/css/a.css");
expect(resolveRef("site/sub", "../img/a%20b.png")).toBe("site/img/a b.png");
expect(resolveRef("site", "/top.css")).toBe("top.css");
});
it("skips URLs, anchors and anything above the root", () => {
for (const r of ["https://x/a.js", "//cdn/a.js", "data:x", "#top", "", "../../etc/passwd"])
expect(resolveRef("site", r)).toBeNull();
});
});
describe("inlineHtml", () => {
it("inlines stylesheets (with their url()s), scripts and images", async () => {
const html = `<!doctype html><html><head><link rel="stylesheet" href="css/app.css">
<script src="js/app.js"></script><script src="https://cdn.example/x.js"></script></head>
<body><img src="img/logo.png" srcset="img/logo@2x.png 2x"><img src="missing.png"></body></html>`;
const out = await inlineHtml(html, "site/index.html", fetchFile);
expect(out.startsWith("<!doctype html>")).toBe(true);
expect(out).toContain(`<style>body{background:url("data:image/png;base64,${btoa("BG")}")}</style>`);
expect(out).toContain(`src="data:text/javascript;base64,${btoa("window.ok=1")}"`);
expect(out).toContain(`src="data:image/png;base64,${btoa("PNG")}"`);
expect(out).not.toContain("srcset");
expect(out).toContain('src="https://cdn.example/x.js"');
expect(out).toContain('src="missing.png"');
expect(fetchFile).not.toHaveBeenCalledWith(expect.stringContaining("cdn"));
});
it("returns the page untouched when nothing is local", async () => {
const html = "<p>hi <a href='https://x'>x</a></p>";
expect(await inlineHtml(html, "a.html", fetchFile)).toBe(html);
});
});
+230
View File
@@ -0,0 +1,230 @@
/**
* @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;
}