Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

@zumer/snapdom-plugins

Package Overview
Dependencies
Maintainers
1
Versions
8
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@zumer/snapdom-plugins - npm Package Compare versions

Comparing version
1.0.1
to
1.0.2
+276
prompt-export.js
/**
* promptExport – Official SnapDOM Plugin
* Produces an LLM-ready package: annotated screenshot + structured element map + prompt text.
*
* Usage:
* import { promptExport } from '@zumer/snapdom-plugins/prompt-export';
* const result = await snapdom(el, { plugins: [promptExport()] });
* const { image, elements, dimensions, prompt } = await result.toPrompt();
*
* @param {Object} [options]
* @param {boolean} [options.annotate=true] - Overlay numbered badges on interactive elements
* @param {string} [options.imageFormat='png'] - Output image format ('png'|'jpg'|'webp')
* @param {number} [options.imageQuality=0.8] - Quality for lossy formats (0..1)
* @param {number} [options.maxImageWidth=1024] - Max width in px (downscales if larger)
* @param {string} [options.interactiveSelector] - Custom CSS selector for interactive elements
* @param {string} [options.semanticSelector] - Custom CSS selector for semantic elements
* @param {Object} [options.labelStyle={}] - Override styles for annotation badges
* @returns {Object} SnapDOM plugin
*/
const DEFAULT_INTERACTIVE =
'a[href], button, input, select, textarea, ' +
'[role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="checkbox"], [role="radio"], ' +
'[tabindex]:not([tabindex="-1"]), summary, [contenteditable="true"]';
const DEFAULT_SEMANTIC =
'h1, h2, h3, h4, h5, h6, p, li, img[alt], nav, main, article, section, ' +
'header, footer, label, td, th, figcaption, blockquote, legend';
export function promptExport(options = {}) {
const {
annotate = true,
imageFormat = 'png',
imageQuality = 0.8,
maxImageWidth = 1024,
interactiveSelector = DEFAULT_INTERACTIVE,
semanticSelector = DEFAULT_SEMANTIC,
labelStyle = {},
} = options;
return {
name: 'prompt-export',
afterClone(ctx) {
const meta = extractMetadata(ctx.element, interactiveSelector, semanticSelector);
ctx.__promptMetadata = meta;
if (annotate) {
addAnnotations(ctx.clone, meta.elements, labelStyle);
}
},
defineExports() {
return {
prompt: async (ctx, opts = {}) => {
const meta = ctx.__promptMetadata;
if (!meta || !meta.elements.length) {
return {
image: ctx.export.url,
elements: [],
dimensions: { width: 0, height: 0 },
prompt: '',
};
}
const format = opts.imageFormat || imageFormat;
const quality = opts.imageQuality || imageQuality;
const maxWidth = opts.maxImageWidth || maxImageWidth;
const img = new Image();
img.src = ctx.export.url;
await new Promise((res, rej) => {
img.onload = res;
img.onerror = rej;
});
const ratio =
img.naturalWidth > maxWidth ? maxWidth / img.naturalWidth : 1;
const w = Math.round(img.naturalWidth * ratio);
const h = Math.round(img.naturalHeight * ratio);
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const c2d = canvas.getContext('2d');
c2d.drawImage(img, 0, 0, w, h);
const mime =
format === 'jpg' || format === 'jpeg'
? 'image/jpeg'
: format === 'webp'
? 'image/webp'
: 'image/png';
const dataURL = canvas.toDataURL(mime, quality);
const sx = w / (meta.dimensions.width || 1);
const sy = h / (meta.dimensions.height || 1);
const elements = meta.elements.map((el) => ({
...el,
bbox: {
x: Math.round(el.bbox.x * sx),
y: Math.round(el.bbox.y * sy),
width: Math.round(el.bbox.width * sx),
height: Math.round(el.bbox.height * sy),
},
}));
return {
image: dataURL,
elements,
dimensions: { width: w, height: h },
prompt: formatPromptText(elements, { width: w, height: h }),
};
},
};
},
};
}
/* ── Metadata extraction ──────────────────────── */
function extractMetadata(element, interactiveSelector, semanticSelector) {
const rootRect = element.getBoundingClientRect();
const elements = [];
let id = 0;
const tracked = new Set();
for (const el of element.querySelectorAll(interactiveSelector)) {
const entry = buildEntry(el, rootRect, id, 'interactive');
if (entry) {
elements.push(entry);
tracked.add(el);
id++;
}
}
for (const el of element.querySelectorAll(semanticSelector)) {
if (tracked.has(el)) continue;
const entry = buildEntry(el, rootRect, id, 'semantic');
if (entry) {
elements.push(entry);
id++;
}
}
return {
elements,
dimensions: { width: rootRect.width, height: rootRect.height },
};
}
const COLLECTED_ATTRS = [
'role', 'aria-label', 'aria-expanded', 'aria-checked', 'aria-disabled',
'alt', 'href', 'placeholder', 'name', 'type', 'value', 'title', 'disabled',
];
function buildEntry(el, rootRect, id, type) {
const rect = el.getBoundingClientRect();
const bbox = {
x: Math.round(rect.left - rootRect.left),
y: Math.round(rect.top - rootRect.top),
width: Math.round(rect.width),
height: Math.round(rect.height),
};
if (bbox.width <= 0 && bbox.height <= 0) return null;
const tag = el.tagName.toLowerCase();
const text = (el.textContent || '').trim().slice(0, 200);
const attributes = {};
for (const attr of COLLECTED_ATTRS) {
const val = el.getAttribute(attr);
if (val != null) attributes[attr] = val;
}
return { id, tag, type, text, bbox, attributes };
}
/* ── Visual annotations ───────────────────────── */
function addAnnotations(clone, elements, customStyle) {
const interactive = elements.filter((e) => e.type === 'interactive');
if (!interactive.length) return;
const overlay = document.createElement('div');
overlay.setAttribute('data-snap-prompt-overlay', 'true');
Object.assign(overlay.style, {
position: 'absolute',
top: '0',
left: '0',
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: '2147483647',
overflow: 'visible',
});
for (const el of interactive) {
const badge = document.createElement('span');
badge.textContent = String(el.id);
badge.setAttribute('data-snap-prompt-label', String(el.id));
Object.assign(badge.style, {
position: 'absolute',
left: `${el.bbox.x}px`,
top: `${el.bbox.y}px`,
transform: 'translate(-50%, -50%)',
minWidth: '18px',
height: '18px',
lineHeight: '18px',
fontSize: '11px',
fontWeight: '700',
fontFamily: 'system-ui, -apple-system, sans-serif',
color: '#fff',
backgroundColor: 'rgba(220, 38, 38, 0.92)',
borderRadius: '9px',
textAlign: 'center',
padding: '0 4px',
boxSizing: 'border-box',
boxShadow: '0 1px 3px rgba(0,0,0,0.3)',
...customStyle,
});
overlay.appendChild(badge);
}
clone.style.position = 'relative';
clone.appendChild(overlay);
}
/* ── Prompt text formatter ────────────────────── */
function formatPromptText(elements, dimensions) {
const lines = [
`Screenshot of a web page (${dimensions.width}\u00d7${dimensions.height}px).`,
'',
];
const interactive = elements.filter((e) => e.type === 'interactive');
const semantic = elements.filter((e) => e.type === 'semantic');
if (interactive.length) {
lines.push('Interactive elements:');
for (const el of interactive) {
const attrParts = Object.entries(el.attributes).map(
([k, v]) => `${k}="${v}"`
);
const text = el.text ? ` "${truncate(el.text, 60)}"` : '';
const pos = `(${el.bbox.x},${el.bbox.y} ${el.bbox.width}\u00d7${el.bbox.height})`;
const attrs = attrParts.length ? ' ' + attrParts.join(' ') : '';
lines.push(` [${el.id}] <${el.tag}>${text} ${pos}${attrs}`);
}
lines.push('');
}
if (semantic.length) {
lines.push('Semantic structure:');
for (const el of semantic) {
const text = el.text ? ` "${truncate(el.text, 80)}"` : '';
const attrParts = [];
if (el.attributes.alt) attrParts.push(`alt="${el.attributes.alt}"`);
if (el.attributes.role) attrParts.push(`role="${el.attributes.role}"`);
const attrs = attrParts.length ? ' ' + attrParts.join(' ') : '';
lines.push(` [${el.id}] <${el.tag}>${text}${attrs}`);
}
lines.push('');
}
return lines.join('\n');
}
function truncate(str, max) {
if (str.length <= max) return str;
return str.slice(0, max - 1) + '\u2026';
}
+1
-1

