Files
site-builder/craft/src/utils/self-review-capture.ts
T

212 lines
10 KiB
TypeScript

/**
* Client-side page capture for the Sitesmith self-review loop.
*
* Renders published-style HTML into a hidden iframe and rasterises it to a JPEG with
* html-to-image (MIT, zero dependencies). The HTML is hostile input (model output, user
* text, customer head code): it is first run through sanitizeCaptureHtml (inert DOMParser
* walk: no script/frame/object/handlers/javascript: URLs/@import/foreign url(); fixed-point
* checked) and the frame itself has a sandbox WITHOUT allow-scripts plus a CSP that forbids
* scripts and all network access except fonts/stylesheets from two hosts. `allow-same-origin`
* is kept only so the parent can read the (already sanitised, script-less) frame document.
*
* Never throws: every failure is a `{ ok: false, reason }` so the caller can
* skip the review with a non-blocking note.
*/
import { toJpeg } from 'html-to-image';
import { sanitizeCaptureHtml } from './sanitize-capture-html';
export const FONT_AWESOME_CSS = 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.3.1/css/all.min.css';
export interface CaptureOptions {
/** Render width in CSS px (default 1280). */
width?: number;
/** The page is cropped to this height (default 3000 px). */
maxHeight?: number;
/** Whole-capture time limit (default 20 s). */
timeoutMs?: number;
/** JPEG quality 0..1 (default 0.8). */
quality?: number;
/** Reject captures bigger than this many bytes (default 3 MB). */
maxBytes?: number;
/** Add the Font Awesome stylesheet when the HTML has none (Preview head may lack it). Default true. */
injectFontAwesome?: boolean;
/** Same-origin URLs (root-relative) the page may load, e.g. the site's asset proxy URLs. */
allowUrls?: string[];
/** Extra settle time after load for fonts/images (default 600 ms). */
settleMs?: number;
}
export type CaptureResult =
| { ok: true; blob: Blob; width: number; height: number; cropped: boolean }
| { ok: false; reason: string };
/** Make `html` safe and complete for capture: FA link if missing, scripts and head code are not executed anyway (sandbox). */
export function prepareCaptureHtml(html: string, injectFontAwesome = true): string {
// A cross-origin stylesheet without CORS is unreadable (cssRules throws), so its @font-face
// rules could never be embedded in the capture. Google Fonts and cdnjs both answer CORS.
let out = html.replace(/<link\b[^>]*>/gi, (tag) => (
/rel=["']?stylesheet/i.test(tag) && /href=["']?https:\/\/(fonts\.googleapis\.com|cdnjs\.cloudflare\.com)\//i.test(tag) && !/crossorigin/i.test(tag)
? tag.replace(/\s*\/?>$/, ' crossorigin="anonymous">')
: tag
));
if (injectFontAwesome && !/font-awesome|fontawesome/i.test(out)) {
const link = `<link rel="stylesheet" href="${FONT_AWESOME_CSS}" crossorigin="anonymous" referrerpolicy="no-referrer">`;
out = /<\/head>/i.test(out) ? out.replace(/<\/head>/i, () => link + '</head>') : link + out;
}
return out;
}
/** Replace relative `assets/<file>` references with fetchable URLs (the editor page is not at the site root). */
export function resolveAssetUrls(html: string, urls: Record<string, string>): string {
let out = html;
for (const [rel, url] of Object.entries(urls)) {
if (!/^assets\/[A-Za-z0-9._%~+-]{1,200}$/.test(rel)) continue;
// Same-origin only, written as a root-relative path (the sanitiser refuses absolute URLs).
let local = url;
try {
const u = new URL(url, typeof location !== 'undefined' ? location.href : 'http://localhost/');
if (typeof location !== 'undefined' && u.origin !== location.origin) continue;
local = u.pathname + u.search;
} catch { continue; }
if (!/^\/[^\s"'<>]*$/.test(local)) continue;
out = out.split(rel).join(local);
}
return out;
}
const MAX_FONT_FACES = 24;
const MAX_FONT_BYTES = 2.5 * 1024 * 1024;
const blobToDataUrl = (b: Blob) => new Promise<string>((resolve, reject) => {
const r = new FileReader();
r.onload = () => resolve(String(r.result));
r.onerror = () => reject(new Error('read failed'));
r.readAsDataURL(b);
});
/** True when a @font-face unicode-range could cover basic Latin (or has no range at all). */
function coversLatin(range: string): boolean {
if (!range.trim()) return true;
return range.split(',').some((part) => {
const m = /^U\+([0-9A-F?]+)(?:-([0-9A-F]+))?$/i.exec(part.trim());
if (!m) return false;
const lo = parseInt(m[1].replace(/\?/g, '0'), 16);
const hi = m[2] ? parseInt(m[2], 16) : m[1].includes('?') ? parseInt(m[1].replace(/\?/g, 'F'), 16) : lo;
return lo <= 0x7e && hi >= 0x41;
});
}
/**
* Our own web-font embedding. html-to-image's built-in version silently produced
* fallback fonts for Google Fonts, Font Awesome and same-origin @font-face in the
* measurement (see the capture notes), so we inline every readable @font-face as a
* data URL ourselves and hand the result in as `fontEmbedCSS`.
*/
export async function buildFontEmbedCss(doc: Document): Promise<{ css: string; faces: number; failed: number }> {
const jobs: Promise<string | null>[] = [];
let failed = 0;
let total = 0;
for (const sheet of Array.from(doc.styleSheets)) {
let rules: CSSRuleList;
try { rules = sheet.cssRules; } catch { failed++; continue; }
for (const rule of Array.from(rules)) {
if (!(rule instanceof (doc.defaultView as unknown as typeof globalThis).CSSFontFaceRule)) continue;
const ff = rule as CSSFontFaceRule;
if (!coversLatin(ff.style.getPropertyValue('unicode-range'))) continue;
if (jobs.length >= MAX_FONT_FACES) break;
const base = sheet.href || doc.baseURI;
const src = ff.style.getPropertyValue('src');
const urls = Array.from(src.matchAll(/url\(\s*["']?([^"')]+)["']?\s*\)/g)).map((m) => m[1]);
const family = ff.style.getPropertyValue('font-family');
const weight = ff.style.getPropertyValue('font-weight') || 'normal';
const style = ff.style.getPropertyValue('font-style') || 'normal';
jobs.push((async () => {
for (const u of urls) {
if (u.startsWith('data:')) return `@font-face{font-family:${family};font-weight:${weight};font-style:${style};src:url(${u})}`;
try {
const res = await fetch(new URL(u, base).href);
if (!res.ok) continue;
const blob = await res.blob();
total += blob.size;
if (total > MAX_FONT_BYTES) return null;
return `@font-face{font-family:${family};font-weight:${weight};font-style:${style};src:url(${await blobToDataUrl(blob)})}`;
} catch { /* try the next format */ }
}
failed++;
return null;
})());
}
}
const parts = (await Promise.all(jobs)).filter((x): x is string => !!x);
return { css: parts.join('\n'), faces: parts.length, failed };
}
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
export async function captureHtml(html: string, opts: CaptureOptions = {}): Promise<CaptureResult> {
const width = opts.width ?? 1280;
const maxHeight = opts.maxHeight ?? 3000;
const timeoutMs = opts.timeoutMs ?? 20000;
const maxBytes = opts.maxBytes ?? 3 * 1024 * 1024;
if (typeof document === 'undefined' || !document.body) return { ok: false, reason: 'no document' };
const frame = document.createElement('iframe');
frame.setAttribute('sandbox', 'allow-same-origin');
frame.setAttribute('aria-hidden', 'true');
frame.tabIndex = -1;
frame.style.cssText = `position:fixed;left:-99999px;top:0;width:${width}px;height:900px;border:0;visibility:hidden;pointer-events:none`;
let timer: ReturnType<typeof setTimeout> | undefined;
const work = (async (): Promise<CaptureResult> => {
await new Promise<void>((resolve, reject) => {
frame.addEventListener('load', () => resolve(), { once: true });
frame.addEventListener('error', () => reject(new Error('frame failed to load')), { once: true });
const clean = sanitizeCaptureHtml(prepareCaptureHtml(html, opts.injectFontAwesome !== false), { allowUrls: opts.allowUrls, origin: typeof location !== 'undefined' ? location.origin : undefined });
if (!clean.ok) { reject(new Error(`unsafe page not captured (${clean.reason})`)); return; }
frame.srcdoc = clean.html;
frame.setAttribute('csp', clean.csp ?? '');
document.body.appendChild(frame);
});
const doc = frame.contentDocument;
if (!doc || !doc.body) return { ok: false, reason: 'page not readable' };
// Fonts + images: wait for what the page asked for, then a short settle.
try { await (doc as Document & { fonts?: { ready: Promise<unknown> } }).fonts?.ready; } catch { /* ignore */ }
await Promise.all(Array.from(doc.images).map((img) => (img.complete ? null : new Promise<void>((r) => {
img.addEventListener('load', () => r(), { once: true });
img.addEventListener('error', () => r(), { once: true });
}))));
await sleep(opts.settleMs ?? 600);
const full = Math.max(doc.documentElement.scrollHeight, doc.body.scrollHeight, 1);
const height = Math.min(full, maxHeight);
frame.style.height = `${height}px`;
await sleep(50);
const fonts = await buildFontEmbedCss(doc);
const dataUrl = await toJpeg(doc.body, {
quality: opts.quality ?? 0.8,
width, height, canvasWidth: width, canvasHeight: height, pixelRatio: 1,
backgroundColor: '#ffffff',
cacheBust: false,
fontEmbedCSS: fonts.css,
style: { margin: '0' },
});
const blob = await (await fetch(dataUrl)).blob();
if (blob.size < 2000) return { ok: false, reason: 'capture came out empty' };
if (blob.size > maxBytes) return { ok: false, reason: 'capture too large' };
return { ok: true, blob, width, height, cropped: full > maxHeight };
})();
const timeout = new Promise<CaptureResult>((resolve) => {
timer = setTimeout(() => resolve({ ok: false, reason: 'capture timed out' }), timeoutMs);
});
try {
return await Promise.race([work, timeout]);
} catch (e) {
return { ok: false, reason: e instanceof Error ? e.message.slice(0, 120) : (typeof Event !== 'undefined' && e instanceof Event ? `an image or resource failed to render (${e.type})` : 'capture failed') };
} finally {
if (timer) clearTimeout(timer);
frame.remove();
work.catch(() => undefined);
}
}