Files
wehub-resource-sync 070959e133
landing-page-staging / Deploy landing page to staging (push) Has been skipped
landing-page-ci / Validate landing page (push) Failing after 4s
visual-baseline / Capture visual baselines (push) Has been cancelled
bake-plugin-previews / Bake plugin previews (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:00:47 +08:00

566 lines
21 KiB
JavaScript

// Open Design web clipper page-capture runtime.
//
// Injected on demand by the service worker via
// `chrome.scripting.executeScript({ files: ['capture.js'] })`. It runs in the
// page (the extension's isolated content-script world — full DOM + layout
// access) and exposes two entry points:
//
// • `window.__odCapture(opts)` — full-page snapshot (the two artifacts below).
// • `window.__odCaptureElement(opts)` — a single picked element captured as a
// self-contained `html` snapshot: the page's CSS is kept so the element's
// cascade resolves, but the DOM is pruned to just the element (and its
// ancestor chain), so opening the saved file shows that one element styled
// as it was on the page. One HTML file, easy to share/distribute.
//
// The full-page pass produces, in ONE go, two artifacts:
//
// 1. `html` — a self-contained, high-fidelity snapshot of the page:
// readable stylesheets inlined, scripts stripped, every URL
// absolutized, and a `<base>` injected. Cross-origin image /
// background URLs are left absolute in the string; the worker
// rewrites them to data URIs afterwards (only the worker can
// fetch cross-origin without CORS).
//
// 2. `figmaIr` — the OD Figma capture IR (a JSON node-tree of FRAME / TEXT /
// RECTANGLE nodes with absolute geometry, fills, strokes,
// corner radii, shadows and fonts). See `figma-plugin/IR.md`.
// Image fills carry a `url` placeholder the worker swaps for a
// data URI.
//
// `resources` is the deduped list of absolute http(s) URLs both outputs depend
// on, so the worker fetches each one only once.
//
// Self-contained, dependency-free, never throws into the caller (the worker
// wraps it best-effort).
(function () {
if (window.__odCapture) return; // idempotent across repeated injections
const IR_VERSION = 1;
const MAX_NODES = 6000; // safety cap on IR size; logged when hit
const MAX_RESOURCES = 300;
// --- small utilities -----------------------------------------------------
function absUrl(url, base) {
if (!url) return url;
try {
return new URL(url, base || document.baseURI).href;
} catch {
return url;
}
}
function isHttp(url) {
return typeof url === 'string' && /^https?:\/\//i.test(url);
}
// Rewrite every `url(...)` inside a CSS string to an absolute URL resolved
// against `base` (the stylesheet's own href, so relative asset paths work).
function absolutizeCss(cssText, base) {
return cssText.replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi, (m, q, ref) => {
if (/^(data:|blob:|#)/i.test(ref)) return m;
return `url(${q}${absUrl(ref, base)}${q})`;
});
}
// Collect absolute http(s) url()s out of a CSS string into `sink`.
function collectCssUrls(cssText, sink) {
const re = /url\(\s*['"]?([^'")]+)['"]?\s*\)/gi;
let m;
while ((m = re.exec(cssText))) {
if (isHttp(m[1])) sink.add(m[1]);
}
}
function absolutizeSrcset(srcset) {
return srcset
.split(',')
.map((part) => {
const seg = part.trim();
if (!seg) return '';
const sp = seg.indexOf(' ');
const url = sp === -1 ? seg : seg.slice(0, sp);
const desc = sp === -1 ? '' : seg.slice(sp);
return `${absUrl(url)}${desc}`;
})
.filter(Boolean)
.join(', ');
}
// --- self-contained HTML -------------------------------------------------
// Clone <html>, strip the bits a saved snapshot must never carry (live
// scripts, our own on-page UI, the page's <base>). Shared by the full-page
// and single-element capture paths.
function cloneDocument() {
const clone = document.documentElement.cloneNode(true);
// Strip scripts (the Library renders snapshots sandboxed; live JS only
// mutates the DOM after load and would never run there anyway).
clone.querySelectorAll('script, noscript').forEach((n) => n.remove());
// Never serialize our own injected on-page UI (toolbar / toast / pickers).
clone.querySelectorAll('[id^="od-clipper-"]').forEach((n) => n.remove());
// Drop any existing <base>; we inject our own from the live baseURI.
clone.querySelectorAll('base').forEach((n) => n.remove());
return clone;
}
// Inline readable stylesheets in place of their <link>, and absolutize the
// page's inline <style> blocks. Live <link rel=stylesheet> elements map 1:1
// (document order) to those in the clone, so this must run BEFORE any pruning
// that could shift that alignment. `collectImageUrls` controls whether the
// stylesheets' `url()` assets are queued for inlining: a full-page capture
// wants them, but a single-element clip leaves page-wide CSS imagery as live
// URLs (resolved against <base>) so a small element doesn't drag in the whole
// site's background art.
function inlineStylesheets(clone, resources, collectImageUrls) {
const liveLinks = Array.from(document.querySelectorAll('link[rel~="stylesheet"]'));
const cloneLinks = Array.from(clone.querySelectorAll('link[rel~="stylesheet"]'));
liveLinks.forEach((live, i) => {
const target = cloneLinks[i];
if (!target) return;
let cssText = null;
const base = live.href || document.baseURI;
try {
const rules = live.sheet && live.sheet.cssRules;
if (rules) cssText = Array.from(rules).map((r) => r.cssText).join('\n');
} catch {
cssText = null; // cross-origin / opaque — leave the <link>, absolutize below
}
if (cssText != null) {
const style = document.createElement('style');
style.setAttribute('data-od-inlined', '');
const css = absolutizeCss(cssText, base);
if (collectImageUrls) collectCssUrls(css, resources);
style.textContent = css;
target.replaceWith(style);
} else if (target.getAttribute('href')) {
target.setAttribute('href', absUrl(target.getAttribute('href')));
}
});
// Inline <style> elements that the CSSOM exposes but that contain relative
// url()s — absolutize them and (optionally) harvest their resources.
clone.querySelectorAll('style:not([data-od-inlined])').forEach((style) => {
if (!style.textContent) return;
const css = absolutizeCss(style.textContent, document.baseURI);
if (collectImageUrls) collectCssUrls(css, resources);
style.textContent = css;
});
}
// Absolutize element URLs + inline-style url()s on the (possibly pruned) clone,
// and harvest the image refs that elements carry in their own markup (<img>
// src and inline `style` background-image). These belong to whatever subtree
// survives, so they are inlined for both capture paths.
function absolutizeUrls(clone, resources, includeImages) {
clone.querySelectorAll('*').forEach((el) => {
for (const attr of ['src', 'href', 'poster']) {
const v = el.getAttribute && el.getAttribute(attr);
if (v && !/^(data:|blob:|#|javascript:)/i.test(v)) {
const abs = absUrl(v);
el.setAttribute(attr, abs);
if (includeImages && el.tagName === 'IMG' && attr === 'src' && isHttp(abs)) {
resources.add(abs);
}
}
}
const srcset = el.getAttribute && el.getAttribute('srcset');
if (srcset) el.setAttribute('srcset', absolutizeSrcset(srcset));
const inlineStyle = el.getAttribute && el.getAttribute('style');
if (inlineStyle && inlineStyle.includes('url(')) {
const css = absolutizeCss(inlineStyle, document.baseURI);
if (includeImages) collectCssUrls(css, resources);
el.setAttribute('style', css);
}
});
}
// Inject <base> so any URL we didn't rewrite still resolves against the live
// page, then serialize with a doctype.
function serializeClone(clone) {
const head = clone.querySelector('head') || clone;
const base = document.createElement('base');
base.setAttribute('href', document.baseURI);
head.insertBefore(base, head.firstChild);
const doctype = document.doctype
? `<!DOCTYPE ${document.doctype.name}>`
: '<!DOCTYPE html>';
return `${doctype}\n${clone.outerHTML}`;
}
function buildHtml(resources, includeImages) {
const clone = cloneDocument();
inlineStylesheets(clone, resources, includeImages);
absolutizeUrls(clone, resources, includeImages);
return serializeClone(clone);
}
// --- single-element capture ----------------------------------------------
//
// Walk up from the marked element, deleting every off-path sibling so only
// the chain <html> → … → target (and the target's own subtree) survives.
// <head> is preserved at the documentElement level so the inlined stylesheets
// — and the cascade/inheritance the element depends on — stay intact. The
// result is a self-contained HTML document of just the picked element, styled
// exactly as it sat on the page.
function pruneToElementPath(root, target) {
let node = target;
while (node && node.parentNode && node !== root) {
const parent = node.parentNode;
for (const sib of Array.from(parent.childNodes)) {
if (sib === node) continue;
if (sib.nodeType === Node.ELEMENT_NODE && sib.tagName === 'HEAD') continue;
parent.removeChild(sib);
}
node = parent;
}
}
function buildElementHtml(marker, resources, includeImages) {
const clone = cloneDocument();
// Full page CSS is kept (so the element's cascade resolves) but its url()
// assets are NOT queued — only the element's own markup imagery is inlined.
inlineStylesheets(clone, resources, false);
const target = clone.querySelector(`[${marker}]`);
if (target) {
pruneToElementPath(clone, target);
target.removeAttribute(marker);
}
absolutizeUrls(clone, resources, includeImages);
return serializeClone(clone);
}
// --- Figma IR ------------------------------------------------------------
function parseColor(str) {
if (!str) return null;
const m = /rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:[,/\s]+([\d.]+))?\s*\)/i.exec(str);
if (!m) return null;
const a = m[4] === undefined ? 1 : Number(m[4]);
return {
r: Math.min(1, Number(m[1]) / 255),
g: Math.min(1, Number(m[2]) / 255),
b: Math.min(1, Number(m[3]) / 255),
a: Number.isFinite(a) ? a : 1,
};
}
function solidFill(color) {
if (!color || color.a === 0) return null;
return { type: 'SOLID', color: { r: color.r, g: color.g, b: color.b }, opacity: color.a };
}
function px(v) {
const n = parseFloat(v);
return Number.isFinite(n) ? n : 0;
}
function uniformRadius(s) {
const tl = px(s.borderTopLeftRadius);
const tr = px(s.borderTopRightRadius);
const br = px(s.borderBottomRightRadius);
const bl = px(s.borderBottomLeftRadius);
if (tl === tr && tr === br && br === bl) return tl;
return { topLeft: tl, topRight: tr, bottomRight: br, bottomLeft: bl };
}
function borderStroke(s) {
const w = px(s.borderTopWidth);
if (!w || s.borderTopStyle === 'none' || s.borderTopStyle === 'hidden') return null;
const color = parseColor(s.borderTopColor);
const fill = solidFill(color);
if (!fill) return null;
// Only emit when all four edges roughly match (the common card/box case).
const uniform =
s.borderTopWidth === s.borderRightWidth &&
s.borderRightWidth === s.borderBottomWidth &&
s.borderBottomWidth === s.borderLeftWidth;
if (!uniform) return null;
return { stroke: fill, weight: w };
}
// Best-effort parse of the first (outer) box-shadow into a DROP_SHADOW.
function parseShadow(boxShadow) {
if (!boxShadow || boxShadow === 'none') return null;
const first = boxShadow.split(/,(?![^(]*\))/)[0].trim();
if (/\binset\b/.test(first)) return null; // inner shadows skipped for now
const colorMatch = /rgba?\([^)]+\)|#[0-9a-f]{3,8}/i.exec(first);
const color = parseColor(colorMatch ? colorMatch[0] : '');
const nums = (first.replace(/rgba?\([^)]+\)|#[0-9a-f]{3,8}/i, '').match(/-?[\d.]+px/g) || []).map(px);
if (!color || nums.length < 2) return null;
return {
type: 'DROP_SHADOW',
color: { r: color.r, g: color.g, b: color.b, a: color.a },
offset: { x: nums[0] || 0, y: nums[1] || 0 },
radius: nums[2] || 0,
spread: nums[3] || 0,
};
}
function fontStyleName(weight, italic) {
const w = Number(weight) || 400;
const name =
w <= 100 ? 'Thin'
: w <= 200 ? 'ExtraLight'
: w <= 300 ? 'Light'
: w <= 400 ? 'Regular'
: w <= 500 ? 'Medium'
: w <= 600 ? 'SemiBold'
: w <= 700 ? 'Bold'
: w <= 800 ? 'ExtraBold'
: 'Black';
if (italic) return name === 'Regular' ? 'Italic' : `${name} Italic`;
return name;
}
function firstFamily(fontFamily) {
return (fontFamily || 'Inter').split(',')[0].replace(/["']/g, '').trim() || 'Inter';
}
const SKIP_TAGS = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'LINK', 'META', 'HEAD', 'TITLE', 'BR', 'svg', 'SVG']);
function isVisible(el, s) {
if (s.display === 'none' || s.visibility === 'hidden' || s.visibility === 'collapse') return false;
if (Number(s.opacity) === 0) return false;
return true;
}
// Rect of a single text node via Range, in absolute document coords.
function textRect(node) {
try {
const range = document.createRange();
range.selectNodeContents(node);
const r = range.getBoundingClientRect();
return r;
} catch {
return null;
}
}
function buildFigmaIr(resources, includeImages) {
const sx = window.scrollX || 0;
const sy = window.scrollY || 0;
const fonts = new Map(); // family -> Set(styles)
let nodeCount = 0;
let truncated = false;
function noteFont(family, style) {
if (!fonts.has(family)) fonts.set(family, new Set());
fonts.get(family).add(style);
}
function elementNode(el) {
if (nodeCount >= MAX_NODES) {
truncated = true;
return null;
}
if (el.id && el.id.startsWith('od-clipper-')) return null; // skip our own UI
const s = getComputedStyle(el);
if (!isVisible(el, s)) return null;
const rect = el.getBoundingClientRect();
const width = rect.width;
const height = rect.height;
if (width <= 0 || height <= 0) return null;
nodeCount += 1;
const node = {
type: 'FRAME',
name: el.tagName.toLowerCase() + (el.id ? `#${el.id}` : ''),
x: rect.left + sx,
y: rect.top + sy,
width,
height,
};
const fills = [];
// Background image (first url) → image fill; else background color.
const bgImage = s.backgroundImage;
let imageUrl = null;
if (includeImages && bgImage && bgImage !== 'none') {
const m = /url\(\s*['"]?([^'")]+)['"]?\s*\)/i.exec(bgImage);
if (m && isHttp(m[1])) imageUrl = absUrl(m[1]);
}
if (el.tagName === 'IMG' && includeImages) {
const src = el.currentSrc || el.src;
if (isHttp(src)) imageUrl = absUrl(src);
}
if (imageUrl) {
resources.add(imageUrl);
fills.push({ type: 'IMAGE', scaleMode: 'FILL', url: imageUrl });
} else {
const bg = solidFill(parseColor(s.backgroundColor));
if (bg) fills.push(bg);
}
if (fills.length) node.fills = fills;
const stroke = borderStroke(s);
if (stroke) {
node.strokes = [stroke.stroke];
node.strokeWeight = stroke.weight;
}
const radius = uniformRadius(s);
if (typeof radius === 'number') {
if (radius > 0) node.cornerRadius = radius;
} else {
node.rectangleCornerRadii = radius;
}
const shadow = parseShadow(s.boxShadow);
if (shadow) node.effects = [shadow];
const opacity = Number(s.opacity);
if (Number.isFinite(opacity) && opacity < 1) node.opacity = opacity;
if (s.overflow === 'hidden' || s.overflowX === 'hidden' || s.overflowY === 'hidden') {
node.clipsContent = true;
}
// Children: element children recurse; direct text nodes become TEXT.
const children = [];
for (const child of el.childNodes) {
if (child.nodeType === Node.ELEMENT_NODE) {
if (SKIP_TAGS.has(child.tagName)) continue;
const c = elementNode(child);
if (c) children.push(c);
} else if (child.nodeType === Node.TEXT_NODE) {
const text = child.nodeValue;
if (!text || !text.trim()) continue;
const t = textNode(child, text, s);
if (t) children.push(t);
}
}
if (children.length) node.children = children;
return node;
}
function textNode(child, text, parentStyle) {
if (nodeCount >= MAX_NODES) {
truncated = true;
return null;
}
const r = textRect(child);
if (!r || r.width <= 0 || r.height <= 0) return null;
nodeCount += 1;
const s = parentStyle;
const family = firstFamily(s.fontFamily);
const italic = s.fontStyle === 'italic' || s.fontStyle === 'oblique';
const style = fontStyleName(s.fontWeight, italic);
noteFont(family, style);
const lh = s.lineHeight === 'normal' ? undefined : px(s.lineHeight);
const ls = px(s.letterSpacing);
const align =
s.textAlign === 'center' ? 'CENTER'
: s.textAlign === 'right' || s.textAlign === 'end' ? 'RIGHT'
: s.textAlign === 'justify' ? 'JUSTIFIED'
: 'LEFT';
const color = parseColor(s.color) || { r: 0, g: 0, b: 0, a: 1 };
const node = {
type: 'TEXT',
name: text.trim().slice(0, 40),
x: r.left + sx,
y: r.top + sy,
width: Math.ceil(r.width) + 1,
height: Math.ceil(r.height),
characters: text.replace(/\s+/g, ' ').trim(),
fontFamily: family,
fontStyle: style,
fontSize: px(s.fontSize) || 16,
textAlign: align,
color: { r: color.r, g: color.g, b: color.b },
opacity: color.a,
};
if (lh) node.lineHeight = lh;
if (ls) node.letterSpacing = ls;
return node;
}
const root = elementNode(document.body) || {
type: 'FRAME',
name: 'body',
x: 0,
y: 0,
width: document.documentElement.scrollWidth,
height: document.documentElement.scrollHeight,
};
const fontList = Array.from(fonts.entries()).map(([family, styles]) => ({
family,
styles: Array.from(styles),
}));
return {
ir: {
version: IR_VERSION,
source: {
url: location.href,
title: document.title,
capturedAt: Date.now(),
viewport: { width: window.innerWidth, height: window.innerHeight },
dpr: window.devicePixelRatio || 1,
},
fonts: fontList,
root,
},
nodeCount,
truncated,
};
}
// --- entry point ---------------------------------------------------------
window.__odCapture = function (opts) {
const options = opts || {};
const includeImages = options.includeImages !== false;
const resources = new Set();
let html = '';
let figma = null;
try {
html = buildHtml(resources, includeImages);
} catch (e) {
html = `<!DOCTYPE html>\n${document.documentElement.outerHTML}`;
}
try {
figma = buildFigmaIr(resources, includeImages);
} catch (e) {
figma = null;
}
const list = Array.from(resources).filter(isHttp).slice(0, MAX_RESOURCES);
return {
html,
figmaIr: figma ? figma.ir : null,
figmaNodeCount: figma ? figma.nodeCount : 0,
figmaTruncated: figma ? figma.truncated : false,
resources: list,
title: document.title,
url: location.href,
};
};
// Single-element capture. The content script marks the picked element with the
// `marker` attribute on the LIVE DOM before calling this; we read it off the
// clone, prune to that element, and hand the worker a self-contained HTML
// snapshot of just that element plus the resource list to inline. The worker
// removes the live marker afterwards.
window.__odCaptureElement = function (opts) {
const options = opts || {};
const includeImages = options.includeImages !== false;
const marker = typeof options.marker === 'string' && options.marker ? options.marker : 'data-od-clip-target';
const resources = new Set();
let html = '';
try {
html = buildElementHtml(marker, resources, includeImages);
} catch (e) {
const el = document.querySelector(`[${marker}]`);
html = el
? `<!DOCTYPE html>\n<html><head><meta charset="utf-8"><base href="${document.baseURI}"></head><body>${el.outerHTML}</body></html>`
: '';
}
const list = Array.from(resources).filter(isHttp).slice(0, MAX_RESOURCES);
return {
html,
resources: list,
title: document.title,
url: location.href,
};
};
})();