@@ -14,4 +14,4 @@ /**

export { colorTint } from './color-tint.js';
export { pictureResolver } from './picture-resolver.js';
export { pdfImage } from './pdf-image.js';
export { promptExport } from './prompt-export.js';
// export { htmlInCanvas } from './html-in-canvas.js';
{
"name": "@zumer/snapdom-plugins",
"version": "1.0.1",
"version": "1.0.2",
"description": "Official plugins for SnapDOM",

@@ -13,5 +13,5 @@ "type": "module",

"./color-tint": "./color-tint.js",
"./picture-resolver": "./picture-resolver.js",
"./html-in-canvas": "./html-in-canvas.js",
"./pdf-image": "./pdf-image.js"
"./pdf-image": "./pdf-image.js",
"./prompt-export": "./prompt-export.js"
},

@@ -18,0 +18,0 @@ "files": [

@@ -120,23 +120,2 @@ # @zumer/snapdom-plugins

### `picture-resolver`
Resolves lazy-loaded `<picture>` placeholders and `data-src` patterns before capture.
Detects base64/blob stubs, fetches the real image, and restores the DOM after cloning (zero side effects).
```js
import { pictureResolver } from '@zumer/snapdom-plugins/picture-resolver';
snapdom(el, { plugins: [pictureResolver({ timeout: 3000 })] });
```
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `timeout` | `number` | `5000` | Max ms per image fetch |
| `concurrency` | `number` | `4` | Max parallel fetches |
| `resolveLazySrc` | `boolean` | `true` | Also resolve `data-src` / `data-srcset` patterns |
| `useProxy` | `string` | `''` | CORS proxy URL for cross-origin images |
| `silent` | `boolean` | `false` | Suppress console warnings |
---
### `ascii-export`

@@ -143,0 +122,0 @@

/**
* pictureResolver - Official SnapDOM Plugin
*
* Resolves lazy-loaded <picture> elements that use base64/low-quality placeholders
* in <img src> while the real image lives in <source srcset>.
*
* Common pattern in news sites (La Nación, Clarín, NYT, Medium, etc.):
* <picture>
* <source srcset="https://cdn.example.com/real-image.jpg" media="...">
* <img src="data:image/jpeg;base64,/9j/4AAQ..." alt="..."> ← tiny placeholder
* </picture>
*
* The browser renders the real image visually, but DOM cloning captures the
* placeholder because cloneNode copies the HTML attribute, not the rendered source.
*
* This plugin:
* 1. Detects <picture> elements with base64/blob placeholder <img src>
* 2. Resolves the real URL from <source srcset> or img.currentSrc
* 3. Fetches the real image using the browser's context (correct Referer/cookies)
* 4. Replaces img src with the fetched data URL (attribute + property)
* 5. Removes <source> elements to prevent re-resolution during clone
* 6. Restores original DOM after cloning (zero side effects)
*
* Also handles common lazy-loading patterns:
* - data-src / data-srcset attributes (lazysizes, vanilla-lazyload, etc.)
* - loading="lazy" images that haven't entered viewport yet
*
* @param {Object} [options]
* @param {number} [options.timeout=5000] Max ms per image fetch
* @param {number} [options.concurrency=4] Max parallel fetches
* @param {boolean} [options.resolveLazySrc=true] Also resolve data-src/data-srcset patterns
* @param {boolean} [options.silent=false] Suppress console warnings
* @param {string} [options.useProxy=''] CORS proxy for cross-origin images
* @returns {import('../../src/core/plugins.js').Plugin}
*
* @example
* // Per-capture usage
* const result = await snapdom(element, {
* plugins: [pictureResolver()]
* });
*
* @example
* // Global registration
* snapdom.plugins(pictureResolver({ timeout: 3000 }));
*
* @example
* // With proxy for cross-origin images
* const result = await snapdom(element, {
* plugins: [pictureResolver({ useProxy: 'https://corsproxy.io/?' })]
* });
*/
export function pictureResolver(options = {}) {
const {
timeout = 5000,
concurrency = 4,
resolveLazySrc = true,
silent = false,
useProxy = '',
} = options
/** @type {Array<() => void>} */
let undoStack = []
/**
* Check if a src string is a placeholder (base64, blob, empty, tiny SVG placeholder, etc.)
* @param {string} src
* @returns {boolean}
*/
function isPlaceholder(src) {
if (!src) return true
if (src.startsWith('data:')) return true
if (src.startsWith('blob:')) return true
// Common 1x1 transparent pixel patterns
if (/^data:image\/(gif|png|svg)/.test(src) && src.length < 200) return true
return false
}
/**
* Find the best real URL for an image inside a <picture>.
* Priority: img.currentSrc > <source srcset> matching viewport > first <source srcset>
* @param {HTMLImageElement} img
* @param {HTMLPictureElement} picture
* @returns {string|null}
*/
function findRealUrl(img, picture) {
// 1) currentSrc is the browser's resolved choice (best match for viewport)
const current = img.currentSrc || ''
if (current && !isPlaceholder(current)) return current
// 2) Walk <source> elements; prefer one whose media query matches
const sources = picture.querySelectorAll('source[srcset]')
let fallback = null
for (const source of sources) {
const srcset = source.getAttribute('srcset')
if (!srcset || isPlaceholder(srcset)) continue
const media = source.getAttribute('media')
if (media) {
try {
if (window.matchMedia(media).matches) {
// Best candidate: matches current viewport
return srcset.split(',')[0].trim().split(/\s+/)[0]
}
} catch { /* invalid media query, skip */ }
}
// Keep first valid source as fallback
if (!fallback) fallback = srcset.split(',')[0].trim().split(/\s+/)[0]
}
return fallback
}
/**
* Find lazy-load URLs from data-* attributes.
* Supports: data-src, data-srcset, data-lazy-src, data-original
* @param {HTMLImageElement} img
* @returns {string|null}
*/
function findLazySrc(img) {
const candidates = [
img.getAttribute('data-src'),
img.getAttribute('data-lazy-src'),
img.getAttribute('data-original'),
img.getAttribute('data-hi-res-src'),
]
for (const c of candidates) {
if (c && !isPlaceholder(c)) return c
}
// data-srcset: take first candidate
const dataSrcset = img.getAttribute('data-srcset') || img.getAttribute('data-lazy-srcset')
if (dataSrcset) {
const first = dataSrcset.split(',')[0].trim().split(/\s+/)[0]
if (first && !isPlaceholder(first)) return first
}
return null
}
/**
* Fetch an image URL using the browser's context (sends correct Referer/cookies).
* Falls back to proxy if direct fetch fails.
* @param {string} url
* @returns {Promise<string|null>} data URL or null on failure
*/
async function fetchAsDataUrl(url) {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeout)
try {
// Try direct fetch first (browser sends Referer/cookies)
let resp = await fetch(url, {
credentials: 'include',
signal: controller.signal,
})
// If blocked, try proxy
if (!resp.ok && useProxy) {
const proxyUrl = useProxy.includes('{url}')
? useProxy.replace('{url}', encodeURIComponent(url))
: useProxy.endsWith('?')
? `${useProxy}${encodeURIComponent(url)}`
: `${useProxy}${useProxy.includes('?') ? '&' : '?'}url=${encodeURIComponent(url)}`
resp = await fetch(proxyUrl, { signal: controller.signal })
}
if (!resp.ok) return null
const blob = await resp.blob()
return await new Promise((resolve, reject) => {
const fr = new FileReader()
fr.onload = () => resolve(/** @type {string} */ (fr.result))
fr.onerror = reject
fr.readAsDataURL(blob)
})
} catch {
return null
} finally {
clearTimeout(timer)
}
}
/**
* Process a batch of tasks with limited concurrency.
* @param {Array<() => Promise<void>>} tasks
*/
async function runBatched(tasks) {
for (let i = 0; i < tasks.length; i += concurrency) {
const batch = tasks.slice(i, i + concurrency)
await Promise.allSettled(batch.map(fn => fn()))
}
}
return {
name: 'picture-resolver',
/**
* Before cloning: resolve placeholders to real images in the live DOM.
* Stores undo functions to restore original state after clone.
*/
async beforeClone(ctx) {
const root = ctx.element
if (!root || !(root instanceof Element)) return
undoStack = []
const tasks = []
// ── 1) <picture> with placeholder <img src> ──
const pictures = root.querySelectorAll('picture')
for (const picture of pictures) {
const img = picture.querySelector('img')
if (!img) continue
const originalSrc = img.getAttribute('src') || ''
if (!isPlaceholder(originalSrc)) continue
const realUrl = findRealUrl(img, picture)
if (!realUrl) continue
tasks.push(async () => {
const dataUrl = await fetchAsDataUrl(realUrl)
if (!dataUrl) {
if (!silent) console.warn(`[snapdom:picture-resolver] Failed to fetch: ${realUrl.slice(0, 60)}`)
return
}
// Save original state for undo
const origAttr = img.getAttribute('src')
const origSrcset = img.getAttribute('srcset')
const origSizes = img.getAttribute('sizes')
const removedSources = []
// Swap to real image
img.src = dataUrl
img.setAttribute('src', dataUrl)
img.removeAttribute('srcset')
img.removeAttribute('sizes')
// Remove <source> elements to prevent clone re-resolution
const sources = picture.querySelectorAll('source')
for (const s of sources) {
removedSources.push({ el: s, parent: s.parentElement, next: s.nextSibling })
s.remove()
}
// Register undo
undoStack.push(() => {
if (origAttr !== null) img.setAttribute('src', origAttr)
else img.removeAttribute('src')
if (origSrcset !== null) img.setAttribute('srcset', origSrcset)
if (origSizes !== null) img.setAttribute('sizes', origSizes)
for (const { el, parent, next } of removedSources) {
if (parent) parent.insertBefore(el, next)
}
})
})
}
// ── 2) Lazy-loaded <img> with data-src / data-srcset ──
if (resolveLazySrc) {
const imgs = root.querySelectorAll('img')
for (const img of imgs) {
// Skip images already handled by <picture> pass
if (img.closest('picture') && isPlaceholder(img.getAttribute('src') || '')) continue
const currentSrc = img.getAttribute('src') || ''
const lazySrc = findLazySrc(img)
// Case A: has data-src but current src is placeholder
if (lazySrc && isPlaceholder(currentSrc)) {
tasks.push(async () => {
const dataUrl = await fetchAsDataUrl(lazySrc)
if (!dataUrl) return
const origSrc = img.getAttribute('src')
img.src = dataUrl
img.setAttribute('src', dataUrl)
img.removeAttribute('srcset')
img.removeAttribute('sizes')
undoStack.push(() => {
if (origSrc !== null) img.setAttribute('src', origSrc)
else img.removeAttribute('src')
})
})
}
}
}
// Run all fetches with concurrency limit
if (tasks.length > 0) {
await runBatched(tasks)
}
},
/**
* After cloning: restore original DOM (undo all mutations).
*/
async afterClone(ctx) {
for (const undo of undoStack) {
try { undo() } catch { /* non-blocking */ }
}
undoStack = []
},
}
}
export default pictureResolver