@ammduncan/easel 0.3.2 → 0.4.0
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/CHANGELOG.md +10 -0
- package/dist/client/viewer.js +87 -67
- package/dist/http-server.js +20 -2
- package/dist/inline-images.js +91 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to easel. This project adheres to [Semantic Versioning](https://semver.org/).
|
|
4
4
|
|
|
5
|
+
## 0.4.0 — 2026-05-26
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **Remote images are now inlined server-side at push time, so pushes that embed cross-origin images export correctly.** A push that referenced a remote image (e.g. a mobbin.com screenshot in an `app`/`mockup` recreation) rendered fine on screen but **couldn't be exported to PNG/PDF**: the browser blocks cross-origin images from canvas rasterisation (CORS), and drawing one taints the canvas so `toDataURL` throws — export would stall until the 30s watchdog fired (see 0.3.3). The fix moves the fetch to the server: `POST /api/push` now scans incoming HTML for remote `<img src>` and CSS `url(...)` references, fetches each server-side (no CORS limit), and rewrites them to self-contained `data:` URLs before storing the push. Exports then work, and the push survives the original URL later expiring (mobbin's `…/mcp/short/…` links are ephemeral). Fetches run in parallel, each bounded by an 8s timeout with an 8MB size cap and an image-content-type check; a URL that can't be inlined (timeout, non-image, too large, network error) is left untouched — it still displays, just won't appear in an export — so a dead link degrades gracefully instead of failing the push. Opt out with `EASEL_INLINE_IMAGES=0`. Covered by `tests/unit/inline-images.test.mjs`.
|
|
9
|
+
|
|
10
|
+
## 0.3.3 — 2026-05-26
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **PNG and PDF export no longer hang when the easel tab isn't the visible foreground tab.** Export rasterised via `html-to-image`'s `toPng`/`toJpeg`, which resolve through the library's internal `createImage()` — and that waits on `requestAnimationFrame`. Chrome freezes rAF in hidden/background tabs, so the rasterize promise never settled: click export, switch back to your terminal, and the button spinner span forever with no error (the viewer had no timeout to recover). Both formats died here because they share the path. The export now stops at `htmlToImage.toSvg()` (no rAF) and rasterises onto a canvas with a plain `Image`, whose `onload` fires even in hidden tabs. Quality is unchanged — the SVG is vector, drawn onto a DPR-4 canvas, so PNG stays lossless and PDF stays JPEG q1.0. The two render paths (`buildDefaultWrapper` and the full-HTML `injectBridge`, which had drifted to capturing `body` vs `documentElement`) now share one `imageExportScript()`. A missing `html-to-image` now posts an error instead of returning silently, and a 30s parent-side watchdog clears the spinner and surfaces a timeout if the iframe never reports back. Covered by `tests/unit/image-export.test.mjs`.
|
|
14
|
+
|
|
5
15
|
## 0.3.2 — 2026-05-26
|
|
6
16
|
|
|
7
17
|
### Fixed
|
package/dist/client/viewer.js
CHANGED
|
@@ -7,6 +7,27 @@
|
|
|
7
7
|
const cardsEl = document.getElementById("cards");
|
|
8
8
|
const emptyEl = document.getElementById("empty-state");
|
|
9
9
|
const countEl = document.getElementById("push-count");
|
|
10
|
+
|
|
11
|
+
// Per-push export watchdog timers. If the iframe never posts back
|
|
12
|
+
// image-ready / image-error (e.g. a render stall), the watchdog clears the
|
|
13
|
+
// button spinner and surfaces an error instead of spinning forever.
|
|
14
|
+
const EXPORT_TIMEOUT_MS = 30000;
|
|
15
|
+
const exportWatchdogs = new Map();
|
|
16
|
+
function clearExportSpinner(pushId) {
|
|
17
|
+
const iframeEl = cardsEl.querySelector(
|
|
18
|
+
'iframe[data-push-id="' + cssEscape(pushId) + '"]',
|
|
19
|
+
);
|
|
20
|
+
const push = iframeEl && iframeEl.closest(".push");
|
|
21
|
+
const ex = push && push.querySelector(".push-export");
|
|
22
|
+
if (ex) delete ex.dataset.loading;
|
|
23
|
+
}
|
|
24
|
+
function clearExportWatchdog(pushId) {
|
|
25
|
+
const t = exportWatchdogs.get(pushId);
|
|
26
|
+
if (t) {
|
|
27
|
+
clearTimeout(t);
|
|
28
|
+
exportWatchdogs.delete(pushId);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
10
31
|
const prunedEl = document.getElementById("pruned-marker");
|
|
11
32
|
const liveDotEl = document.getElementById("live-dot");
|
|
12
33
|
const liveLabelEl = document.getElementById("live-label");
|
|
@@ -276,27 +297,15 @@
|
|
|
276
297
|
}
|
|
277
298
|
if (data.type === "easel:image-error") {
|
|
278
299
|
console.error("[easel] iframe export error", data);
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
);
|
|
282
|
-
if (iframeEl && iframeEl.closest(".push")) {
|
|
283
|
-
const ex = iframeEl.closest(".push").querySelector(".push-export");
|
|
284
|
-
if (ex) delete ex.dataset.loading;
|
|
285
|
-
}
|
|
300
|
+
clearExportWatchdog(data.pushId);
|
|
301
|
+
clearExportSpinner(data.pushId);
|
|
286
302
|
alert("Export failed (" + (data.format || "?") + "): " + (data.message || "unknown"));
|
|
287
303
|
return;
|
|
288
304
|
}
|
|
289
305
|
if (data.type === "easel:image-ready") {
|
|
290
306
|
const format = data.format === "pdf" ? "pdf" : "png";
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
'iframe[data-push-id="' + cssEscape(data.pushId) + '"]',
|
|
294
|
-
);
|
|
295
|
-
if (iframeEl && iframeEl.closest(".push")) {
|
|
296
|
-
const ex = iframeEl.closest(".push").querySelector(".push-export");
|
|
297
|
-
if (ex) delete ex.dataset.loading;
|
|
298
|
-
}
|
|
299
|
-
};
|
|
307
|
+
clearExportWatchdog(data.pushId);
|
|
308
|
+
const clearLoading = () => clearExportSpinner(data.pushId);
|
|
300
309
|
|
|
301
310
|
if (format === "pdf") {
|
|
302
311
|
downloadAsPdf(data.dataUrl, data.filename || "push.pdf")
|
|
@@ -654,6 +663,20 @@
|
|
|
654
663
|
function requestExport(format) {
|
|
655
664
|
exportBtn.dataset.loading = "true";
|
|
656
665
|
|
|
666
|
+
clearExportWatchdog(push.id);
|
|
667
|
+
exportWatchdogs.set(
|
|
668
|
+
push.id,
|
|
669
|
+
setTimeout(() => {
|
|
670
|
+
exportWatchdogs.delete(push.id);
|
|
671
|
+
clearExportSpinner(push.id);
|
|
672
|
+
alert(
|
|
673
|
+
"Export timed out after " +
|
|
674
|
+
EXPORT_TIMEOUT_MS / 1000 +
|
|
675
|
+
"s. Try again with the easel tab in the foreground.",
|
|
676
|
+
);
|
|
677
|
+
}, EXPORT_TIMEOUT_MS),
|
|
678
|
+
);
|
|
679
|
+
|
|
657
680
|
// Match the export bg to what the user sees inside this card:
|
|
658
681
|
// carded → card's elevated surface (--ds-bg-elev)
|
|
659
682
|
// flat → page canvas (--ds-bg) since the iframe body is transparent
|
|
@@ -676,6 +699,7 @@
|
|
|
676
699
|
"*",
|
|
677
700
|
);
|
|
678
701
|
} catch (err) {
|
|
702
|
+
clearExportWatchdog(push.id);
|
|
679
703
|
delete exportBtn.dataset.loading;
|
|
680
704
|
console.error("[easel] export failed", err);
|
|
681
705
|
}
|
|
@@ -769,6 +793,49 @@
|
|
|
769
793
|
);
|
|
770
794
|
}
|
|
771
795
|
|
|
796
|
+
/**
|
|
797
|
+
* In-iframe message listener that turns the rendered push into a PNG/JPEG
|
|
798
|
+
* dataURL on `easel:image` and posts back `easel:image-ready` / `-error`.
|
|
799
|
+
*
|
|
800
|
+
* Deliberately stops at htmlToImage.toSvg() and does the canvas rasterisation
|
|
801
|
+
* by hand. toPng/toJpeg/toCanvas resolve via the library's internal
|
|
802
|
+
* createImage(), which waits on requestAnimationFrame — and Chrome freezes
|
|
803
|
+
* rAF in hidden/background tabs, so the export hung forever whenever the
|
|
804
|
+
* easel tab wasn't the visible one. toSvg has no rAF, and a plain Image's
|
|
805
|
+
* onload fires even in hidden tabs, so this path works regardless of tab
|
|
806
|
+
* visibility. Quality is unchanged: the SVG is vector, drawn onto a
|
|
807
|
+
* PIXEL_RATIO-scaled canvas → still crisp at DPR 4.
|
|
808
|
+
*
|
|
809
|
+
* Shared verbatim by buildDefaultWrapper and injectBridge so the two render
|
|
810
|
+
* paths can't drift.
|
|
811
|
+
*/
|
|
812
|
+
function imageExportScript() {
|
|
813
|
+
return (
|
|
814
|
+
"(function(){var PR=4;" +
|
|
815
|
+
"function fail(id,fmt,err){console.error('[easel] export failed',err);" +
|
|
816
|
+
"parent.postMessage({type:'easel:image-error',pushId:id,format:fmt,message:(err&&err.message)?err.message:String(err)},'*')}" +
|
|
817
|
+
"function rasterize(svgUrl,w,h,bg,fmt){return new Promise(function(resolve,reject){" +
|
|
818
|
+
"var img=new Image();" +
|
|
819
|
+
"img.onload=function(){try{var c=document.createElement('canvas');" +
|
|
820
|
+
"c.width=Math.max(1,Math.round(w*PR));c.height=Math.max(1,Math.round(h*PR));" +
|
|
821
|
+
"var x=c.getContext('2d');x.fillStyle=bg;x.fillRect(0,0,c.width,c.height);" +
|
|
822
|
+
"x.drawImage(img,0,0,c.width,c.height);" +
|
|
823
|
+
"resolve(fmt==='pdf'?c.toDataURL('image/jpeg',1.0):c.toDataURL('image/png'))}catch(e){reject(e)}};" +
|
|
824
|
+
"img.onerror=function(){reject(new Error('SVG snapshot failed to load'))};img.src=svgUrl})}" +
|
|
825
|
+
"function run(d){var id=d.pushId;var fn=d.filename||'push.png';var fmt=d.format==='pdf'?'pdf':'png';" +
|
|
826
|
+
"var bg=d.bgColor||getComputedStyle(document.documentElement).getPropertyValue('--ds-bg-elev').trim()||'#ffffff';" +
|
|
827
|
+
"function render(){if(!window.htmlToImage){fail(id,fmt,new Error('html-to-image not loaded'));return}" +
|
|
828
|
+
"var w=document.documentElement.clientWidth;" +
|
|
829
|
+
"var h=Math.max(document.documentElement.scrollHeight,document.body?document.body.scrollHeight:0);" +
|
|
830
|
+
"window.htmlToImage.toSvg(document.documentElement,{width:w,height:h,cacheBust:true})" +
|
|
831
|
+
".then(function(u){return rasterize(u,w,h,bg,fmt)})" +
|
|
832
|
+
".then(function(u){parent.postMessage({type:'easel:image-ready',pushId:id,dataUrl:u,filename:fn,format:fmt},'*')})" +
|
|
833
|
+
".catch(function(e){fail(id,fmt,e)})}" +
|
|
834
|
+
"if(document.fonts&&document.fonts.ready){document.fonts.ready.then(render).catch(render)}else{render()}}" +
|
|
835
|
+
"window.addEventListener('message',function(e){if(e&&e.data&&e.data.type==='easel:image')run(e.data)})})();"
|
|
836
|
+
);
|
|
837
|
+
}
|
|
838
|
+
|
|
772
839
|
function buildDefaultWrapper(body, theme, preset, pushId, appFidelity) {
|
|
773
840
|
const density = currentDensity();
|
|
774
841
|
// app-fidelity mode: skip presentation defaults (presets, semantic chips,
|
|
@@ -1004,58 +1071,10 @@ ${body}
|
|
|
1004
1071
|
if (e.data.type === "easel:print") {
|
|
1005
1072
|
try { window.print(); } catch(_) {}
|
|
1006
1073
|
}
|
|
1007
|
-
if (e.data.type === "easel:image") {
|
|
1008
|
-
var pushId = e.data.pushId;
|
|
1009
|
-
var filename = e.data.filename || "push.png";
|
|
1010
|
-
var format = e.data.format === "pdf" ? "pdf" : "png";
|
|
1011
|
-
var bgColor =
|
|
1012
|
-
e.data.bgColor ||
|
|
1013
|
-
getComputedStyle(document.documentElement).getPropertyValue("--ds-bg-elev").trim() ||
|
|
1014
|
-
"#ffffff";
|
|
1015
|
-
function render() {
|
|
1016
|
-
if (!window.htmlToImage) {
|
|
1017
|
-
console.error("[easel] html-to-image not loaded");
|
|
1018
|
-
return;
|
|
1019
|
-
}
|
|
1020
|
-
// Capture the html root at full viewport width so fixed/absolute
|
|
1021
|
-
// positioning resolves the same as on screen. Capturing body alone
|
|
1022
|
-
// honours max-width:auto-margins and breaks fixed elements that
|
|
1023
|
-
// anchor to the viewport (modals at left:50% etc).
|
|
1024
|
-
var width = document.documentElement.clientWidth;
|
|
1025
|
-
var height = Math.max(
|
|
1026
|
-
document.documentElement.scrollHeight,
|
|
1027
|
-
document.body ? document.body.scrollHeight : 0,
|
|
1028
|
-
);
|
|
1029
|
-
// PNG target → lossless PNG @ pixelRatio 4 for crisp standalone files.
|
|
1030
|
-
// PDF target → JPEG @ quality 1.0 + pixelRatio 4. PDFs natively use
|
|
1031
|
-
// DCT compression for embedded JPEGs, so even at max quality the PDF
|
|
1032
|
-
// stays in the ~3-8 MB range for a typical card (vs ~300 MB if we
|
|
1033
|
-
// embedded as PNG — see 0.2.15). 1.0 + DPR 4 keeps text razor-sharp
|
|
1034
|
-
// at any zoom level; tuned down to 0.92 + DPR 2 in 0.2.15 dropped to
|
|
1035
|
-
// ~800 KB but at the cost of visible JPEG artefacts on type.
|
|
1036
|
-
var rasterFn = format === "pdf" ? window.htmlToImage.toJpeg : window.htmlToImage.toPng;
|
|
1037
|
-
var rasterOpts = {
|
|
1038
|
-
backgroundColor: bgColor,
|
|
1039
|
-
pixelRatio: 4,
|
|
1040
|
-
cacheBust: true,
|
|
1041
|
-
width: width,
|
|
1042
|
-
height: height,
|
|
1043
|
-
};
|
|
1044
|
-
if (format === "pdf") rasterOpts.quality = 1.0;
|
|
1045
|
-
rasterFn(document.documentElement, rasterOpts).then(function(dataUrl){
|
|
1046
|
-
parent.postMessage({ type: "easel:image-ready", pushId: pushId, dataUrl: dataUrl, filename: filename, format: format }, "*");
|
|
1047
|
-
}).catch(function(err){
|
|
1048
|
-
console.error("[easel] export failed", err);
|
|
1049
|
-
parent.postMessage({ type: "easel:image-error", pushId: pushId, format: format, message: (err && err.message) ? err.message : String(err) }, "*");
|
|
1050
|
-
});
|
|
1051
|
-
}
|
|
1052
|
-
if (document.fonts && document.fonts.ready) {
|
|
1053
|
-
document.fonts.ready.then(render).catch(render);
|
|
1054
|
-
} else { render(); }
|
|
1055
|
-
}
|
|
1056
1074
|
});
|
|
1057
1075
|
})();
|
|
1058
1076
|
</script>
|
|
1077
|
+
<script>${imageExportScript()}</script>
|
|
1059
1078
|
<script>${selfMeasureScript(pushId)}</script>
|
|
1060
1079
|
</body>
|
|
1061
1080
|
</html>`;
|
|
@@ -1066,9 +1085,10 @@ ${body}
|
|
|
1066
1085
|
const configScript =
|
|
1067
1086
|
"<script src='https://cdn.jsdelivr.net/npm/html-to-image@1.11.13/dist/html-to-image.js'></script><script>(function(){function a(c){if(!c)return;if(c.theme==='light'||c.theme==='dark'){document.documentElement.setAttribute('data-theme',c.theme);window.__claudeDisplayTheme=c.theme}if(c.preset==='paper'||c.preset==='aurora'||c.preset==='slate'){document.documentElement.setAttribute('data-preset',c.preset);window.__claudeDisplayPreset=c.preset}if(c.density==='carded'||c.density==='flat'){document.documentElement.setAttribute('data-density',c.density);window.__claudeDisplayDensity=c.density}}a(" +
|
|
1068
1087
|
JSON.stringify({ theme, preset, density }) +
|
|
1069
|
-
");window.addEventListener('message',function(e){if(!e||!e.data)return;if(e.data.type==='easel:config')a(e.data);if(e.data.type==='easel:theme')a({theme:e.data.theme});if(e.data.type==='easel:print'){try{window.print()}catch(_){}}
|
|
1088
|
+
");window.addEventListener('message',function(e){if(!e||!e.data)return;if(e.data.type==='easel:config')a(e.data);if(e.data.type==='easel:theme')a({theme:e.data.theme});if(e.data.type==='easel:print'){try{window.print()}catch(_){}}})})();</script>";
|
|
1089
|
+
const imageScript = "<script>" + imageExportScript() + "</script>";
|
|
1070
1090
|
const measureScript = "<script>" + selfMeasureScript(pushId) + "</script>";
|
|
1071
|
-
const combined = configScript + measureScript;
|
|
1091
|
+
const combined = configScript + imageScript + measureScript;
|
|
1072
1092
|
if (/<\/body>/i.test(html)) return html.replace(/<\/body>/i, combined + "</body>");
|
|
1073
1093
|
return html + combined;
|
|
1074
1094
|
}
|
package/dist/http-server.js
CHANGED
|
@@ -6,6 +6,7 @@ import { appendPush, deletePush, deleteSession, getSessionView, listSessionSumma
|
|
|
6
6
|
import { readConfig, writeConfig } from "./config-store.js";
|
|
7
7
|
import { clearLockIfMine, writeLock } from "./server-manager.js";
|
|
8
8
|
import { resolvePort } from "./paths.js";
|
|
9
|
+
import { inlineRemoteImages } from "./inline-images.js";
|
|
9
10
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
11
|
const CLIENT_DIR = resolve(__dirname, "client");
|
|
11
12
|
const clients = new Map();
|
|
@@ -157,7 +158,7 @@ export function startHttpServer() {
|
|
|
157
158
|
const ok = deleteSession(id);
|
|
158
159
|
res.json({ ok });
|
|
159
160
|
});
|
|
160
|
-
app.post("/api/push", (req, res) => {
|
|
161
|
+
app.post("/api/push", async (req, res) => {
|
|
161
162
|
const { sessionId, html, title, kind } = req.body ?? {};
|
|
162
163
|
if (typeof sessionId !== "string" || !sessionId.trim()) {
|
|
163
164
|
res.status(400).json({ error: "sessionId required" });
|
|
@@ -167,7 +168,24 @@ export function startHttpServer() {
|
|
|
167
168
|
res.status(400).json({ error: "html required" });
|
|
168
169
|
return;
|
|
169
170
|
}
|
|
170
|
-
|
|
171
|
+
// Inline remote images server-side so the stored push is self-contained
|
|
172
|
+
// and exportable (cross-origin images are CORS-blocked from client-side
|
|
173
|
+
// rasterisation). Best-effort: on any failure we store the original html.
|
|
174
|
+
let storedHtml = html;
|
|
175
|
+
if (process.env.EASEL_INLINE_IMAGES !== "0") {
|
|
176
|
+
try {
|
|
177
|
+
const result = await inlineRemoteImages(html);
|
|
178
|
+
storedHtml = result.html;
|
|
179
|
+
if (result.failed.length > 0) {
|
|
180
|
+
console.warn(`[easel] ${result.failed.length} remote image(s) left un-inlined (won't export): ` +
|
|
181
|
+
result.failed.map((f) => `${f.url} — ${f.reason}`).join("; "));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
console.warn("[easel] image inlining failed; storing original html:", err);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const push = appendPush(sessionId, { html: storedHtml, title, kind });
|
|
171
189
|
touchSession(sessionId);
|
|
172
190
|
broadcast(sessionId, "push", push);
|
|
173
191
|
if (Math.random() < 0.05) {
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline remote images into pushed HTML so exports work.
|
|
3
|
+
*
|
|
4
|
+
* Cross-origin images (e.g. mobbin screenshots) render on screen but can't be
|
|
5
|
+
* rasterised client-side: fetching them for inlining is CORS-blocked, and
|
|
6
|
+
* drawing a cross-origin image taints the canvas so PNG/PDF export throws. We
|
|
7
|
+
* fetch them server-side (no CORS) at push time and rewrite the references to
|
|
8
|
+
* self-contained `data:` URLs, so the stored push exports cleanly and survives
|
|
9
|
+
* the original URL later expiring.
|
|
10
|
+
*
|
|
11
|
+
* A remote URL that can't be inlined (timeout, non-image, too large, network
|
|
12
|
+
* error) is left untouched — it still displays cross-origin, it just won't
|
|
13
|
+
* appear in an export — so a dead link degrades gracefully instead of failing
|
|
14
|
+
* the push. Only `http(s)` URLs are touched; `data:`, `blob:`, and relative
|
|
15
|
+
* references are left alone.
|
|
16
|
+
*/
|
|
17
|
+
const PER_IMAGE_TIMEOUT_MS = 8000;
|
|
18
|
+
const MAX_IMAGE_BYTES = 8_000_000;
|
|
19
|
+
// `src="https://…"` (img/source/…) and CSS `url(https://…)` with optional quotes.
|
|
20
|
+
// Capture group 2 is the URL in both.
|
|
21
|
+
const URL_PATTERNS = [
|
|
22
|
+
/\bsrc\s*=\s*(["'])(https?:\/\/[^"']+?)\1/gi,
|
|
23
|
+
/url\(\s*(["']?)(https?:\/\/[^)"']+?)\1\s*\)/gi,
|
|
24
|
+
];
|
|
25
|
+
function collectRemoteUrls(html) {
|
|
26
|
+
const urls = new Set();
|
|
27
|
+
for (const pattern of URL_PATTERNS) {
|
|
28
|
+
const re = new RegExp(pattern.source, pattern.flags);
|
|
29
|
+
let match;
|
|
30
|
+
while ((match = re.exec(html)) !== null) {
|
|
31
|
+
urls.add(match[2]);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return [...urls];
|
|
35
|
+
}
|
|
36
|
+
async function fetchAsDataUri(url, fetchImpl) {
|
|
37
|
+
const ac = new AbortController();
|
|
38
|
+
const timer = setTimeout(() => ac.abort(), PER_IMAGE_TIMEOUT_MS);
|
|
39
|
+
try {
|
|
40
|
+
const res = await fetchImpl(url, { signal: ac.signal, redirect: "follow" });
|
|
41
|
+
if (!res.ok) {
|
|
42
|
+
throw new Error(`HTTP ${res.status}`);
|
|
43
|
+
}
|
|
44
|
+
const type = (res.headers.get("content-type") || "").split(";")[0].trim();
|
|
45
|
+
if (!type.startsWith("image/")) {
|
|
46
|
+
throw new Error(`not an image (${type || "unknown content-type"})`);
|
|
47
|
+
}
|
|
48
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
49
|
+
if (buf.byteLength > MAX_IMAGE_BYTES) {
|
|
50
|
+
throw new Error(`too large (${buf.byteLength} bytes)`);
|
|
51
|
+
}
|
|
52
|
+
return `data:${type};base64,${buf.toString("base64")}`;
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Fetch every remote image referenced in `html` and rewrite the references to
|
|
60
|
+
* `data:` URIs. Fetches run in parallel, each bounded by its own timeout, so
|
|
61
|
+
* total latency is roughly the slowest single image, not the sum.
|
|
62
|
+
*
|
|
63
|
+
* @param fetchImpl - injectable for tests; defaults to global `fetch`.
|
|
64
|
+
*/
|
|
65
|
+
export async function inlineRemoteImages(html, fetchImpl = fetch) {
|
|
66
|
+
const urls = collectRemoteUrls(html);
|
|
67
|
+
if (urls.length === 0) {
|
|
68
|
+
return { html, inlined: 0, failed: [] };
|
|
69
|
+
}
|
|
70
|
+
const dataUriByUrl = new Map();
|
|
71
|
+
const failed = [];
|
|
72
|
+
await Promise.all(urls.map(async (url) => {
|
|
73
|
+
try {
|
|
74
|
+
dataUriByUrl.set(url, await fetchAsDataUri(url, fetchImpl));
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
failed.push({ url, reason: err instanceof Error ? err.message : String(err) });
|
|
78
|
+
}
|
|
79
|
+
}));
|
|
80
|
+
// Swap each URL inside its matched attribute/url() context only, so a URL
|
|
81
|
+
// that is a prefix of another can't be corrupted by a blind global replace.
|
|
82
|
+
let out = html;
|
|
83
|
+
for (const pattern of URL_PATTERNS) {
|
|
84
|
+
const re = new RegExp(pattern.source, pattern.flags);
|
|
85
|
+
out = out.replace(re, (full, _quote, url) => {
|
|
86
|
+
const dataUri = dataUriByUrl.get(url);
|
|
87
|
+
return dataUri ? full.replace(url, dataUri) : full;
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return { html: out, inlined: dataUriByUrl.size, failed };
|
|
91
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ammduncan/easel",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "A live browser tab for every Claude Code (and MCP) session. The push MCP tool appends HTML cards to a scrolling feed you keep open in split-screen.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|