@zumer/snapdom-plugins 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,210 @@
1
+ # @zumer/snapdom-plugins
2
+
3
+ Official plugins for [SnapDOM](https://github.com/zumerlab/snapdom) — extend and transform DOM captures with zero core changes.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @zumer/snapdom-plugins
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ Import plugins individually (recommended for tree-shaking):
14
+
15
+ ```js
16
+ import { snapdom } from '@zumer/snapdom';
17
+ import { filter } from '@zumer/snapdom-plugins/filter';
18
+ import { timestampOverlay } from '@zumer/snapdom-plugins/timestamp-overlay';
19
+
20
+ const result = await snapdom(element, {
21
+ plugins: [filter({ preset: 'grayscale' }), timestampOverlay()]
22
+ });
23
+ const png = await result.toPng();
24
+ ```
25
+
26
+ Or import everything at once:
27
+
28
+ ```js
29
+ import { filter, timestampOverlay, replaceText } from '@zumer/snapdom-plugins';
30
+ ```
31
+
32
+ CDN (no install):
33
+
34
+ ```js
35
+ import { snapdom } from 'https://esm.sh/@zumer/snapdom';
36
+ import { filter } from 'https://esm.sh/@zumer/snapdom-plugins/filter';
37
+ ```
38
+
39
+ ---
40
+
41
+ ## Plugins
42
+
43
+ ### `filter`
44
+
45
+ Applies CSS filter effects to the captured clone.
46
+
47
+ ```js
48
+ import { filter } from '@zumer/snapdom-plugins/filter';
49
+
50
+ snapdom(el, { plugins: [filter({ preset: 'grayscale' })] });
51
+ ```
52
+
53
+ | Option | Type | Default | Description |
54
+ |--------|------|---------|-------------|
55
+ | `preset` | `string` | — | `'grayscale'` \| `'sepia'` \| `'blur'` \| `'invert'` \| `'vintage'` \| `'dramatic'` |
56
+ | `filter` | `string` | `''` | Raw CSS filter string, e.g. `'blur(2px) contrast(1.2)'` |
57
+
58
+ ---
59
+
60
+ ### `timestamp-overlay`
61
+
62
+ Adds a translucent timestamp label to the captured clone.
63
+
64
+ ```js
65
+ import { timestampOverlay } from '@zumer/snapdom-plugins/timestamp-overlay';
66
+
67
+ snapdom(el, { plugins: [timestampOverlay({ position: 'top-right', format: 'date' })] });
68
+ ```
69
+
70
+ | Option | Type | Default | Description |
71
+ |--------|------|---------|-------------|
72
+ | `format` | `string \| function` | `'datetime'` | `'datetime'` \| `'date'` \| `'time'` \| `'iso'` \| custom `(Date) => string` |
73
+ | `position` | `string` | `'bottom-right'` | `'top-left'` \| `'top-right'` \| `'bottom-left'` \| `'bottom-right'` |
74
+ | `background` | `string` | `'rgba(0,0,0,0.6)'` | Label background color |
75
+ | `color` | `string` | `'#fff'` | Label text color |
76
+ | `fontSize` | `number` | `11` | Font size in px |
77
+
78
+ ---
79
+
80
+ ### `replace-text`
81
+
82
+ Find-and-replace text in the captured clone. Supports strings and regex.
83
+
84
+ ```js
85
+ import { replaceText } from '@zumer/snapdom-plugins/replace-text';
86
+
87
+ snapdom(el, {
88
+ plugins: [replaceText({
89
+ replacements: [
90
+ { find: 'DRAFT', replace: 'APPROVED' },
91
+ { find: /\d{4}-\d{2}-\d{2}/, replace: '[REDACTED]' }
92
+ ]
93
+ })]
94
+ });
95
+ ```
96
+
97
+ | Option | Type | Default | Description |
98
+ |--------|------|---------|-------------|
99
+ | `replacements` | `Array<{ find: string\|RegExp, replace: string }>` | `[]` | List of find/replace pairs |
100
+
101
+ ---
102
+
103
+ ### `color-tint`
104
+
105
+ Tints the entire capture to a specified color using a `mix-blend-mode` overlay.
106
+
107
+ ```js
108
+ import { colorTint } from '@zumer/snapdom-plugins/color-tint';
109
+
110
+ snapdom(el, { plugins: [colorTint({ color: 'royalblue', opacity: 0.4 })] });
111
+ ```
112
+
113
+ | Option | Type | Default | Description |
114
+ |--------|------|---------|-------------|
115
+ | `color` | `string` | `'red'` | Any CSS color value |
116
+ | `opacity` | `number` | `1` | Overlay opacity (0–1) |
117
+
118
+ ---
119
+
120
+ ### `ascii-export`
121
+
122
+ Adds a `toAscii()` export method that converts captures to ASCII art.
123
+
124
+ ```js
125
+ import { asciiExport } from '@zumer/snapdom-plugins/ascii-export';
126
+
127
+ const result = await snapdom(el, { plugins: [asciiExport({ width: 100 })] });
128
+ const art = await result.toAscii();
129
+ console.log(art);
130
+ ```
131
+
132
+ | Option | Type | Default | Description |
133
+ |--------|------|---------|-------------|
134
+ | `width` | `number` | `80` | Character width of output |
135
+ | `charset` | `string` | `' .:-=+*#%@'` | Characters from lightest to darkest |
136
+ | `invert` | `boolean` | `false` | Invert luminance mapping |
137
+
138
+ ---
139
+
140
+ ### `pdf-image`
141
+
142
+ Exports the capture as a PNG embedded in a downloadable PDF (A4). Adds a `toPdfImage()` method.
143
+
144
+ ```js
145
+ import { pdfImage } from '@zumer/snapdom-plugins/pdf-image';
146
+
147
+ const result = await snapdom(el, { plugins: [pdfImage({ orientation: 'landscape' })] });
148
+ await result.toPdfImage(); // triggers download
149
+ ```
150
+
151
+ | Option | Type | Default | Description |
152
+ |--------|------|---------|-------------|
153
+ | `orientation` | `string` | `'portrait'` | `'portrait'` \| `'landscape'` |
154
+ | `quality` | `number` | `0.92` | JPEG quality (0–1) |
155
+ | `filename` | `string` | `'capture.pdf'` | Download filename |
156
+
157
+ ---
158
+
159
+ ### `html-in-canvas`
160
+
161
+ Uses the experimental [WICG `drawElementImage`](https://github.com/WICG/canvas-place-element) API for direct DOM-to-canvas rendering where supported. Falls back gracefully.
162
+
163
+ ```js
164
+ import { htmlInCanvas } from '@zumer/snapdom-plugins/html-in-canvas';
165
+
166
+ snapdom(el, { plugins: [htmlInCanvas()] });
167
+ ```
168
+
169
+ > This plugin uses an experimental browser API and may not work in all environments.
170
+
171
+ ---
172
+
173
+ ## Plugin registration
174
+
175
+ **Global** (applies to all captures):
176
+
177
+ ```js
178
+ import { snapdom } from '@zumer/snapdom';
179
+ import { filter } from '@zumer/snapdom-plugins/filter';
180
+
181
+ snapdom.plugins(filter({ preset: 'sepia' }));
182
+ ```
183
+
184
+ **Per-capture** (overrides global for that call):
185
+
186
+ ```js
187
+ const result = await snapdom(element, {
188
+ plugins: [filter({ preset: 'dramatic' })]
189
+ });
190
+ ```
191
+
192
+ Per-capture plugins run before global ones. Duplicate plugin names are skipped automatically.
193
+
194
+ ---
195
+
196
+ ## Build your own plugin
197
+
198
+ Use the template to scaffold a new plugin in seconds:
199
+
200
+ ```bash
201
+ npx degit zumerlab/snapdom/packages/plugin-template my-plugin
202
+ ```
203
+
204
+ See [PLUGIN_SPEC.md](../../PLUGIN_SPEC.md) for the full hook specification and [CONTRIBUTING_PLUGINS.md](../../CONTRIBUTING_PLUGINS.md) to get your plugin listed on the community page.
205
+
206
+ ---
207
+
208
+ ## License
209
+
210
+ MIT
package/index.js CHANGED
@@ -11,6 +11,6 @@ export { asciiExport } from './ascii-export.js';
11
11
  export { replaceText } from './replace-text.js';
12
12
  export { filter } from './filter.js';
13
13
  export { colorTint } from './color-tint.js';
14
- export { pictureResolver } from './picture-resolver.js';
15
14
  export { pdfImage } from './pdf-image.js';
15
+ export { promptExport } from './prompt-export.js';
16
16
  // export { htmlInCanvas } from './html-in-canvas.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zumer/snapdom-plugins",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Official plugins for SnapDOM",
5
5
  "type": "module",
6
6
  "exports": {
@@ -10,18 +10,32 @@
10
10
  "./replace-text": "./replace-text.js",
11
11
  "./filter": "./filter.js",
12
12
  "./color-tint": "./color-tint.js",
13
- "./picture-resolver": "./picture-resolver.js",
14
13
  "./html-in-canvas": "./html-in-canvas.js",
15
- "./pdf-image": "./pdf-image.js"
14
+ "./pdf-image": "./pdf-image.js",
15
+ "./prompt-export": "./prompt-export.js"
16
16
  },
17
+ "files": [
18
+ "*.js",
19
+ "README.md"
20
+ ],
17
21
  "keywords": [
18
22
  "snapdom",
19
23
  "snapdom-plugin",
20
24
  "dom-capture",
21
25
  "plugins"
22
26
  ],
23
- "peerDependencies": {
24
- "@zumer/snapdom": ">=0.9.0"
27
+ "author": "Juan Martin Muda",
28
+ "license": "MIT",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/zumerlab/snapdom.git",
32
+ "directory": "packages/plugins"
25
33
  },
26
- "license": "MIT"
34
+ "bugs": {
35
+ "url": "https://github.com/zumerlab/snapdom/issues"
36
+ },
37
+ "homepage": "https://zumerlab.github.io/snapdom/",
38
+ "peerDependencies": {
39
+ "@zumer/snapdom": ">=2.7.0"
40
+ }
27
41
  }
@@ -0,0 +1,276 @@
1
+ /**
2
+ * promptExport – Official SnapDOM Plugin
3
+ * Produces an LLM-ready package: annotated screenshot + structured element map + prompt text.
4
+ *
5
+ * Usage:
6
+ * import { promptExport } from '@zumer/snapdom-plugins/prompt-export';
7
+ * const result = await snapdom(el, { plugins: [promptExport()] });
8
+ * const { image, elements, dimensions, prompt } = await result.toPrompt();
9
+ *
10
+ * @param {Object} [options]
11
+ * @param {boolean} [options.annotate=true] - Overlay numbered badges on interactive elements
12
+ * @param {string} [options.imageFormat='png'] - Output image format ('png'|'jpg'|'webp')
13
+ * @param {number} [options.imageQuality=0.8] - Quality for lossy formats (0..1)
14
+ * @param {number} [options.maxImageWidth=1024] - Max width in px (downscales if larger)
15
+ * @param {string} [options.interactiveSelector] - Custom CSS selector for interactive elements
16
+ * @param {string} [options.semanticSelector] - Custom CSS selector for semantic elements
17
+ * @param {Object} [options.labelStyle={}] - Override styles for annotation badges
18
+ * @returns {Object} SnapDOM plugin
19
+ */
20
+
21
+ const DEFAULT_INTERACTIVE =
22
+ 'a[href], button, input, select, textarea, ' +
23
+ '[role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="checkbox"], [role="radio"], ' +
24
+ '[tabindex]:not([tabindex="-1"]), summary, [contenteditable="true"]';
25
+
26
+ const DEFAULT_SEMANTIC =
27
+ 'h1, h2, h3, h4, h5, h6, p, li, img[alt], nav, main, article, section, ' +
28
+ 'header, footer, label, td, th, figcaption, blockquote, legend';
29
+
30
+ export function promptExport(options = {}) {
31
+ const {
32
+ annotate = true,
33
+ imageFormat = 'png',
34
+ imageQuality = 0.8,
35
+ maxImageWidth = 1024,
36
+ interactiveSelector = DEFAULT_INTERACTIVE,
37
+ semanticSelector = DEFAULT_SEMANTIC,
38
+ labelStyle = {},
39
+ } = options;
40
+
41
+ return {
42
+ name: 'prompt-export',
43
+
44
+ afterClone(ctx) {
45
+ const meta = extractMetadata(ctx.element, interactiveSelector, semanticSelector);
46
+ ctx.__promptMetadata = meta;
47
+
48
+ if (annotate) {
49
+ addAnnotations(ctx.clone, meta.elements, labelStyle);
50
+ }
51
+ },
52
+
53
+ defineExports() {
54
+ return {
55
+ prompt: async (ctx, opts = {}) => {
56
+ const meta = ctx.__promptMetadata;
57
+ if (!meta || !meta.elements.length) {
58
+ return {
59
+ image: ctx.export.url,
60
+ elements: [],
61
+ dimensions: { width: 0, height: 0 },
62
+ prompt: '',
63
+ };
64
+ }
65
+
66
+ const format = opts.imageFormat || imageFormat;
67
+ const quality = opts.imageQuality || imageQuality;
68
+ const maxWidth = opts.maxImageWidth || maxImageWidth;
69
+
70
+ const img = new Image();
71
+ img.src = ctx.export.url;
72
+ await new Promise((res, rej) => {
73
+ img.onload = res;
74
+ img.onerror = rej;
75
+ });
76
+
77
+ const ratio =
78
+ img.naturalWidth > maxWidth ? maxWidth / img.naturalWidth : 1;
79
+ const w = Math.round(img.naturalWidth * ratio);
80
+ const h = Math.round(img.naturalHeight * ratio);
81
+
82
+ const canvas = document.createElement('canvas');
83
+ canvas.width = w;
84
+ canvas.height = h;
85
+ const c2d = canvas.getContext('2d');
86
+ c2d.drawImage(img, 0, 0, w, h);
87
+
88
+ const mime =
89
+ format === 'jpg' || format === 'jpeg'
90
+ ? 'image/jpeg'
91
+ : format === 'webp'
92
+ ? 'image/webp'
93
+ : 'image/png';
94
+ const dataURL = canvas.toDataURL(mime, quality);
95
+
96
+ const sx = w / (meta.dimensions.width || 1);
97
+ const sy = h / (meta.dimensions.height || 1);
98
+
99
+ const elements = meta.elements.map((el) => ({
100
+ ...el,
101
+ bbox: {
102
+ x: Math.round(el.bbox.x * sx),
103
+ y: Math.round(el.bbox.y * sy),
104
+ width: Math.round(el.bbox.width * sx),
105
+ height: Math.round(el.bbox.height * sy),
106
+ },
107
+ }));
108
+
109
+ return {
110
+ image: dataURL,
111
+ elements,
112
+ dimensions: { width: w, height: h },
113
+ prompt: formatPromptText(elements, { width: w, height: h }),
114
+ };
115
+ },
116
+ };
117
+ },
118
+ };
119
+ }
120
+
121
+ /* ── Metadata extraction ──────────────────────── */
122
+
123
+ function extractMetadata(element, interactiveSelector, semanticSelector) {
124
+ const rootRect = element.getBoundingClientRect();
125
+ const elements = [];
126
+ let id = 0;
127
+
128
+ const tracked = new Set();
129
+
130
+ for (const el of element.querySelectorAll(interactiveSelector)) {
131
+ const entry = buildEntry(el, rootRect, id, 'interactive');
132
+ if (entry) {
133
+ elements.push(entry);
134
+ tracked.add(el);
135
+ id++;
136
+ }
137
+ }
138
+
139
+ for (const el of element.querySelectorAll(semanticSelector)) {
140
+ if (tracked.has(el)) continue;
141
+ const entry = buildEntry(el, rootRect, id, 'semantic');
142
+ if (entry) {
143
+ elements.push(entry);
144
+ id++;
145
+ }
146
+ }
147
+
148
+ return {
149
+ elements,
150
+ dimensions: { width: rootRect.width, height: rootRect.height },
151
+ };
152
+ }
153
+
154
+ const COLLECTED_ATTRS = [
155
+ 'role', 'aria-label', 'aria-expanded', 'aria-checked', 'aria-disabled',
156
+ 'alt', 'href', 'placeholder', 'name', 'type', 'value', 'title', 'disabled',
157
+ ];
158
+
159
+ function buildEntry(el, rootRect, id, type) {
160
+ const rect = el.getBoundingClientRect();
161
+ const bbox = {
162
+ x: Math.round(rect.left - rootRect.left),
163
+ y: Math.round(rect.top - rootRect.top),
164
+ width: Math.round(rect.width),
165
+ height: Math.round(rect.height),
166
+ };
167
+
168
+ if (bbox.width <= 0 && bbox.height <= 0) return null;
169
+
170
+ const tag = el.tagName.toLowerCase();
171
+ const text = (el.textContent || '').trim().slice(0, 200);
172
+
173
+ const attributes = {};
174
+ for (const attr of COLLECTED_ATTRS) {
175
+ const val = el.getAttribute(attr);
176
+ if (val != null) attributes[attr] = val;
177
+ }
178
+
179
+ return { id, tag, type, text, bbox, attributes };
180
+ }
181
+
182
+ /* ── Visual annotations ───────────────────────── */
183
+
184
+ function addAnnotations(clone, elements, customStyle) {
185
+ const interactive = elements.filter((e) => e.type === 'interactive');
186
+ if (!interactive.length) return;
187
+
188
+ const overlay = document.createElement('div');
189
+ overlay.setAttribute('data-snap-prompt-overlay', 'true');
190
+ Object.assign(overlay.style, {
191
+ position: 'absolute',
192
+ top: '0',
193
+ left: '0',
194
+ width: '100%',
195
+ height: '100%',
196
+ pointerEvents: 'none',
197
+ zIndex: '2147483647',
198
+ overflow: 'visible',
199
+ });
200
+
201
+ for (const el of interactive) {
202
+ const badge = document.createElement('span');
203
+ badge.textContent = String(el.id);
204
+ badge.setAttribute('data-snap-prompt-label', String(el.id));
205
+ Object.assign(badge.style, {
206
+ position: 'absolute',
207
+ left: `${el.bbox.x}px`,
208
+ top: `${el.bbox.y}px`,
209
+ transform: 'translate(-50%, -50%)',
210
+ minWidth: '18px',
211
+ height: '18px',
212
+ lineHeight: '18px',
213
+ fontSize: '11px',
214
+ fontWeight: '700',
215
+ fontFamily: 'system-ui, -apple-system, sans-serif',
216
+ color: '#fff',
217
+ backgroundColor: 'rgba(220, 38, 38, 0.92)',
218
+ borderRadius: '9px',
219
+ textAlign: 'center',
220
+ padding: '0 4px',
221
+ boxSizing: 'border-box',
222
+ boxShadow: '0 1px 3px rgba(0,0,0,0.3)',
223
+ ...customStyle,
224
+ });
225
+ overlay.appendChild(badge);
226
+ }
227
+
228
+ clone.style.position = 'relative';
229
+ clone.appendChild(overlay);
230
+ }
231
+
232
+ /* ── Prompt text formatter ────────────────────── */
233
+
234
+ function formatPromptText(elements, dimensions) {
235
+ const lines = [
236
+ `Screenshot of a web page (${dimensions.width}\u00d7${dimensions.height}px).`,
237
+ '',
238
+ ];
239
+
240
+ const interactive = elements.filter((e) => e.type === 'interactive');
241
+ const semantic = elements.filter((e) => e.type === 'semantic');
242
+
243
+ if (interactive.length) {
244
+ lines.push('Interactive elements:');
245
+ for (const el of interactive) {
246
+ const attrParts = Object.entries(el.attributes).map(
247
+ ([k, v]) => `${k}="${v}"`
248
+ );
249
+ const text = el.text ? ` "${truncate(el.text, 60)}"` : '';
250
+ const pos = `(${el.bbox.x},${el.bbox.y} ${el.bbox.width}\u00d7${el.bbox.height})`;
251
+ const attrs = attrParts.length ? ' ' + attrParts.join(' ') : '';
252
+ lines.push(` [${el.id}] <${el.tag}>${text} ${pos}${attrs}`);
253
+ }
254
+ lines.push('');
255
+ }
256
+
257
+ if (semantic.length) {
258
+ lines.push('Semantic structure:');
259
+ for (const el of semantic) {
260
+ const text = el.text ? ` "${truncate(el.text, 80)}"` : '';
261
+ const attrParts = [];
262
+ if (el.attributes.alt) attrParts.push(`alt="${el.attributes.alt}"`);
263
+ if (el.attributes.role) attrParts.push(`role="${el.attributes.role}"`);
264
+ const attrs = attrParts.length ? ' ' + attrParts.join(' ') : '';
265
+ lines.push(` [${el.id}] <${el.tag}>${text}${attrs}`);
266
+ }
267
+ lines.push('');
268
+ }
269
+
270
+ return lines.join('\n');
271
+ }
272
+
273
+ function truncate(str, max) {
274
+ if (str.length <= max) return str;
275
+ return str.slice(0, max - 1) + '\u2026';
276
+ }
@@ -1,306 +0,0 @@
1
- /**
2
- * pictureResolver - Official SnapDOM Plugin
3
- *
4
- * Resolves lazy-loaded <picture> elements that use base64/low-quality placeholders
5
- * in <img src> while the real image lives in <source srcset>.
6
- *
7
- * Common pattern in news sites (La Nación, Clarín, NYT, Medium, etc.):
8
- * <picture>
9
- * <source srcset="https://cdn.example.com/real-image.jpg" media="...">
10
- * <img src="data:image/jpeg;base64,/9j/4AAQ..." alt="..."> ← tiny placeholder
11
- * </picture>
12
- *
13
- * The browser renders the real image visually, but DOM cloning captures the
14
- * placeholder because cloneNode copies the HTML attribute, not the rendered source.
15
- *
16
- * This plugin:
17
- * 1. Detects <picture> elements with base64/blob placeholder <img src>
18
- * 2. Resolves the real URL from <source srcset> or img.currentSrc
19
- * 3. Fetches the real image using the browser's context (correct Referer/cookies)
20
- * 4. Replaces img src with the fetched data URL (attribute + property)
21
- * 5. Removes <source> elements to prevent re-resolution during clone
22
- * 6. Restores original DOM after cloning (zero side effects)
23
- *
24
- * Also handles common lazy-loading patterns:
25
- * - data-src / data-srcset attributes (lazysizes, vanilla-lazyload, etc.)
26
- * - loading="lazy" images that haven't entered viewport yet
27
- *
28
- * @param {Object} [options]
29
- * @param {number} [options.timeout=5000] Max ms per image fetch
30
- * @param {number} [options.concurrency=4] Max parallel fetches
31
- * @param {boolean} [options.resolveLazySrc=true] Also resolve data-src/data-srcset patterns
32
- * @param {boolean} [options.silent=false] Suppress console warnings
33
- * @param {string} [options.useProxy=''] CORS proxy for cross-origin images
34
- * @returns {import('../../src/core/plugins.js').Plugin}
35
- *
36
- * @example
37
- * // Per-capture usage
38
- * const result = await snapdom(element, {
39
- * plugins: [pictureResolver()]
40
- * });
41
- *
42
- * @example
43
- * // Global registration
44
- * snapdom.plugins(pictureResolver({ timeout: 3000 }));
45
- *
46
- * @example
47
- * // With proxy for cross-origin images
48
- * const result = await snapdom(element, {
49
- * plugins: [pictureResolver({ useProxy: 'https://corsproxy.io/?' })]
50
- * });
51
- */
52
- export function pictureResolver(options = {}) {
53
- const {
54
- timeout = 5000,
55
- concurrency = 4,
56
- resolveLazySrc = true,
57
- silent = false,
58
- useProxy = '',
59
- } = options
60
-
61
- /** @type {Array<() => void>} */
62
- let undoStack = []
63
-
64
- /**
65
- * Check if a src string is a placeholder (base64, blob, empty, tiny SVG placeholder, etc.)
66
- * @param {string} src
67
- * @returns {boolean}
68
- */
69
- function isPlaceholder(src) {
70
- if (!src) return true
71
- if (src.startsWith('data:')) return true
72
- if (src.startsWith('blob:')) return true
73
- // Common 1x1 transparent pixel patterns
74
- if (/^data:image\/(gif|png|svg)/.test(src) && src.length < 200) return true
75
- return false
76
- }
77
-
78
- /**
79
- * Find the best real URL for an image inside a <picture>.
80
- * Priority: img.currentSrc > <source srcset> matching viewport > first <source srcset>
81
- * @param {HTMLImageElement} img
82
- * @param {HTMLPictureElement} picture
83
- * @returns {string|null}
84
- */
85
- function findRealUrl(img, picture) {
86
- // 1) currentSrc is the browser's resolved choice (best match for viewport)
87
- const current = img.currentSrc || ''
88
- if (current && !isPlaceholder(current)) return current
89
-
90
- // 2) Walk <source> elements; prefer one whose media query matches
91
- const sources = picture.querySelectorAll('source[srcset]')
92
- let fallback = null
93
- for (const source of sources) {
94
- const srcset = source.getAttribute('srcset')
95
- if (!srcset || isPlaceholder(srcset)) continue
96
-
97
- const media = source.getAttribute('media')
98
- if (media) {
99
- try {
100
- if (window.matchMedia(media).matches) {
101
- // Best candidate: matches current viewport
102
- return srcset.split(',')[0].trim().split(/\s+/)[0]
103
- }
104
- } catch { /* invalid media query, skip */ }
105
- }
106
- // Keep first valid source as fallback
107
- if (!fallback) fallback = srcset.split(',')[0].trim().split(/\s+/)[0]
108
- }
109
-
110
- return fallback
111
- }
112
-
113
- /**
114
- * Find lazy-load URLs from data-* attributes.
115
- * Supports: data-src, data-srcset, data-lazy-src, data-original
116
- * @param {HTMLImageElement} img
117
- * @returns {string|null}
118
- */
119
- function findLazySrc(img) {
120
- const candidates = [
121
- img.getAttribute('data-src'),
122
- img.getAttribute('data-lazy-src'),
123
- img.getAttribute('data-original'),
124
- img.getAttribute('data-hi-res-src'),
125
- ]
126
- for (const c of candidates) {
127
- if (c && !isPlaceholder(c)) return c
128
- }
129
- // data-srcset: take first candidate
130
- const dataSrcset = img.getAttribute('data-srcset') || img.getAttribute('data-lazy-srcset')
131
- if (dataSrcset) {
132
- const first = dataSrcset.split(',')[0].trim().split(/\s+/)[0]
133
- if (first && !isPlaceholder(first)) return first
134
- }
135
- return null
136
- }
137
-
138
- /**
139
- * Fetch an image URL using the browser's context (sends correct Referer/cookies).
140
- * Falls back to proxy if direct fetch fails.
141
- * @param {string} url
142
- * @returns {Promise<string|null>} data URL or null on failure
143
- */
144
- async function fetchAsDataUrl(url) {
145
- const controller = new AbortController()
146
- const timer = setTimeout(() => controller.abort(), timeout)
147
-
148
- try {
149
- // Try direct fetch first (browser sends Referer/cookies)
150
- let resp = await fetch(url, {
151
- credentials: 'include',
152
- signal: controller.signal,
153
- })
154
-
155
- // If blocked, try proxy
156
- if (!resp.ok && useProxy) {
157
- const proxyUrl = useProxy.includes('{url}')
158
- ? useProxy.replace('{url}', encodeURIComponent(url))
159
- : useProxy.endsWith('?')
160
- ? `${useProxy}${encodeURIComponent(url)}`
161
- : `${useProxy}${useProxy.includes('?') ? '&' : '?'}url=${encodeURIComponent(url)}`
162
- resp = await fetch(proxyUrl, { signal: controller.signal })
163
- }
164
-
165
- if (!resp.ok) return null
166
-
167
- const blob = await resp.blob()
168
- return await new Promise((resolve, reject) => {
169
- const fr = new FileReader()
170
- fr.onload = () => resolve(/** @type {string} */ (fr.result))
171
- fr.onerror = reject
172
- fr.readAsDataURL(blob)
173
- })
174
- } catch {
175
- return null
176
- } finally {
177
- clearTimeout(timer)
178
- }
179
- }
180
-
181
- /**
182
- * Process a batch of tasks with limited concurrency.
183
- * @param {Array<() => Promise<void>>} tasks
184
- */
185
- async function runBatched(tasks) {
186
- for (let i = 0; i < tasks.length; i += concurrency) {
187
- const batch = tasks.slice(i, i + concurrency)
188
- await Promise.allSettled(batch.map(fn => fn()))
189
- }
190
- }
191
-
192
- return {
193
- name: 'picture-resolver',
194
-
195
- /**
196
- * Before cloning: resolve placeholders to real images in the live DOM.
197
- * Stores undo functions to restore original state after clone.
198
- */
199
- async beforeClone(ctx) {
200
- const root = ctx.element
201
- if (!root || !(root instanceof Element)) return
202
-
203
- undoStack = []
204
- const tasks = []
205
-
206
- // ── 1) <picture> with placeholder <img src> ──
207
- const pictures = root.querySelectorAll('picture')
208
- for (const picture of pictures) {
209
- const img = picture.querySelector('img')
210
- if (!img) continue
211
-
212
- const originalSrc = img.getAttribute('src') || ''
213
- if (!isPlaceholder(originalSrc)) continue
214
-
215
- const realUrl = findRealUrl(img, picture)
216
- if (!realUrl) continue
217
-
218
- tasks.push(async () => {
219
- const dataUrl = await fetchAsDataUrl(realUrl)
220
- if (!dataUrl) {
221
- if (!silent) console.warn(`[snapdom:picture-resolver] Failed to fetch: ${realUrl.slice(0, 60)}`)
222
- return
223
- }
224
-
225
- // Save original state for undo
226
- const origAttr = img.getAttribute('src')
227
- const origSrcset = img.getAttribute('srcset')
228
- const origSizes = img.getAttribute('sizes')
229
- const removedSources = []
230
-
231
- // Swap to real image
232
- img.src = dataUrl
233
- img.setAttribute('src', dataUrl)
234
- img.removeAttribute('srcset')
235
- img.removeAttribute('sizes')
236
-
237
- // Remove <source> elements to prevent clone re-resolution
238
- const sources = picture.querySelectorAll('source')
239
- for (const s of sources) {
240
- removedSources.push({ el: s, parent: s.parentElement, next: s.nextSibling })
241
- s.remove()
242
- }
243
-
244
- // Register undo
245
- undoStack.push(() => {
246
- if (origAttr !== null) img.setAttribute('src', origAttr)
247
- else img.removeAttribute('src')
248
- if (origSrcset !== null) img.setAttribute('srcset', origSrcset)
249
- if (origSizes !== null) img.setAttribute('sizes', origSizes)
250
- for (const { el, parent, next } of removedSources) {
251
- if (parent) parent.insertBefore(el, next)
252
- }
253
- })
254
- })
255
- }
256
-
257
- // ── 2) Lazy-loaded <img> with data-src / data-srcset ──
258
- if (resolveLazySrc) {
259
- const imgs = root.querySelectorAll('img')
260
- for (const img of imgs) {
261
- // Skip images already handled by <picture> pass
262
- if (img.closest('picture') && isPlaceholder(img.getAttribute('src') || '')) continue
263
-
264
- const currentSrc = img.getAttribute('src') || ''
265
- const lazySrc = findLazySrc(img)
266
-
267
- // Case A: has data-src but current src is placeholder
268
- if (lazySrc && isPlaceholder(currentSrc)) {
269
- tasks.push(async () => {
270
- const dataUrl = await fetchAsDataUrl(lazySrc)
271
- if (!dataUrl) return
272
-
273
- const origSrc = img.getAttribute('src')
274
- img.src = dataUrl
275
- img.setAttribute('src', dataUrl)
276
- img.removeAttribute('srcset')
277
- img.removeAttribute('sizes')
278
-
279
- undoStack.push(() => {
280
- if (origSrc !== null) img.setAttribute('src', origSrc)
281
- else img.removeAttribute('src')
282
- })
283
- })
284
- }
285
- }
286
- }
287
-
288
- // Run all fetches with concurrency limit
289
- if (tasks.length > 0) {
290
- await runBatched(tasks)
291
- }
292
- },
293
-
294
- /**
295
- * After cloning: restore original DOM (undo all mutations).
296
- */
297
- async afterClone(ctx) {
298
- for (const undo of undoStack) {
299
- try { undo() } catch { /* non-blocking */ }
300
- }
301
- undoStack = []
302
- },
303
- }
304
- }
305
-
306
- export default pictureResolver