morph-embed 0.1.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/LICENSE +21 -0
- package/README.md +152 -0
- package/dist/morph-embed.esm.mjs +2424 -0
- package/dist/morph-embed.js +2420 -0
- package/package.json +37 -0
|
@@ -0,0 +1,2424 @@
|
|
|
1
|
+
/*! Morph Embed SDK v0.1.0 — https://usemorph.xyz — MIT */
|
|
2
|
+
const g = typeof window !== "undefined" ? window : globalThis;
|
|
3
|
+
|
|
4
|
+
/* ---- shared/dsl.js ---- */
|
|
5
|
+
/*
|
|
6
|
+
* Morph DSL — the contract between the model and the page.
|
|
7
|
+
*
|
|
8
|
+
* The model returns a Transform: { version, explanation, ops: [...] }.
|
|
9
|
+
* This module:
|
|
10
|
+
* - validateTransform() : structural validation + sanitization (PURE, no DOM)
|
|
11
|
+
* - applyTransform() : interprets ops against a real document (needs a DOM)
|
|
12
|
+
* - undoTransform() : reverses an applied transform (in-session)
|
|
13
|
+
*
|
|
14
|
+
* SECURITY: there is no code-execution op. addElement HTML is whitelist-sanitized,
|
|
15
|
+
* injected CSS is neutralized, and no op can run script. The interpreter never trusts
|
|
16
|
+
* the model — every op is validated/sanitized here, on our side.
|
|
17
|
+
*
|
|
18
|
+
* UMD: works as a Node module (tests) and as a browser global `MorphDSL` (content script).
|
|
19
|
+
*/
|
|
20
|
+
(function (global, factory) {
|
|
21
|
+
const api = factory();
|
|
22
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
23
|
+
else global.MorphDSL = api;
|
|
24
|
+
})(typeof self !== 'undefined' ? self : this, function () {
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
27
|
+
const SCHEMA_VERSION = 1;
|
|
28
|
+
|
|
29
|
+
// Op name -> required string fields (besides `op`). Optional fields handled per-op.
|
|
30
|
+
const OP_SPECS = {
|
|
31
|
+
injectCSS: { fields: ['css'] },
|
|
32
|
+
hide: { fields: ['selector'] },
|
|
33
|
+
show: { fields: ['selector'] },
|
|
34
|
+
remove: { fields: ['selector'] },
|
|
35
|
+
setStyle: { fields: ['selector'], object: 'style' },
|
|
36
|
+
setText: { fields: ['selector', 'text'] },
|
|
37
|
+
setAttr: { fields: ['selector', 'name', 'value'] },
|
|
38
|
+
addClass: { fields: ['selector', 'class'] },
|
|
39
|
+
removeClass: { fields: ['selector', 'class'] },
|
|
40
|
+
move: { fields: ['selector', 'target', 'position'] },
|
|
41
|
+
addElement: { fields: ['html', 'target', 'position'] },
|
|
42
|
+
renderData: { fields: ['target', 'position', 'chart'] },
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const POSITIONS = ['before', 'after', 'prepend', 'append'];
|
|
46
|
+
|
|
47
|
+
const ALLOWED_TAGS = new Set([
|
|
48
|
+
'div', 'span', 'p', 'a', 'ul', 'ol', 'li', 'img', 'b', 'i', 'strong', 'em',
|
|
49
|
+
'br', 'hr', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'table', 'thead', 'tbody',
|
|
50
|
+
'tr', 'td', 'th', 'section', 'article', 'header', 'footer', 'nav', 'aside',
|
|
51
|
+
'main', 'small', 'code', 'pre', 'blockquote', 'figure', 'figcaption',
|
|
52
|
+
'label', 'button', 'mark', 'time', 'sub', 'sup', 'caption', 'dl', 'dt', 'dd',
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
const ALLOWED_ATTRS = new Set([
|
|
56
|
+
'class', 'id', 'style', 'href', 'src', 'alt', 'title', 'role', 'target',
|
|
57
|
+
'rel', 'width', 'height', 'colspan', 'rowspan', 'datetime', 'type',
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
const SAFE_URL = /^(https?:|mailto:|tel:|\/|#|\.\/|\.\.\/)/i;
|
|
61
|
+
|
|
62
|
+
// Namespaces whose elements must be DROPPED with their whole subtree (none are
|
|
63
|
+
// in ALLOWED_TAGS, and foreign content enables mXSS / <svg><script> leakage).
|
|
64
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
65
|
+
const MATHML_NS = 'http://www.w3.org/1998/Math/MathML';
|
|
66
|
+
|
|
67
|
+
// Elements whose ENTIRE subtree must be dropped (not unwrapped): their text
|
|
68
|
+
// content is executable/active in some context (script) or is a parsing trap
|
|
69
|
+
// that re-activates on reparse (noscript/template-script, style breakout,
|
|
70
|
+
// <title>/<textarea> mXSS). None are in ALLOWED_TAGS.
|
|
71
|
+
const DROP_SUBTREE_TAGS = new Set([
|
|
72
|
+
'script', 'style', 'noscript', 'noembed', 'noframes', 'iframe', 'frame',
|
|
73
|
+
'frameset', 'object', 'embed', 'applet', 'title', 'textarea', 'xmp',
|
|
74
|
+
'plaintext', 'listing',
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
// URL-bearing attribute names (used by both HTML sanitizer and setAttr guard).
|
|
78
|
+
const URL_ATTRS = new Set(['href', 'src', 'srcset', 'xlink:href']);
|
|
79
|
+
|
|
80
|
+
// Modest length caps (DoS hygiene; generous enough not to clip real payloads).
|
|
81
|
+
const MAX_CSS = 200000;
|
|
82
|
+
const MAX_HTML = 200000;
|
|
83
|
+
const MAX_TEXT = 5000;
|
|
84
|
+
const MAX_EXPLANATION = 500;
|
|
85
|
+
const MAX_SELECTOR = 2000;
|
|
86
|
+
const MAX_LABEL = 120; // chart title cap
|
|
87
|
+
const MAX_CHART_POINTS = 60; // bars / points per chart
|
|
88
|
+
// Trusted chart geometry — rendered by US via createElementNS, never the model.
|
|
89
|
+
const CHART_W = 600, CHART_H = 280, CHART_PAD = 40;
|
|
90
|
+
|
|
91
|
+
// ---- pure helpers --------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
function isString(v) { return typeof v === 'string'; }
|
|
94
|
+
|
|
95
|
+
// Normalize a CSS string for DANGER DETECTION only (never used as output):
|
|
96
|
+
// strip /*...*/ comments, decode CSS backslash escapes (\65 -> e, \6a -> j),
|
|
97
|
+
// and collapse whitespace so 'expr/**/ession(' and '\65 xpression' are caught.
|
|
98
|
+
function normalizeCssForScan(css) {
|
|
99
|
+
if (!isString(css)) return '';
|
|
100
|
+
let s = css.replace(/\/\*[\s\S]*?\*\//g, ''); // strip comments
|
|
101
|
+
// Decode CSS escapes: \<1-6 hex> (optional trailing ws) or \<char>
|
|
102
|
+
s = s.replace(/\\([0-9a-fA-F]{1,6})\s?/g, function (_, hex) {
|
|
103
|
+
try {
|
|
104
|
+
const cp = parseInt(hex, 16);
|
|
105
|
+
if (cp > 0 && cp <= 0x10ffff) return String.fromCodePoint(cp);
|
|
106
|
+
} catch (_e) { /* fall through */ }
|
|
107
|
+
return '';
|
|
108
|
+
});
|
|
109
|
+
s = s.replace(/\\([^\r\n0-9a-fA-F])/g, '$1'); // \<char> -> char
|
|
110
|
+
// strip control chars / NUL, collapse whitespace
|
|
111
|
+
s = s.replace(/[\x00-\x20\x7f]+/g, ' ');
|
|
112
|
+
return s.toLowerCase();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Does the (already normalized) CSS contain a dangerous construct?
|
|
116
|
+
function cssHasDanger(normalized) {
|
|
117
|
+
return (
|
|
118
|
+
/@import/.test(normalized) ||
|
|
119
|
+
/expression\s*\(/.test(normalized) ||
|
|
120
|
+
/behavior\s*:/.test(normalized) ||
|
|
121
|
+
/-moz-binding/.test(normalized) ||
|
|
122
|
+
/url\(\s*(['"]?)\s*(javascript|vbscript|mocha|livescript|data\s*:\s*text\/html|data\s*:\s*application)/.test(normalized) ||
|
|
123
|
+
/(javascript|vbscript|mocha|livescript)\s*:/.test(normalized)
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Shared CSS neutralizer. `mode` is 'css' (injectCSS) or 'style' (style attr /
|
|
128
|
+
// setStyle value). When the normalized form trips cssHasDanger we strip the
|
|
129
|
+
// offending tokens from the ORIGINAL text (comment-/escape-aware) rather than
|
|
130
|
+
// rewriting valid styling. If we cannot prove it safe we blank it.
|
|
131
|
+
function neutralizeCssValue(value, mode) {
|
|
132
|
+
if (!isString(value)) return '';
|
|
133
|
+
let out = value;
|
|
134
|
+
// Token-level removals that are safe to apply unconditionally. Each pattern
|
|
135
|
+
// tolerates intervening CSS comments/whitespace.
|
|
136
|
+
const ws = '(?:\\s|/\\*[\\s\\S]*?\\*/)*';
|
|
137
|
+
// @import ...; -> drop
|
|
138
|
+
out = out.replace(new RegExp('@import[^;]*;?', 'gi'), '');
|
|
139
|
+
// expression( possibly comment-split: expr/**/ession(
|
|
140
|
+
out = out.replace(new RegExp('e' + ws + 'x' + ws + 'p' + ws + 'r' + ws + 'e' + ws + 's' + ws + 's' + ws + 'i' + ws + 'o' + ws + 'n' + ws + '\\(', 'gi'), 'void(');
|
|
141
|
+
// behavior: and -moz-binding:
|
|
142
|
+
out = out.replace(new RegExp('behavior' + ws + ':', 'gi'), '/* behavior */:');
|
|
143
|
+
out = out.replace(new RegExp('-moz-binding' + ws + ':', 'gi'), '/* binding */:');
|
|
144
|
+
// url( <comments/ws> <dangerous-scheme> ...) -> url(about:blank#)
|
|
145
|
+
out = out.replace(
|
|
146
|
+
/url\(\s*(['"]?)((?:\/\*[\s\S]*?\*\/|\s)*)(javascript|vbscript|mocha|livescript|data\s*:\s*text\/html|data\s*:\s*application)[^)]*\)/gi,
|
|
147
|
+
'url($1about:blank#)'
|
|
148
|
+
);
|
|
149
|
+
// bare scheme prefixes inside the value (style attr context)
|
|
150
|
+
if (mode === 'style') {
|
|
151
|
+
out = out.replace(/(javascript|vbscript|mocha|livescript)\s*:/gi, '');
|
|
152
|
+
}
|
|
153
|
+
// <style> breakout literal (injectCSS goes into a <style> textContent)
|
|
154
|
+
if (mode === 'css') {
|
|
155
|
+
out = out.replace(/<\/style/gi, '<\\/style');
|
|
156
|
+
}
|
|
157
|
+
// Final safety net: if normalization STILL detects danger, blank it out
|
|
158
|
+
// rather than emit something that could execute.
|
|
159
|
+
if (cssHasDanger(normalizeCssForScan(out))) return '';
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function neutralizeCss(css) {
|
|
164
|
+
if (!isString(css)) return '';
|
|
165
|
+
return neutralizeCssValue(css.slice(0, MAX_CSS), 'css');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function sanitizeStyleAttr(value) {
|
|
169
|
+
if (!isString(value)) return '';
|
|
170
|
+
return neutralizeCssValue(value, 'style');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Normalize a URL value for SAFE_URL testing: strip control chars / tabs /
|
|
174
|
+
// newlines / NUL that browsers ignore inside a scheme ('java\tscript:').
|
|
175
|
+
function normalizeUrl(value) {
|
|
176
|
+
if (!isString(value)) return '';
|
|
177
|
+
// remove all control chars and whitespace anywhere (defeats java\tscript:,
|
|
178
|
+
// leading spaces, embedded newlines) for the scheme test
|
|
179
|
+
return value.replace(/[\x00-\x20\x7f]+/g, '');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function isSafeUrl(value) {
|
|
183
|
+
return SAFE_URL.test(normalizeUrl(value));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// srcset is a comma-separated list of "<url> <descriptor>" candidates; every
|
|
187
|
+
// candidate URL must be SAFE.
|
|
188
|
+
function isSafeSrcset(value) {
|
|
189
|
+
if (!isString(value)) return false;
|
|
190
|
+
const candidates = value.split(',');
|
|
191
|
+
for (const cand of candidates) {
|
|
192
|
+
const url = cand.trim().split(/\s+/)[0];
|
|
193
|
+
if (!url) continue;
|
|
194
|
+
if (!isSafeUrl(url)) return false;
|
|
195
|
+
}
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Is this attribute name a URL-bearing one (after normalization)?
|
|
200
|
+
function isUrlAttr(name) {
|
|
201
|
+
const n = String(name).toLowerCase();
|
|
202
|
+
return URL_ATTRS.has(n);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Normalize an attribute name for the on* / namespaced checks: strip NUL and
|
|
206
|
+
// whitespace that browsers may tolerate around the name.
|
|
207
|
+
function normalizeAttrName(name) {
|
|
208
|
+
return String(name).replace(/[\x00-\x20\x7f]+/g, '').toLowerCase();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function getDOMParser() {
|
|
212
|
+
if (typeof DOMParser !== 'undefined') return new DOMParser();
|
|
213
|
+
throw new Error('Morph: no DOMParser available in this environment');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Whitelist-sanitize an HTML fragment. Returns a SAFE html string.
|
|
218
|
+
* Requires a DOMParser (browser, or jsdom in tests).
|
|
219
|
+
*/
|
|
220
|
+
function sanitizeHtml(html) {
|
|
221
|
+
if (!isString(html) || html.trim() === '') return '';
|
|
222
|
+
if (html.length > MAX_HTML) html = html.slice(0, MAX_HTML);
|
|
223
|
+
const parser = getDOMParser();
|
|
224
|
+
const doc = parser.parseFromString('<body>' + html + '</body>', 'text/html');
|
|
225
|
+
const body = doc.body;
|
|
226
|
+
sanitizeNode(body, doc);
|
|
227
|
+
return body.innerHTML;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function sanitizeNode(node, doc) {
|
|
231
|
+
const children = Array.from(node.childNodes);
|
|
232
|
+
for (const child of children) {
|
|
233
|
+
if (child.nodeType === 8) { node.removeChild(child); continue; } // comments
|
|
234
|
+
if (child.nodeType !== 1) continue; // keep text
|
|
235
|
+
|
|
236
|
+
// Foreign content (SVG / MathML): DROP element AND its whole subtree.
|
|
237
|
+
// None are in ALLOWED_TAGS, and unwrapping enables <svg><script>/mXSS and
|
|
238
|
+
// xlink:href=javascript: leakage. namespaceURI is the reliable signal.
|
|
239
|
+
const ns = child.namespaceURI;
|
|
240
|
+
if (ns === SVG_NS || ns === MATHML_NS) { node.removeChild(child); continue; }
|
|
241
|
+
|
|
242
|
+
const tag = child.tagName.toLowerCase();
|
|
243
|
+
|
|
244
|
+
// Drop script-like / parsing-trap elements WITH their subtree (their text
|
|
245
|
+
// is active or re-activates on reparse). Never unwrap these.
|
|
246
|
+
if (DROP_SUBTREE_TAGS.has(tag)) { node.removeChild(child); continue; }
|
|
247
|
+
|
|
248
|
+
// <template>: its real children live in a separate .content DocumentFragment
|
|
249
|
+
// (off the childNodes tree). Sanitize that fragment, lift the surviving
|
|
250
|
+
// children up into the document, then drop the (disallowed) <template>.
|
|
251
|
+
if (tag === 'template' && child.content) {
|
|
252
|
+
sanitizeNode(child.content, doc);
|
|
253
|
+
while (child.content.firstChild) node.insertBefore(child.content.firstChild, child);
|
|
254
|
+
// also handle any (rare) light-DOM children
|
|
255
|
+
sanitizeNode(child, doc);
|
|
256
|
+
while (child.firstChild) node.insertBefore(child.firstChild, child);
|
|
257
|
+
node.removeChild(child);
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (!ALLOWED_TAGS.has(tag)) {
|
|
262
|
+
// unwrap: keep sanitized children, drop the disallowed element itself.
|
|
263
|
+
sanitizeNode(child, doc);
|
|
264
|
+
const moved = [];
|
|
265
|
+
while (child.firstChild) {
|
|
266
|
+
const m = child.firstChild;
|
|
267
|
+
node.insertBefore(m, child);
|
|
268
|
+
moved.push(m);
|
|
269
|
+
}
|
|
270
|
+
node.removeChild(child);
|
|
271
|
+
// Re-run sanitizeNode on the moved children in their new home so any
|
|
272
|
+
// foreign/disallowed node that only became reachable after the unwrap is
|
|
273
|
+
// re-evaluated (defense against reparenting-based mXSS).
|
|
274
|
+
for (const m of moved) {
|
|
275
|
+
if (m.nodeType === 1) {
|
|
276
|
+
const mns = m.namespaceURI;
|
|
277
|
+
if (mns === SVG_NS || mns === MATHML_NS) { node.removeChild(m); continue; }
|
|
278
|
+
const mtag = m.tagName.toLowerCase();
|
|
279
|
+
if (DROP_SUBTREE_TAGS.has(mtag)) { node.removeChild(m); continue; }
|
|
280
|
+
if (!ALLOWED_TAGS.has(mtag)) {
|
|
281
|
+
sanitizeNode(m, doc);
|
|
282
|
+
while (m.firstChild) node.insertBefore(m.firstChild, m);
|
|
283
|
+
node.removeChild(m);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
// scrub attributes
|
|
290
|
+
for (const attr of Array.from(child.attributes)) {
|
|
291
|
+
const rawName = attr.name;
|
|
292
|
+
const name = rawName.toLowerCase();
|
|
293
|
+
// strip ALL namespaced attrs (xlink:href, xml:*, etc.) — they are never
|
|
294
|
+
// whitelisted and carry foreign-content URL/script vectors.
|
|
295
|
+
if (name.indexOf(':') !== -1) { child.removeAttribute(rawName); continue; }
|
|
296
|
+
// normalize for on* detection: remove NUL/whitespace browsers may tolerate
|
|
297
|
+
const normName = normalizeAttrName(rawName);
|
|
298
|
+
if (normName.startsWith('on') || !ALLOWED_ATTRS.has(name)) {
|
|
299
|
+
child.removeAttribute(rawName);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
if (name === 'href' || name === 'src') {
|
|
303
|
+
if (!isSafeUrl(attr.value)) { child.removeAttribute(rawName); continue; }
|
|
304
|
+
}
|
|
305
|
+
if (name === 'srcset') {
|
|
306
|
+
// not currently whitelisted, but if it ever is, guard every candidate.
|
|
307
|
+
if (!isSafeSrcset(attr.value)) { child.removeAttribute(rawName); continue; }
|
|
308
|
+
}
|
|
309
|
+
if (name === 'style') {
|
|
310
|
+
child.setAttribute('style', sanitizeStyleAttr(attr.value));
|
|
311
|
+
}
|
|
312
|
+
if (name === 'target' && child.getAttribute('target') === '_blank') {
|
|
313
|
+
child.setAttribute('rel', 'noopener noreferrer');
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
sanitizeNode(child, doc);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ---- validation ----------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Validate + sanitize a raw transform object from the model.
|
|
324
|
+
* Returns { ok, transform, errors[], skipped[] }.
|
|
325
|
+
* Invalid ops are DROPPED (not fatal) and reported in `skipped`.
|
|
326
|
+
* Pure except sanitizeHtml needs a DOMParser; callers in Node tests provide jsdom.
|
|
327
|
+
*/
|
|
328
|
+
function validateTransform(raw) {
|
|
329
|
+
const errors = [];
|
|
330
|
+
const skipped = [];
|
|
331
|
+
|
|
332
|
+
if (!raw || typeof raw !== 'object') {
|
|
333
|
+
return { ok: false, transform: null, errors: ['transform is not an object'], skipped };
|
|
334
|
+
}
|
|
335
|
+
if (!Array.isArray(raw.ops)) {
|
|
336
|
+
return { ok: false, transform: null, errors: ['transform.ops must be an array'], skipped };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const cleanOps = [];
|
|
340
|
+
raw.ops.forEach((op, i) => {
|
|
341
|
+
const reason = opError(op);
|
|
342
|
+
if (reason) { skipped.push({ index: i, op: op && op.op, reason }); return; }
|
|
343
|
+
cleanOps.push(sanitizeOp(op));
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
const transform = {
|
|
347
|
+
version: SCHEMA_VERSION,
|
|
348
|
+
explanation: isString(raw.explanation) ? raw.explanation.slice(0, MAX_EXPLANATION) : '',
|
|
349
|
+
ops: cleanOps,
|
|
350
|
+
};
|
|
351
|
+
return { ok: cleanOps.length > 0, transform, errors, skipped };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function opError(op) {
|
|
355
|
+
if (!op || typeof op !== 'object') return 'op is not an object';
|
|
356
|
+
const spec = OP_SPECS[op.op];
|
|
357
|
+
if (!spec) return 'unknown op: ' + op.op;
|
|
358
|
+
for (const f of spec.fields) {
|
|
359
|
+
if (!isString(op[f])) return `missing/invalid field "${f}"`;
|
|
360
|
+
}
|
|
361
|
+
if (op.op === 'move' || op.op === 'addElement') {
|
|
362
|
+
if (!POSITIONS.includes(op.position)) return 'invalid position';
|
|
363
|
+
}
|
|
364
|
+
if (spec.object && (typeof op[spec.object] !== 'object' || op[spec.object] === null)) {
|
|
365
|
+
return `missing/invalid object "${spec.object}"`;
|
|
366
|
+
}
|
|
367
|
+
// Fail-fast guard for obviously malformed/oversized selectors. Does NOT
|
|
368
|
+
// reject valid CSS selectors used elsewhere ('.promoted', '#content',
|
|
369
|
+
// 'article.post', '*'); only catches non-string / absurdly long values.
|
|
370
|
+
if (spec.fields.includes('selector') && !isValidSelectorShape(op.selector)) {
|
|
371
|
+
return 'invalid selector';
|
|
372
|
+
}
|
|
373
|
+
if (op.op === 'move' && !isValidSelectorShape(op.target)) {
|
|
374
|
+
return 'invalid target selector';
|
|
375
|
+
}
|
|
376
|
+
// setAttr: reject event-handler attribute NAMES and enforce the SAME
|
|
377
|
+
// attribute whitelist that sanitizeHtml uses, so an attr that would be
|
|
378
|
+
// stripped inside addElement HTML (srcdoc, formaction, action, ...) cannot
|
|
379
|
+
// be smuggled in via setAttr onto live elements.
|
|
380
|
+
if (op.op === 'setAttr') {
|
|
381
|
+
const n = normalizeAttrName(op.name);
|
|
382
|
+
if (n.startsWith('on')) return 'event-handler attr blocked';
|
|
383
|
+
if (!ALLOWED_ATTRS.has(n)) return 'attr not allowed: ' + n;
|
|
384
|
+
}
|
|
385
|
+
// renderData: chart type + position must be from the fixed sets. The op has
|
|
386
|
+
// two forms; either way the actual numbers come from caller-attached data at
|
|
387
|
+
// apply time, never the model.
|
|
388
|
+
// - source form (SDK): names a vendor-registered source + a query DESCRIPTION;
|
|
389
|
+
// trusted code resolves rows out-of-band.
|
|
390
|
+
// - legacy form: dataKey/x/y field mappings over caller-attached records.
|
|
391
|
+
if (op.op === 'renderData') {
|
|
392
|
+
if (!POSITIONS.includes(op.position)) return 'invalid position';
|
|
393
|
+
if (op.chart !== 'bar' && op.chart !== 'line' && op.chart !== 'stat') return 'invalid chart type';
|
|
394
|
+
if (op.source !== undefined) {
|
|
395
|
+
if (!isString(op.source) || !op.source) return 'invalid source';
|
|
396
|
+
if (!op.query || typeof op.query !== 'object' || Array.isArray(op.query)) return 'invalid query';
|
|
397
|
+
} else {
|
|
398
|
+
for (const f of ['dataKey', 'x', 'y']) {
|
|
399
|
+
if (!isString(op[f])) return `missing/invalid field "${f}"`;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Lightweight selector sanity check. We deliberately accept anything that is a
|
|
407
|
+
// reasonably-sized string and reject only clearly-bogus inputs (empty, huge,
|
|
408
|
+
// or containing characters that can never appear in a CSS selector). The
|
|
409
|
+
// authoritative validity check still happens at apply time via
|
|
410
|
+
// querySelectorAll (which throws -> caught per-op), so this never changes the
|
|
411
|
+
// "bad selector fails that op only" contract — it just fails such ops earlier.
|
|
412
|
+
function isValidSelectorShape(sel) {
|
|
413
|
+
if (!isString(sel)) return false;
|
|
414
|
+
if (sel.length === 0 || sel.length > MAX_SELECTOR) return false;
|
|
415
|
+
// NOTE: we intentionally do NOT reject angle brackets or other syntactically
|
|
416
|
+
// invalid selectors here. The existing contract ("bad selector fails that op
|
|
417
|
+
// only") requires a malformed selector like '<<<invalid>>>' to PASS validate
|
|
418
|
+
// and FAIL at apply time via querySelectorAll's throw. We only fail fast on
|
|
419
|
+
// non-string / empty / absurdly long values, which can never be valid.
|
|
420
|
+
return true;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Bound + type-check a self-healing anchors descriptor from a (semi-trusted,
|
|
424
|
+
// locally-stored) recipe. Drops unknown fields and caps sizes.
|
|
425
|
+
function sanitizeAnchors(arr) {
|
|
426
|
+
const out = [];
|
|
427
|
+
for (const a of arr.slice(0, 10)) {
|
|
428
|
+
if (!a || typeof a !== 'object') continue;
|
|
429
|
+
const s = {};
|
|
430
|
+
if (typeof a.tag === 'string') s.tag = a.tag.slice(0, 40).toLowerCase();
|
|
431
|
+
if (typeof a.id === 'string') s.id = a.id.slice(0, 200);
|
|
432
|
+
if (typeof a.role === 'string') s.role = a.role.slice(0, 80);
|
|
433
|
+
if (typeof a.aria === 'string') s.aria = a.aria.slice(0, 200);
|
|
434
|
+
if (typeof a.text === 'string') s.text = a.text.slice(0, 120);
|
|
435
|
+
if (Array.isArray(a.classes)) s.classes = a.classes.filter((c) => typeof c === 'string').slice(0, 8).map((c) => c.slice(0, 80));
|
|
436
|
+
if (typeof a.nthOfType === 'number' && isFinite(a.nthOfType)) s.nthOfType = a.nthOfType;
|
|
437
|
+
out.push(s);
|
|
438
|
+
}
|
|
439
|
+
return out;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Bound a model-supplied query DESCRIPTION (renderData source form). Whitelist
|
|
443
|
+
// keys only; the SDK's registry + query engine do the semantic (schema)
|
|
444
|
+
// validation — this just guarantees the op stays small, inert JSON with no
|
|
445
|
+
// surprise keys.
|
|
446
|
+
const QUERY_KEYS = ['filter', 'dateField', 'since', 'until', 'groupBy', 'aggregate', 'limit'];
|
|
447
|
+
function sanitizeQuery(q) {
|
|
448
|
+
const out = {};
|
|
449
|
+
for (const k of QUERY_KEYS) {
|
|
450
|
+
const v = q[k];
|
|
451
|
+
if (v === undefined) continue;
|
|
452
|
+
if (k === 'filter' && v && typeof v === 'object' && !Array.isArray(v)) {
|
|
453
|
+
const f = {};
|
|
454
|
+
for (const fk of Object.keys(v).slice(0, 10)) {
|
|
455
|
+
const fv = v[fk];
|
|
456
|
+
if (isString(fv) || typeof fv === 'number') {
|
|
457
|
+
f[String(fk).slice(0, 80)] = isString(fv) ? fv.slice(0, 200) : fv;
|
|
458
|
+
} else if (Array.isArray(fv)) {
|
|
459
|
+
f[String(fk).slice(0, 80)] = fv.slice(0, 20)
|
|
460
|
+
.filter((x) => isString(x) || typeof x === 'number')
|
|
461
|
+
.map((x) => (isString(x) ? x.slice(0, 200) : x));
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
out.filter = f;
|
|
465
|
+
} else if (k === 'aggregate' && v && typeof v === 'object' && !Array.isArray(v)) {
|
|
466
|
+
const a = {};
|
|
467
|
+
if (isString(v.fn)) a.fn = v.fn.slice(0, 20);
|
|
468
|
+
if (isString(v.field)) a.field = v.field.slice(0, 80);
|
|
469
|
+
out.aggregate = a;
|
|
470
|
+
} else if (k === 'limit') {
|
|
471
|
+
if (typeof v === 'number' && isFinite(v)) out.limit = v;
|
|
472
|
+
} else if (isString(v)) {
|
|
473
|
+
out[k] = v.slice(0, 80);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return out;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function sanitizeOp(op) {
|
|
480
|
+
const out = { op: op.op };
|
|
481
|
+
const spec = OP_SPECS[op.op];
|
|
482
|
+
for (const f of spec.fields) out[f] = op[f];
|
|
483
|
+
if (op.op === 'injectCSS') out.css = neutralizeCss(op.css);
|
|
484
|
+
if (op.op === 'addElement') {
|
|
485
|
+
out.html = sanitizeHtml(String(op.html).slice(0, MAX_HTML));
|
|
486
|
+
// Optional action binding (SDK): bounded inert data. Trusted code decides
|
|
487
|
+
// at apply time whether anything listens (opts.onAction) — never the model.
|
|
488
|
+
if (isString(op.action) && op.action) {
|
|
489
|
+
out.action = op.action.slice(0, 80);
|
|
490
|
+
const args = {};
|
|
491
|
+
if (op.args && typeof op.args === 'object' && !Array.isArray(op.args)) {
|
|
492
|
+
for (const k of Object.keys(op.args).slice(0, 20)) {
|
|
493
|
+
const v = op.args[k];
|
|
494
|
+
if (isString(v)) args[String(k).slice(0, 80)] = v.slice(0, 500);
|
|
495
|
+
else if (typeof v === 'number' && isFinite(v)) args[String(k).slice(0, 80)] = v;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
out.args = args;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
if (op.op === 'setStyle') {
|
|
502
|
+
const style = {};
|
|
503
|
+
for (const k of Object.keys(op.style)) {
|
|
504
|
+
if (isString(op.style[k]) || typeof op.style[k] === 'number') {
|
|
505
|
+
style[k] = sanitizeStyleAttr(String(op.style[k]));
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
out.style = style;
|
|
509
|
+
}
|
|
510
|
+
if (op.op === 'setText') out.text = String(op.text).slice(0, MAX_TEXT);
|
|
511
|
+
if (op.op === 'renderData') {
|
|
512
|
+
out.title = isString(op.title) ? op.title.slice(0, MAX_LABEL) : '';
|
|
513
|
+
if (op.source !== undefined) {
|
|
514
|
+
out.source = String(op.source).slice(0, 80);
|
|
515
|
+
out.query = sanitizeQuery(op.query);
|
|
516
|
+
} else {
|
|
517
|
+
// Legacy form: dataKey/x/y are no longer in spec.fields (they're only
|
|
518
|
+
// required for THIS form), so copy them explicitly.
|
|
519
|
+
out.dataKey = op.dataKey; out.x = op.x; out.y = op.y;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
if (op.op === 'setAttr') {
|
|
523
|
+
// Name is already whitelisted in opError. Sanitize the VALUE for
|
|
524
|
+
// URL-bearing attributes and for the style attribute (apply-side never
|
|
525
|
+
// checks values), matching how sanitizeHtml treats the same attributes.
|
|
526
|
+
if (isUrlAttr(out.name)) {
|
|
527
|
+
const ok = out.name.toLowerCase() === 'srcset' ? isSafeSrcset(out.value) : isSafeUrl(out.value);
|
|
528
|
+
if (!ok) out.value = '';
|
|
529
|
+
}
|
|
530
|
+
if (normalizeAttrName(out.name) === 'style') out.value = sanitizeStyleAttr(String(out.value));
|
|
531
|
+
}
|
|
532
|
+
// Preserve a self-healing descriptor if the (saved) op carries one.
|
|
533
|
+
if (Array.isArray(op.anchors)) out.anchors = sanitizeAnchors(op.anchors);
|
|
534
|
+
return out;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// ---- application (needs a DOM) -------------------------------------------
|
|
538
|
+
|
|
539
|
+
const STYLE_GROUP_ATTR = 'data-morph-style';
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Apply a (validated) transform to `doc`. Returns a handle:
|
|
543
|
+
* { undos: [fn...], applied: n, failed: n, errors: [...] }
|
|
544
|
+
* Each op is wrapped in try/catch so one bad op never aborts the rest.
|
|
545
|
+
*/
|
|
546
|
+
function applyTransform(transform, doc, opts) {
|
|
547
|
+
doc = doc || (typeof document !== 'undefined' ? document : null);
|
|
548
|
+
if (!doc) throw new Error('Morph: applyTransform needs a document');
|
|
549
|
+
const groupId = (opts && opts.groupId) || 'morph';
|
|
550
|
+
const data = (opts && opts.data) || {};
|
|
551
|
+
const undos = [];
|
|
552
|
+
let applied = 0, failed = 0;
|
|
553
|
+
const errors = [];
|
|
554
|
+
|
|
555
|
+
const onAction = (opts && typeof opts.onAction === 'function') ? opts.onAction : null;
|
|
556
|
+
|
|
557
|
+
for (const op of transform.ops) {
|
|
558
|
+
try {
|
|
559
|
+
const undo = applyOp(op, doc, groupId, data, onAction);
|
|
560
|
+
// null == the op matched nothing (e.g. a selector whose targets haven't
|
|
561
|
+
// rendered yet): a MISS, not an applied op and not a failure. This keeps
|
|
562
|
+
// `applied` honest so callers don't set a phantom "it's applied" sentinel.
|
|
563
|
+
if (undo === null) continue;
|
|
564
|
+
if (undo) undos.push(undo);
|
|
565
|
+
applied++;
|
|
566
|
+
} catch (e) {
|
|
567
|
+
failed++;
|
|
568
|
+
errors.push({ op: op.op, message: String(e && e.message || e) });
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
return { undos, applied, failed, errors };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function undoTransform(handle) {
|
|
575
|
+
if (!handle || !Array.isArray(handle.undos)) return;
|
|
576
|
+
// reverse order so structural moves unwind cleanly
|
|
577
|
+
for (let i = handle.undos.length - 1; i >= 0; i--) {
|
|
578
|
+
try { handle.undos[i](); } catch (_) { /* best effort */ }
|
|
579
|
+
}
|
|
580
|
+
handle.undos.length = 0;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function selectAll(doc, selector) {
|
|
584
|
+
// throws on invalid selector -> caught by applyTransform
|
|
585
|
+
return Array.from(doc.querySelectorAll(selector));
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Element resolution for applyOp. An op carrying an `anchors` descriptor uses
|
|
589
|
+
// the self-healing resolver (survives selector rot). An op WITHOUT anchors keeps
|
|
590
|
+
// the exact legacy behavior: selectAll throws on an invalid selector so that op
|
|
591
|
+
// fails (the "bad selector fails that op only" contract) — nothing changes.
|
|
592
|
+
function resolveEls(doc, op) {
|
|
593
|
+
if (Array.isArray(op.anchors) && op.anchors.length) return resolveSelector(doc, op).els;
|
|
594
|
+
return selectAll(doc, op.selector);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function applyOp(op, doc, groupId, data, onAction) {
|
|
598
|
+
switch (op.op) {
|
|
599
|
+
case 'injectCSS': {
|
|
600
|
+
const style = doc.createElement('style');
|
|
601
|
+
style.setAttribute(STYLE_GROUP_ATTR, groupId);
|
|
602
|
+
style.textContent = op.css; // textContent: no breakout
|
|
603
|
+
(doc.head || doc.documentElement).appendChild(style);
|
|
604
|
+
return () => style.remove();
|
|
605
|
+
}
|
|
606
|
+
case 'hide':
|
|
607
|
+
case 'show': {
|
|
608
|
+
const val = op.op === 'hide' ? 'none' : '';
|
|
609
|
+
const els = resolveEls(doc, op);
|
|
610
|
+
if (!els.length) return null; // matched nothing — a miss, not an applied op
|
|
611
|
+
const prev = els.map((el) => el.style.display);
|
|
612
|
+
els.forEach((el) => {
|
|
613
|
+
if (op.op === 'show' && val === '') el.style.removeProperty('display');
|
|
614
|
+
else el.style.setProperty('display', val, 'important');
|
|
615
|
+
});
|
|
616
|
+
return () => els.forEach((el, i) => { el.style.display = prev[i]; });
|
|
617
|
+
}
|
|
618
|
+
case 'remove': {
|
|
619
|
+
const els = resolveEls(doc, op);
|
|
620
|
+
if (!els.length) return null; // nothing to remove yet — a miss, not applied
|
|
621
|
+
const markers = els.map((el) => {
|
|
622
|
+
const marker = doc.createComment('morph-removed');
|
|
623
|
+
el.parentNode && el.parentNode.replaceChild(marker, el);
|
|
624
|
+
return { el, marker };
|
|
625
|
+
});
|
|
626
|
+
return () => markers.forEach(({ el, marker }) => {
|
|
627
|
+
marker.parentNode && marker.parentNode.replaceChild(el, marker);
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
case 'setStyle': {
|
|
631
|
+
const els = resolveEls(doc, op);
|
|
632
|
+
if (!els.length) return null;
|
|
633
|
+
const keys = Object.keys(op.style);
|
|
634
|
+
const prev = els.map((el) => keys.map((k) => el.style.getPropertyValue(k)));
|
|
635
|
+
els.forEach((el) => keys.forEach((k) => el.style.setProperty(k, op.style[k], 'important')));
|
|
636
|
+
return () => els.forEach((el, i) => keys.forEach((k, j) => {
|
|
637
|
+
if (prev[i][j]) el.style.setProperty(k, prev[i][j]); else el.style.removeProperty(k);
|
|
638
|
+
}));
|
|
639
|
+
}
|
|
640
|
+
case 'setText': {
|
|
641
|
+
// Refuse to write into active-content / reparse-trap elements: setting the
|
|
642
|
+
// textContent of a live <style> IS the stylesheet (arbitrary CSS injection
|
|
643
|
+
// that bypasses neutralizeCss), and <script>/<title>/<textarea> are mXSS
|
|
644
|
+
// reparse traps. These are never legitimate setText targets. Skip them;
|
|
645
|
+
// apply to the safe elements only.
|
|
646
|
+
const els = resolveEls(doc, op)
|
|
647
|
+
.filter((el) => !DROP_SUBTREE_TAGS.has(el.tagName.toLowerCase()));
|
|
648
|
+
if (!els.length) return null;
|
|
649
|
+
const prev = els.map((el) => el.textContent);
|
|
650
|
+
els.forEach((el) => { el.textContent = op.text; });
|
|
651
|
+
return () => els.forEach((el, i) => { el.textContent = prev[i]; });
|
|
652
|
+
}
|
|
653
|
+
case 'setAttr': {
|
|
654
|
+
const els = resolveEls(doc, op);
|
|
655
|
+
if (op.name.toLowerCase().startsWith('on')) throw new Error('event-handler attr blocked');
|
|
656
|
+
if (!els.length) return null;
|
|
657
|
+
const prev = els.map((el) => (el.hasAttribute(op.name) ? el.getAttribute(op.name) : null));
|
|
658
|
+
els.forEach((el) => el.setAttribute(op.name, op.value));
|
|
659
|
+
return () => els.forEach((el, i) => {
|
|
660
|
+
if (prev[i] === null) el.removeAttribute(op.name); else el.setAttribute(op.name, prev[i]);
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
case 'addClass':
|
|
664
|
+
case 'removeClass': {
|
|
665
|
+
const els = resolveEls(doc, op);
|
|
666
|
+
if (!els.length) return null;
|
|
667
|
+
const had = els.map((el) => el.classList.contains(op.class));
|
|
668
|
+
els.forEach((el) => el.classList[op.op === 'addClass' ? 'add' : 'remove'](op.class));
|
|
669
|
+
return () => els.forEach((el, i) => {
|
|
670
|
+
if (op.op === 'addClass' && !had[i]) el.classList.remove(op.class);
|
|
671
|
+
if (op.op === 'removeClass' && had[i]) el.classList.add(op.class);
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
case 'move': {
|
|
675
|
+
const els = resolveEls(doc, op);
|
|
676
|
+
const target = doc.querySelector(op.target);
|
|
677
|
+
if (!target) throw new Error('move target not found: ' + op.target);
|
|
678
|
+
const origins = els.map((el) => ({ el, parent: el.parentNode, next: el.nextSibling }));
|
|
679
|
+
els.forEach((el) => insertRelative(el, target, op.position));
|
|
680
|
+
return () => origins.forEach(({ el, parent, next }) => {
|
|
681
|
+
if (parent) parent.insertBefore(el, next);
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
case 'addElement': {
|
|
685
|
+
const target = doc.querySelector(op.target);
|
|
686
|
+
if (!target) throw new Error('addElement target not found: ' + op.target);
|
|
687
|
+
const tpl = doc.createElement('template');
|
|
688
|
+
tpl.innerHTML = op.html; // already sanitized
|
|
689
|
+
const nodes = Array.from(tpl.content ? tpl.content.childNodes : tpl.childNodes);
|
|
690
|
+
const wrapper = doc.createElement('div');
|
|
691
|
+
wrapper.setAttribute('data-morph-added', groupId);
|
|
692
|
+
nodes.forEach((n) => wrapper.appendChild(n));
|
|
693
|
+
insertRelative(wrapper, target, op.position);
|
|
694
|
+
// Action binding: only when the CALLER provided an invoker (the SDK).
|
|
695
|
+
// The model only NAMED the action; a real user click is the sole trigger.
|
|
696
|
+
if (op.action && onAction) {
|
|
697
|
+
wrapper.querySelectorAll('button').forEach((btn) => {
|
|
698
|
+
btn.addEventListener('click', () => onAction(op.action, op.args || {}));
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
return () => wrapper.remove();
|
|
702
|
+
}
|
|
703
|
+
case 'renderData': {
|
|
704
|
+
const target = doc.querySelector(op.target);
|
|
705
|
+
if (!target) throw new Error('renderData target not found: ' + op.target);
|
|
706
|
+
const svg = buildChart(doc, op, data); // built by US from attached data
|
|
707
|
+
const wrapper = doc.createElement('div');
|
|
708
|
+
wrapper.setAttribute('data-morph-added', groupId);
|
|
709
|
+
wrapper.appendChild(svg);
|
|
710
|
+
insertRelative(wrapper, target, op.position);
|
|
711
|
+
return () => wrapper.remove();
|
|
712
|
+
}
|
|
713
|
+
default:
|
|
714
|
+
throw new Error('unhandled op: ' + op.op);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function insertRelative(node, target, position) {
|
|
719
|
+
switch (position) {
|
|
720
|
+
case 'before': target.parentNode.insertBefore(node, target); break;
|
|
721
|
+
case 'after': target.parentNode.insertBefore(node, target.nextSibling); break;
|
|
722
|
+
case 'prepend': target.insertBefore(node, target.firstChild); break;
|
|
723
|
+
case 'append': target.appendChild(node); break;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// ---- trusted chart rendering (renderData) --------------------------------
|
|
728
|
+
// Built entirely by US from typed records the CALLER attached (opts.data).
|
|
729
|
+
// The model supplies only the chart type and field names; every value is
|
|
730
|
+
// coerced through Number() before it touches a numeric attribute, and every
|
|
731
|
+
// label/title is set via textContent. No model- or data-supplied string is
|
|
732
|
+
// ever parsed as markup (createElementNS only, never innerHTML).
|
|
733
|
+
function svgEl(doc, name, attrs) {
|
|
734
|
+
const el = doc.createElementNS(SVG_NS, name);
|
|
735
|
+
for (const k of Object.keys(attrs)) el.setAttribute(k, String(attrs[k]));
|
|
736
|
+
return el;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function coerceNum(v) { const n = Number(v); return Number.isFinite(n) ? n : 0; }
|
|
740
|
+
|
|
741
|
+
function formatStat(n) {
|
|
742
|
+
if (Math.abs(n) >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, '') + 'M';
|
|
743
|
+
if (Math.abs(n) >= 1e3) return (n / 1e3).toFixed(1).replace(/\.0$/, '') + 'k';
|
|
744
|
+
return Number.isInteger(n) ? String(n) : n.toFixed(2);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function buildChart(doc, op, data) {
|
|
748
|
+
let points;
|
|
749
|
+
if (op.source !== undefined) {
|
|
750
|
+
// Source form: rows were resolved by TRUSTED code (registry + query
|
|
751
|
+
// engine) and attached as data[source] = [{label, value}].
|
|
752
|
+
const rows = (data && typeof data === 'object' && Array.isArray(data[op.source]))
|
|
753
|
+
? data[op.source].slice(0, MAX_CHART_POINTS) : null;
|
|
754
|
+
if (!rows || rows.length === 0) throw new Error('renderData: no rows for source ' + op.source);
|
|
755
|
+
points = rows.map((r) => ({
|
|
756
|
+
label: String(r && r.label != null ? r.label : '').slice(0, 40),
|
|
757
|
+
value: coerceNum(r && r.value),
|
|
758
|
+
}));
|
|
759
|
+
} else {
|
|
760
|
+
const ds = (data && typeof data === 'object') ? data[op.dataKey] : null;
|
|
761
|
+
const records = (ds && Array.isArray(ds.records)) ? ds.records.slice(0, MAX_CHART_POINTS) : null;
|
|
762
|
+
if (!records || records.length === 0) throw new Error('renderData: no records for ' + op.dataKey);
|
|
763
|
+
points = records.map((r) => ({
|
|
764
|
+
label: String((r && r[op.x] != null) ? r[op.x] : '').slice(0, 40),
|
|
765
|
+
value: coerceNum(r && r[op.y]),
|
|
766
|
+
}));
|
|
767
|
+
}
|
|
768
|
+
if (op.chart === 'stat') {
|
|
769
|
+
// Single big number + caption. Text nodes only — same contract as charts.
|
|
770
|
+
const stat = svgEl(doc, 'svg', {
|
|
771
|
+
width: 280, height: 120, viewBox: '0 0 280 120',
|
|
772
|
+
'data-morph-chart': 'stat', 'font-family': 'sans-serif',
|
|
773
|
+
});
|
|
774
|
+
const val = svgEl(doc, 'text', { x: 20, y: 64, 'font-size': 40, 'font-weight': 'bold', fill: 'currentColor' });
|
|
775
|
+
val.textContent = formatStat(points[0].value); // text only — never markup
|
|
776
|
+
stat.appendChild(val);
|
|
777
|
+
const cap = svgEl(doc, 'text', { x: 20, y: 96, 'font-size': 13, fill: 'currentColor', opacity: '0.7' });
|
|
778
|
+
cap.textContent = op.title || points[0].label; // text only — never markup
|
|
779
|
+
stat.appendChild(cap);
|
|
780
|
+
return stat;
|
|
781
|
+
}
|
|
782
|
+
const maxV = Math.max(1, ...points.map((p) => p.value));
|
|
783
|
+
const n = points.length;
|
|
784
|
+
const innerW = CHART_W - 2 * CHART_PAD;
|
|
785
|
+
const top = op.title ? 34 : 12;
|
|
786
|
+
const bottom = CHART_H - CHART_PAD;
|
|
787
|
+
const plotH = bottom - top;
|
|
788
|
+
|
|
789
|
+
const svg = svgEl(doc, 'svg', {
|
|
790
|
+
width: CHART_W, height: CHART_H, viewBox: '0 0 ' + CHART_W + ' ' + CHART_H,
|
|
791
|
+
'data-morph-chart': op.chart, 'font-family': 'sans-serif',
|
|
792
|
+
});
|
|
793
|
+
if (op.title) {
|
|
794
|
+
const t = svgEl(doc, 'text', { x: CHART_PAD, y: 22, 'font-size': 16, fill: 'currentColor' });
|
|
795
|
+
t.textContent = op.title; // text only — never markup
|
|
796
|
+
svg.appendChild(t);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
if (op.chart === 'line') {
|
|
800
|
+
const step = n > 1 ? innerW / (n - 1) : 0;
|
|
801
|
+
const coords = points.map((p, i) =>
|
|
802
|
+
(CHART_PAD + i * step).toFixed(1) + ',' + (bottom - (p.value / maxV) * plotH).toFixed(1)
|
|
803
|
+
).join(' ');
|
|
804
|
+
svg.appendChild(svgEl(doc, 'polyline', { points: coords, fill: 'none', stroke: 'currentColor', 'stroke-width': 2 }));
|
|
805
|
+
} else {
|
|
806
|
+
const bw = innerW / n;
|
|
807
|
+
points.forEach((p, i) => {
|
|
808
|
+
const h = (p.value / maxV) * plotH;
|
|
809
|
+
svg.appendChild(svgEl(doc, 'rect', {
|
|
810
|
+
x: (CHART_PAD + i * bw + bw * 0.1).toFixed(1), y: (bottom - h).toFixed(1),
|
|
811
|
+
width: (bw * 0.8).toFixed(1), height: h.toFixed(1), fill: 'currentColor',
|
|
812
|
+
}));
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// x-axis labels (text only)
|
|
817
|
+
points.forEach((p, i) => {
|
|
818
|
+
const x = op.chart === 'line'
|
|
819
|
+
? CHART_PAD + (n > 1 ? innerW / (n - 1) : 0) * i
|
|
820
|
+
: CHART_PAD + (innerW / n) * i + (innerW / n) / 2;
|
|
821
|
+
const lab = svgEl(doc, 'text', { x: x.toFixed(1), y: CHART_H - 14, 'font-size': 10, fill: 'currentColor', 'text-anchor': 'middle' });
|
|
822
|
+
lab.textContent = p.label; // text only — never markup
|
|
823
|
+
svg.appendChild(lab);
|
|
824
|
+
});
|
|
825
|
+
return svg;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// ---- reconcile: idempotent re-assertion (survive SPA re-renders) ---------
|
|
829
|
+
// Re-applies each element-targeting op ONLY to matching nodes that don't yet
|
|
830
|
+
// carry that op's marker (the freshly-rendered ones), and re-adds a removed
|
|
831
|
+
// injectCSS <style>. Safe to call repeatedly. Returns { reapplied, undos };
|
|
832
|
+
// reapplied counts ops that touched >=1 new node (0 == stable — drives a hard
|
|
833
|
+
// cap in the caller so a constantly-mutating page can't spin the CPU).
|
|
834
|
+
const RECON_ATTR = 'data-morph-r'; // space-separated op tokens on touched els
|
|
835
|
+
const RECON_CSS_ATTR = 'data-morph-rcss'; // marks an injected style tag (value = token)
|
|
836
|
+
|
|
837
|
+
function reconHas(el, token) {
|
|
838
|
+
return (el.getAttribute(RECON_ATTR) || '').split(/\s+/).indexOf(token) !== -1;
|
|
839
|
+
}
|
|
840
|
+
function reconAdd(el, token) {
|
|
841
|
+
const v = el.getAttribute(RECON_ATTR) || '';
|
|
842
|
+
el.setAttribute(RECON_ATTR, v ? v + ' ' + token : token);
|
|
843
|
+
}
|
|
844
|
+
function reconDel(el, token) {
|
|
845
|
+
const v = (el.getAttribute(RECON_ATTR) || '').split(/\s+/).filter((t) => t && t !== token);
|
|
846
|
+
if (v.length) el.setAttribute(RECON_ATTR, v.join(' ')); else el.removeAttribute(RECON_ATTR);
|
|
847
|
+
}
|
|
848
|
+
function unmarked(doc, selector, token) {
|
|
849
|
+
return selectAll(doc, selector).filter((el) => !reconHas(el, token));
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function reconcile(transform, doc, opts) {
|
|
853
|
+
doc = doc || (typeof document !== 'undefined' ? document : null);
|
|
854
|
+
if (!doc) throw new Error('Morph: reconcile needs a document');
|
|
855
|
+
if (!transform || !Array.isArray(transform.ops)) return { reapplied: 0, undos: [] };
|
|
856
|
+
const groupId = (opts && opts.groupId) || 'morph';
|
|
857
|
+
const undos = [];
|
|
858
|
+
let reapplied = 0;
|
|
859
|
+
transform.ops.forEach((op, i) => {
|
|
860
|
+
try {
|
|
861
|
+
const r = reassertOp(op, doc, groupId, i);
|
|
862
|
+
if (r && r.touched > 0) { reapplied++; if (r.undo) undos.push(r.undo); }
|
|
863
|
+
} catch (_) { /* per-op safety — never break the page */ }
|
|
864
|
+
});
|
|
865
|
+
return { reapplied, undos };
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function reassertOp(op, doc, groupId, idx) {
|
|
869
|
+
const token = groupId + ':' + idx;
|
|
870
|
+
switch (op.op) {
|
|
871
|
+
case 'injectCSS': {
|
|
872
|
+
if (doc.querySelector('style[' + RECON_CSS_ATTR + '="' + token + '"]')) return { touched: 0 };
|
|
873
|
+
const style = doc.createElement('style');
|
|
874
|
+
style.setAttribute(RECON_CSS_ATTR, token);
|
|
875
|
+
style.setAttribute(STYLE_GROUP_ATTR, groupId);
|
|
876
|
+
style.textContent = op.css; // textContent: no breakout
|
|
877
|
+
(doc.head || doc.documentElement).appendChild(style);
|
|
878
|
+
return { touched: 1, undo: () => style.remove() };
|
|
879
|
+
}
|
|
880
|
+
case 'hide':
|
|
881
|
+
case 'show': {
|
|
882
|
+
const els = unmarked(doc, op.selector, token);
|
|
883
|
+
if (!els.length) return { touched: 0 };
|
|
884
|
+
const val = op.op === 'hide' ? 'none' : '';
|
|
885
|
+
const prev = els.map((el) => el.style.display);
|
|
886
|
+
els.forEach((el) => {
|
|
887
|
+
if (op.op === 'show' && val === '') el.style.removeProperty('display');
|
|
888
|
+
else el.style.setProperty('display', val, 'important');
|
|
889
|
+
reconAdd(el, token);
|
|
890
|
+
});
|
|
891
|
+
return { touched: els.length, undo: () => els.forEach((el, i) => { el.style.display = prev[i]; reconDel(el, token); }) };
|
|
892
|
+
}
|
|
893
|
+
case 'setStyle': {
|
|
894
|
+
const els = unmarked(doc, op.selector, token);
|
|
895
|
+
if (!els.length) return { touched: 0 };
|
|
896
|
+
const keys = Object.keys(op.style);
|
|
897
|
+
const prev = els.map((el) => keys.map((k) => el.style.getPropertyValue(k)));
|
|
898
|
+
els.forEach((el) => { keys.forEach((k) => el.style.setProperty(k, op.style[k], 'important')); reconAdd(el, token); });
|
|
899
|
+
return {
|
|
900
|
+
touched: els.length,
|
|
901
|
+
undo: () => els.forEach((el, i) => { keys.forEach((k, j) => { if (prev[i][j]) el.style.setProperty(k, prev[i][j]); else el.style.removeProperty(k); }); reconDel(el, token); }),
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
case 'setText': {
|
|
905
|
+
const els = unmarked(doc, op.selector, token).filter((el) => !DROP_SUBTREE_TAGS.has(el.tagName.toLowerCase()));
|
|
906
|
+
if (!els.length) return { touched: 0 };
|
|
907
|
+
const prev = els.map((el) => el.textContent);
|
|
908
|
+
els.forEach((el) => { el.textContent = op.text; reconAdd(el, token); });
|
|
909
|
+
return { touched: els.length, undo: () => els.forEach((el, i) => { el.textContent = prev[i]; reconDel(el, token); }) };
|
|
910
|
+
}
|
|
911
|
+
case 'setAttr': {
|
|
912
|
+
if (op.name.toLowerCase().startsWith('on')) return { touched: 0 };
|
|
913
|
+
const els = unmarked(doc, op.selector, token);
|
|
914
|
+
if (!els.length) return { touched: 0 };
|
|
915
|
+
const prev = els.map((el) => (el.hasAttribute(op.name) ? el.getAttribute(op.name) : null));
|
|
916
|
+
els.forEach((el) => { el.setAttribute(op.name, op.value); reconAdd(el, token); });
|
|
917
|
+
return { touched: els.length, undo: () => els.forEach((el, i) => { if (prev[i] === null) el.removeAttribute(op.name); else el.setAttribute(op.name, prev[i]); reconDel(el, token); }) };
|
|
918
|
+
}
|
|
919
|
+
case 'addClass':
|
|
920
|
+
case 'removeClass': {
|
|
921
|
+
const els = unmarked(doc, op.selector, token);
|
|
922
|
+
if (!els.length) return { touched: 0 };
|
|
923
|
+
const had = els.map((el) => el.classList.contains(op.class));
|
|
924
|
+
els.forEach((el) => { el.classList[op.op === 'addClass' ? 'add' : 'remove'](op.class); reconAdd(el, token); });
|
|
925
|
+
return {
|
|
926
|
+
touched: els.length,
|
|
927
|
+
undo: () => els.forEach((el, i) => { if (op.op === 'addClass' && !had[i]) el.classList.remove(op.class); if (op.op === 'removeClass' && had[i]) el.classList.add(op.class); reconDel(el, token); }),
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
case 'remove': {
|
|
931
|
+
// Re-remove LIVE matches the page re-rendered / hydrated / lazy-loaded
|
|
932
|
+
// after we first removed them — the dominant revert-on-refresh case on
|
|
933
|
+
// SPA pages. Removal deletes the node (no per-node marker is possible),
|
|
934
|
+
// so re-query each tick and remove whatever currently matches.
|
|
935
|
+
const els = selectAll(doc, op.selector);
|
|
936
|
+
if (!els.length) return { touched: 0 };
|
|
937
|
+
const removed = els.map((el) => ({ el, parent: el.parentNode, next: el.nextSibling }));
|
|
938
|
+
els.forEach((el) => { if (el.parentNode) el.parentNode.removeChild(el); });
|
|
939
|
+
return {
|
|
940
|
+
touched: els.length,
|
|
941
|
+
undo: () => removed.forEach(({ el, parent, next }) => { try { if (parent) parent.insertBefore(el, next); } catch (_) { /* node moved */ } }),
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
// Other structural ops (move / addElement / renderData) add wrappers that
|
|
945
|
+
// survive most partial re-renders; full re-add support is a follow-up.
|
|
946
|
+
default:
|
|
947
|
+
return { touched: 0 };
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// ---- self-healing selectors (the moat) -----------------------------------
|
|
952
|
+
// A recipe can store a multi-signal descriptor per targeted element. When the
|
|
953
|
+
// literal selector stops matching (class-hash rotation / redeploy),
|
|
954
|
+
// resolveSelector finds the best current match via a scoring cascade and
|
|
955
|
+
// rebinds ONLY above a confidence threshold — never silently onto the wrong
|
|
956
|
+
// element (a wrong rebind is worse than going stale).
|
|
957
|
+
const HEAL_THRESHOLD = 0.5;
|
|
958
|
+
const HEAL_MAX_CANDIDATES = 3000;
|
|
959
|
+
|
|
960
|
+
function normText(t) { return String(t == null ? '' : t).replace(/\s+/g, ' ').trim().toLowerCase(); }
|
|
961
|
+
|
|
962
|
+
function nthOfType(el) {
|
|
963
|
+
const parent = el.parentNode;
|
|
964
|
+
if (!parent || !parent.children) return 0;
|
|
965
|
+
const tag = el.tagName;
|
|
966
|
+
let i = 0;
|
|
967
|
+
for (const sib of parent.children) { if (sib.tagName === tag) { i++; if (sib === el) return i; } }
|
|
968
|
+
return 0;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// Capture a resilient descriptor of `el` (called at recipe-save time).
|
|
972
|
+
function captureAnchor(el) {
|
|
973
|
+
if (!el || el.nodeType !== 1) return null;
|
|
974
|
+
const a = { tag: el.tagName.toLowerCase() };
|
|
975
|
+
if (el.id) a.id = el.id;
|
|
976
|
+
const role = el.getAttribute && el.getAttribute('role');
|
|
977
|
+
if (role) a.role = role;
|
|
978
|
+
const aria = el.getAttribute && el.getAttribute('aria-label');
|
|
979
|
+
if (aria) a.aria = aria;
|
|
980
|
+
const text = normText(el.textContent).slice(0, 80);
|
|
981
|
+
if (text) a.text = text;
|
|
982
|
+
a.classes = (el.classList ? Array.from(el.classList) : []).slice(0, 6);
|
|
983
|
+
a.nthOfType = nthOfType(el);
|
|
984
|
+
return a;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// Score 0..1: how well `el` matches `anchor`. Only weighs signals the anchor has.
|
|
988
|
+
function scoreAnchor(el, anchor) {
|
|
989
|
+
const W = { tag: 1, id: 4, role: 3, aria: 3, text: 5, cls: 2 };
|
|
990
|
+
let score = 0, max = 0;
|
|
991
|
+
max += W.tag; if (anchor.tag && el.tagName.toLowerCase() === anchor.tag) score += W.tag;
|
|
992
|
+
if (anchor.id) { max += W.id; if (el.id === anchor.id) score += W.id; }
|
|
993
|
+
if (anchor.role) { max += W.role; if ((el.getAttribute && el.getAttribute('role')) === anchor.role) score += W.role; }
|
|
994
|
+
if (anchor.aria) { max += W.aria; if ((el.getAttribute && el.getAttribute('aria-label')) === anchor.aria) score += W.aria; }
|
|
995
|
+
if (anchor.text) {
|
|
996
|
+
max += W.text;
|
|
997
|
+
const ct = normText(el.textContent).slice(0, 200);
|
|
998
|
+
if (ct === anchor.text) score += W.text;
|
|
999
|
+
else if (ct && ct.indexOf(anchor.text) !== -1) score += W.text * 0.5;
|
|
1000
|
+
}
|
|
1001
|
+
if (anchor.classes && anchor.classes.length) {
|
|
1002
|
+
max += W.cls;
|
|
1003
|
+
const have = anchor.classes.filter((c) => el.classList && el.classList.contains(c)).length;
|
|
1004
|
+
score += W.cls * (have / anchor.classes.length);
|
|
1005
|
+
}
|
|
1006
|
+
return max > 0 ? score / max : 0;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function findByAnchor(doc, anchor) {
|
|
1010
|
+
if (!anchor || typeof anchor !== 'object') return null;
|
|
1011
|
+
let candidates;
|
|
1012
|
+
try { candidates = Array.from(doc.querySelectorAll(anchor.tag || '*')); }
|
|
1013
|
+
catch (_) { try { candidates = Array.from(doc.querySelectorAll('*')); } catch (_e) { return null; } }
|
|
1014
|
+
if (candidates.length > HEAL_MAX_CANDIDATES) candidates = candidates.slice(0, HEAL_MAX_CANDIDATES);
|
|
1015
|
+
let best = null, bestScore = 0;
|
|
1016
|
+
for (const el of candidates) {
|
|
1017
|
+
const s = scoreAnchor(el, anchor);
|
|
1018
|
+
if (s > bestScore) { bestScore = s; best = el; }
|
|
1019
|
+
}
|
|
1020
|
+
return bestScore >= HEAL_THRESHOLD ? best : null;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
// Resolve op.selector to elements. Literal first; if nothing matches, heal via
|
|
1024
|
+
// op.anchors. Returns { els, healed }. Never throws.
|
|
1025
|
+
function resolveSelector(doc, op) {
|
|
1026
|
+
let els = [];
|
|
1027
|
+
try { els = Array.from(doc.querySelectorAll(op.selector)); } catch (_) { els = []; }
|
|
1028
|
+
if (els.length) return { els, healed: false };
|
|
1029
|
+
const anchors = Array.isArray(op.anchors) ? op.anchors : null;
|
|
1030
|
+
if (!anchors || !anchors.length) return { els: [], healed: false };
|
|
1031
|
+
const seen = new Set();
|
|
1032
|
+
const healed = [];
|
|
1033
|
+
for (const anchor of anchors) {
|
|
1034
|
+
const el = findByAnchor(doc, anchor);
|
|
1035
|
+
if (el && !seen.has(el)) { seen.add(el); healed.push(el); }
|
|
1036
|
+
}
|
|
1037
|
+
return { els: healed, healed: healed.length > 0 };
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// Enrich a transform's selector-bearing ops with anchors captured from `doc`
|
|
1041
|
+
// (called at recipe-save time). Ops without a selector, with no current match,
|
|
1042
|
+
// or that already carry anchors are left untouched. Returns a new transform.
|
|
1043
|
+
function withAnchors(transform, doc) {
|
|
1044
|
+
if (!transform || !Array.isArray(transform.ops) || !doc) return transform;
|
|
1045
|
+
const ops = transform.ops.map((op) => {
|
|
1046
|
+
if (!op || typeof op.selector !== 'string' || Array.isArray(op.anchors)) return op;
|
|
1047
|
+
let matches = [];
|
|
1048
|
+
try { matches = Array.from(doc.querySelectorAll(op.selector)).slice(0, 5); } catch (_) { matches = []; }
|
|
1049
|
+
if (!matches.length) return op;
|
|
1050
|
+
const anchors = matches.map((el) => captureAnchor(el)).filter(Boolean);
|
|
1051
|
+
return anchors.length ? Object.assign({}, op, { anchors }) : op;
|
|
1052
|
+
});
|
|
1053
|
+
return Object.assign({}, transform, { ops });
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// Union the ops of two transforms so a NEW reshape stacks on top of prior ones
|
|
1057
|
+
// instead of replacing them ("remove images" then "remove text" => both gone).
|
|
1058
|
+
// Pure: never mutates either input. Carries the latest transform's metadata.
|
|
1059
|
+
function mergeTransforms(existing, incoming) {
|
|
1060
|
+
const exOps = (existing && Array.isArray(existing.ops)) ? existing.ops : [];
|
|
1061
|
+
const inOps = (incoming && Array.isArray(incoming.ops)) ? incoming.ops : [];
|
|
1062
|
+
const base = (incoming && typeof incoming === 'object') ? incoming : (existing || {});
|
|
1063
|
+
return Object.assign({}, base, { ops: exOps.concat(inOps) });
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
return {
|
|
1067
|
+
SCHEMA_VERSION,
|
|
1068
|
+
OP_SPECS,
|
|
1069
|
+
POSITIONS,
|
|
1070
|
+
validateTransform,
|
|
1071
|
+
sanitizeHtml,
|
|
1072
|
+
neutralizeCss,
|
|
1073
|
+
applyTransform,
|
|
1074
|
+
undoTransform,
|
|
1075
|
+
reconcile,
|
|
1076
|
+
captureAnchor,
|
|
1077
|
+
resolveSelector,
|
|
1078
|
+
withAnchors,
|
|
1079
|
+
mergeTransforms,
|
|
1080
|
+
};
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
/* ---- sdk/src/query.js ---- */
|
|
1085
|
+
/*
|
|
1086
|
+
* Morph SDK — trusted query engine.
|
|
1087
|
+
* The model emits a query DESCRIPTION; this module (trusted code) executes it
|
|
1088
|
+
* over records the vendor's own fetch returned, entirely client-side. Unknown
|
|
1089
|
+
* fields are hard errors: a hallucinated field must never yield silent zeros.
|
|
1090
|
+
* UMD: Node module in tests, browser global `MorphQuery` in the SDK bundle.
|
|
1091
|
+
*/
|
|
1092
|
+
(function (global, factory) {
|
|
1093
|
+
const api = factory();
|
|
1094
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
1095
|
+
else global.MorphQuery = api;
|
|
1096
|
+
})(typeof self !== 'undefined' ? self : this, function () {
|
|
1097
|
+
'use strict';
|
|
1098
|
+
|
|
1099
|
+
const MAX_RECORDS = 10000;
|
|
1100
|
+
const MAX_ROWS = 60;
|
|
1101
|
+
const AGG_FNS = new Set(['sum', 'count', 'avg', 'min', 'max']);
|
|
1102
|
+
|
|
1103
|
+
function parseDuration(s) {
|
|
1104
|
+
if (typeof s !== 'string') return null;
|
|
1105
|
+
const m = /^(\d{1,5})([mhd])$/.exec(s.trim());
|
|
1106
|
+
if (!m) return null;
|
|
1107
|
+
const n = Number(m[1]);
|
|
1108
|
+
return m[2] === 'm' ? n * 60000 : m[2] === 'h' ? n * 3600000 : n * 86400000;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function need(field, schema, what) {
|
|
1112
|
+
if (typeof field !== 'string' || !Object.prototype.hasOwnProperty.call(schema, field)) {
|
|
1113
|
+
throw new Error('unknown field: ' + field + (what ? ' (' + what + ')' : ''));
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
function toTime(v) { const t = Date.parse(v); return Number.isFinite(t) ? t : null; }
|
|
1118
|
+
|
|
1119
|
+
// A bound is either a relative duration ('15d': since = now - d, until = now + d)
|
|
1120
|
+
// or an absolute date string.
|
|
1121
|
+
function bound(query, key, nowMs) {
|
|
1122
|
+
const v = query[key];
|
|
1123
|
+
if (v == null) return null;
|
|
1124
|
+
const dur = parseDuration(v);
|
|
1125
|
+
if (dur != null) return key === 'since' ? nowMs - dur : nowMs + dur;
|
|
1126
|
+
return toTime(v);
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function runQuery(records, query, schema, nowMs) {
|
|
1130
|
+
if (!Array.isArray(records)) throw new Error('records must be an array');
|
|
1131
|
+
if (!query || typeof query !== 'object') throw new Error('query must be an object');
|
|
1132
|
+
if (!schema || typeof schema !== 'object') throw new Error('schema must be an object');
|
|
1133
|
+
const now = Number.isFinite(nowMs) ? nowMs : Date.now();
|
|
1134
|
+
let rows = records.slice(0, MAX_RECORDS);
|
|
1135
|
+
|
|
1136
|
+
if (query.filter != null) {
|
|
1137
|
+
if (typeof query.filter !== 'object' || Array.isArray(query.filter)) throw new Error('filter must be an object');
|
|
1138
|
+
for (const f of Object.keys(query.filter)) {
|
|
1139
|
+
need(f, schema, 'filter');
|
|
1140
|
+
const want = query.filter[f];
|
|
1141
|
+
const set = Array.isArray(want) ? want.map(String) : [String(want)];
|
|
1142
|
+
rows = rows.filter((r) => r && set.includes(String(r[f])));
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
if (query.since != null || query.until != null) {
|
|
1147
|
+
need(query.dateField, schema, 'dateField');
|
|
1148
|
+
const lo = bound(query, 'since', now);
|
|
1149
|
+
const hi = bound(query, 'until', now);
|
|
1150
|
+
rows = rows.filter((r) => {
|
|
1151
|
+
const t = r && toTime(r[query.dateField]);
|
|
1152
|
+
if (t == null) return false; // malformed date: excluded, never coerced
|
|
1153
|
+
if (lo != null && t < lo) return false;
|
|
1154
|
+
if (hi != null && t > hi) return false;
|
|
1155
|
+
return true;
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
const agg = query.aggregate;
|
|
1160
|
+
if (!agg || typeof agg !== 'object' || !AGG_FNS.has(agg.fn)) {
|
|
1161
|
+
throw new Error('aggregate {fn} is required (sum|count|avg|min|max)');
|
|
1162
|
+
}
|
|
1163
|
+
if (agg.fn !== 'count') need(agg.field, schema, 'aggregate');
|
|
1164
|
+
|
|
1165
|
+
function fold(list) {
|
|
1166
|
+
if (agg.fn === 'count') return list.length;
|
|
1167
|
+
const nums = list.map((r) => Number(r && r[agg.field])).filter(Number.isFinite);
|
|
1168
|
+
if (!nums.length) return 0;
|
|
1169
|
+
if (agg.fn === 'sum') return nums.reduce((a, b) => a + b, 0);
|
|
1170
|
+
if (agg.fn === 'avg') return nums.reduce((a, b) => a + b, 0) / nums.length;
|
|
1171
|
+
if (agg.fn === 'min') return Math.min.apply(null, nums);
|
|
1172
|
+
return Math.max.apply(null, nums);
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
let out;
|
|
1176
|
+
if (query.groupBy != null) {
|
|
1177
|
+
need(query.groupBy, schema, 'groupBy');
|
|
1178
|
+
const groups = new Map();
|
|
1179
|
+
for (const r of rows) {
|
|
1180
|
+
const k = String(r && r[query.groupBy]);
|
|
1181
|
+
if (!groups.has(k)) groups.set(k, []);
|
|
1182
|
+
groups.get(k).push(r);
|
|
1183
|
+
}
|
|
1184
|
+
out = Array.from(groups, (pair) => ({ label: pair[0], value: fold(pair[1]) }));
|
|
1185
|
+
} else {
|
|
1186
|
+
out = [{ label: (agg.fn + ' ' + (agg.field || '')).trim(), value: fold(rows) }];
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
const limit = Number.isFinite(query.limit) && query.limit > 0 ? Math.min(query.limit, MAX_ROWS) : MAX_ROWS;
|
|
1190
|
+
return out.slice(0, limit);
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
return { parseDuration, runQuery, MAX_ROWS };
|
|
1194
|
+
});
|
|
1195
|
+
|
|
1196
|
+
|
|
1197
|
+
/* ---- sdk/src/registry.js ---- */
|
|
1198
|
+
/*
|
|
1199
|
+
* Morph SDK — vendor registry. Validates the vendor's init() config (throws
|
|
1200
|
+
* TypeError: vendors are developers, fail loud), exposes JSON-safe manifests
|
|
1201
|
+
* for the model, and is the ONLY path from a model-emitted op to vendor code:
|
|
1202
|
+
* data via resolveData (fetch + trusted query engine), behavior via
|
|
1203
|
+
* invokeAction (schema-checked args + confirm gate).
|
|
1204
|
+
* UMD: Node module in tests, browser global `MorphRegistry` in the SDK bundle.
|
|
1205
|
+
*/
|
|
1206
|
+
(function (global, factory) {
|
|
1207
|
+
const api = factory(
|
|
1208
|
+
typeof module !== 'undefined' && module.exports ? require('./query.js') : global.MorphQuery
|
|
1209
|
+
);
|
|
1210
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
1211
|
+
else global.MorphRegistry = api;
|
|
1212
|
+
})(typeof self !== 'undefined' ? self : this, function (Query) {
|
|
1213
|
+
'use strict';
|
|
1214
|
+
|
|
1215
|
+
const NAME_RE = /^[a-zA-Z][\w-]{0,59}$/;
|
|
1216
|
+
const FIELD_TYPES = new Set(['string', 'number', 'date', 'boolean']);
|
|
1217
|
+
const ARG_TYPES = new Set(['string', 'number']);
|
|
1218
|
+
const MAX_ENTRIES = 20;
|
|
1219
|
+
const MAX_FIELDS = 40;
|
|
1220
|
+
const FETCH_TIMEOUT_MS = 10000;
|
|
1221
|
+
|
|
1222
|
+
function checkSchema(schema, allowed, what, name) {
|
|
1223
|
+
if (!schema || typeof schema !== 'object') throw new TypeError('Morph: ' + what + ' "' + name + '" needs a schema object');
|
|
1224
|
+
const fields = Object.keys(schema);
|
|
1225
|
+
if (fields.length === 0 || fields.length > MAX_FIELDS) throw new TypeError('Morph: ' + what + ' "' + name + '" schema needs 1-' + MAX_FIELDS + ' fields');
|
|
1226
|
+
for (const f of fields) {
|
|
1227
|
+
if (!NAME_RE.test(f)) throw new TypeError('Morph: bad field name "' + f + '" in ' + what + ' "' + name + '"');
|
|
1228
|
+
if (!allowed.has(schema[f])) throw new TypeError('Morph: bad type "' + schema[f] + '" for field "' + f + '" in ' + what + ' "' + name + '"');
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
function createRegistry(config) {
|
|
1233
|
+
config = config || {};
|
|
1234
|
+
const sources = {};
|
|
1235
|
+
const actions = {};
|
|
1236
|
+
|
|
1237
|
+
const srcEntries = Object.keys(config.sources || {});
|
|
1238
|
+
const actEntries = Object.keys(config.actions || {});
|
|
1239
|
+
if (srcEntries.length > MAX_ENTRIES || actEntries.length > MAX_ENTRIES) {
|
|
1240
|
+
throw new TypeError('Morph: at most ' + MAX_ENTRIES + ' sources/actions');
|
|
1241
|
+
}
|
|
1242
|
+
for (const name of srcEntries) {
|
|
1243
|
+
const s = config.sources[name];
|
|
1244
|
+
if (!NAME_RE.test(name)) throw new TypeError('Morph: bad source name "' + name + '"');
|
|
1245
|
+
if (!s || typeof s.fetch !== 'function') throw new TypeError('Morph: source "' + name + '" needs a fetch function');
|
|
1246
|
+
checkSchema(s.schema, FIELD_TYPES, 'source', name);
|
|
1247
|
+
sources[name] = {
|
|
1248
|
+
fetch: s.fetch,
|
|
1249
|
+
schema: Object.assign({}, s.schema),
|
|
1250
|
+
description: typeof s.description === 'string' ? s.description.slice(0, 200) : '',
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
for (const name of actEntries) {
|
|
1254
|
+
const a = config.actions[name];
|
|
1255
|
+
if (!NAME_RE.test(name)) throw new TypeError('Morph: bad action name "' + name + '"');
|
|
1256
|
+
if (!a || typeof a.run !== 'function') throw new TypeError('Morph: action "' + name + '" needs a run function');
|
|
1257
|
+
checkSchema(a.argsSchema, ARG_TYPES, 'action', name);
|
|
1258
|
+
actions[name] = {
|
|
1259
|
+
run: a.run,
|
|
1260
|
+
argsSchema: Object.assign({}, a.argsSchema),
|
|
1261
|
+
description: typeof a.description === 'string' ? a.description.slice(0, 200) : '',
|
|
1262
|
+
confirm: a.confirm !== false, // default true: safe unless the vendor opts out
|
|
1263
|
+
};
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
function manifests() {
|
|
1267
|
+
const out = { sources: {}, actions: {} };
|
|
1268
|
+
for (const n of Object.keys(sources)) out.sources[n] = { schema: sources[n].schema, description: sources[n].description };
|
|
1269
|
+
for (const n of Object.keys(actions)) out.actions[n] = { argsSchema: actions[n].argsSchema, description: actions[n].description, confirm: actions[n].confirm };
|
|
1270
|
+
return out;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
function validateDataOp(op) {
|
|
1274
|
+
if (!op || typeof op.source !== 'string' || !Object.prototype.hasOwnProperty.call(sources, op.source)) {
|
|
1275
|
+
return { ok: false, reason: 'unknown source' };
|
|
1276
|
+
}
|
|
1277
|
+
if (!op.query || typeof op.query !== 'object' || Array.isArray(op.query)) {
|
|
1278
|
+
return { ok: false, reason: 'query must be an object' };
|
|
1279
|
+
}
|
|
1280
|
+
return { ok: true };
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
async function resolveData(op) {
|
|
1284
|
+
const v = validateDataOp(op);
|
|
1285
|
+
if (!v.ok) throw new Error('Morph: ' + v.reason);
|
|
1286
|
+
const src = sources[op.source];
|
|
1287
|
+
let timer;
|
|
1288
|
+
const records = await Promise.race([
|
|
1289
|
+
Promise.resolve(src.fetch(op.query)),
|
|
1290
|
+
new Promise((resolve, reject) => {
|
|
1291
|
+
timer = setTimeout(() => reject(new Error('Morph: source "' + op.source + '" timed out')), FETCH_TIMEOUT_MS);
|
|
1292
|
+
}),
|
|
1293
|
+
]).finally(() => clearTimeout(timer));
|
|
1294
|
+
return Query.runQuery(Array.isArray(records) ? records : [], op.query, src.schema);
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
async function invokeAction(name, args, confirmFn) {
|
|
1298
|
+
const a = Object.prototype.hasOwnProperty.call(actions, name) ? actions[name] : null;
|
|
1299
|
+
if (!a) throw new Error('Morph: unknown action "' + name + '"');
|
|
1300
|
+
args = args && typeof args === 'object' ? args : {};
|
|
1301
|
+
for (const k of Object.keys(args)) {
|
|
1302
|
+
if (!Object.prototype.hasOwnProperty.call(a.argsSchema, k)) throw new Error('Morph: unexpected arg "' + k + '"');
|
|
1303
|
+
if (typeof args[k] !== a.argsSchema[k]) throw new Error('Morph: arg "' + k + '" wrong type (want ' + a.argsSchema[k] + ')');
|
|
1304
|
+
}
|
|
1305
|
+
if (a.confirm) {
|
|
1306
|
+
const ok = await (typeof confirmFn === 'function' ? confirmFn(name, a.description) : false);
|
|
1307
|
+
if (!ok) throw new Error('Morph: action declined');
|
|
1308
|
+
}
|
|
1309
|
+
return a.run(args);
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
return {
|
|
1313
|
+
manifests, validateDataOp, resolveData, invokeAction,
|
|
1314
|
+
hasSources: () => Object.keys(sources).length > 0,
|
|
1315
|
+
hasActions: () => Object.keys(actions).length > 0,
|
|
1316
|
+
};
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
return { createRegistry };
|
|
1320
|
+
});
|
|
1321
|
+
|
|
1322
|
+
|
|
1323
|
+
/* ---- sdk/src/storage.js ---- */
|
|
1324
|
+
/*
|
|
1325
|
+
* Morph SDK — recipe store. Persists one recipe per page path so generated
|
|
1326
|
+
* widgets/reshapes reapply on every visit. Default backing is localStorage
|
|
1327
|
+
* (per-browser); a vendor may pass a custom adapter {get,set,remove} (sync or
|
|
1328
|
+
* Promise) to store recipes in their own backend so a user's generated
|
|
1329
|
+
* features follow them across devices. Storage failures NEVER throw — a
|
|
1330
|
+
* broken store must never break the host page.
|
|
1331
|
+
* UMD: Node module in tests, browser global `MorphStorage` in the SDK bundle.
|
|
1332
|
+
*/
|
|
1333
|
+
(function (global, factory) {
|
|
1334
|
+
const api = factory();
|
|
1335
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
1336
|
+
else global.MorphStorage = api;
|
|
1337
|
+
})(typeof self !== 'undefined' ? self : this, function () {
|
|
1338
|
+
'use strict';
|
|
1339
|
+
|
|
1340
|
+
const PREFIX = 'morph:sdk:recipe:';
|
|
1341
|
+
const SCHEMA_VERSION = 1;
|
|
1342
|
+
const MAX_PROMPT_LABEL = 300;
|
|
1343
|
+
|
|
1344
|
+
// Default adapter: window.localStorage, JSON values, fully guarded.
|
|
1345
|
+
function localAdapter() {
|
|
1346
|
+
function ls() {
|
|
1347
|
+
try {
|
|
1348
|
+
return (typeof window !== 'undefined' && window.localStorage) ? window.localStorage : null;
|
|
1349
|
+
} catch (_) { return null; } // SecurityError in sandboxed frames
|
|
1350
|
+
}
|
|
1351
|
+
return {
|
|
1352
|
+
get(key) {
|
|
1353
|
+
const s = ls();
|
|
1354
|
+
if (!s) return undefined;
|
|
1355
|
+
const raw = s.getItem(key);
|
|
1356
|
+
if (raw == null) return undefined;
|
|
1357
|
+
try { return JSON.parse(raw); } catch (_) { return undefined; }
|
|
1358
|
+
},
|
|
1359
|
+
set(key, value) {
|
|
1360
|
+
const s = ls();
|
|
1361
|
+
if (!s) return;
|
|
1362
|
+
s.setItem(key, JSON.stringify(value));
|
|
1363
|
+
},
|
|
1364
|
+
remove(key) {
|
|
1365
|
+
const s = ls();
|
|
1366
|
+
if (!s) return;
|
|
1367
|
+
s.removeItem(key);
|
|
1368
|
+
},
|
|
1369
|
+
};
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
// mergeTransforms concats; recipes must UNION. Drop structurally identical
|
|
1373
|
+
// ops (first occurrence wins) so repeating a prompt can't bloat the recipe.
|
|
1374
|
+
function dedupeOps(transform) {
|
|
1375
|
+
if (!transform || !Array.isArray(transform.ops)) return transform;
|
|
1376
|
+
const seen = new Set();
|
|
1377
|
+
const ops = transform.ops.filter((op) => {
|
|
1378
|
+
let key;
|
|
1379
|
+
try { key = JSON.stringify(op); } catch (_) { return true; }
|
|
1380
|
+
if (seen.has(key)) return false;
|
|
1381
|
+
seen.add(key);
|
|
1382
|
+
return true;
|
|
1383
|
+
});
|
|
1384
|
+
return Object.assign({}, transform, { ops });
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
// Dedupe prompt-label segments exactly like extension/src/recipes.js
|
|
1388
|
+
// appendTransform: never re-append a segment that is already present.
|
|
1389
|
+
function mergePrompt(existingPrompt, incoming) {
|
|
1390
|
+
if (existingPrompt && incoming) {
|
|
1391
|
+
const dup = existingPrompt.split(' + ')
|
|
1392
|
+
.some((s) => s.trim().toLowerCase() === incoming.trim().toLowerCase());
|
|
1393
|
+
return dup ? existingPrompt : String(existingPrompt + ' + ' + incoming).slice(0, MAX_PROMPT_LABEL);
|
|
1394
|
+
}
|
|
1395
|
+
return incoming || existingPrompt || '';
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
function createStore(adapter) {
|
|
1399
|
+
const backing = (adapter && typeof adapter === 'object') ? adapter : localAdapter();
|
|
1400
|
+
const keyOf = (pathKey) => PREFIX + String(pathKey || '/');
|
|
1401
|
+
|
|
1402
|
+
// Serialize read-modify-write so rapid appends can't lose ops (same
|
|
1403
|
+
// pattern as extension/src/recipes.js).
|
|
1404
|
+
let writeChain = Promise.resolve();
|
|
1405
|
+
function serialize(fn) {
|
|
1406
|
+
const run = writeChain.then(fn, fn);
|
|
1407
|
+
writeChain = run.then(() => {}, () => {});
|
|
1408
|
+
return run;
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
async function loadRecipe(pathKey) {
|
|
1412
|
+
try {
|
|
1413
|
+
const v = await backing.get(keyOf(pathKey));
|
|
1414
|
+
return (v && typeof v === 'object') ? v : null;
|
|
1415
|
+
} catch (_) { return null; }
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
function saveRecipe(pathKey, recipe) {
|
|
1419
|
+
return serialize(async () => {
|
|
1420
|
+
try {
|
|
1421
|
+
const merged = Object.assign(
|
|
1422
|
+
{ path: String(pathKey || '/'), enabled: true, createdAt: Date.now() },
|
|
1423
|
+
recipe,
|
|
1424
|
+
{ schemaVersion: SCHEMA_VERSION, path: String(pathKey || '/') }
|
|
1425
|
+
);
|
|
1426
|
+
await backing.set(keyOf(pathKey), merged);
|
|
1427
|
+
return merged;
|
|
1428
|
+
} catch (_) { return null; }
|
|
1429
|
+
});
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
function appendTransform(pathKey, transform, meta, mergeFn) {
|
|
1433
|
+
return serialize(async () => {
|
|
1434
|
+
try {
|
|
1435
|
+
let existing = null;
|
|
1436
|
+
try { existing = await backing.get(keyOf(pathKey)); } catch (_) { /* treat as none */ }
|
|
1437
|
+
if (!existing || typeof existing !== 'object') existing = null;
|
|
1438
|
+
const mergedTransform = dedupeOps(
|
|
1439
|
+
(existing && existing.transform && typeof mergeFn === 'function')
|
|
1440
|
+
? mergeFn(existing.transform, transform)
|
|
1441
|
+
: transform
|
|
1442
|
+
);
|
|
1443
|
+
const prompt = mergePrompt(existing && existing.prompt, (meta && meta.prompt) || '');
|
|
1444
|
+
const merged = Object.assign(
|
|
1445
|
+
{ path: String(pathKey || '/'), createdAt: Date.now() },
|
|
1446
|
+
existing || {},
|
|
1447
|
+
{
|
|
1448
|
+
path: String(pathKey || '/'), prompt, transform: mergedTransform,
|
|
1449
|
+
enabled: true, schemaVersion: SCHEMA_VERSION,
|
|
1450
|
+
}
|
|
1451
|
+
);
|
|
1452
|
+
await backing.set(keyOf(pathKey), merged);
|
|
1453
|
+
return merged;
|
|
1454
|
+
} catch (_) { return null; }
|
|
1455
|
+
});
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
function removeRecipe(pathKey) {
|
|
1459
|
+
return serialize(async () => {
|
|
1460
|
+
try { await backing.remove(keyOf(pathKey)); } catch (_) { /* never throws */ }
|
|
1461
|
+
});
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
return { loadRecipe, saveRecipe, appendTransform, removeRecipe };
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
return { createStore, SCHEMA_VERSION };
|
|
1468
|
+
});
|
|
1469
|
+
|
|
1470
|
+
|
|
1471
|
+
/* ---- extension/src/context.js ---- */
|
|
1472
|
+
/*
|
|
1473
|
+
* Context extractor — serialize a compact, pruned view of the page so the model
|
|
1474
|
+
* can pick real selectors without shipping the whole DOM. Runs in the content script
|
|
1475
|
+
* (isolated world). Attaches to window.Morph.extractContext.
|
|
1476
|
+
*/
|
|
1477
|
+
(function () {
|
|
1478
|
+
'use strict';
|
|
1479
|
+
window.Morph = window.Morph || {};
|
|
1480
|
+
|
|
1481
|
+
const SKIP_TAGS = new Set([
|
|
1482
|
+
'script', 'style', 'noscript', 'template', 'svg', 'path', 'meta', 'link',
|
|
1483
|
+
'br', 'head', 'iframe', 'canvas',
|
|
1484
|
+
]);
|
|
1485
|
+
const MAX_NODES = 600;
|
|
1486
|
+
const MAX_DEPTH = 12;
|
|
1487
|
+
const MAX_CHILDREN = 14; // cap repetitive lists
|
|
1488
|
+
const TEXT_PREVIEW = 50;
|
|
1489
|
+
const MAX_CHARS = 55000;
|
|
1490
|
+
|
|
1491
|
+
function simpleId(id) {
|
|
1492
|
+
// only emit ids that are valid, stable-looking selectors
|
|
1493
|
+
return id && /^[A-Za-z][\w-]*$/.test(id) ? id : null;
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
function classes(el) {
|
|
1497
|
+
if (!el.classList || el.classList.length === 0) return [];
|
|
1498
|
+
return Array.from(el.classList)
|
|
1499
|
+
.filter((c) => /^[A-Za-z][\w-]*$/.test(c) && c.length <= 40)
|
|
1500
|
+
.slice(0, 3);
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
function selectorHint(el) {
|
|
1504
|
+
const tag = el.tagName.toLowerCase();
|
|
1505
|
+
const id = simpleId(el.id);
|
|
1506
|
+
if (id) return `${tag}#${id}`;
|
|
1507
|
+
const cls = classes(el);
|
|
1508
|
+
return cls.length ? `${tag}.${cls.join('.')}` : tag;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
function ownText(el) {
|
|
1512
|
+
let t = '';
|
|
1513
|
+
for (const n of el.childNodes) {
|
|
1514
|
+
if (n.nodeType === 3) t += n.nodeValue;
|
|
1515
|
+
if (t.length > TEXT_PREVIEW * 2) break;
|
|
1516
|
+
}
|
|
1517
|
+
t = t.replace(/\s+/g, ' ').trim();
|
|
1518
|
+
return t.length > TEXT_PREVIEW ? t.slice(0, TEXT_PREVIEW) + '…' : t;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
function isVisible(el) {
|
|
1522
|
+
// cheap visibility filter; avoids dumping hidden subtrees
|
|
1523
|
+
if (el.hidden) return false;
|
|
1524
|
+
// Fast path: an inline display:none / visibility:hidden is a strict subset of
|
|
1525
|
+
// the computed value, so we can rule the node out WITHOUT forcing a
|
|
1526
|
+
// getComputedStyle reflow. The computed-style check below still catches
|
|
1527
|
+
// CSS-class / stylesheet-based hiding.
|
|
1528
|
+
const inline = el.style;
|
|
1529
|
+
if (inline && (inline.display === 'none' || inline.visibility === 'hidden')) return false;
|
|
1530
|
+
const view = el.ownerDocument.defaultView;
|
|
1531
|
+
const style = view && view.getComputedStyle(el);
|
|
1532
|
+
if (style && (style.display === 'none' || style.visibility === 'hidden')) return false;
|
|
1533
|
+
return true;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
function extractContext(root) {
|
|
1537
|
+
root = root || document.body || document.documentElement;
|
|
1538
|
+
const lines = [];
|
|
1539
|
+
let nodeCount = 0;
|
|
1540
|
+
let charCount = 0;
|
|
1541
|
+
let truncated = false;
|
|
1542
|
+
|
|
1543
|
+
function walk(el, depth) {
|
|
1544
|
+
if (truncated || nodeCount >= MAX_NODES || depth > MAX_DEPTH) return;
|
|
1545
|
+
const tag = el.tagName ? el.tagName.toLowerCase() : null;
|
|
1546
|
+
if (!tag || SKIP_TAGS.has(tag)) return;
|
|
1547
|
+
if (!isVisible(el)) return;
|
|
1548
|
+
|
|
1549
|
+
nodeCount++;
|
|
1550
|
+
const indent = ' '.repeat(Math.min(depth, 10));
|
|
1551
|
+
const role = el.getAttribute && el.getAttribute('role');
|
|
1552
|
+
const aria = el.getAttribute && el.getAttribute('aria-label');
|
|
1553
|
+
const text = ownText(el);
|
|
1554
|
+
|
|
1555
|
+
let line = indent + selectorHint(el);
|
|
1556
|
+
if (role) line += ` [role=${role}]`;
|
|
1557
|
+
if (aria) line += ` [aria-label="${aria.slice(0, 40)}"]`;
|
|
1558
|
+
if (text) line += ` "${text}"`;
|
|
1559
|
+
|
|
1560
|
+
charCount += line.length + 1;
|
|
1561
|
+
if (charCount > MAX_CHARS) { truncated = true; return; }
|
|
1562
|
+
lines.push(line);
|
|
1563
|
+
|
|
1564
|
+
const kids = Array.from(el.children || []);
|
|
1565
|
+
const shown = kids.slice(0, MAX_CHILDREN);
|
|
1566
|
+
for (const child of shown) walk(child, depth + 1);
|
|
1567
|
+
if (kids.length > shown.length) {
|
|
1568
|
+
lines.push(indent + ` …(+${kids.length - shown.length} more siblings)`);
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
walk(root, 0);
|
|
1573
|
+
let out = lines.join('\n');
|
|
1574
|
+
if (truncated) out += '\n…(snapshot truncated)';
|
|
1575
|
+
return out;
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
window.Morph.extractContext = extractContext;
|
|
1579
|
+
})();
|
|
1580
|
+
|
|
1581
|
+
|
|
1582
|
+
/* ---- extension/src/promptbar.js ---- */
|
|
1583
|
+
/*
|
|
1584
|
+
* Prompt bar — the universal, style-isolated overlay (Shadow DOM) shown on every site.
|
|
1585
|
+
* Pure UI: it knows nothing about the model or recipes; content.js wires the handlers.
|
|
1586
|
+
* Attaches to window.Morph.bar.
|
|
1587
|
+
*
|
|
1588
|
+
* Public contract consumed by content.js (DO NOT remove/rename or change semantics):
|
|
1589
|
+
* mount(handlers {onSubmit, onUndo}), show, hide, toggle, isVisible,
|
|
1590
|
+
* setStatus(text, kind), clearStatus, setBusy(bool), showActions(bool), clearInput.
|
|
1591
|
+
* A few NON-breaking helpers are added below (focusInput, setRecent).
|
|
1592
|
+
*/
|
|
1593
|
+
(function () {
|
|
1594
|
+
'use strict';
|
|
1595
|
+
window.Morph = window.Morph || {};
|
|
1596
|
+
|
|
1597
|
+
const HOST_ID = 'morph-host-root';
|
|
1598
|
+
// Recent prompts are stored PER-ORIGIN: a map { origin -> string[] } under one key,
|
|
1599
|
+
// so the bar only ever shows prompts you typed on the site you're currently on.
|
|
1600
|
+
const RECENT_KEY = 'morph:recentByOrigin';
|
|
1601
|
+
const LEGACY_RECENT_KEY = 'morph:recentPrompts'; // pre-1.0.5 global list; removed on mount
|
|
1602
|
+
const RECENT_CAP = 6; // prompts kept per origin
|
|
1603
|
+
const MAX_ORIGINS = 60; // bound total storage growth
|
|
1604
|
+
|
|
1605
|
+
let host, root, els, handlers = {}, visible = false;
|
|
1606
|
+
let lastFocused = null; // page element focused before show(), restored on hide()
|
|
1607
|
+
let recipeActive = false; // when a recipe is active we keep example chips hidden
|
|
1608
|
+
let connectHandler = null; // onConnect callback for the connector suggestion
|
|
1609
|
+
let currentSuggestions = []; // site-special model prompts (via setSuggestions); empty hides the row
|
|
1610
|
+
let targetClear = null; // onClear callback for the targeted-element indicator
|
|
1611
|
+
|
|
1612
|
+
const CSS = `
|
|
1613
|
+
:host { all: initial; }
|
|
1614
|
+
|
|
1615
|
+
/* Precision-instrument palette: monochrome, white accent, hairline rules.
|
|
1616
|
+
Dark by default; inverted (black accent) under prefers-color-scheme: light. */
|
|
1617
|
+
.wrap {
|
|
1618
|
+
--bg: #0a0a0b;
|
|
1619
|
+
--fg: #fafafa;
|
|
1620
|
+
--muted: #a1a1aa;
|
|
1621
|
+
--faint: #6e6e76;
|
|
1622
|
+
--line: rgba(255,255,255,0.14);
|
|
1623
|
+
--line-strong: rgba(255,255,255,0.30);
|
|
1624
|
+
--field: #161618;
|
|
1625
|
+
--accent: #ffffff; /* was purple */
|
|
1626
|
+
--accent-ink: #0a0a0b; /* text/icon on the white accent */
|
|
1627
|
+
--err: #ff6b6b;
|
|
1628
|
+
--shadow: 0 1px 0 rgba(255,255,255,0.05) inset, 0 24px 64px -22px rgba(0,0,0,0.9);
|
|
1629
|
+
--mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;
|
|
1630
|
+
}
|
|
1631
|
+
@media (prefers-color-scheme: light) {
|
|
1632
|
+
.wrap {
|
|
1633
|
+
--bg: #ffffff;
|
|
1634
|
+
--fg: #0a0a0b;
|
|
1635
|
+
--muted: #52525b;
|
|
1636
|
+
--faint: #9a9aa2;
|
|
1637
|
+
--line: rgba(0,0,0,0.14);
|
|
1638
|
+
--line-strong: rgba(0,0,0,0.32);
|
|
1639
|
+
--field: #f5f5f5;
|
|
1640
|
+
--accent: #0a0a0b; /* black accent on light */
|
|
1641
|
+
--accent-ink: #ffffff;
|
|
1642
|
+
--err: #c4302b;
|
|
1643
|
+
--shadow: 0 1px 0 rgba(255,255,255,0.6) inset, 0 24px 64px -22px rgba(0,0,0,0.28);
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
.wrap {
|
|
1648
|
+
position: fixed; left: 50%; bottom: 26px; transform: translateX(-50%) translateY(6px);
|
|
1649
|
+
z-index: 2147483647; font-family: var(--mono);
|
|
1650
|
+
width: min(660px, calc(100vw - 32px));
|
|
1651
|
+
background: var(--bg); color: var(--fg);
|
|
1652
|
+
border: 1px solid var(--line); border-radius: 4px;
|
|
1653
|
+
box-shadow: var(--shadow); padding: 14px;
|
|
1654
|
+
opacity: 0; pointer-events: none; transition: opacity .14s ease, transform .14s cubic-bezier(.2,.7,.2,1);
|
|
1655
|
+
box-sizing: border-box;
|
|
1656
|
+
}
|
|
1657
|
+
.wrap.show { opacity: 1; pointer-events: auto; transform: translateX(-50%) translateY(0); }
|
|
1658
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1659
|
+
.wrap, .wrap.show { transition: opacity .1s linear; transform: translateX(-50%) translateY(0); }
|
|
1660
|
+
.spinner { animation: none; }
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
.row { display: flex; align-items: stretch; gap: 10px; }
|
|
1664
|
+
|
|
1665
|
+
/* Wordmark: a hard accent block + tracked monospace MORPH. */
|
|
1666
|
+
.logo { display: flex; align-items: center; gap: 9px; user-select: none; white-space: nowrap;
|
|
1667
|
+
font-size: 11px; font-weight: 700; letter-spacing: .24em; color: var(--fg); padding: 0 2px; }
|
|
1668
|
+
.logo .mark { width: 9px; height: 9px; background: var(--accent); display: inline-block; }
|
|
1669
|
+
|
|
1670
|
+
input.q {
|
|
1671
|
+
flex: 1; min-width: 0; background: var(--field);
|
|
1672
|
+
border: 1px solid var(--line); border-radius: 2px;
|
|
1673
|
+
color: var(--fg); font-family: var(--mono); font-size: 13.5px; letter-spacing: -.01em;
|
|
1674
|
+
padding: 11px 12px; outline: none; text-overflow: ellipsis;
|
|
1675
|
+
transition: border-color .12s ease;
|
|
1676
|
+
}
|
|
1677
|
+
input.q:focus { border-color: var(--line-strong); }
|
|
1678
|
+
input.q::placeholder { color: var(--faint); }
|
|
1679
|
+
|
|
1680
|
+
button {
|
|
1681
|
+
border: 0; border-radius: 2px; padding: 0 16px; font-family: var(--mono);
|
|
1682
|
+
font-size: 11px; font-weight: 700; letter-spacing: .14em; text-transform: uppercase;
|
|
1683
|
+
cursor: pointer; white-space: nowrap; transition: opacity .12s ease, color .12s ease, border-color .12s ease;
|
|
1684
|
+
}
|
|
1685
|
+
button:disabled { opacity: .4; cursor: default; }
|
|
1686
|
+
.go { background: var(--accent); color: var(--accent-ink);
|
|
1687
|
+
display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-width: 100px; }
|
|
1688
|
+
.go:hover:not(:disabled) { opacity: .82; }
|
|
1689
|
+
.ghost { background: transparent; color: var(--muted); border: 1px solid var(--line); padding: 8px 12px; }
|
|
1690
|
+
.ghost:hover:not(:disabled) { color: var(--fg); border-color: var(--line-strong); }
|
|
1691
|
+
.x { background: transparent; color: var(--faint); padding: 0 8px; font-size: 17px; line-height: 1; }
|
|
1692
|
+
.x:hover { color: var(--fg); }
|
|
1693
|
+
|
|
1694
|
+
/* Crisp keyboard focus ring. */
|
|
1695
|
+
input.q:focus-visible, button:focus-visible, .chip:focus-visible {
|
|
1696
|
+
outline: 1px solid var(--accent); outline-offset: 2px;
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
/* Suggestion + recent chips — square, hairline, mono. */
|
|
1700
|
+
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; align-items: center; }
|
|
1701
|
+
.chips.hidden { display: none; }
|
|
1702
|
+
.chips .lead { font-size: 9.5px; letter-spacing: .2em; text-transform: uppercase;
|
|
1703
|
+
color: var(--faint); margin-right: 4px; user-select: none; }
|
|
1704
|
+
.chip {
|
|
1705
|
+
background: transparent; color: var(--muted); border: 1px solid var(--line);
|
|
1706
|
+
border-radius: 2px; padding: 5px 9px; font-family: var(--mono); font-size: 11px; letter-spacing: .01em;
|
|
1707
|
+
cursor: pointer; white-space: nowrap; transition: color .12s ease, border-color .12s ease, background .12s ease;
|
|
1708
|
+
/* Recent prompts are arbitrary user text — clamp each chip so a long prompt
|
|
1709
|
+
can't overflow the bar; full text stays available via the title tooltip. */
|
|
1710
|
+
max-width: 200px; overflow: hidden; text-overflow: ellipsis;
|
|
1711
|
+
}
|
|
1712
|
+
.chip.dismiss { max-width: none; }
|
|
1713
|
+
.chip:hover { color: var(--fg); border-color: var(--line-strong); background: var(--field); }
|
|
1714
|
+
.chip.dismiss { padding: 5px 7px; color: var(--faint); }
|
|
1715
|
+
|
|
1716
|
+
.status {
|
|
1717
|
+
font-size: 11.5px; margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--line);
|
|
1718
|
+
min-height: 15px; color: var(--muted); display: none; line-height: 1.55;
|
|
1719
|
+
word-break: break-word; overflow-wrap: anywhere; max-height: 8.5em; overflow-y: auto;
|
|
1720
|
+
}
|
|
1721
|
+
.status.show { display: block; }
|
|
1722
|
+
.status.err { color: var(--err); }
|
|
1723
|
+
.status.ok { color: var(--fg); }
|
|
1724
|
+
.status b { font-weight: 700; color: var(--fg); }
|
|
1725
|
+
|
|
1726
|
+
.actions { display: none; gap: 8px; margin-top: 12px; }
|
|
1727
|
+
.actions.show { display: flex; }
|
|
1728
|
+
|
|
1729
|
+
/* Connector suggestion ("You're on Gmail — connect …"). */
|
|
1730
|
+
.connector { display: none; align-items: center; gap: 10px; margin-top: 12px;
|
|
1731
|
+
padding: 10px 12px; border: 1px solid var(--line); border-radius: 2px; background: var(--field); }
|
|
1732
|
+
.connector.show { display: flex; }
|
|
1733
|
+
.connector .ctext { flex: 1; font-size: 11px; color: var(--muted); line-height: 1.45; letter-spacing: .01em; }
|
|
1734
|
+
.connector .ctext b { color: var(--fg); font-weight: 700; }
|
|
1735
|
+
.connector .cbtn { background: var(--accent); color: var(--accent-ink); border: 0; border-radius: 2px;
|
|
1736
|
+
padding: 7px 12px; font-family: var(--mono); font-size: 10.5px; font-weight: 700; letter-spacing: .12em;
|
|
1737
|
+
text-transform: uppercase; cursor: pointer; white-space: nowrap; }
|
|
1738
|
+
.connector .cbtn:hover { opacity: .85; }
|
|
1739
|
+
.connector .cx { background: transparent; border: 0; color: var(--faint); cursor: pointer;
|
|
1740
|
+
font-size: 15px; line-height: 1; padding: 0 4px; }
|
|
1741
|
+
.connector .cx:hover { color: var(--fg); }
|
|
1742
|
+
|
|
1743
|
+
/* Element-picker button + targeted-element indicator. */
|
|
1744
|
+
.aim { background: transparent; color: var(--muted); border: 1px solid var(--line);
|
|
1745
|
+
padding: 0 12px; font-size: 15px; line-height: 1; }
|
|
1746
|
+
.aim:hover:not(:disabled) { color: var(--fg); border-color: var(--line-strong); }
|
|
1747
|
+
.aim.on { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); }
|
|
1748
|
+
.target { display: none; align-items: center; gap: 8px; margin-top: 10px;
|
|
1749
|
+
padding: 8px 10px; border: 1px solid var(--line-strong); border-radius: 2px; background: var(--field); }
|
|
1750
|
+
.target.show { display: flex; }
|
|
1751
|
+
.target .ttext { flex: 1; font-size: 11px; color: var(--fg); letter-spacing: .01em; line-height: 1.4;
|
|
1752
|
+
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
1753
|
+
.target .ttext b { font-weight: 700; }
|
|
1754
|
+
.target .tx { background: transparent; border: 0; color: var(--faint); cursor: pointer;
|
|
1755
|
+
font-size: 15px; line-height: 1; padding: 0 4px; }
|
|
1756
|
+
.target .tx:hover { color: var(--fg); }
|
|
1757
|
+
|
|
1758
|
+
.spinner { display: inline-block; width: 11px; height: 11px; border: 1.5px solid rgba(127,127,140,0.4);
|
|
1759
|
+
border-top-color: var(--accent-ink); border-radius: 50%; animation: spin .6s linear infinite; vertical-align: -1px; }
|
|
1760
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
1761
|
+
`;
|
|
1762
|
+
|
|
1763
|
+
function build() {
|
|
1764
|
+
host = document.createElement('div');
|
|
1765
|
+
host.id = HOST_ID;
|
|
1766
|
+
root = host.attachShadow({ mode: 'open' });
|
|
1767
|
+
|
|
1768
|
+
const style = document.createElement('style');
|
|
1769
|
+
style.textContent = CSS;
|
|
1770
|
+
root.appendChild(style);
|
|
1771
|
+
|
|
1772
|
+
const wrap = document.createElement('div');
|
|
1773
|
+
wrap.className = 'wrap';
|
|
1774
|
+
wrap.setAttribute('role', 'dialog');
|
|
1775
|
+
wrap.setAttribute('aria-modal', 'true');
|
|
1776
|
+
wrap.setAttribute('aria-label', 'Morph prompt');
|
|
1777
|
+
// Static structure only — no untrusted text is interpolated here.
|
|
1778
|
+
wrap.innerHTML = `
|
|
1779
|
+
<div class="row">
|
|
1780
|
+
<div class="logo" aria-hidden="true"><i class="mark"></i>MORPH</div>
|
|
1781
|
+
<input class="q" type="text" autocomplete="off" spellcheck="false"
|
|
1782
|
+
aria-label="Describe how this page should look"
|
|
1783
|
+
placeholder="Reshape this page…" />
|
|
1784
|
+
<button class="aim" type="button" aria-label="Pick an element to target" title="Pick an element to change (Esc to cancel)">⌖</button>
|
|
1785
|
+
<button class="go" aria-label="Reshape the page">
|
|
1786
|
+
<span class="go-spin" hidden><span class="spinner"></span></span><span class="go-label">Morph it</span>
|
|
1787
|
+
</button>
|
|
1788
|
+
<button class="x" aria-label="Close Morph" title="Close (Esc)">×</button>
|
|
1789
|
+
</div>
|
|
1790
|
+
<div class="connector" role="note">
|
|
1791
|
+
<span class="ctext"></span>
|
|
1792
|
+
<button class="cbtn" type="button">Connect</button>
|
|
1793
|
+
<button class="cx" type="button" aria-label="Dismiss connector suggestion">×</button>
|
|
1794
|
+
</div>
|
|
1795
|
+
<div class="target" role="note">
|
|
1796
|
+
<span class="ttext"></span>
|
|
1797
|
+
<button class="tx" type="button" aria-label="Clear the targeted element">×</button>
|
|
1798
|
+
</div>
|
|
1799
|
+
<div class="chips examples" aria-label="Example prompts"></div>
|
|
1800
|
+
<div class="chips recent" aria-label="Recent prompts" hidden></div>
|
|
1801
|
+
<div class="status" role="status" aria-live="polite"></div>
|
|
1802
|
+
<div class="actions">
|
|
1803
|
+
<button class="ghost undo" aria-label="Undo and forget this site's recipe">Undo & forget</button>
|
|
1804
|
+
</div>
|
|
1805
|
+
`;
|
|
1806
|
+
root.appendChild(wrap);
|
|
1807
|
+
|
|
1808
|
+
els = {
|
|
1809
|
+
wrap,
|
|
1810
|
+
input: wrap.querySelector('input.q'),
|
|
1811
|
+
go: wrap.querySelector('.go'),
|
|
1812
|
+
goLabel: wrap.querySelector('.go-label'),
|
|
1813
|
+
goSpin: wrap.querySelector('.go-spin'),
|
|
1814
|
+
close: wrap.querySelector('.x'),
|
|
1815
|
+
examples: wrap.querySelector('.chips.examples'),
|
|
1816
|
+
recent: wrap.querySelector('.chips.recent'),
|
|
1817
|
+
status: wrap.querySelector('.status'),
|
|
1818
|
+
actions: wrap.querySelector('.actions'),
|
|
1819
|
+
undo: wrap.querySelector('.undo'),
|
|
1820
|
+
connector: wrap.querySelector('.connector'),
|
|
1821
|
+
ctext: wrap.querySelector('.connector .ctext'),
|
|
1822
|
+
cbtn: wrap.querySelector('.connector .cbtn'),
|
|
1823
|
+
cx: wrap.querySelector('.connector .cx'),
|
|
1824
|
+
aim: wrap.querySelector('.aim'),
|
|
1825
|
+
target: wrap.querySelector('.target'),
|
|
1826
|
+
ttext: wrap.querySelector('.target .ttext'),
|
|
1827
|
+
tx: wrap.querySelector('.target .tx'),
|
|
1828
|
+
};
|
|
1829
|
+
|
|
1830
|
+
renderExamples();
|
|
1831
|
+
|
|
1832
|
+
els.go.addEventListener('click', submit);
|
|
1833
|
+
els.input.addEventListener('keydown', (e) => {
|
|
1834
|
+
if (e.key === 'Enter') { e.preventDefault(); submit(); }
|
|
1835
|
+
if (e.key === 'Escape') { e.preventDefault(); hide(); }
|
|
1836
|
+
});
|
|
1837
|
+
els.input.addEventListener('input', updateChipVisibility);
|
|
1838
|
+
|
|
1839
|
+
// Contain ALL keystrokes inside the bar so they never reach the host page's
|
|
1840
|
+
// global keyboard shortcuts (e.g. GitHub/Gmail/X single-key hotkeys that
|
|
1841
|
+
// would otherwise fire because our shadow-host is not a recognized input).
|
|
1842
|
+
// stopPropagation does not block typing (default action still happens).
|
|
1843
|
+
['keydown', 'keyup', 'keypress'].forEach((evt) =>
|
|
1844
|
+
wrap.addEventListener(evt, (e) => { e.stopPropagation(); }));
|
|
1845
|
+
els.close.addEventListener('click', hide);
|
|
1846
|
+
els.undo.addEventListener('click', () => handlers.onUndo && handlers.onUndo());
|
|
1847
|
+
els.cbtn.addEventListener('click', () => { if (connectHandler) connectHandler(); });
|
|
1848
|
+
els.cx.addEventListener('click', hideConnector);
|
|
1849
|
+
els.aim.addEventListener('click', () => { if (handlers.onPick) handlers.onPick(); });
|
|
1850
|
+
els.tx.addEventListener('click', () => { const cb = targetClear; clearTarget(); if (cb) cb(); });
|
|
1851
|
+
|
|
1852
|
+
// Focus trap + Esc, scoped to the shadow root while visible.
|
|
1853
|
+
wrap.addEventListener('keydown', onWrapKeydown);
|
|
1854
|
+
|
|
1855
|
+
(document.body || document.documentElement).appendChild(host);
|
|
1856
|
+
|
|
1857
|
+
// Drop the legacy global recents, then pull THIS site's recents asynchronously;
|
|
1858
|
+
// never throws if chrome.storage is absent.
|
|
1859
|
+
removeLegacyRecent();
|
|
1860
|
+
loadRecent();
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
// --- example / recent chip rendering (text is injected via textContent only) ---
|
|
1864
|
+
|
|
1865
|
+
function makeChip(text, cls) {
|
|
1866
|
+
const b = document.createElement('button');
|
|
1867
|
+
b.type = 'button';
|
|
1868
|
+
b.className = 'chip' + (cls ? ' ' + cls : '');
|
|
1869
|
+
b.textContent = text; // NEVER innerHTML — text may be user-authored.
|
|
1870
|
+
b.setAttribute('aria-label', 'Use prompt: ' + text);
|
|
1871
|
+
return b;
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
function fillAndSubmit(text) {
|
|
1875
|
+
if (!els) return;
|
|
1876
|
+
els.input.value = text;
|
|
1877
|
+
els.input.focus();
|
|
1878
|
+
submit(); // routes through the same submit() so onSubmit fires unchanged
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
function renderExamples() {
|
|
1882
|
+
if (!els) return;
|
|
1883
|
+
els.examples.textContent = '';
|
|
1884
|
+
if (!currentSuggestions.length) { els.examples.hidden = true; return; }
|
|
1885
|
+
els.examples.hidden = false;
|
|
1886
|
+
currentSuggestions.forEach((ex) => {
|
|
1887
|
+
const chip = makeChip(ex);
|
|
1888
|
+
chip.addEventListener('click', () => fillAndSubmit(ex));
|
|
1889
|
+
els.examples.appendChild(chip);
|
|
1890
|
+
});
|
|
1891
|
+
updateChipVisibility();
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
// Set the site-special suggestion chips (plain strings; rendered via textContent).
|
|
1895
|
+
// An empty list HIDES the row — generic restyle is offered by the one-tap presets,
|
|
1896
|
+
// so we never duplicate it here.
|
|
1897
|
+
function setSuggestions(list) {
|
|
1898
|
+
currentSuggestions = Array.isArray(list)
|
|
1899
|
+
? list.filter((s) => typeof s === 'string' && s.trim()).slice(0, 8)
|
|
1900
|
+
: [];
|
|
1901
|
+
if (els) renderExamples();
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
function renderRecent(list) {
|
|
1905
|
+
if (!els) return;
|
|
1906
|
+
els.recent.textContent = '';
|
|
1907
|
+
const items = Array.isArray(list) ? list.filter((s) => typeof s === 'string' && s.trim()) : [];
|
|
1908
|
+
if (!items.length) { els.recent.hidden = true; return; }
|
|
1909
|
+
|
|
1910
|
+
const lead = document.createElement('span');
|
|
1911
|
+
lead.className = 'lead';
|
|
1912
|
+
lead.textContent = 'Recent';
|
|
1913
|
+
els.recent.appendChild(lead);
|
|
1914
|
+
|
|
1915
|
+
items.slice(0, RECENT_CAP).forEach((p) => {
|
|
1916
|
+
const chip = makeChip(p);
|
|
1917
|
+
chip.title = p;
|
|
1918
|
+
chip.addEventListener('click', () => fillAndSubmit(p));
|
|
1919
|
+
els.recent.appendChild(chip);
|
|
1920
|
+
});
|
|
1921
|
+
|
|
1922
|
+
const clear = makeChip('×', 'dismiss');
|
|
1923
|
+
clear.setAttribute('aria-label', 'Clear recent prompts');
|
|
1924
|
+
clear.title = 'Clear recent prompts';
|
|
1925
|
+
clear.addEventListener('click', clearRecent);
|
|
1926
|
+
els.recent.appendChild(clear);
|
|
1927
|
+
|
|
1928
|
+
els.recent.hidden = false;
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
// Examples are unobtrusive: hidden once a recipe is active or the input has text.
|
|
1932
|
+
function updateChipVisibility() {
|
|
1933
|
+
if (!els) return;
|
|
1934
|
+
const hasText = !!els.input.value.trim();
|
|
1935
|
+
els.examples.classList.toggle('hidden', recipeActive || hasText);
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
// --- recent-prompts memory (chrome.storage.local, guarded for jsdom/plain DOM) ---
|
|
1939
|
+
|
|
1940
|
+
function hasStorage() {
|
|
1941
|
+
return typeof chrome !== 'undefined' && chrome.storage && chrome.storage.local;
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
// Pure: dedupe (case-insensitive), most-recent-first, capped. Exported for tests below.
|
|
1945
|
+
function updateRecentList(list, prompt, cap) {
|
|
1946
|
+
const max = cap || RECENT_CAP;
|
|
1947
|
+
const next = Array.isArray(list) ? list.slice() : [];
|
|
1948
|
+
const p = String(prompt == null ? '' : prompt).trim();
|
|
1949
|
+
if (!p) return next.slice(0, max);
|
|
1950
|
+
const out = [p];
|
|
1951
|
+
for (const item of next) {
|
|
1952
|
+
if (typeof item !== 'string') continue;
|
|
1953
|
+
const t = item.trim();
|
|
1954
|
+
if (!t) continue;
|
|
1955
|
+
if (t.toLowerCase() === p.toLowerCase()) continue;
|
|
1956
|
+
out.push(t);
|
|
1957
|
+
if (out.length >= max) break;
|
|
1958
|
+
}
|
|
1959
|
+
return out.slice(0, max);
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
// The current site's bucket key. Recents are scoped to the page origin.
|
|
1963
|
+
function originKey() {
|
|
1964
|
+
try { return location.origin || '(unknown)'; } catch (_) { return '(unknown)'; }
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
// Pure: write `prompt` into the per-origin recent map and return a NEW map. Each origin
|
|
1968
|
+
// keeps its own deduped/capped list; the number of origins is bounded (oldest-written
|
|
1969
|
+
// dropped first). Exported for tests below.
|
|
1970
|
+
function updateRecentMap(map, origin, prompt, cap, maxOrigins) {
|
|
1971
|
+
const m = map && typeof map === 'object' && !Array.isArray(map) ? Object.assign({}, map) : {};
|
|
1972
|
+
const key = origin || '(unknown)';
|
|
1973
|
+
const list = updateRecentList(Array.isArray(m[key]) ? m[key] : [], prompt, cap);
|
|
1974
|
+
delete m[key]; // re-insert last so this origin is "most recently written"
|
|
1975
|
+
if (list.length) m[key] = list;
|
|
1976
|
+
const capN = maxOrigins || MAX_ORIGINS;
|
|
1977
|
+
const keys = Object.keys(m); // insertion order
|
|
1978
|
+
if (keys.length > capN) for (const k of keys.slice(0, keys.length - capN)) delete m[k];
|
|
1979
|
+
return m;
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
function loadRecent() {
|
|
1983
|
+
if (!hasStorage()) return;
|
|
1984
|
+
try {
|
|
1985
|
+
chrome.storage.local.get(RECENT_KEY, (d) => {
|
|
1986
|
+
if (chrome.runtime && chrome.runtime.lastError) return;
|
|
1987
|
+
const map = (d && d[RECENT_KEY]) || {};
|
|
1988
|
+
renderRecent(map[originKey()] || []);
|
|
1989
|
+
});
|
|
1990
|
+
} catch (_) { /* ignore */ }
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
function pushRecent(prompt) {
|
|
1994
|
+
if (!hasStorage()) return;
|
|
1995
|
+
try {
|
|
1996
|
+
chrome.storage.local.get(RECENT_KEY, (d) => {
|
|
1997
|
+
const origin = originKey();
|
|
1998
|
+
const map = updateRecentMap((d && d[RECENT_KEY]) || {}, origin, prompt, RECENT_CAP, MAX_ORIGINS);
|
|
1999
|
+
chrome.storage.local.set({ [RECENT_KEY]: map }, () => {
|
|
2000
|
+
if (chrome.runtime && chrome.runtime.lastError) return;
|
|
2001
|
+
renderRecent(map[origin] || []);
|
|
2002
|
+
});
|
|
2003
|
+
});
|
|
2004
|
+
} catch (_) { /* ignore */ }
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
function clearRecent() {
|
|
2008
|
+
if (hasStorage()) {
|
|
2009
|
+
try {
|
|
2010
|
+
chrome.storage.local.get(RECENT_KEY, (d) => {
|
|
2011
|
+
const map = (d && d[RECENT_KEY]) || {};
|
|
2012
|
+
delete map[originKey()]; // clear THIS site only
|
|
2013
|
+
chrome.storage.local.set({ [RECENT_KEY]: map });
|
|
2014
|
+
});
|
|
2015
|
+
} catch (_) {}
|
|
2016
|
+
}
|
|
2017
|
+
renderRecent([]);
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
// One-time cleanup of the pre-1.0.5 global recents key so old cross-site prompts vanish.
|
|
2021
|
+
function removeLegacyRecent() {
|
|
2022
|
+
if (!hasStorage()) return;
|
|
2023
|
+
try { chrome.storage.local.remove(LEGACY_RECENT_KEY); } catch (_) {}
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
// --- submit ---
|
|
2027
|
+
|
|
2028
|
+
function submit() {
|
|
2029
|
+
const q = els.input.value.trim();
|
|
2030
|
+
if (!q || !handlers.onSubmit) return;
|
|
2031
|
+
// Optimistically remember the prompt; content.js drives the actual success/error UI.
|
|
2032
|
+
pushRecent(q);
|
|
2033
|
+
handlers.onSubmit(q);
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
// --- status (TRUSTED markup) ---
|
|
2037
|
+
//
|
|
2038
|
+
// INVARIANT: setStatus receives TRUSTED HTML from content.js — it passes spinner/<b>
|
|
2039
|
+
// markup plus user text that content.js has ALREADY escaped via its own escapeHtml().
|
|
2040
|
+
// We MUST render it with innerHTML and MUST NOT escape here; escaping would double-encode
|
|
2041
|
+
// the user text and break the spinner/bold markup. This is part of the public contract.
|
|
2042
|
+
function setStatus(text, kind) {
|
|
2043
|
+
els.status.className = 'status show' + (kind ? ' ' + kind : '');
|
|
2044
|
+
els.status.innerHTML = text;
|
|
2045
|
+
els.status.scrollTop = 0;
|
|
2046
|
+
}
|
|
2047
|
+
function clearStatus() { els.status.className = 'status'; els.status.innerHTML = ''; }
|
|
2048
|
+
|
|
2049
|
+
function setBusy(busy) {
|
|
2050
|
+
els.go.disabled = busy;
|
|
2051
|
+
els.input.disabled = busy;
|
|
2052
|
+
if (els.goSpin) els.goSpin.hidden = !busy;
|
|
2053
|
+
if (els.goLabel) els.goLabel.textContent = busy ? 'Reshaping…' : 'Morph it';
|
|
2054
|
+
// Disable chips while busy so a second submit can't race.
|
|
2055
|
+
setChipsDisabled(busy);
|
|
2056
|
+
els.wrap.setAttribute('aria-busy', busy ? 'true' : 'false');
|
|
2057
|
+
if (busy) setStatus('<span class="spinner"></span>Reshaping the page…');
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
function setChipsDisabled(disabled) {
|
|
2061
|
+
if (!els) return;
|
|
2062
|
+
const chips = els.wrap.querySelectorAll('.chip');
|
|
2063
|
+
chips.forEach((c) => { c.disabled = !!disabled; });
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
function showActions(show) {
|
|
2067
|
+
els.actions.className = 'actions' + (show ? ' show' : '');
|
|
2068
|
+
// A live recipe means example chips are no longer the right first thing to show.
|
|
2069
|
+
recipeActive = !!show;
|
|
2070
|
+
updateChipVisibility();
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
// --- focus trap ---
|
|
2074
|
+
|
|
2075
|
+
function focusable() {
|
|
2076
|
+
if (!root) return [];
|
|
2077
|
+
const sel = 'input:not([disabled]), button:not([disabled]):not([hidden])';
|
|
2078
|
+
return Array.prototype.filter.call(root.querySelectorAll(sel), (el) => {
|
|
2079
|
+
// Skip elements inside a hidden chips row.
|
|
2080
|
+
if (el.classList && el.classList.contains('chip')) {
|
|
2081
|
+
const rowHidden = el.closest('.chips') && el.closest('.chips').hidden;
|
|
2082
|
+
const rowOff = el.closest('.chips') && el.closest('.chips').classList.contains('hidden');
|
|
2083
|
+
if (rowHidden || rowOff) return false;
|
|
2084
|
+
}
|
|
2085
|
+
return el.offsetParent !== null || el === els.input;
|
|
2086
|
+
});
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
function onWrapKeydown(e) {
|
|
2090
|
+
if (e.key === 'Escape') { e.preventDefault(); hide(); return; }
|
|
2091
|
+
if (e.key !== 'Tab' || !visible) return;
|
|
2092
|
+
const items = focusable();
|
|
2093
|
+
if (!items.length) return;
|
|
2094
|
+
const first = items[0];
|
|
2095
|
+
const last = items[items.length - 1];
|
|
2096
|
+
const active = root.activeElement;
|
|
2097
|
+
if (e.shiftKey) {
|
|
2098
|
+
if (active === first || !root.contains(active)) { e.preventDefault(); last.focus(); }
|
|
2099
|
+
} else {
|
|
2100
|
+
if (active === last || !root.contains(active)) { e.preventDefault(); first.focus(); }
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
// --- show / hide ---
|
|
2105
|
+
|
|
2106
|
+
function show() {
|
|
2107
|
+
if (!host) build();
|
|
2108
|
+
visible = true;
|
|
2109
|
+
lastFocused = (document.activeElement && document.activeElement !== document.body)
|
|
2110
|
+
? document.activeElement : null;
|
|
2111
|
+
updateChipVisibility();
|
|
2112
|
+
requestAnimationFrame(() => els.wrap.classList.add('show'));
|
|
2113
|
+
setTimeout(() => els.input && els.input.focus(), 60);
|
|
2114
|
+
}
|
|
2115
|
+
function hide() {
|
|
2116
|
+
if (!host) return;
|
|
2117
|
+
visible = false;
|
|
2118
|
+
els.wrap.classList.remove('show');
|
|
2119
|
+
// Let content.js tear down transient state (cancel pick mode, drop the target).
|
|
2120
|
+
if (handlers.onHide) { try { handlers.onHide(); } catch (_) {} }
|
|
2121
|
+
// Restore focus to whatever the page had before we opened — never trap when idle.
|
|
2122
|
+
const target = lastFocused;
|
|
2123
|
+
lastFocused = null;
|
|
2124
|
+
if (target && typeof target.focus === 'function' && document.contains(target)) {
|
|
2125
|
+
try { target.focus(); } catch (_) {}
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
function toggle() { visible ? hide() : show(); }
|
|
2129
|
+
|
|
2130
|
+
function focusInput() { if (els && els.input) els.input.focus(); }
|
|
2131
|
+
|
|
2132
|
+
// Connector suggestion ("You're on Gmail — connect …"). info: {name, blurb, onConnect}.
|
|
2133
|
+
function setConnector(info) {
|
|
2134
|
+
if (!els || !info) return;
|
|
2135
|
+
connectHandler = typeof info.onConnect === 'function' ? info.onConnect : null;
|
|
2136
|
+
els.ctext.textContent = '';
|
|
2137
|
+
const b = document.createElement('b');
|
|
2138
|
+
b.textContent = info.name || 'this app';
|
|
2139
|
+
els.ctext.appendChild(b);
|
|
2140
|
+
els.ctext.appendChild(document.createTextNode(' — connect to ' + (info.blurb || 'pull real data') + '.'));
|
|
2141
|
+
els.cbtn.textContent = info.cta || 'Connect';
|
|
2142
|
+
els.connector.classList.add('show');
|
|
2143
|
+
}
|
|
2144
|
+
function hideConnector() { if (els) els.connector.classList.remove('show'); }
|
|
2145
|
+
function setConnectorBusy(text) { if (els) els.ctext.textContent = text || 'Connecting…'; }
|
|
2146
|
+
|
|
2147
|
+
// Targeted-element indicator. info: { label, onClear }. Text is set via
|
|
2148
|
+
// textContent (label may be page-authored), never innerHTML.
|
|
2149
|
+
function setTarget(info) {
|
|
2150
|
+
if (!els || !info) return;
|
|
2151
|
+
targetClear = typeof info.onClear === 'function' ? info.onClear : null;
|
|
2152
|
+
els.ttext.textContent = '';
|
|
2153
|
+
els.ttext.appendChild(document.createTextNode('Targeting '));
|
|
2154
|
+
const b = document.createElement('b');
|
|
2155
|
+
b.textContent = info.label || 'element';
|
|
2156
|
+
els.ttext.appendChild(b);
|
|
2157
|
+
els.target.classList.add('show');
|
|
2158
|
+
if (els.aim) els.aim.classList.add('on');
|
|
2159
|
+
}
|
|
2160
|
+
function clearTarget() {
|
|
2161
|
+
if (!els) return;
|
|
2162
|
+
els.target.classList.remove('show');
|
|
2163
|
+
if (els.aim) els.aim.classList.remove('on');
|
|
2164
|
+
targetClear = null;
|
|
2165
|
+
}
|
|
2166
|
+
function aimActive(on) { if (els && els.aim) els.aim.classList.toggle('on', !!on); }
|
|
2167
|
+
|
|
2168
|
+
window.Morph.bar = {
|
|
2169
|
+
mount(h) { handlers = h || {}; if (!host) build(); },
|
|
2170
|
+
show, hide, toggle,
|
|
2171
|
+
setStatus, clearStatus, setBusy, showActions,
|
|
2172
|
+
isVisible: () => visible,
|
|
2173
|
+
clearInput: () => { if (els) { els.input.value = ''; updateChipVisibility(); } },
|
|
2174
|
+
// Non-breaking additions:
|
|
2175
|
+
focusInput,
|
|
2176
|
+
setRecent: (list) => renderRecent(list),
|
|
2177
|
+
setSuggestions,
|
|
2178
|
+
setConnector, hideConnector, setConnectorBusy,
|
|
2179
|
+
setTarget, clearTarget, aimActive,
|
|
2180
|
+
};
|
|
2181
|
+
|
|
2182
|
+
// Expose pure helper for the optional test harness without coupling to chrome/DOM.
|
|
2183
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
2184
|
+
module.exports = { updateRecentList, updateRecentMap };
|
|
2185
|
+
}
|
|
2186
|
+
})();
|
|
2187
|
+
|
|
2188
|
+
|
|
2189
|
+
/* ---- sdk/src/embed.js ---- */
|
|
2190
|
+
/*
|
|
2191
|
+
* Morph SDK — embed entry. Wires the engine (MorphDSL), the vendor registry,
|
|
2192
|
+
* the recipe store, and the prompt bar into the host page.
|
|
2193
|
+
*
|
|
2194
|
+
* createEmbed(deps) is pure wiring with every dependency injected (tests use
|
|
2195
|
+
* it directly with a stubbed backend). The browser block at the bottom builds
|
|
2196
|
+
* the real deps and exposes window.Morph.init(config) for vendors.
|
|
2197
|
+
*
|
|
2198
|
+
* Security invariants enforced here:
|
|
2199
|
+
* - only source ops that pass registry.validateDataOp survive to apply;
|
|
2200
|
+
* - rows are resolved by TRUSTED code (registry -> query engine) and the
|
|
2201
|
+
* vendor's records never leave the page (only manifests go to the backend);
|
|
2202
|
+
* - action buttons route through registry.invokeAction (schema + confirm).
|
|
2203
|
+
*/
|
|
2204
|
+
(function (global, factory) {
|
|
2205
|
+
const api = factory();
|
|
2206
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
2207
|
+
else global.MorphEmbed = api;
|
|
2208
|
+
})(typeof self !== 'undefined' ? self : this, function () {
|
|
2209
|
+
'use strict';
|
|
2210
|
+
|
|
2211
|
+
const DEFAULT_BACKEND = 'https://morph-api-234584917638.us-central1.run.app';
|
|
2212
|
+
|
|
2213
|
+
function createEmbed(deps) {
|
|
2214
|
+
const config = (deps && deps.config) || {};
|
|
2215
|
+
const win = deps.win;
|
|
2216
|
+
const DSL = deps.DSL;
|
|
2217
|
+
const registry = deps.registry;
|
|
2218
|
+
const store = deps.store;
|
|
2219
|
+
const bar = deps.bar;
|
|
2220
|
+
const extractContext = deps.extractContext;
|
|
2221
|
+
const fetchFn = deps.fetchFn;
|
|
2222
|
+
|
|
2223
|
+
let backend = DEFAULT_BACKEND;
|
|
2224
|
+
let currentHandle = null; // live undo handle for everything applied this session
|
|
2225
|
+
let activeTransform = null; // merged transform applied this session
|
|
2226
|
+
|
|
2227
|
+
function pathKey() {
|
|
2228
|
+
try { return win.location.pathname || '/'; } catch (_) { return '/'; }
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
function init() {
|
|
2232
|
+
if (typeof config.key !== 'string' || !config.key) {
|
|
2233
|
+
throw new TypeError('Morph: init requires a publishable key (config.key)');
|
|
2234
|
+
}
|
|
2235
|
+
backend = (typeof config.backend === 'string' && config.backend)
|
|
2236
|
+
? config.backend.replace(/\/$/, '') : DEFAULT_BACKEND;
|
|
2237
|
+
bar.mount({ onSubmit, onUndo });
|
|
2238
|
+
return api;
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
// Confirm gate for actions. window.confirm is trusted browser chrome the
|
|
2242
|
+
// page can't spoof; a bar-styled dialog can replace it later.
|
|
2243
|
+
function confirmFn(name, description) {
|
|
2244
|
+
try {
|
|
2245
|
+
return win.confirm('Morph: run action "' + name + '"?' + (description ? '\n' + description : ''));
|
|
2246
|
+
} catch (_) { return false; }
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
function onAction(name, args) {
|
|
2250
|
+
return registry.invokeAction(name, args, confirmFn).then(
|
|
2251
|
+
() => { try { bar.setStatus('✓ Action "' + name + '" done.', 'ok'); } catch (_) { /* bar gone */ } },
|
|
2252
|
+
(e) => {
|
|
2253
|
+
const msg = String((e && e.message) || e);
|
|
2254
|
+
try { bar.setStatus(/declined/.test(msg) ? 'Action cancelled.' : 'Action failed: ' + msg, /declined/.test(msg) ? '' : 'err'); } catch (_) { /* bar gone */ }
|
|
2255
|
+
}
|
|
2256
|
+
);
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
// Keep only source-form renderData ops the registry recognizes.
|
|
2260
|
+
function dropUnknownSourceOps(transform) {
|
|
2261
|
+
const ops = transform.ops.filter((op) => {
|
|
2262
|
+
if (op.op !== 'renderData' || op.source === undefined) return true;
|
|
2263
|
+
return registry.validateDataOp(op).ok;
|
|
2264
|
+
});
|
|
2265
|
+
return Object.assign({}, transform, { ops });
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
// Resolve rows for every (registry-valid) source op. Failures drop that op
|
|
2269
|
+
// so one dead source never blocks the rest of the reshape.
|
|
2270
|
+
async function resolveAllData(transform) {
|
|
2271
|
+
const data = {};
|
|
2272
|
+
const sourceOps = transform.ops.filter((op) => op.op === 'renderData' && op.source !== undefined);
|
|
2273
|
+
const results = await Promise.allSettled(sourceOps.map(async (op) => {
|
|
2274
|
+
data[op.source] = await registry.resolveData(op);
|
|
2275
|
+
}));
|
|
2276
|
+
const failedSources = new Set();
|
|
2277
|
+
results.forEach((r, i) => { if (r.status === 'rejected') failedSources.add(sourceOps[i].source); });
|
|
2278
|
+
const ops = transform.ops.filter((op) =>
|
|
2279
|
+
!(op.op === 'renderData' && op.source !== undefined && failedSources.has(op.source) && !Array.isArray(data[op.source])));
|
|
2280
|
+
return { transform: Object.assign({}, transform, { ops }), data, failedCount: failedSources.size };
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
async function applyMerged(incoming, data) {
|
|
2284
|
+
// Stack on top of anything already applied this session (additive), so
|
|
2285
|
+
// successive prompts accumulate instead of replacing each other.
|
|
2286
|
+
const merged = activeTransform ? DSL.mergeTransforms(activeTransform, incoming) : incoming;
|
|
2287
|
+
if (currentHandle) DSL.undoTransform(currentHandle);
|
|
2288
|
+
const handle = DSL.applyTransform(merged, win.document, { groupId: 'morph-sdk', data, onAction });
|
|
2289
|
+
currentHandle = handle;
|
|
2290
|
+
activeTransform = merged;
|
|
2291
|
+
return handle;
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2294
|
+
async function onSubmit(prompt) {
|
|
2295
|
+
try { bar.setBusy(true); bar.showActions(false); } catch (_) { /* bar optional in tests */ }
|
|
2296
|
+
let context = '';
|
|
2297
|
+
try { context = extractContext() || ''; } catch (_) { /* snapshot is best-effort */ }
|
|
2298
|
+
|
|
2299
|
+
let resp;
|
|
2300
|
+
try {
|
|
2301
|
+
const body = { prompt, context, url: win.location.href };
|
|
2302
|
+
if (registry.hasSources() || registry.hasActions()) body.manifests = registry.manifests();
|
|
2303
|
+
const r = await fetchFn(backend + '/api/transform', {
|
|
2304
|
+
method: 'POST',
|
|
2305
|
+
headers: { 'Content-Type': 'application/json', 'X-Morph-Key': config.key },
|
|
2306
|
+
body: JSON.stringify(body),
|
|
2307
|
+
});
|
|
2308
|
+
if (!r || !r.ok) {
|
|
2309
|
+
const j = r && r.json ? await r.json().catch(() => null) : null;
|
|
2310
|
+
throw new Error((j && j.error) || ('backend error ' + (r && r.status)));
|
|
2311
|
+
}
|
|
2312
|
+
resp = await r.json();
|
|
2313
|
+
} catch (e) {
|
|
2314
|
+
try { bar.setBusy(false); bar.setStatus('Could not reach Morph: ' + String((e && e.message) || e), 'err'); } catch (_) { /* noop */ }
|
|
2315
|
+
return;
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
try {
|
|
2319
|
+
const v = DSL.validateTransform(resp && resp.transform);
|
|
2320
|
+
if (!v.ok) {
|
|
2321
|
+
bar.setBusy(false);
|
|
2322
|
+
bar.setStatus('Couldn’t make that change here — try rephrasing.', '');
|
|
2323
|
+
return;
|
|
2324
|
+
}
|
|
2325
|
+
const gated = dropUnknownSourceOps(v.transform);
|
|
2326
|
+
const { transform, data, failedCount } = await resolveAllData(gated);
|
|
2327
|
+
if (!transform.ops.length) {
|
|
2328
|
+
bar.setBusy(false);
|
|
2329
|
+
bar.setStatus(failedCount ? 'Couldn’t load data for that widget.' : 'Couldn’t make that change here — try rephrasing.', failedCount ? 'err' : '');
|
|
2330
|
+
return;
|
|
2331
|
+
}
|
|
2332
|
+
const handle = await applyMerged(transform, data);
|
|
2333
|
+
bar.setBusy(false);
|
|
2334
|
+
if (!handle || handle.applied === 0) {
|
|
2335
|
+
bar.setStatus('Couldn’t make that change here — try rephrasing.', '');
|
|
2336
|
+
return;
|
|
2337
|
+
}
|
|
2338
|
+
const anchored = DSL.withAnchors ? DSL.withAnchors(transform, win.document) : transform;
|
|
2339
|
+
await store.appendTransform(pathKey(), anchored, { prompt }, DSL.mergeTransforms);
|
|
2340
|
+
const note = handle.failed ? ' (' + handle.failed + ' op(s) skipped)' : '';
|
|
2341
|
+
bar.setStatus('✓ ' + ((resp.transform && resp.transform.explanation) || 'Done.') + ' Saved for this page.' + note, 'ok');
|
|
2342
|
+
bar.showActions(true);
|
|
2343
|
+
try { bar.clearInput(); } catch (_) { /* noop */ }
|
|
2344
|
+
} catch (e) {
|
|
2345
|
+
try { bar.setBusy(false); bar.setStatus('Something went wrong applying that change.', 'err'); } catch (_) { /* noop */ }
|
|
2346
|
+
}
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2349
|
+
async function onUndo() {
|
|
2350
|
+
if (currentHandle) { DSL.undoTransform(currentHandle); currentHandle = null; }
|
|
2351
|
+
activeTransform = null;
|
|
2352
|
+
await store.removeRecipe(pathKey());
|
|
2353
|
+
try { bar.setStatus('Reverted this page to its original look.', ''); bar.showActions(false); } catch (_) { /* noop */ }
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
// Reapply the saved recipe: CSS immediately (FOUC kill), the full
|
|
2357
|
+
// transform (with fresh data) once the DOM is ready.
|
|
2358
|
+
async function applySaved() {
|
|
2359
|
+
const recipe = await store.loadRecipe(pathKey());
|
|
2360
|
+
if (!recipe || recipe.enabled === false || !recipe.transform) return;
|
|
2361
|
+
const v = DSL.validateTransform(recipe.transform);
|
|
2362
|
+
if (!v.ok) return;
|
|
2363
|
+
|
|
2364
|
+
// 1) instant CSS so the page doesn't flash unstyled
|
|
2365
|
+
const cssOnly = Object.assign({}, v.transform, { ops: v.transform.ops.filter((o) => o.op === 'injectCSS') });
|
|
2366
|
+
let cssHandle = null;
|
|
2367
|
+
if (cssOnly.ops.length) cssHandle = DSL.applyTransform(cssOnly, win.document, { groupId: 'morph-sdk-early' });
|
|
2368
|
+
|
|
2369
|
+
// 2) full transform with fresh data once the DOM is ready
|
|
2370
|
+
const full = async () => {
|
|
2371
|
+
const gated = dropUnknownSourceOps(v.transform);
|
|
2372
|
+
const { transform, data } = await resolveAllData(gated);
|
|
2373
|
+
if (cssHandle) { DSL.undoTransform(cssHandle); cssHandle = null; }
|
|
2374
|
+
if (!transform.ops.length) return;
|
|
2375
|
+
await applyMerged(transform, data);
|
|
2376
|
+
};
|
|
2377
|
+
if (win.document.readyState === 'loading') {
|
|
2378
|
+
win.document.addEventListener('DOMContentLoaded', () => { full().catch(() => {}); }, { once: true });
|
|
2379
|
+
} else {
|
|
2380
|
+
await full();
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
const api = { init, onSubmit, onUndo, applySaved };
|
|
2385
|
+
return api;
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
// ---- browser wiring (skipped under Node/tests) ----------------------------
|
|
2389
|
+
if (typeof window !== 'undefined' && !(typeof module !== 'undefined' && module.exports)) {
|
|
2390
|
+
window.Morph = window.Morph || {};
|
|
2391
|
+
window.Morph.init = function (config) {
|
|
2392
|
+
config = config || {};
|
|
2393
|
+
const registry = window.MorphRegistry.createRegistry({ sources: config.sources, actions: config.actions });
|
|
2394
|
+
const store = window.MorphStorage.createStore(config.storage);
|
|
2395
|
+
// window.fetch exists in every real browser; the guard keeps init from
|
|
2396
|
+
// throwing in DOM test environments without fetch.
|
|
2397
|
+
const doFetch = typeof window.fetch === 'function'
|
|
2398
|
+
? window.fetch.bind(window)
|
|
2399
|
+
: () => Promise.reject(new Error('Morph: fetch unavailable'));
|
|
2400
|
+
const embed = createEmbed({
|
|
2401
|
+
config, win: window, DSL: window.MorphDSL, registry, store,
|
|
2402
|
+
bar: window.Morph.bar, extractContext: window.Morph.extractContext,
|
|
2403
|
+
fetchFn: doFetch,
|
|
2404
|
+
});
|
|
2405
|
+
embed.init();
|
|
2406
|
+
// Hotkey (default alt+m) + a programmatic opener for vendor-built buttons.
|
|
2407
|
+
const trigger = typeof config.trigger === 'string' ? config.trigger.toLowerCase() : 'alt+m';
|
|
2408
|
+
if (trigger !== 'none') {
|
|
2409
|
+
const key = trigger.split('+').pop();
|
|
2410
|
+
window.addEventListener('keydown', (e) => {
|
|
2411
|
+
if (e.altKey && String(e.key).toLowerCase() === key) { e.preventDefault(); window.Morph.bar.toggle(); }
|
|
2412
|
+
});
|
|
2413
|
+
}
|
|
2414
|
+
window.Morph.open = () => window.Morph.bar.show();
|
|
2415
|
+
embed.applySaved().catch(() => {});
|
|
2416
|
+
return embed;
|
|
2417
|
+
};
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
return { createEmbed };
|
|
2421
|
+
});
|
|
2422
|
+
|
|
2423
|
+
export function init(config) { return g.Morph.init(config); }
|
|
2424
|
+
export default { init };
|