@panphora/clayjs 0.1.0 → 0.2.1

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.
Files changed (45) hide show
  1. package/NOTICE +19 -0
  2. package/README.md +2 -0
  3. package/_headers +2 -0
  4. package/all.js +24 -0
  5. package/clay-data.js +16 -0
  6. package/clay-dom.js +18 -0
  7. package/clay-events.js +28 -0
  8. package/clay-options.js +27 -0
  9. package/clay-ui.js +29 -0
  10. package/clay-utils.js +18 -0
  11. package/clay.js +17 -5
  12. package/package.json +25 -2
  13. package/sap.js +16 -0
  14. package/src/core/save.js +5 -4
  15. package/src/core/snapshot.js +12 -9
  16. package/src/dom/all.js +440 -0
  17. package/src/dom/dom-helpers.js +218 -0
  18. package/src/dom/form-data.js +62 -0
  19. package/src/dom/insert-style-tag.js +109 -0
  20. package/src/dom/nearest.js +97 -0
  21. package/src/dom/pipe.js +1 -0
  22. package/src/events/index.js +22 -0
  23. package/src/events/onclickaway.js +27 -0
  24. package/src/events/onclickchildren.js +12 -0
  25. package/src/events/onclone.js +54 -0
  26. package/src/events/onmutation.js +54 -0
  27. package/src/events/onpagemutation.js +37 -0
  28. package/src/events/onrender.js +32 -0
  29. package/src/lib/mutation.js +26 -9
  30. package/src/lib/on-load.js +9 -0
  31. package/src/loader-logic.js +2 -1
  32. package/src/loader.js +2 -0
  33. package/src/options/options.js +253 -0
  34. package/src/plugins/demo.js +124 -0
  35. package/src/sync/live-sync.js +61 -19
  36. package/src/sync/merge-tags.js +36 -0
  37. package/src/ui/dialogs.js +206 -0
  38. package/src/ui/index.js +38 -0
  39. package/src/ui/modal.js +884 -0
  40. package/src/ui/toast.js +434 -0
  41. package/src/utils/copy-to-clipboard.js +27 -0
  42. package/src/utils/debounce.js +23 -0
  43. package/src/utils/index.js +10 -0
  44. package/src/utils/slugify.js +20 -0
  45. package/src/vendor/hyper-morph.vendor.js +2 -1
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Option Visibility
3
+ *
4
+ * Shows/hides elements based on `option:` and `option-not:` attributes.
5
+ *
6
+ * SYNTAX:
7
+ * option:name="value" - Show when ancestor has name="value"
8
+ * show-when:name="value" - Same as option: (the canonical, sapjs-aligned spelling)
9
+ * option:name="a|b|c" - Show when ancestor has name="a" OR "b" OR "c"
10
+ * option:name="" - Show when ancestor has name="" (empty value)
11
+ * option:name="|saved" - Show when ancestor has name="" OR name="saved"
12
+ * hide-when:name="value" - Inverse of show-when: hide WHEN ancestor has name="value"
13
+ * option-not:name="value" - Show when ancestor has name attr but ≠ "value"
14
+ * option-not:name="a|b" - Show when ancestor has name attr but ≠ "a" AND ≠ "b"
15
+ *
16
+ * show-when:/hide-when: are honored identically here and in sapjs, so visibility
17
+ * authored against either library just works. option:/option-not: stay permanent
18
+ * aliases.
19
+ *
20
+ * EXAMPLES:
21
+ * <div view="kanban">
22
+ * <button option:view="kanban">Visible when view=kanban</button>
23
+ * <button option:view="list">Hidden</button>
24
+ * </div>
25
+ *
26
+ * The ancestor carries the plain attribute (view="kanban"); descendants opt in with
27
+ * option:view / option-not:view. editmode/pageowner are stamped on <html> by clay.js,
28
+ * so option:/show-when:editmode is an edit-mode bridge that sapjs honors too.
29
+ *
30
+ * <div savestatus="error">
31
+ * <span option:savestatus="saved|error">Visible (matches error)</span>
32
+ * <span option-not:savestatus="saving">Visible (error ≠ saving)</span>
33
+ * </div>
34
+ *
35
+ * HOW IT WORKS:
36
+ * Uses a single conditional-hide rule per pattern. Elements get `display: none !important`
37
+ * ONLY when they are NOT inside a matching ancestor scope. When the ancestor condition
38
+ * IS met, the hide rule doesn't match, so the author's original display value
39
+ * (flex, grid, block, etc.) applies naturally — no recovery needed.
40
+ *
41
+ * BROWSER SUPPORT:
42
+ * Requires `:is()` and `:not()` with selector lists (~96% of browsers, 2021+).
43
+ * Falls back gracefully - elements remain visible if unsupported.
44
+ *
45
+ * TRADEOFFS:
46
+ * - Pro: Pure CSS after generation, zero JS overhead for toggling
47
+ * - Pro: No @layer or revert-layer — works with any author CSS (layered or unlayered)
48
+ * - Pro: One rule per pattern instead of two
49
+ * - Con: Pipe character `|` cannot be used as a literal value (reserved as OR delimiter)
50
+ */
51
+
52
+ import Mutation from "../lib/mutation.js";
53
+ import insertStyles from "../dom/insert-style-tag.js";
54
+
55
+ const STYLE_NAME = 'option-visibility';
56
+
57
+ // The visibility prefixes, longest-disambiguating first. show-when/option are the
58
+ // "show when match" pair; hide-when is its inverse; option-not is "present but not
59
+ // match". All four compile to ancestor-attribute CSS below.
60
+ const OPTION_PREFIXES = [
61
+ { prefix: 'option-not', kind: 'show-not' },
62
+ { prefix: 'option', kind: 'show' },
63
+ { prefix: 'show-when', kind: 'show' },
64
+ { prefix: 'hide-when', kind: 'hide' },
65
+ ];
66
+
67
+ /**
68
+ * Parse an option:/option-not:/show-when:/hide-when: attribute into a pattern object.
69
+ * Pure function for easy testing.
70
+ *
71
+ * @param {string} attrName - Attribute name (e.g., 'show-when:editmode', 'option-not:status')
72
+ * @param {string} attrValue - Attribute value (e.g., 'true', 'a|b|c')
73
+ * @returns {Object|null} Pattern object or null if not a valid visibility attribute
74
+ */
75
+ export function parseOptionAttribute(attrName, attrValue) {
76
+ let match = null;
77
+ for (const p of OPTION_PREFIXES) {
78
+ if (attrName.startsWith(p.prefix + ':')) { match = p; break; }
79
+ }
80
+ if (!match) return null;
81
+
82
+ const name = attrName.slice(match.prefix.length + 1);
83
+ const rawValue = attrValue;
84
+ // Split by pipe, keep empty strings (they match empty attribute values)
85
+ const values = rawValue.split('|');
86
+
87
+ return { name, rawValue, values, kind: match.kind, prefix: match.prefix, negated: match.kind === 'show-not' };
88
+ }
89
+
90
+ const optionVisibility = {
91
+ debug: false,
92
+ _started: false,
93
+ _unsubscribe: null,
94
+
95
+ log(...args) {
96
+ if (this.debug) console.log('[OptionVisibility]', ...args);
97
+ },
98
+
99
+ /**
100
+ * Find all unique option:/option-not: patterns using XPath
101
+ * Returns array of { name, rawValue, values, negated }
102
+ */
103
+ findOptionAttributes() {
104
+ const patterns = new Map();
105
+
106
+ try {
107
+ const snapshot = document.evaluate(
108
+ '//*[@*[starts-with(name(), "option:") or starts-with(name(), "option-not:") or starts-with(name(), "show-when:") or starts-with(name(), "hide-when:")]]',
109
+ document.documentElement,
110
+ null,
111
+ XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE,
112
+ null
113
+ );
114
+
115
+ for (let i = 0; i < snapshot.snapshotLength; i++) {
116
+ const el = snapshot.snapshotItem(i);
117
+ for (const attr of el.attributes) {
118
+ const pattern = parseOptionAttribute(attr.name, attr.value);
119
+ if (!pattern) continue;
120
+
121
+ // key includes the prefix so each authored spelling gets its own rule
122
+ // (option:tab and show-when:tab target different attributes, same scope).
123
+ const key = `${pattern.prefix}:${pattern.name}=${pattern.rawValue}`;
124
+ if (!patterns.has(key)) {
125
+ patterns.set(key, pattern);
126
+ }
127
+ }
128
+ }
129
+ } catch (error) {
130
+ this.log('XPath error, falling back to empty', error);
131
+ }
132
+
133
+ return [...patterns.values()];
134
+ },
135
+
136
+ /**
137
+ * Generate CSS rules for conditional hiding.
138
+ *
139
+ * Each pattern produces a single rule that hides the element ONLY when
140
+ * it's NOT inside a matching ancestor scope. When the condition IS met,
141
+ * the rule doesn't match, so the author's original display value applies.
142
+ */
143
+ generateCSS(patterns) {
144
+ if (!patterns.length) return '';
145
+
146
+ return patterns.map((pattern) => {
147
+ const { name, rawValue, values } = pattern;
148
+ // Tolerate the legacy {negated} pattern shape so external callers keep working.
149
+ const kind = pattern.kind || (pattern.negated ? 'show-not' : 'show');
150
+ const prefix = pattern.prefix || (kind === 'show-not' ? 'option-not' : kind === 'hide' ? 'hide-when' : 'option');
151
+ const safeName = CSS.escape(name);
152
+ const safeRawValue = CSS.escape(rawValue);
153
+ const safePrefix = CSS.escape(prefix);
154
+ const attrSelector = `[${safePrefix}\\:${safeName}="${safeRawValue}"]`;
155
+
156
+ // The "matching scope": an ancestor (or self) carries name="value".
157
+ const self = values.map(v => `[${safeName}="${CSS.escape(v)}"]`);
158
+ const desc = values.map(v => `[${safeName}="${CSS.escape(v)}"] *`);
159
+ const matchScope = [...self, ...desc].join(',');
160
+
161
+ if (kind === 'hide') {
162
+ // hide-when: hide WHEN inside a matching scope — the inverse selector shape.
163
+ return `${attrSelector}:is(${matchScope}){display:none!important}`;
164
+ }
165
+
166
+ let scopeSelectors;
167
+ if (kind === 'show-not') {
168
+ // option-not: active when ancestor has attr but NOT any of the values
169
+ const notList = values.map(v => `[${safeName}="${CSS.escape(v)}"]`).join(',');
170
+ const scope = `[${safeName}]:not(${notList})`;
171
+ scopeSelectors = `${scope},${scope} *`;
172
+ } else {
173
+ // option / show-when: active when ancestor has ANY of the values
174
+ scopeSelectors = matchScope;
175
+ }
176
+
177
+ return `${attrSelector}:not(:is(${scopeSelectors})){display:none!important}`;
178
+ }).join('');
179
+ },
180
+
181
+ /**
182
+ * Update the style element with current rules
183
+ */
184
+ update() {
185
+ try {
186
+ const attributes = this.findOptionAttributes();
187
+ const css = this.generateCSS(attributes);
188
+ insertStyles(STYLE_NAME, css, (style) => {
189
+ style.setAttribute('clay', 'no-watch');
190
+ });
191
+ this.log(`Generated ${attributes.length} rules`);
192
+ } catch (error) {
193
+ console.error('[OptionVisibility] Error generating rules:', error);
194
+ }
195
+ },
196
+
197
+ start() {
198
+ if (this._started) return;
199
+
200
+ if (document.readyState === 'loading') {
201
+ document.addEventListener('DOMContentLoaded', () => this.start(), { once: true });
202
+ return;
203
+ }
204
+
205
+ this._started = true;
206
+
207
+ this.update();
208
+
209
+ // selectorFilter only triggers on option:/option-not: attribute changes (new patterns).
210
+ // Ancestor attribute changes (e.g., editmode="true" -> "false") are handled
211
+ // automatically by the browser - CSS rules re-evaluate when attributes change.
212
+ this._unsubscribe = Mutation.onAnyChange({
213
+ debounce: 200,
214
+ selectorFilter: el => [...el.attributes].some(attr =>
215
+ attr.name.startsWith('option:') || attr.name.startsWith('option-not:') ||
216
+ attr.name.startsWith('show-when:') || attr.name.startsWith('hide-when:')
217
+ ),
218
+ omitChangeDetails: true,
219
+ require: 'observed',
220
+ pausable: false
221
+ }, () => this.update());
222
+
223
+ this.log('Started');
224
+ },
225
+
226
+ stop() {
227
+ if (!this._started) return;
228
+
229
+ this._started = false;
230
+
231
+ if (this._unsubscribe) {
232
+ this._unsubscribe();
233
+ this._unsubscribe = null;
234
+ }
235
+
236
+ const style = document.querySelector(`style[data-name="${STYLE_NAME}"]`);
237
+ if (style) style.remove();
238
+
239
+ this.log('Stopped');
240
+ }
241
+ };
242
+
243
+ // vendor-compat mirror: sap reads window.hyperclay.optionVisibility at runtime.
244
+ window.hyperclay = window.hyperclay || {};
245
+ window.hyperclay.optionVisibility = optionVisibility;
246
+
247
+ export default optionVisibility;
248
+
249
+ export function init() {
250
+ optionVisibility.start();
251
+ }
252
+
253
+ init();
@@ -0,0 +1,124 @@
1
+ // Browser-backed demo mode: the page saves into the visitor's localStorage
2
+ // instead of a server, and restores itself on the next load. Setup:
3
+ //
4
+ // <html autosave demo-key="my-page-v1"> <!-- both attributes optional -->
5
+ // <script>window.clayEditMode = true;</script> <!-- before the clay.js tag -->
6
+ // <script src="https://clayjs.com/clay.js?plugins=demo"></script>
7
+ //
8
+ // The fetch shim answers POST /_/save the way a clayjs server would, so the
9
+ // whole real pipeline (savestatus, events, autosave, ⌘S) runs unchanged. The
10
+ // stored body comes from the clay:snapshot-ready clone (pre-strip, the same
11
+ // source live-sync morphs from), not the POSTed payload: the payload has
12
+ // richclay's runtime state stripped, and morphing toward it would disable the
13
+ // live editors. richclay chrome and squire artifacts are dropped from storage
14
+ // because richclay recreates them. The shim answers after a short delay:
15
+ // localStorage is instant, and the saving state only shows past save.js's
16
+ // 500ms debounce, so an instant answer would keep it invisible.
17
+ //
18
+ // The storage key includes <html demo-key> (fallback: the page's pathname).
19
+ // Bump the attribute whenever the page's authored content changes, or
20
+ // returning visitors' old saved bodies morph over the new page.
21
+
22
+ import { morph } from "../vendor/hyper-morph.vendor.js";
23
+
24
+ const KEY = "clay:demo:" +
25
+ (document.documentElement.getAttribute("demo-key") || window.location.pathname);
26
+ const LATENCY_MS = 800;
27
+ const CHROME_SELECTOR = [
28
+ "[save-remove]",
29
+ "[data-richclay-toolbar]",
30
+ "[data-richclay-menu]",
31
+ "[data-richclay-dialog]",
32
+ "[data-richclay-live]",
33
+ "[data-richclay-float]",
34
+ "#squire-selection-start",
35
+ "#squire-selection-end",
36
+ ".squire-image-resize-container",
37
+ ].join(",");
38
+
39
+ let failNextSave = false;
40
+ let snapshotBody = null;
41
+
42
+ document.addEventListener("clay:snapshot-ready", (event) => {
43
+ snapshotBody = event.detail.documentElement.querySelector("body")?.outerHTML || null;
44
+ });
45
+
46
+ function postedHtml(raw) {
47
+ const text = typeof raw === "string" ? raw : "";
48
+ try {
49
+ const envelope = JSON.parse(text);
50
+ if (envelope && typeof envelope === "object") {
51
+ return envelope.snapshotHtml || envelope.content || text;
52
+ }
53
+ } catch {}
54
+ return text;
55
+ }
56
+
57
+ const realFetch = window.fetch.bind(window);
58
+
59
+ function isSaveUrl(input) {
60
+ const target = typeof input === "string" ? input : (input && input.url) || "";
61
+ try {
62
+ const path = new URL(target, window.location.href).pathname;
63
+ return path === "/_/save" || path.startsWith("/_/save/");
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+
69
+ window.fetch = (url, options = {}) => {
70
+ const method = (options.method || (url && url.method) || "GET").toUpperCase();
71
+ if (method !== "POST" || !isSaveUrl(url)) {
72
+ return realFetch(url, options);
73
+ }
74
+ return new Promise((resolve) => {
75
+ setTimeout(() => {
76
+ if (failNextSave) {
77
+ failNextSave = false;
78
+ resolve(new Response(JSON.stringify({ msg: "Server not responding", msgType: "error" }), {
79
+ status: 503,
80
+ headers: { "Content-Type": "application/json" },
81
+ }));
82
+ return;
83
+ }
84
+ const html = snapshotBody || postedHtml(options.body);
85
+ const doc = new DOMParser().parseFromString(html, "text/html");
86
+ doc.querySelectorAll(CHROME_SELECTOR).forEach((node) => node.remove());
87
+ try {
88
+ localStorage.setItem(KEY, doc.body.outerHTML);
89
+ } catch {
90
+ resolve(new Response(JSON.stringify({ msg: "Couldn't save in this browser (storage is full or blocked)", msgType: "error" }), {
91
+ status: 507,
92
+ headers: { "Content-Type": "application/json" },
93
+ }));
94
+ return;
95
+ }
96
+ resolve(new Response(JSON.stringify({ msg: "Saved in your browser", msgType: "success" }), {
97
+ status: 200,
98
+ headers: { "Content-Type": "application/json" },
99
+ }));
100
+ }, LATENCY_MS);
101
+ });
102
+ };
103
+
104
+ async function restore() {
105
+ const saved = localStorage.getItem(KEY);
106
+ if (!saved) return;
107
+ const doc = new DOMParser().parseFromString(saved, "text/html");
108
+ await morph(document.body, doc.body, {
109
+ morphStyle: "outerHTML",
110
+ ignoreActiveValue: true,
111
+ scripts: { handle: true, matchMode: "smart" },
112
+ });
113
+ }
114
+
115
+ export const ready = restore();
116
+
117
+ export const demo = {
118
+ key: KEY,
119
+ failNext() { failNextSave = true; },
120
+ reset() {
121
+ localStorage.removeItem(KEY);
122
+ window.location.reload();
123
+ },
124
+ };
@@ -39,6 +39,7 @@ import { HyperMorph, morph } from "../vendor/hyper-morph.vendor.js";
39
39
  import Mutation from "../lib/mutation.js";
40
40
  import { isSnapshotRemoved } from "../lib/region-policy.js";
41
41
  import { isEditMode } from "../core/is-edit-mode.js";
42
+ import { mergeTagRecognizers } from "./merge-tags.js";
42
43
 
43
44
  class LiveSync {
44
45
  constructor() {
@@ -46,6 +47,15 @@ class LiveSync {
46
47
  this.currentFile = null;
47
48
  this.lastHtml = null;
48
49
  this.clientId = this.generateClientId();
50
+
51
+ // Per-stream resume id for the htmlclay replay server's wire contract.
52
+ // Regenerated on every start() (see below) so an explicit stop/start is a
53
+ // brand-new stream that never inherits the old baseline. Native
54
+ // EventSource reuses the same URL — and thus this id — on its own
55
+ // automatic reconnect. Deliberately NOT the sender clientId, which is the
56
+ // durable per-tab identity used for echo suppression.
57
+ this.resumeId = null;
58
+
49
59
  this.debounceMs = 150;
50
60
  this.debounceTimer = null;
51
61
  this.isPaused = false;
@@ -132,6 +142,21 @@ class LiveSync {
132
142
  return id;
133
143
  }
134
144
 
145
+ /**
146
+ * Generate a fresh per-stream resume id (URL-safe, well under the 128-byte
147
+ * wire limit). Minted on every start(), never persisted: an explicit
148
+ * stop/start must not reuse the previous stream's baseline.
149
+ */
150
+ generateResumeId() {
151
+ try {
152
+ const bytes = new Uint8Array(16);
153
+ (globalThis.crypto || window.crypto).getRandomValues(bytes);
154
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
155
+ } catch (e) {
156
+ return Math.random().toString(36).slice(2) + Date.now().toString(36);
157
+ }
158
+ }
159
+
135
160
  /**
136
161
  * Start the LiveSync system
137
162
  * Can be called after stop() to restart with a new file
@@ -152,9 +177,11 @@ class LiveSync {
152
177
  return;
153
178
  }
154
179
 
155
- // Reset state for new connection
180
+ // Reset state for new connection. Resetting lastSeenSeq is a new baseline,
181
+ // so mint a fresh resume id — this stream must not resume the previous one.
156
182
  this.lastHtml = null;
157
183
  this.lastSeenSeq = 0;
184
+ this.resumeId = this.generateResumeId();
158
185
 
159
186
  console.log(`[LiveSync] Starting for: ${this.currentFile} (lane=${this.lane})`);
160
187
  this.connect();
@@ -360,7 +387,7 @@ class LiveSync {
360
387
  if (this.isDestroyed) return;
361
388
 
362
389
  const pageUrl = encodeURIComponent(window.location.href);
363
- const url = `/_/live-sync/stream?page-url=${pageUrl}&lane=${this.lane}`;
390
+ const url = `/_/live-sync/stream?page-url=${pageUrl}&lane=${this.lane}&resume-id=${this.resumeId}`;
364
391
  this.sse = new EventSource(url);
365
392
 
366
393
  this.sse.onopen = () => {
@@ -371,6 +398,26 @@ class LiveSync {
371
398
  this.sse.onmessage = (event) => {
372
399
  const data = JSON.parse(event.data);
373
400
 
401
+ // Staleness check + watermark advance run FIRST — before the notification
402
+ // and error branches — against the high-water mark of seqs we've seen
403
+ // (own echoes count too, see below). Notification frames now carry a seq,
404
+ // so a conservative-cursor replay after reconnect must not duplicate a
405
+ // toast or let a later stale data frame apply an older snapshot. `seq` is
406
+ // optional for back-compat with older servers that don't stamp it.
407
+ const { seq } = data;
408
+ if (typeof seq === 'number' && seq <= this.lastSeenSeq) {
409
+ this._log(`Dropping stale message: seq=${seq}, lastSeen=${this.lastSeenSeq}`);
410
+ return;
411
+ }
412
+
413
+ // Advance the watermark before the notification/sender branches so that
414
+ // our own save echoes count toward "seen". Without this, a later
415
+ // buffered/replayed message with a smaller seq could rewind us past our
416
+ // local edit.
417
+ if (typeof seq === 'number') {
418
+ this.lastSeenSeq = seq;
419
+ }
420
+
374
421
  // Handle notifications (show toast, don't morph)
375
422
  if (data.type === "notification") {
376
423
  this.handleNotification(data);
@@ -384,22 +431,7 @@ class LiveSync {
384
431
  return;
385
432
  }
386
433
 
387
- const { html, sender, seq, identityMap } = data;
388
-
389
- // Staleness check runs FIRST — compared against the high-water mark of
390
- // seqs we've seen (own echoes count too, see below). `seq` is optional
391
- // for back-compat with older server builds that don't stamp it.
392
- if (typeof seq === 'number' && seq <= this.lastSeenSeq) {
393
- this._log(`Dropping stale message: seq=${seq}, lastSeen=${this.lastSeenSeq}`);
394
- return;
395
- }
396
-
397
- // Advance the watermark before the sender filter so that our own save
398
- // echoes count toward "seen". Without this, a later buffered/replayed
399
- // peer message with a smaller seq could rewind us past our local edit.
400
- if (typeof seq === 'number') {
401
- this.lastSeenSeq = seq;
402
- }
434
+ const { html, sender, identityMap } = data;
403
435
 
404
436
  // Ignore own changes — already reflected in the DOM, nothing to morph
405
437
  if (sender === this.clientId) {
@@ -639,7 +671,17 @@ class LiveSync {
639
671
  morphStyle: 'outerHTML',
640
672
  ignoreActiveValue: true,
641
673
  head: { style: 'merge' },
642
- scripts: { handle: true, matchMode: 'smart' },
674
+ // mergeBase: mergeable script tags ([merge] + rules tags) three-way
675
+ // merge against the last synced state instead of being clobbered by
676
+ // the incoming save; lastHtml is exactly that base (set after every
677
+ // own save and every applied morph). Null on the first update →
678
+ // two-way merge, which still keeps local-only keys.
679
+ scripts: {
680
+ handle: true,
681
+ matchMode: 'smart',
682
+ mergeBase: this.lastHtml,
683
+ mergeTags: mergeTagRecognizers
684
+ },
643
685
  key,
644
686
  callbacks: { afterNodeMorphed }
645
687
  });
@@ -0,0 +1,36 @@
1
+ /**
2
+ * merge-tags.js — extra mergeable script-tag recognizers for hyper-morph.
3
+ *
4
+ * hyper-morph's built-in recognizer handles `merge="<name>"` tags with
5
+ * relaxed JSON. This adds the Hyperclay rules-tag family (hypercms and
6
+ * hyper-html-api): `script[data-rules-name][data-rules-version]`, whose
7
+ * bodies use the rules dialect (unquoted selector barewords become strings).
8
+ * Identity is the version plus the sorted name-token list, so token order
9
+ * never matters and differing schema versions never pair. Parsing goes
10
+ * through hyper-morph's parseRulesRelaxed, the canonical port of the
11
+ * engine's parseRelaxed, so the tags merge exactly as they load.
12
+ *
13
+ * Pass as `scripts: { mergeTags: mergeTagRecognizers }` on morph configs.
14
+ */
15
+
16
+ import { HyperMorph } from "../vendor/hyper-morph.vendor.js";
17
+
18
+ export const mergeTagRecognizers = [
19
+ {
20
+ match: (el) => el.hasAttribute("data-rules-name"),
21
+ identity: (el) => {
22
+ const names = (el.getAttribute("data-rules-name") || "")
23
+ .split(/\s+/)
24
+ .filter(Boolean)
25
+ .sort()
26
+ .join(" ");
27
+ if (!names) return null;
28
+ return (
29
+ "rules:" + (el.getAttribute("data-rules-version") || "") + ":" + names
30
+ );
31
+ },
32
+ parse: HyperMorph.parseRulesRelaxed,
33
+ },
34
+ ];
35
+
36
+ export default mergeTagRecognizers;