212 lines
10 KiB
TypeScript
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);
|
|
}
|
|
}
|