morph-embed 0.2.2 → 0.3.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/dist/morph-embed.esm.mjs +295 -251
- package/dist/morph-embed.js +295 -251
- package/package.json +37 -37
package/dist/morph-embed.esm.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! Morph Embed SDK v0.
|
|
1
|
+
/*! Morph Embed SDK v0.3.0 — https://usemorph.xyz — MIT */
|
|
2
2
|
const g = typeof window !== "undefined" ? window : globalThis;
|
|
3
3
|
|
|
4
4
|
/* ---- shared/dsl.js ---- */
|
|
@@ -1365,255 +1365,259 @@ const g = typeof window !== "undefined" ? window : globalThis;
|
|
|
1365
1365
|
|
|
1366
1366
|
|
|
1367
1367
|
/* ---- sdk/src/storage.js ---- */
|
|
1368
|
-
/*
|
|
1369
|
-
* Morph SDK — recipe store. SCHEMA v2: a recipe is a LIST OF EDITS
|
|
1370
|
-
* ({id, prompt, ts, enabled, transform}) per page path, which is what makes
|
|
1371
|
-
* per-edit undo, history, and toggling possible in the SDK UI (Nielsen #3,
|
|
1372
|
-
* user control and freedom). v1 recipes (one merged blob) migrate on read.
|
|
1373
|
-
*
|
|
1374
|
-
* Default backing is localStorage (per-browser); a vendor may pass a custom
|
|
1375
|
-
* adapter {get,set,remove} (sync or Promise) to store recipes in their own
|
|
1376
|
-
* backend so a user's generated features follow them across devices.
|
|
1377
|
-
* Storage failures NEVER throw — a broken store must never break the host page.
|
|
1378
|
-
* UMD: Node module in tests, browser global `MorphStorage` in the SDK bundle.
|
|
1379
|
-
*/
|
|
1380
|
-
(function (global, factory) {
|
|
1381
|
-
const api = factory();
|
|
1382
|
-
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
1383
|
-
else global.MorphStorage = api;
|
|
1384
|
-
})(typeof self !== 'undefined' ? self : this, function () {
|
|
1385
|
-
'use strict';
|
|
1386
|
-
|
|
1387
|
-
const PREFIX = 'morph:sdk:recipe:';
|
|
1388
|
-
const SCHEMA_VERSION = 2;
|
|
1389
|
-
const MAX_PROMPT_LABEL = 300;
|
|
1390
|
-
const MAX_EDITS = 20;
|
|
1391
|
-
|
|
1392
|
-
// Default adapter: window.localStorage, JSON values, fully guarded.
|
|
1393
|
-
function localAdapter() {
|
|
1394
|
-
function ls() {
|
|
1395
|
-
try {
|
|
1396
|
-
return (typeof window !== 'undefined' && window.localStorage) ? window.localStorage : null;
|
|
1397
|
-
} catch (_) { return null; } // SecurityError in sandboxed frames
|
|
1398
|
-
}
|
|
1399
|
-
return {
|
|
1400
|
-
get(key) {
|
|
1401
|
-
const s = ls();
|
|
1402
|
-
if (!s) return undefined;
|
|
1403
|
-
const raw = s.getItem(key);
|
|
1404
|
-
if (raw == null) return undefined;
|
|
1405
|
-
try { return JSON.parse(raw); } catch (_) { return undefined; }
|
|
1406
|
-
},
|
|
1407
|
-
set(key, value) {
|
|
1408
|
-
const s = ls();
|
|
1409
|
-
if (!s) return;
|
|
1410
|
-
s.setItem(key, JSON.stringify(value));
|
|
1411
|
-
},
|
|
1412
|
-
remove(key) {
|
|
1413
|
-
const s = ls();
|
|
1414
|
-
if (!s) return;
|
|
1415
|
-
s.removeItem(key);
|
|
1416
|
-
},
|
|
1417
|
-
};
|
|
1418
|
-
}
|
|
1419
|
-
|
|
1420
|
-
function genId() {
|
|
1421
|
-
return 'e_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
|
1422
|
-
}
|
|
1423
|
-
|
|
1424
|
-
// mergeTransforms concats; recipes must UNION. Drop structurally identical
|
|
1425
|
-
// ops (first occurrence wins) so repeating a prompt can't bloat the recipe.
|
|
1426
|
-
function dedupeOps(transform) {
|
|
1427
|
-
if (!transform || !Array.isArray(transform.ops)) return transform;
|
|
1428
|
-
const seen = new Set();
|
|
1429
|
-
const ops = transform.ops.filter((op) => {
|
|
1430
|
-
let key;
|
|
1431
|
-
try { key = JSON.stringify(op); } catch (_) { return true; }
|
|
1432
|
-
if (seen.has(key)) return false;
|
|
1433
|
-
seen.add(key);
|
|
1434
|
-
return true;
|
|
1435
|
-
});
|
|
1436
|
-
return Object.assign({}, transform, { ops });
|
|
1437
|
-
}
|
|
1438
|
-
|
|
1439
|
-
// Merge the ENABLED edits, in order, into one transform (deduped). Returns
|
|
1440
|
-
// null when nothing is enabled. mergeFn is DSL.mergeTransforms when the
|
|
1441
|
-
// caller has the DSL; the concat fallback keeps this module dependency-free.
|
|
1442
|
-
function composeEdits(edits, mergeFn) {
|
|
1443
|
-
const enabled = (Array.isArray(edits) ? edits : []).filter((e) => e && e.enabled !== false && e.transform);
|
|
1444
|
-
if (!enabled.length) return null;
|
|
1445
|
-
let merged = enabled[0].transform;
|
|
1446
|
-
for (let i = 1; i < enabled.length; i++) {
|
|
1447
|
-
merged = (typeof mergeFn === 'function')
|
|
1448
|
-
? mergeFn(merged, enabled[i].transform)
|
|
1449
|
-
: Object.assign({}, merged, { ops: (merged.ops || []).concat(enabled[i].transform.ops || []) });
|
|
1450
|
-
}
|
|
1451
|
-
return dedupeOps(merged);
|
|
1452
|
-
}
|
|
1453
|
-
|
|
1454
|
-
// Human label for the whole recipe: unique enabled-edit prompts, in order.
|
|
1455
|
-
function promptLabel(edits) {
|
|
1456
|
-
const seen = new Set();
|
|
1457
|
-
const parts = [];
|
|
1458
|
-
for (const e of Array.isArray(edits) ? edits : []) {
|
|
1459
|
-
if (!e || e.enabled === false) continue;
|
|
1460
|
-
const p = String(e.prompt || '').trim();
|
|
1461
|
-
const k = p.toLowerCase();
|
|
1462
|
-
if (!p || seen.has(k)) continue;
|
|
1463
|
-
seen.add(k);
|
|
1464
|
-
parts.push(p);
|
|
1465
|
-
}
|
|
1466
|
-
return parts.join(' + ').slice(0, MAX_PROMPT_LABEL);
|
|
1467
|
-
}
|
|
1468
|
-
|
|
1469
|
-
// Any stored shape -> v2. v1 recipes (single merged transform) become one
|
|
1470
|
-
// edit so nothing a user saved before the redesign is lost.
|
|
1471
|
-
function migrate(raw, pathKey) {
|
|
1472
|
-
const base = { path: String(pathKey || '/'), enabled: true, schemaVersion: SCHEMA_VERSION, createdAt: (raw && raw.createdAt) || Date.now(), edits: [] };
|
|
1473
|
-
if (!raw || typeof raw !== 'object') return base;
|
|
1474
|
-
if (Array.isArray(raw.edits)) {
|
|
1475
|
-
base.edits = raw.edits.filter((e) => e && typeof e === 'object' && e.transform);
|
|
1476
|
-
return base;
|
|
1477
|
-
}
|
|
1478
|
-
if (raw.transform) {
|
|
1479
|
-
base.edits = [{
|
|
1480
|
-
id: genId(),
|
|
1481
|
-
prompt: String(raw.prompt || 'Earlier changes').slice(0, MAX_PROMPT_LABEL),
|
|
1482
|
-
ts: raw.createdAt || Date.now(),
|
|
1483
|
-
enabled: raw.enabled !== false,
|
|
1484
|
-
transform: raw.transform,
|
|
1485
|
-
}];
|
|
1486
|
-
}
|
|
1487
|
-
return base;
|
|
1488
|
-
}
|
|
1489
|
-
|
|
1490
|
-
function createStore(adapter) {
|
|
1491
|
-
const backing = (adapter && typeof adapter === 'object') ? adapter : localAdapter();
|
|
1492
|
-
const keyOf = (pathKey) => PREFIX + String(pathKey || '/');
|
|
1493
|
-
|
|
1494
|
-
// Serialize read-modify-write so rapid appends can't lose edits (same
|
|
1495
|
-
// pattern as extension/src/recipes.js).
|
|
1496
|
-
let writeChain = Promise.resolve();
|
|
1497
|
-
function serialize(fn) {
|
|
1498
|
-
const run = writeChain.then(fn, fn);
|
|
1499
|
-
writeChain = run.then(() => {}, () => {});
|
|
1500
|
-
return run;
|
|
1501
|
-
}
|
|
1502
|
-
|
|
1503
|
-
async function readMigrated(pathKey) {
|
|
1504
|
-
let raw;
|
|
1505
|
-
try { raw = await backing.get(keyOf(pathKey)); } catch (_) { raw = undefined; }
|
|
1506
|
-
if (!raw || typeof raw !== 'object') return null;
|
|
1507
|
-
return migrate(raw, pathKey);
|
|
1508
|
-
}
|
|
1509
|
-
|
|
1510
|
-
async function persist(pathKey, recipe) {
|
|
1511
|
-
await backing.set(keyOf(pathKey), recipe);
|
|
1512
|
-
return recipe;
|
|
1513
|
-
}
|
|
1514
|
-
|
|
1515
|
-
// Back-compat read shape: composed transform + merged prompt + the edits.
|
|
1516
|
-
async function loadRecipe(pathKey) {
|
|
1517
|
-
try {
|
|
1518
|
-
const r = await readMigrated(pathKey);
|
|
1519
|
-
if (!r) return null;
|
|
1520
|
-
return {
|
|
1521
|
-
path: r.path,
|
|
1522
|
-
enabled: r.edits.some((e) => e.enabled !== false),
|
|
1523
|
-
prompt: promptLabel(r.edits),
|
|
1524
|
-
transform: composeEdits(r.edits),
|
|
1525
|
-
edits: r.edits,
|
|
1526
|
-
schemaVersion: SCHEMA_VERSION,
|
|
1527
|
-
createdAt: r.createdAt,
|
|
1528
|
-
};
|
|
1529
|
-
} catch (_) { return null; }
|
|
1530
|
-
}
|
|
1531
|
-
|
|
1532
|
-
function saveRecipe(pathKey, recipe) {
|
|
1533
|
-
return serialize(async () => {
|
|
1534
|
-
try {
|
|
1535
|
-
const merged = Object.assign(
|
|
1536
|
-
{ path: String(pathKey || '/'), enabled: true, createdAt: Date.now() },
|
|
1537
|
-
recipe,
|
|
1538
|
-
{ schemaVersion: SCHEMA_VERSION, path: String(pathKey || '/') }
|
|
1539
|
-
);
|
|
1540
|
-
await persist(pathKey, merged);
|
|
1541
|
-
return merged;
|
|
1542
|
-
} catch (_) { return null; }
|
|
1543
|
-
});
|
|
1544
|
-
}
|
|
1545
|
-
|
|
1546
|
-
// Add one edit (the unit of undo/history). Returns the new edit or null.
|
|
1547
|
-
function appendEdit(pathKey, transform, meta) {
|
|
1548
|
-
return serialize(async () => {
|
|
1549
|
-
try {
|
|
1550
|
-
const r = (await readMigrated(pathKey)) || migrate(null, pathKey);
|
|
1551
|
-
const edit = {
|
|
1552
|
-
id: genId(),
|
|
1553
|
-
prompt: String((meta && meta.prompt) || '').slice(0, MAX_PROMPT_LABEL),
|
|
1554
|
-
ts: Date.now(),
|
|
1555
|
-
enabled: true,
|
|
1556
|
-
transform,
|
|
1557
|
-
};
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
r
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
function
|
|
1607
|
-
return
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
}
|
|
1368
|
+
/*
|
|
1369
|
+
* Morph SDK — recipe store. SCHEMA v2: a recipe is a LIST OF EDITS
|
|
1370
|
+
* ({id, prompt, ts, enabled, transform}) per page path, which is what makes
|
|
1371
|
+
* per-edit undo, history, and toggling possible in the SDK UI (Nielsen #3,
|
|
1372
|
+
* user control and freedom). v1 recipes (one merged blob) migrate on read.
|
|
1373
|
+
*
|
|
1374
|
+
* Default backing is localStorage (per-browser); a vendor may pass a custom
|
|
1375
|
+
* adapter {get,set,remove} (sync or Promise) to store recipes in their own
|
|
1376
|
+
* backend so a user's generated features follow them across devices.
|
|
1377
|
+
* Storage failures NEVER throw — a broken store must never break the host page.
|
|
1378
|
+
* UMD: Node module in tests, browser global `MorphStorage` in the SDK bundle.
|
|
1379
|
+
*/
|
|
1380
|
+
(function (global, factory) {
|
|
1381
|
+
const api = factory();
|
|
1382
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
1383
|
+
else global.MorphStorage = api;
|
|
1384
|
+
})(typeof self !== 'undefined' ? self : this, function () {
|
|
1385
|
+
'use strict';
|
|
1386
|
+
|
|
1387
|
+
const PREFIX = 'morph:sdk:recipe:';
|
|
1388
|
+
const SCHEMA_VERSION = 2;
|
|
1389
|
+
const MAX_PROMPT_LABEL = 300;
|
|
1390
|
+
const MAX_EDITS = 20;
|
|
1391
|
+
|
|
1392
|
+
// Default adapter: window.localStorage, JSON values, fully guarded.
|
|
1393
|
+
function localAdapter() {
|
|
1394
|
+
function ls() {
|
|
1395
|
+
try {
|
|
1396
|
+
return (typeof window !== 'undefined' && window.localStorage) ? window.localStorage : null;
|
|
1397
|
+
} catch (_) { return null; } // SecurityError in sandboxed frames
|
|
1398
|
+
}
|
|
1399
|
+
return {
|
|
1400
|
+
get(key) {
|
|
1401
|
+
const s = ls();
|
|
1402
|
+
if (!s) return undefined;
|
|
1403
|
+
const raw = s.getItem(key);
|
|
1404
|
+
if (raw == null) return undefined;
|
|
1405
|
+
try { return JSON.parse(raw); } catch (_) { return undefined; }
|
|
1406
|
+
},
|
|
1407
|
+
set(key, value) {
|
|
1408
|
+
const s = ls();
|
|
1409
|
+
if (!s) return;
|
|
1410
|
+
s.setItem(key, JSON.stringify(value));
|
|
1411
|
+
},
|
|
1412
|
+
remove(key) {
|
|
1413
|
+
const s = ls();
|
|
1414
|
+
if (!s) return;
|
|
1415
|
+
s.removeItem(key);
|
|
1416
|
+
},
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
function genId() {
|
|
1421
|
+
return 'e_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
// mergeTransforms concats; recipes must UNION. Drop structurally identical
|
|
1425
|
+
// ops (first occurrence wins) so repeating a prompt can't bloat the recipe.
|
|
1426
|
+
function dedupeOps(transform) {
|
|
1427
|
+
if (!transform || !Array.isArray(transform.ops)) return transform;
|
|
1428
|
+
const seen = new Set();
|
|
1429
|
+
const ops = transform.ops.filter((op) => {
|
|
1430
|
+
let key;
|
|
1431
|
+
try { key = JSON.stringify(op); } catch (_) { return true; }
|
|
1432
|
+
if (seen.has(key)) return false;
|
|
1433
|
+
seen.add(key);
|
|
1434
|
+
return true;
|
|
1435
|
+
});
|
|
1436
|
+
return Object.assign({}, transform, { ops });
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
// Merge the ENABLED edits, in order, into one transform (deduped). Returns
|
|
1440
|
+
// null when nothing is enabled. mergeFn is DSL.mergeTransforms when the
|
|
1441
|
+
// caller has the DSL; the concat fallback keeps this module dependency-free.
|
|
1442
|
+
function composeEdits(edits, mergeFn) {
|
|
1443
|
+
const enabled = (Array.isArray(edits) ? edits : []).filter((e) => e && e.enabled !== false && e.transform);
|
|
1444
|
+
if (!enabled.length) return null;
|
|
1445
|
+
let merged = enabled[0].transform;
|
|
1446
|
+
for (let i = 1; i < enabled.length; i++) {
|
|
1447
|
+
merged = (typeof mergeFn === 'function')
|
|
1448
|
+
? mergeFn(merged, enabled[i].transform)
|
|
1449
|
+
: Object.assign({}, merged, { ops: (merged.ops || []).concat(enabled[i].transform.ops || []) });
|
|
1450
|
+
}
|
|
1451
|
+
return dedupeOps(merged);
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
// Human label for the whole recipe: unique enabled-edit prompts, in order.
|
|
1455
|
+
function promptLabel(edits) {
|
|
1456
|
+
const seen = new Set();
|
|
1457
|
+
const parts = [];
|
|
1458
|
+
for (const e of Array.isArray(edits) ? edits : []) {
|
|
1459
|
+
if (!e || e.enabled === false) continue;
|
|
1460
|
+
const p = String(e.prompt || '').trim();
|
|
1461
|
+
const k = p.toLowerCase();
|
|
1462
|
+
if (!p || seen.has(k)) continue;
|
|
1463
|
+
seen.add(k);
|
|
1464
|
+
parts.push(p);
|
|
1465
|
+
}
|
|
1466
|
+
return parts.join(' + ').slice(0, MAX_PROMPT_LABEL);
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
// Any stored shape -> v2. v1 recipes (single merged transform) become one
|
|
1470
|
+
// edit so nothing a user saved before the redesign is lost.
|
|
1471
|
+
function migrate(raw, pathKey) {
|
|
1472
|
+
const base = { path: String(pathKey || '/'), enabled: true, schemaVersion: SCHEMA_VERSION, createdAt: (raw && raw.createdAt) || Date.now(), edits: [] };
|
|
1473
|
+
if (!raw || typeof raw !== 'object') return base;
|
|
1474
|
+
if (Array.isArray(raw.edits)) {
|
|
1475
|
+
base.edits = raw.edits.filter((e) => e && typeof e === 'object' && e.transform);
|
|
1476
|
+
return base;
|
|
1477
|
+
}
|
|
1478
|
+
if (raw.transform) {
|
|
1479
|
+
base.edits = [{
|
|
1480
|
+
id: genId(),
|
|
1481
|
+
prompt: String(raw.prompt || 'Earlier changes').slice(0, MAX_PROMPT_LABEL),
|
|
1482
|
+
ts: raw.createdAt || Date.now(),
|
|
1483
|
+
enabled: raw.enabled !== false,
|
|
1484
|
+
transform: raw.transform,
|
|
1485
|
+
}];
|
|
1486
|
+
}
|
|
1487
|
+
return base;
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
function createStore(adapter) {
|
|
1491
|
+
const backing = (adapter && typeof adapter === 'object') ? adapter : localAdapter();
|
|
1492
|
+
const keyOf = (pathKey) => PREFIX + String(pathKey || '/');
|
|
1493
|
+
|
|
1494
|
+
// Serialize read-modify-write so rapid appends can't lose edits (same
|
|
1495
|
+
// pattern as extension/src/recipes.js).
|
|
1496
|
+
let writeChain = Promise.resolve();
|
|
1497
|
+
function serialize(fn) {
|
|
1498
|
+
const run = writeChain.then(fn, fn);
|
|
1499
|
+
writeChain = run.then(() => {}, () => {});
|
|
1500
|
+
return run;
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
async function readMigrated(pathKey) {
|
|
1504
|
+
let raw;
|
|
1505
|
+
try { raw = await backing.get(keyOf(pathKey)); } catch (_) { raw = undefined; }
|
|
1506
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
1507
|
+
return migrate(raw, pathKey);
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
async function persist(pathKey, recipe) {
|
|
1511
|
+
await backing.set(keyOf(pathKey), recipe);
|
|
1512
|
+
return recipe;
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
// Back-compat read shape: composed transform + merged prompt + the edits.
|
|
1516
|
+
async function loadRecipe(pathKey) {
|
|
1517
|
+
try {
|
|
1518
|
+
const r = await readMigrated(pathKey);
|
|
1519
|
+
if (!r) return null;
|
|
1520
|
+
return {
|
|
1521
|
+
path: r.path,
|
|
1522
|
+
enabled: r.edits.some((e) => e.enabled !== false),
|
|
1523
|
+
prompt: promptLabel(r.edits),
|
|
1524
|
+
transform: composeEdits(r.edits),
|
|
1525
|
+
edits: r.edits,
|
|
1526
|
+
schemaVersion: SCHEMA_VERSION,
|
|
1527
|
+
createdAt: r.createdAt,
|
|
1528
|
+
};
|
|
1529
|
+
} catch (_) { return null; }
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
function saveRecipe(pathKey, recipe) {
|
|
1533
|
+
return serialize(async () => {
|
|
1534
|
+
try {
|
|
1535
|
+
const merged = Object.assign(
|
|
1536
|
+
{ path: String(pathKey || '/'), enabled: true, createdAt: Date.now() },
|
|
1537
|
+
recipe,
|
|
1538
|
+
{ schemaVersion: SCHEMA_VERSION, path: String(pathKey || '/') }
|
|
1539
|
+
);
|
|
1540
|
+
await persist(pathKey, merged);
|
|
1541
|
+
return merged;
|
|
1542
|
+
} catch (_) { return null; }
|
|
1543
|
+
});
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
// Add one edit (the unit of undo/history). Returns the new edit or null.
|
|
1547
|
+
function appendEdit(pathKey, transform, meta) {
|
|
1548
|
+
return serialize(async () => {
|
|
1549
|
+
try {
|
|
1550
|
+
const r = (await readMigrated(pathKey)) || migrate(null, pathKey);
|
|
1551
|
+
const edit = {
|
|
1552
|
+
id: genId(),
|
|
1553
|
+
prompt: String((meta && meta.prompt) || '').slice(0, MAX_PROMPT_LABEL),
|
|
1554
|
+
ts: Date.now(),
|
|
1555
|
+
enabled: true,
|
|
1556
|
+
transform,
|
|
1557
|
+
};
|
|
1558
|
+
const fps = (meta && Array.isArray(meta.fps))
|
|
1559
|
+
? meta.fps.filter((f) => typeof f === 'string' && /^[a-f0-9]{12}$/.test(f)).slice(0, 8)
|
|
1560
|
+
: [];
|
|
1561
|
+
if (fps.length) edit.fps = fps;
|
|
1562
|
+
r.edits.push(edit);
|
|
1563
|
+
if (r.edits.length > MAX_EDITS) r.edits.splice(0, r.edits.length - MAX_EDITS);
|
|
1564
|
+
await persist(pathKey, r);
|
|
1565
|
+
return edit;
|
|
1566
|
+
} catch (_) { return null; }
|
|
1567
|
+
});
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
async function listEdits(pathKey) {
|
|
1571
|
+
try {
|
|
1572
|
+
const r = await readMigrated(pathKey);
|
|
1573
|
+
return r ? r.edits : [];
|
|
1574
|
+
} catch (_) { return []; }
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
function setEditEnabled(pathKey, id, enabled) {
|
|
1578
|
+
return serialize(async () => {
|
|
1579
|
+
try {
|
|
1580
|
+
const r = await readMigrated(pathKey);
|
|
1581
|
+
if (!r) return false;
|
|
1582
|
+
const edit = r.edits.find((e) => e.id === id);
|
|
1583
|
+
if (!edit) return false;
|
|
1584
|
+
edit.enabled = enabled !== false;
|
|
1585
|
+
await persist(pathKey, r);
|
|
1586
|
+
return true;
|
|
1587
|
+
} catch (_) { return false; }
|
|
1588
|
+
});
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
function removeEdit(pathKey, id) {
|
|
1592
|
+
return serialize(async () => {
|
|
1593
|
+
try {
|
|
1594
|
+
const r = await readMigrated(pathKey);
|
|
1595
|
+
if (!r) return false;
|
|
1596
|
+
const i = r.edits.findIndex((e) => e.id === id);
|
|
1597
|
+
if (i < 0) return false;
|
|
1598
|
+
r.edits.splice(i, 1);
|
|
1599
|
+
await persist(pathKey, r);
|
|
1600
|
+
return true;
|
|
1601
|
+
} catch (_) { return false; }
|
|
1602
|
+
});
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
// v1 API kept for existing callers: appends as an edit under the hood.
|
|
1606
|
+
function appendTransform(pathKey, transform, meta, mergeFn) {
|
|
1607
|
+
return appendEdit(pathKey, transform, meta).then((edit) => (edit ? loadRecipe(pathKey) : null), () => null);
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
function removeRecipe(pathKey) {
|
|
1611
|
+
return serialize(async () => {
|
|
1612
|
+
try { await backing.remove(keyOf(pathKey)); } catch (_) { /* never throws */ }
|
|
1613
|
+
});
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
return { loadRecipe, saveRecipe, appendTransform, appendEdit, listEdits, setEditEnabled, removeEdit, removeRecipe };
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
return { createStore, composeEdits, SCHEMA_VERSION };
|
|
1620
|
+
});
|
|
1617
1621
|
|
|
1618
1622
|
|
|
1619
1623
|
/* ---- extension/src/context.js ---- */
|
|
@@ -2887,6 +2891,22 @@ const g = typeof window !== "undefined" ? window : globalThis;
|
|
|
2887
2891
|
} catch (_) { /* badge is best-effort */ }
|
|
2888
2892
|
}
|
|
2889
2893
|
|
|
2894
|
+
// Signal events: strictly fail-open, fire-and-forget. keepalive:true so a
|
|
2895
|
+
// keep followed by navigation still lands. fps come from the backend's
|
|
2896
|
+
// transform response and ride the stored edit records.
|
|
2897
|
+
function sendSignals(kind, fps) {
|
|
2898
|
+
const list = Array.isArray(fps) ? fps.slice(0, 8) : [];
|
|
2899
|
+
if (!list.length) return;
|
|
2900
|
+
try {
|
|
2901
|
+
fetchFn(backend + '/api/embed/event', {
|
|
2902
|
+
method: 'POST',
|
|
2903
|
+
headers: { 'Content-Type': 'application/json', 'X-Morph-Key': config.key },
|
|
2904
|
+
body: JSON.stringify({ kind, fps: list }),
|
|
2905
|
+
keepalive: true,
|
|
2906
|
+
}).catch(() => {});
|
|
2907
|
+
} catch (_) { /* signals must never break the host page */ }
|
|
2908
|
+
}
|
|
2909
|
+
|
|
2890
2910
|
function init() {
|
|
2891
2911
|
if (typeof config.key !== 'string' || !config.key) {
|
|
2892
2912
|
throw new TypeError('Morph: init requires a publishable key (config.key)');
|
|
@@ -3088,8 +3108,10 @@ const g = typeof window !== "undefined" ? window : globalThis;
|
|
|
3088
3108
|
bar.setStatus('Couldn’t make that change here — try rephrasing.', '');
|
|
3089
3109
|
return;
|
|
3090
3110
|
}
|
|
3111
|
+
const fps = (resp && resp.signal && Array.isArray(resp.signal.fps)) ? resp.signal.fps : [];
|
|
3091
3112
|
const anchored = DSL.withAnchors ? DSL.withAnchors(transform, win.document) : transform;
|
|
3092
|
-
const edit = await store.appendEdit(pathKey(), anchored, { prompt });
|
|
3113
|
+
const edit = await store.appendEdit(pathKey(), anchored, { prompt, fps });
|
|
3114
|
+
if (edit) sendSignals('kept', fps);
|
|
3093
3115
|
const note = handle.failed ? ' (' + handle.failed + ' op(s) skipped)' : '';
|
|
3094
3116
|
const explanation = (resp.transform && resp.transform.explanation) || 'Done.';
|
|
3095
3117
|
// setStatus renders TRUSTED HTML (innerHTML) — escape the model's
|
|
@@ -3114,7 +3136,14 @@ const g = typeof window !== "undefined" ? window : globalThis;
|
|
|
3114
3136
|
|
|
3115
3137
|
// Per-edit dismissal (HAX G8/G9): drop ONE edit, keep the rest.
|
|
3116
3138
|
async function removeEdit(id) {
|
|
3139
|
+
let fps = [];
|
|
3140
|
+
try {
|
|
3141
|
+
const edits = await store.listEdits(pathKey());
|
|
3142
|
+
const e = edits.find((x) => x.id === id);
|
|
3143
|
+
fps = (e && e.fps) || [];
|
|
3144
|
+
} catch (_) { /* signal lookup is best-effort */ }
|
|
3117
3145
|
await store.removeEdit(pathKey(), id);
|
|
3146
|
+
sendSignals('undone', fps);
|
|
3118
3147
|
await reapplyFromStore();
|
|
3119
3148
|
await refreshCount();
|
|
3120
3149
|
try { bar.setStatus('Change removed.', ''); } catch (_) { /* noop */ }
|
|
@@ -3132,6 +3161,14 @@ const g = typeof window !== "undefined" ? window : globalThis;
|
|
|
3132
3161
|
async function resetPage() {
|
|
3133
3162
|
if (currentHandle) { DSL.undoTransform(currentHandle); currentHandle = null; }
|
|
3134
3163
|
activeTransform = null;
|
|
3164
|
+
try {
|
|
3165
|
+
const edits = await store.listEdits(pathKey());
|
|
3166
|
+
const union = [];
|
|
3167
|
+
for (const e of edits) for (const f of (e.fps || [])) {
|
|
3168
|
+
if (union.length < 8 && !union.includes(f)) union.push(f);
|
|
3169
|
+
}
|
|
3170
|
+
sendSignals('undone', union);
|
|
3171
|
+
} catch (_) { /* best-effort */ }
|
|
3135
3172
|
await store.removeRecipe(pathKey());
|
|
3136
3173
|
await refreshCount();
|
|
3137
3174
|
if (ui) await openPanelIfRendered();
|
|
@@ -3172,6 +3209,12 @@ const g = typeof window !== "undefined" ? window : globalThis;
|
|
|
3172
3209
|
const v = DSL.validateTransform(recipe.transform);
|
|
3173
3210
|
if (!v.ok) return;
|
|
3174
3211
|
|
|
3212
|
+
const savedFps = [];
|
|
3213
|
+
for (const e of (recipe.edits || [])) {
|
|
3214
|
+
if (!e || e.enabled === false) continue;
|
|
3215
|
+
for (const f of (e.fps || [])) if (savedFps.length < 8 && !savedFps.includes(f)) savedFps.push(f);
|
|
3216
|
+
}
|
|
3217
|
+
|
|
3175
3218
|
// 1) instant CSS so the page doesn't flash unstyled
|
|
3176
3219
|
const cssOnly = Object.assign({}, v.transform, { ops: v.transform.ops.filter((o) => o.op === 'injectCSS') });
|
|
3177
3220
|
let cssHandle = null;
|
|
@@ -3184,6 +3227,7 @@ const g = typeof window !== "undefined" ? window : globalThis;
|
|
|
3184
3227
|
if (cssHandle) { DSL.undoTransform(cssHandle); cssHandle = null; }
|
|
3185
3228
|
if (!transform.ops.length) return;
|
|
3186
3229
|
await applyMerged(transform, data);
|
|
3230
|
+
sendSignals('reapplied', savedFps);
|
|
3187
3231
|
};
|
|
3188
3232
|
if (win.document.readyState === 'loading') {
|
|
3189
3233
|
win.document.addEventListener('DOMContentLoaded', () => { full().catch(() => {}); }, { once: true });
|
package/dist/morph-embed.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! Morph Embed SDK v0.
|
|
1
|
+
/*! Morph Embed SDK v0.3.0 — https://usemorph.xyz — MIT */
|
|
2
2
|
|
|
3
3
|
/* ---- shared/dsl.js ---- */
|
|
4
4
|
/*
|
|
@@ -1364,255 +1364,259 @@
|
|
|
1364
1364
|
|
|
1365
1365
|
|
|
1366
1366
|
/* ---- sdk/src/storage.js ---- */
|
|
1367
|
-
/*
|
|
1368
|
-
* Morph SDK — recipe store. SCHEMA v2: a recipe is a LIST OF EDITS
|
|
1369
|
-
* ({id, prompt, ts, enabled, transform}) per page path, which is what makes
|
|
1370
|
-
* per-edit undo, history, and toggling possible in the SDK UI (Nielsen #3,
|
|
1371
|
-
* user control and freedom). v1 recipes (one merged blob) migrate on read.
|
|
1372
|
-
*
|
|
1373
|
-
* Default backing is localStorage (per-browser); a vendor may pass a custom
|
|
1374
|
-
* adapter {get,set,remove} (sync or Promise) to store recipes in their own
|
|
1375
|
-
* backend so a user's generated features follow them across devices.
|
|
1376
|
-
* Storage failures NEVER throw — a broken store must never break the host page.
|
|
1377
|
-
* UMD: Node module in tests, browser global `MorphStorage` in the SDK bundle.
|
|
1378
|
-
*/
|
|
1379
|
-
(function (global, factory) {
|
|
1380
|
-
const api = factory();
|
|
1381
|
-
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
1382
|
-
else global.MorphStorage = api;
|
|
1383
|
-
})(typeof self !== 'undefined' ? self : this, function () {
|
|
1384
|
-
'use strict';
|
|
1385
|
-
|
|
1386
|
-
const PREFIX = 'morph:sdk:recipe:';
|
|
1387
|
-
const SCHEMA_VERSION = 2;
|
|
1388
|
-
const MAX_PROMPT_LABEL = 300;
|
|
1389
|
-
const MAX_EDITS = 20;
|
|
1390
|
-
|
|
1391
|
-
// Default adapter: window.localStorage, JSON values, fully guarded.
|
|
1392
|
-
function localAdapter() {
|
|
1393
|
-
function ls() {
|
|
1394
|
-
try {
|
|
1395
|
-
return (typeof window !== 'undefined' && window.localStorage) ? window.localStorage : null;
|
|
1396
|
-
} catch (_) { return null; } // SecurityError in sandboxed frames
|
|
1397
|
-
}
|
|
1398
|
-
return {
|
|
1399
|
-
get(key) {
|
|
1400
|
-
const s = ls();
|
|
1401
|
-
if (!s) return undefined;
|
|
1402
|
-
const raw = s.getItem(key);
|
|
1403
|
-
if (raw == null) return undefined;
|
|
1404
|
-
try { return JSON.parse(raw); } catch (_) { return undefined; }
|
|
1405
|
-
},
|
|
1406
|
-
set(key, value) {
|
|
1407
|
-
const s = ls();
|
|
1408
|
-
if (!s) return;
|
|
1409
|
-
s.setItem(key, JSON.stringify(value));
|
|
1410
|
-
},
|
|
1411
|
-
remove(key) {
|
|
1412
|
-
const s = ls();
|
|
1413
|
-
if (!s) return;
|
|
1414
|
-
s.removeItem(key);
|
|
1415
|
-
},
|
|
1416
|
-
};
|
|
1417
|
-
}
|
|
1418
|
-
|
|
1419
|
-
function genId() {
|
|
1420
|
-
return 'e_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
|
1421
|
-
}
|
|
1422
|
-
|
|
1423
|
-
// mergeTransforms concats; recipes must UNION. Drop structurally identical
|
|
1424
|
-
// ops (first occurrence wins) so repeating a prompt can't bloat the recipe.
|
|
1425
|
-
function dedupeOps(transform) {
|
|
1426
|
-
if (!transform || !Array.isArray(transform.ops)) return transform;
|
|
1427
|
-
const seen = new Set();
|
|
1428
|
-
const ops = transform.ops.filter((op) => {
|
|
1429
|
-
let key;
|
|
1430
|
-
try { key = JSON.stringify(op); } catch (_) { return true; }
|
|
1431
|
-
if (seen.has(key)) return false;
|
|
1432
|
-
seen.add(key);
|
|
1433
|
-
return true;
|
|
1434
|
-
});
|
|
1435
|
-
return Object.assign({}, transform, { ops });
|
|
1436
|
-
}
|
|
1437
|
-
|
|
1438
|
-
// Merge the ENABLED edits, in order, into one transform (deduped). Returns
|
|
1439
|
-
// null when nothing is enabled. mergeFn is DSL.mergeTransforms when the
|
|
1440
|
-
// caller has the DSL; the concat fallback keeps this module dependency-free.
|
|
1441
|
-
function composeEdits(edits, mergeFn) {
|
|
1442
|
-
const enabled = (Array.isArray(edits) ? edits : []).filter((e) => e && e.enabled !== false && e.transform);
|
|
1443
|
-
if (!enabled.length) return null;
|
|
1444
|
-
let merged = enabled[0].transform;
|
|
1445
|
-
for (let i = 1; i < enabled.length; i++) {
|
|
1446
|
-
merged = (typeof mergeFn === 'function')
|
|
1447
|
-
? mergeFn(merged, enabled[i].transform)
|
|
1448
|
-
: Object.assign({}, merged, { ops: (merged.ops || []).concat(enabled[i].transform.ops || []) });
|
|
1449
|
-
}
|
|
1450
|
-
return dedupeOps(merged);
|
|
1451
|
-
}
|
|
1452
|
-
|
|
1453
|
-
// Human label for the whole recipe: unique enabled-edit prompts, in order.
|
|
1454
|
-
function promptLabel(edits) {
|
|
1455
|
-
const seen = new Set();
|
|
1456
|
-
const parts = [];
|
|
1457
|
-
for (const e of Array.isArray(edits) ? edits : []) {
|
|
1458
|
-
if (!e || e.enabled === false) continue;
|
|
1459
|
-
const p = String(e.prompt || '').trim();
|
|
1460
|
-
const k = p.toLowerCase();
|
|
1461
|
-
if (!p || seen.has(k)) continue;
|
|
1462
|
-
seen.add(k);
|
|
1463
|
-
parts.push(p);
|
|
1464
|
-
}
|
|
1465
|
-
return parts.join(' + ').slice(0, MAX_PROMPT_LABEL);
|
|
1466
|
-
}
|
|
1467
|
-
|
|
1468
|
-
// Any stored shape -> v2. v1 recipes (single merged transform) become one
|
|
1469
|
-
// edit so nothing a user saved before the redesign is lost.
|
|
1470
|
-
function migrate(raw, pathKey) {
|
|
1471
|
-
const base = { path: String(pathKey || '/'), enabled: true, schemaVersion: SCHEMA_VERSION, createdAt: (raw && raw.createdAt) || Date.now(), edits: [] };
|
|
1472
|
-
if (!raw || typeof raw !== 'object') return base;
|
|
1473
|
-
if (Array.isArray(raw.edits)) {
|
|
1474
|
-
base.edits = raw.edits.filter((e) => e && typeof e === 'object' && e.transform);
|
|
1475
|
-
return base;
|
|
1476
|
-
}
|
|
1477
|
-
if (raw.transform) {
|
|
1478
|
-
base.edits = [{
|
|
1479
|
-
id: genId(),
|
|
1480
|
-
prompt: String(raw.prompt || 'Earlier changes').slice(0, MAX_PROMPT_LABEL),
|
|
1481
|
-
ts: raw.createdAt || Date.now(),
|
|
1482
|
-
enabled: raw.enabled !== false,
|
|
1483
|
-
transform: raw.transform,
|
|
1484
|
-
}];
|
|
1485
|
-
}
|
|
1486
|
-
return base;
|
|
1487
|
-
}
|
|
1488
|
-
|
|
1489
|
-
function createStore(adapter) {
|
|
1490
|
-
const backing = (adapter && typeof adapter === 'object') ? adapter : localAdapter();
|
|
1491
|
-
const keyOf = (pathKey) => PREFIX + String(pathKey || '/');
|
|
1492
|
-
|
|
1493
|
-
// Serialize read-modify-write so rapid appends can't lose edits (same
|
|
1494
|
-
// pattern as extension/src/recipes.js).
|
|
1495
|
-
let writeChain = Promise.resolve();
|
|
1496
|
-
function serialize(fn) {
|
|
1497
|
-
const run = writeChain.then(fn, fn);
|
|
1498
|
-
writeChain = run.then(() => {}, () => {});
|
|
1499
|
-
return run;
|
|
1500
|
-
}
|
|
1501
|
-
|
|
1502
|
-
async function readMigrated(pathKey) {
|
|
1503
|
-
let raw;
|
|
1504
|
-
try { raw = await backing.get(keyOf(pathKey)); } catch (_) { raw = undefined; }
|
|
1505
|
-
if (!raw || typeof raw !== 'object') return null;
|
|
1506
|
-
return migrate(raw, pathKey);
|
|
1507
|
-
}
|
|
1508
|
-
|
|
1509
|
-
async function persist(pathKey, recipe) {
|
|
1510
|
-
await backing.set(keyOf(pathKey), recipe);
|
|
1511
|
-
return recipe;
|
|
1512
|
-
}
|
|
1513
|
-
|
|
1514
|
-
// Back-compat read shape: composed transform + merged prompt + the edits.
|
|
1515
|
-
async function loadRecipe(pathKey) {
|
|
1516
|
-
try {
|
|
1517
|
-
const r = await readMigrated(pathKey);
|
|
1518
|
-
if (!r) return null;
|
|
1519
|
-
return {
|
|
1520
|
-
path: r.path,
|
|
1521
|
-
enabled: r.edits.some((e) => e.enabled !== false),
|
|
1522
|
-
prompt: promptLabel(r.edits),
|
|
1523
|
-
transform: composeEdits(r.edits),
|
|
1524
|
-
edits: r.edits,
|
|
1525
|
-
schemaVersion: SCHEMA_VERSION,
|
|
1526
|
-
createdAt: r.createdAt,
|
|
1527
|
-
};
|
|
1528
|
-
} catch (_) { return null; }
|
|
1529
|
-
}
|
|
1530
|
-
|
|
1531
|
-
function saveRecipe(pathKey, recipe) {
|
|
1532
|
-
return serialize(async () => {
|
|
1533
|
-
try {
|
|
1534
|
-
const merged = Object.assign(
|
|
1535
|
-
{ path: String(pathKey || '/'), enabled: true, createdAt: Date.now() },
|
|
1536
|
-
recipe,
|
|
1537
|
-
{ schemaVersion: SCHEMA_VERSION, path: String(pathKey || '/') }
|
|
1538
|
-
);
|
|
1539
|
-
await persist(pathKey, merged);
|
|
1540
|
-
return merged;
|
|
1541
|
-
} catch (_) { return null; }
|
|
1542
|
-
});
|
|
1543
|
-
}
|
|
1544
|
-
|
|
1545
|
-
// Add one edit (the unit of undo/history). Returns the new edit or null.
|
|
1546
|
-
function appendEdit(pathKey, transform, meta) {
|
|
1547
|
-
return serialize(async () => {
|
|
1548
|
-
try {
|
|
1549
|
-
const r = (await readMigrated(pathKey)) || migrate(null, pathKey);
|
|
1550
|
-
const edit = {
|
|
1551
|
-
id: genId(),
|
|
1552
|
-
prompt: String((meta && meta.prompt) || '').slice(0, MAX_PROMPT_LABEL),
|
|
1553
|
-
ts: Date.now(),
|
|
1554
|
-
enabled: true,
|
|
1555
|
-
transform,
|
|
1556
|
-
};
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
r
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
function
|
|
1606
|
-
return
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
}
|
|
1367
|
+
/*
|
|
1368
|
+
* Morph SDK — recipe store. SCHEMA v2: a recipe is a LIST OF EDITS
|
|
1369
|
+
* ({id, prompt, ts, enabled, transform}) per page path, which is what makes
|
|
1370
|
+
* per-edit undo, history, and toggling possible in the SDK UI (Nielsen #3,
|
|
1371
|
+
* user control and freedom). v1 recipes (one merged blob) migrate on read.
|
|
1372
|
+
*
|
|
1373
|
+
* Default backing is localStorage (per-browser); a vendor may pass a custom
|
|
1374
|
+
* adapter {get,set,remove} (sync or Promise) to store recipes in their own
|
|
1375
|
+
* backend so a user's generated features follow them across devices.
|
|
1376
|
+
* Storage failures NEVER throw — a broken store must never break the host page.
|
|
1377
|
+
* UMD: Node module in tests, browser global `MorphStorage` in the SDK bundle.
|
|
1378
|
+
*/
|
|
1379
|
+
(function (global, factory) {
|
|
1380
|
+
const api = factory();
|
|
1381
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
1382
|
+
else global.MorphStorage = api;
|
|
1383
|
+
})(typeof self !== 'undefined' ? self : this, function () {
|
|
1384
|
+
'use strict';
|
|
1385
|
+
|
|
1386
|
+
const PREFIX = 'morph:sdk:recipe:';
|
|
1387
|
+
const SCHEMA_VERSION = 2;
|
|
1388
|
+
const MAX_PROMPT_LABEL = 300;
|
|
1389
|
+
const MAX_EDITS = 20;
|
|
1390
|
+
|
|
1391
|
+
// Default adapter: window.localStorage, JSON values, fully guarded.
|
|
1392
|
+
function localAdapter() {
|
|
1393
|
+
function ls() {
|
|
1394
|
+
try {
|
|
1395
|
+
return (typeof window !== 'undefined' && window.localStorage) ? window.localStorage : null;
|
|
1396
|
+
} catch (_) { return null; } // SecurityError in sandboxed frames
|
|
1397
|
+
}
|
|
1398
|
+
return {
|
|
1399
|
+
get(key) {
|
|
1400
|
+
const s = ls();
|
|
1401
|
+
if (!s) return undefined;
|
|
1402
|
+
const raw = s.getItem(key);
|
|
1403
|
+
if (raw == null) return undefined;
|
|
1404
|
+
try { return JSON.parse(raw); } catch (_) { return undefined; }
|
|
1405
|
+
},
|
|
1406
|
+
set(key, value) {
|
|
1407
|
+
const s = ls();
|
|
1408
|
+
if (!s) return;
|
|
1409
|
+
s.setItem(key, JSON.stringify(value));
|
|
1410
|
+
},
|
|
1411
|
+
remove(key) {
|
|
1412
|
+
const s = ls();
|
|
1413
|
+
if (!s) return;
|
|
1414
|
+
s.removeItem(key);
|
|
1415
|
+
},
|
|
1416
|
+
};
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
function genId() {
|
|
1420
|
+
return 'e_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
// mergeTransforms concats; recipes must UNION. Drop structurally identical
|
|
1424
|
+
// ops (first occurrence wins) so repeating a prompt can't bloat the recipe.
|
|
1425
|
+
function dedupeOps(transform) {
|
|
1426
|
+
if (!transform || !Array.isArray(transform.ops)) return transform;
|
|
1427
|
+
const seen = new Set();
|
|
1428
|
+
const ops = transform.ops.filter((op) => {
|
|
1429
|
+
let key;
|
|
1430
|
+
try { key = JSON.stringify(op); } catch (_) { return true; }
|
|
1431
|
+
if (seen.has(key)) return false;
|
|
1432
|
+
seen.add(key);
|
|
1433
|
+
return true;
|
|
1434
|
+
});
|
|
1435
|
+
return Object.assign({}, transform, { ops });
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
// Merge the ENABLED edits, in order, into one transform (deduped). Returns
|
|
1439
|
+
// null when nothing is enabled. mergeFn is DSL.mergeTransforms when the
|
|
1440
|
+
// caller has the DSL; the concat fallback keeps this module dependency-free.
|
|
1441
|
+
function composeEdits(edits, mergeFn) {
|
|
1442
|
+
const enabled = (Array.isArray(edits) ? edits : []).filter((e) => e && e.enabled !== false && e.transform);
|
|
1443
|
+
if (!enabled.length) return null;
|
|
1444
|
+
let merged = enabled[0].transform;
|
|
1445
|
+
for (let i = 1; i < enabled.length; i++) {
|
|
1446
|
+
merged = (typeof mergeFn === 'function')
|
|
1447
|
+
? mergeFn(merged, enabled[i].transform)
|
|
1448
|
+
: Object.assign({}, merged, { ops: (merged.ops || []).concat(enabled[i].transform.ops || []) });
|
|
1449
|
+
}
|
|
1450
|
+
return dedupeOps(merged);
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
// Human label for the whole recipe: unique enabled-edit prompts, in order.
|
|
1454
|
+
function promptLabel(edits) {
|
|
1455
|
+
const seen = new Set();
|
|
1456
|
+
const parts = [];
|
|
1457
|
+
for (const e of Array.isArray(edits) ? edits : []) {
|
|
1458
|
+
if (!e || e.enabled === false) continue;
|
|
1459
|
+
const p = String(e.prompt || '').trim();
|
|
1460
|
+
const k = p.toLowerCase();
|
|
1461
|
+
if (!p || seen.has(k)) continue;
|
|
1462
|
+
seen.add(k);
|
|
1463
|
+
parts.push(p);
|
|
1464
|
+
}
|
|
1465
|
+
return parts.join(' + ').slice(0, MAX_PROMPT_LABEL);
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
// Any stored shape -> v2. v1 recipes (single merged transform) become one
|
|
1469
|
+
// edit so nothing a user saved before the redesign is lost.
|
|
1470
|
+
function migrate(raw, pathKey) {
|
|
1471
|
+
const base = { path: String(pathKey || '/'), enabled: true, schemaVersion: SCHEMA_VERSION, createdAt: (raw && raw.createdAt) || Date.now(), edits: [] };
|
|
1472
|
+
if (!raw || typeof raw !== 'object') return base;
|
|
1473
|
+
if (Array.isArray(raw.edits)) {
|
|
1474
|
+
base.edits = raw.edits.filter((e) => e && typeof e === 'object' && e.transform);
|
|
1475
|
+
return base;
|
|
1476
|
+
}
|
|
1477
|
+
if (raw.transform) {
|
|
1478
|
+
base.edits = [{
|
|
1479
|
+
id: genId(),
|
|
1480
|
+
prompt: String(raw.prompt || 'Earlier changes').slice(0, MAX_PROMPT_LABEL),
|
|
1481
|
+
ts: raw.createdAt || Date.now(),
|
|
1482
|
+
enabled: raw.enabled !== false,
|
|
1483
|
+
transform: raw.transform,
|
|
1484
|
+
}];
|
|
1485
|
+
}
|
|
1486
|
+
return base;
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
function createStore(adapter) {
|
|
1490
|
+
const backing = (adapter && typeof adapter === 'object') ? adapter : localAdapter();
|
|
1491
|
+
const keyOf = (pathKey) => PREFIX + String(pathKey || '/');
|
|
1492
|
+
|
|
1493
|
+
// Serialize read-modify-write so rapid appends can't lose edits (same
|
|
1494
|
+
// pattern as extension/src/recipes.js).
|
|
1495
|
+
let writeChain = Promise.resolve();
|
|
1496
|
+
function serialize(fn) {
|
|
1497
|
+
const run = writeChain.then(fn, fn);
|
|
1498
|
+
writeChain = run.then(() => {}, () => {});
|
|
1499
|
+
return run;
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
async function readMigrated(pathKey) {
|
|
1503
|
+
let raw;
|
|
1504
|
+
try { raw = await backing.get(keyOf(pathKey)); } catch (_) { raw = undefined; }
|
|
1505
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
1506
|
+
return migrate(raw, pathKey);
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
async function persist(pathKey, recipe) {
|
|
1510
|
+
await backing.set(keyOf(pathKey), recipe);
|
|
1511
|
+
return recipe;
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
// Back-compat read shape: composed transform + merged prompt + the edits.
|
|
1515
|
+
async function loadRecipe(pathKey) {
|
|
1516
|
+
try {
|
|
1517
|
+
const r = await readMigrated(pathKey);
|
|
1518
|
+
if (!r) return null;
|
|
1519
|
+
return {
|
|
1520
|
+
path: r.path,
|
|
1521
|
+
enabled: r.edits.some((e) => e.enabled !== false),
|
|
1522
|
+
prompt: promptLabel(r.edits),
|
|
1523
|
+
transform: composeEdits(r.edits),
|
|
1524
|
+
edits: r.edits,
|
|
1525
|
+
schemaVersion: SCHEMA_VERSION,
|
|
1526
|
+
createdAt: r.createdAt,
|
|
1527
|
+
};
|
|
1528
|
+
} catch (_) { return null; }
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
function saveRecipe(pathKey, recipe) {
|
|
1532
|
+
return serialize(async () => {
|
|
1533
|
+
try {
|
|
1534
|
+
const merged = Object.assign(
|
|
1535
|
+
{ path: String(pathKey || '/'), enabled: true, createdAt: Date.now() },
|
|
1536
|
+
recipe,
|
|
1537
|
+
{ schemaVersion: SCHEMA_VERSION, path: String(pathKey || '/') }
|
|
1538
|
+
);
|
|
1539
|
+
await persist(pathKey, merged);
|
|
1540
|
+
return merged;
|
|
1541
|
+
} catch (_) { return null; }
|
|
1542
|
+
});
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
// Add one edit (the unit of undo/history). Returns the new edit or null.
|
|
1546
|
+
function appendEdit(pathKey, transform, meta) {
|
|
1547
|
+
return serialize(async () => {
|
|
1548
|
+
try {
|
|
1549
|
+
const r = (await readMigrated(pathKey)) || migrate(null, pathKey);
|
|
1550
|
+
const edit = {
|
|
1551
|
+
id: genId(),
|
|
1552
|
+
prompt: String((meta && meta.prompt) || '').slice(0, MAX_PROMPT_LABEL),
|
|
1553
|
+
ts: Date.now(),
|
|
1554
|
+
enabled: true,
|
|
1555
|
+
transform,
|
|
1556
|
+
};
|
|
1557
|
+
const fps = (meta && Array.isArray(meta.fps))
|
|
1558
|
+
? meta.fps.filter((f) => typeof f === 'string' && /^[a-f0-9]{12}$/.test(f)).slice(0, 8)
|
|
1559
|
+
: [];
|
|
1560
|
+
if (fps.length) edit.fps = fps;
|
|
1561
|
+
r.edits.push(edit);
|
|
1562
|
+
if (r.edits.length > MAX_EDITS) r.edits.splice(0, r.edits.length - MAX_EDITS);
|
|
1563
|
+
await persist(pathKey, r);
|
|
1564
|
+
return edit;
|
|
1565
|
+
} catch (_) { return null; }
|
|
1566
|
+
});
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
async function listEdits(pathKey) {
|
|
1570
|
+
try {
|
|
1571
|
+
const r = await readMigrated(pathKey);
|
|
1572
|
+
return r ? r.edits : [];
|
|
1573
|
+
} catch (_) { return []; }
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
function setEditEnabled(pathKey, id, enabled) {
|
|
1577
|
+
return serialize(async () => {
|
|
1578
|
+
try {
|
|
1579
|
+
const r = await readMigrated(pathKey);
|
|
1580
|
+
if (!r) return false;
|
|
1581
|
+
const edit = r.edits.find((e) => e.id === id);
|
|
1582
|
+
if (!edit) return false;
|
|
1583
|
+
edit.enabled = enabled !== false;
|
|
1584
|
+
await persist(pathKey, r);
|
|
1585
|
+
return true;
|
|
1586
|
+
} catch (_) { return false; }
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
function removeEdit(pathKey, id) {
|
|
1591
|
+
return serialize(async () => {
|
|
1592
|
+
try {
|
|
1593
|
+
const r = await readMigrated(pathKey);
|
|
1594
|
+
if (!r) return false;
|
|
1595
|
+
const i = r.edits.findIndex((e) => e.id === id);
|
|
1596
|
+
if (i < 0) return false;
|
|
1597
|
+
r.edits.splice(i, 1);
|
|
1598
|
+
await persist(pathKey, r);
|
|
1599
|
+
return true;
|
|
1600
|
+
} catch (_) { return false; }
|
|
1601
|
+
});
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
// v1 API kept for existing callers: appends as an edit under the hood.
|
|
1605
|
+
function appendTransform(pathKey, transform, meta, mergeFn) {
|
|
1606
|
+
return appendEdit(pathKey, transform, meta).then((edit) => (edit ? loadRecipe(pathKey) : null), () => null);
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
function removeRecipe(pathKey) {
|
|
1610
|
+
return serialize(async () => {
|
|
1611
|
+
try { await backing.remove(keyOf(pathKey)); } catch (_) { /* never throws */ }
|
|
1612
|
+
});
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
return { loadRecipe, saveRecipe, appendTransform, appendEdit, listEdits, setEditEnabled, removeEdit, removeRecipe };
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
return { createStore, composeEdits, SCHEMA_VERSION };
|
|
1619
|
+
});
|
|
1616
1620
|
|
|
1617
1621
|
|
|
1618
1622
|
/* ---- extension/src/context.js ---- */
|
|
@@ -2886,6 +2890,22 @@
|
|
|
2886
2890
|
} catch (_) { /* badge is best-effort */ }
|
|
2887
2891
|
}
|
|
2888
2892
|
|
|
2893
|
+
// Signal events: strictly fail-open, fire-and-forget. keepalive:true so a
|
|
2894
|
+
// keep followed by navigation still lands. fps come from the backend's
|
|
2895
|
+
// transform response and ride the stored edit records.
|
|
2896
|
+
function sendSignals(kind, fps) {
|
|
2897
|
+
const list = Array.isArray(fps) ? fps.slice(0, 8) : [];
|
|
2898
|
+
if (!list.length) return;
|
|
2899
|
+
try {
|
|
2900
|
+
fetchFn(backend + '/api/embed/event', {
|
|
2901
|
+
method: 'POST',
|
|
2902
|
+
headers: { 'Content-Type': 'application/json', 'X-Morph-Key': config.key },
|
|
2903
|
+
body: JSON.stringify({ kind, fps: list }),
|
|
2904
|
+
keepalive: true,
|
|
2905
|
+
}).catch(() => {});
|
|
2906
|
+
} catch (_) { /* signals must never break the host page */ }
|
|
2907
|
+
}
|
|
2908
|
+
|
|
2889
2909
|
function init() {
|
|
2890
2910
|
if (typeof config.key !== 'string' || !config.key) {
|
|
2891
2911
|
throw new TypeError('Morph: init requires a publishable key (config.key)');
|
|
@@ -3087,8 +3107,10 @@
|
|
|
3087
3107
|
bar.setStatus('Couldn’t make that change here — try rephrasing.', '');
|
|
3088
3108
|
return;
|
|
3089
3109
|
}
|
|
3110
|
+
const fps = (resp && resp.signal && Array.isArray(resp.signal.fps)) ? resp.signal.fps : [];
|
|
3090
3111
|
const anchored = DSL.withAnchors ? DSL.withAnchors(transform, win.document) : transform;
|
|
3091
|
-
const edit = await store.appendEdit(pathKey(), anchored, { prompt });
|
|
3112
|
+
const edit = await store.appendEdit(pathKey(), anchored, { prompt, fps });
|
|
3113
|
+
if (edit) sendSignals('kept', fps);
|
|
3092
3114
|
const note = handle.failed ? ' (' + handle.failed + ' op(s) skipped)' : '';
|
|
3093
3115
|
const explanation = (resp.transform && resp.transform.explanation) || 'Done.';
|
|
3094
3116
|
// setStatus renders TRUSTED HTML (innerHTML) — escape the model's
|
|
@@ -3113,7 +3135,14 @@
|
|
|
3113
3135
|
|
|
3114
3136
|
// Per-edit dismissal (HAX G8/G9): drop ONE edit, keep the rest.
|
|
3115
3137
|
async function removeEdit(id) {
|
|
3138
|
+
let fps = [];
|
|
3139
|
+
try {
|
|
3140
|
+
const edits = await store.listEdits(pathKey());
|
|
3141
|
+
const e = edits.find((x) => x.id === id);
|
|
3142
|
+
fps = (e && e.fps) || [];
|
|
3143
|
+
} catch (_) { /* signal lookup is best-effort */ }
|
|
3116
3144
|
await store.removeEdit(pathKey(), id);
|
|
3145
|
+
sendSignals('undone', fps);
|
|
3117
3146
|
await reapplyFromStore();
|
|
3118
3147
|
await refreshCount();
|
|
3119
3148
|
try { bar.setStatus('Change removed.', ''); } catch (_) { /* noop */ }
|
|
@@ -3131,6 +3160,14 @@
|
|
|
3131
3160
|
async function resetPage() {
|
|
3132
3161
|
if (currentHandle) { DSL.undoTransform(currentHandle); currentHandle = null; }
|
|
3133
3162
|
activeTransform = null;
|
|
3163
|
+
try {
|
|
3164
|
+
const edits = await store.listEdits(pathKey());
|
|
3165
|
+
const union = [];
|
|
3166
|
+
for (const e of edits) for (const f of (e.fps || [])) {
|
|
3167
|
+
if (union.length < 8 && !union.includes(f)) union.push(f);
|
|
3168
|
+
}
|
|
3169
|
+
sendSignals('undone', union);
|
|
3170
|
+
} catch (_) { /* best-effort */ }
|
|
3134
3171
|
await store.removeRecipe(pathKey());
|
|
3135
3172
|
await refreshCount();
|
|
3136
3173
|
if (ui) await openPanelIfRendered();
|
|
@@ -3171,6 +3208,12 @@
|
|
|
3171
3208
|
const v = DSL.validateTransform(recipe.transform);
|
|
3172
3209
|
if (!v.ok) return;
|
|
3173
3210
|
|
|
3211
|
+
const savedFps = [];
|
|
3212
|
+
for (const e of (recipe.edits || [])) {
|
|
3213
|
+
if (!e || e.enabled === false) continue;
|
|
3214
|
+
for (const f of (e.fps || [])) if (savedFps.length < 8 && !savedFps.includes(f)) savedFps.push(f);
|
|
3215
|
+
}
|
|
3216
|
+
|
|
3174
3217
|
// 1) instant CSS so the page doesn't flash unstyled
|
|
3175
3218
|
const cssOnly = Object.assign({}, v.transform, { ops: v.transform.ops.filter((o) => o.op === 'injectCSS') });
|
|
3176
3219
|
let cssHandle = null;
|
|
@@ -3183,6 +3226,7 @@
|
|
|
3183
3226
|
if (cssHandle) { DSL.undoTransform(cssHandle); cssHandle = null; }
|
|
3184
3227
|
if (!transform.ops.length) return;
|
|
3185
3228
|
await applyMerged(transform, data);
|
|
3229
|
+
sendSignals('reapplied', savedFps);
|
|
3186
3230
|
};
|
|
3187
3231
|
if (win.document.readyState === 'loading') {
|
|
3188
3232
|
win.document.addEventListener('DOMContentLoaded', () => { full().catch(() => {}); }, { once: true });
|
package/package.json
CHANGED
|
@@ -1,37 +1,37 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "morph-embed",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Embed a prompt bar in your product so users can generate the features you haven't built — real-data widgets, layout reshapes, and confirm-gated actions. Declarative-DSL security model; your data never leaves the page.",
|
|
5
|
-
"license": "MIT",
|
|
6
|
-
"main": "dist/morph-embed.esm.mjs",
|
|
7
|
-
"exports": {
|
|
8
|
-
".": "./dist/morph-embed.esm.mjs",
|
|
9
|
-
"./iife": "./dist/morph-embed.js"
|
|
10
|
-
},
|
|
11
|
-
"files": [
|
|
12
|
-
"dist",
|
|
13
|
-
"README.md",
|
|
14
|
-
"LICENSE"
|
|
15
|
-
],
|
|
16
|
-
"repository": {
|
|
17
|
-
"type": "git",
|
|
18
|
-
"url": "git+https://github.com/mandarwagh9/morph.git",
|
|
19
|
-
"directory": "sdk"
|
|
20
|
-
},
|
|
21
|
-
"homepage": "https://usemorph.xyz",
|
|
22
|
-
"bugs": {
|
|
23
|
-
"url": "https://github.com/mandarwagh9/morph/issues"
|
|
24
|
-
},
|
|
25
|
-
"author": "Mandar Wagh (https://usemorph.xyz)",
|
|
26
|
-
"keywords": [
|
|
27
|
-
"generative-ui",
|
|
28
|
-
"copilot",
|
|
29
|
-
"embed",
|
|
30
|
-
"widgets",
|
|
31
|
-
"prompt",
|
|
32
|
-
"llm",
|
|
33
|
-
"dashboard",
|
|
34
|
-
"no-code",
|
|
35
|
-
"morph"
|
|
36
|
-
]
|
|
37
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "morph-embed",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Embed a prompt bar in your product so users can generate the features you haven't built — real-data widgets, layout reshapes, and confirm-gated actions. Declarative-DSL security model; your data never leaves the page.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"main": "dist/morph-embed.esm.mjs",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./dist/morph-embed.esm.mjs",
|
|
9
|
+
"./iife": "./dist/morph-embed.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/mandarwagh9/morph-embed.git",
|
|
19
|
+
"directory": "sdk"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://usemorph.xyz",
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/mandarwagh9/morph/issues"
|
|
24
|
+
},
|
|
25
|
+
"author": "Mandar Wagh (https://usemorph.xyz)",
|
|
26
|
+
"keywords": [
|
|
27
|
+
"generative-ui",
|
|
28
|
+
"copilot",
|
|
29
|
+
"embed",
|
|
30
|
+
"widgets",
|
|
31
|
+
"prompt",
|
|
32
|
+
"llm",
|
|
33
|
+
"dashboard",
|
|
34
|
+
"no-code",
|
|
35
|
+
"morph"
|
|
36
|
+
]
|
|
37
|
+
}
|