@panphora/clayjs 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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +57 -0
  3. package/clay.js +21 -0
  4. package/package.json +27 -0
  5. package/src/attrs/onaftersave.js +38 -0
  6. package/src/attrs/refetch-on-save.js +36 -0
  7. package/src/attrs/save-freeze.js +102 -0
  8. package/src/core/admin-attrs.js +22 -0
  9. package/src/core/admin-contenteditable.js +47 -0
  10. package/src/core/admin-inputs.js +48 -0
  11. package/src/core/admin-onclick.js +50 -0
  12. package/src/core/admin-resources.js +49 -0
  13. package/src/core/autosave.js +61 -0
  14. package/src/core/edit-mode.js +38 -0
  15. package/src/core/is-edit-mode.js +30 -0
  16. package/src/core/persist.js +103 -0
  17. package/src/core/save-core.js +385 -0
  18. package/src/core/save.js +475 -0
  19. package/src/core/snapshot.js +282 -0
  20. package/src/core/unsaved-warning.js +38 -0
  21. package/src/lib/autosave-debug.js +223 -0
  22. package/src/lib/cache-bust.js +12 -0
  23. package/src/lib/cookie.js +37 -0
  24. package/src/lib/dom-ready.js +9 -0
  25. package/src/lib/extension-noise.js +63 -0
  26. package/src/lib/load-vendor-script.js +57 -0
  27. package/src/lib/mutation.js +719 -0
  28. package/src/lib/query.js +3 -0
  29. package/src/lib/region-policy.js +220 -0
  30. package/src/lib/throttle.js +41 -0
  31. package/src/lib/user-gesture.js +126 -0
  32. package/src/loader-logic.js +62 -0
  33. package/src/loader.js +123 -0
  34. package/src/plugins/indicator.js +51 -0
  35. package/src/plugins/sortable.js +119 -0
  36. package/src/plugins/undo.js +23 -0
  37. package/src/sync/live-sync.js +752 -0
  38. package/src/vendor/Sortable.vendor.js +2 -0
  39. package/src/vendor/control-serialize.vendor.js +88 -0
  40. package/src/vendor/hyper-morph.vendor.js +22 -0
  41. package/src/vendor/hyper-undo.vendor.js +11 -0
  42. package/src/vendor/hypercms.vendor.js +1751 -0
  43. package/src/vendor/richclay.vendor.js +456 -0
@@ -0,0 +1,3 @@
1
+ const query = Object.fromEntries(new URLSearchParams(window.location.search));
2
+
3
+ export default query;
@@ -0,0 +1,220 @@
1
+ /**
2
+ * region-policy.js — the region capability model.
3
+ *
4
+ * A region declares how it participates in the framework via a small set of
5
+ * orthogonal tokens. The canonical spelling is the space-separated `clay`
6
+ * attribute (`clay="no-save no-undo"`); bare naked attributes are still
7
+ * resolved for back-compat. They apply to an element and its descendants:
8
+ *
9
+ * no-save — not written to the saved file (stripped). Live at runtime.
10
+ * no-trigger-autosave — saved, but editing it doesn't trigger an autosave / mark dirty.
11
+ * no-undo — edits here are not recorded in the undo stack.
12
+ * no-watch — invisible to the whole mutation system (high-churn regions). Still saved.
13
+ * freeze — saved as authored (runtime changes not persisted). Live at runtime.
14
+ *
15
+ * Four legacy markers map onto bundles of the above for back-compat. They all
16
+ * additionally gain "watched" (behaviors now run inside them):
17
+ *
18
+ * mutations-ignore -> no-watch
19
+ * save-remove -> no-save no-undo
20
+ * save-ignore -> no-trigger-autosave no-undo
21
+ * save-freeze -> freeze no-undo
22
+ *
23
+ * Separately, a snapshot-layer marker controls whether an element appears in any
24
+ * snapshot at all (save file, live-sync broadcast, and dirty-comparison):
25
+ *
26
+ * snapshot-remove / no-snapshot — removed from every snapshot. Runtime-only
27
+ * local chrome. hyper-morph also treats it as sync-ignored, so a live-sync
28
+ * receiver preserves its own copy instead of deleting it. Handled in
29
+ * snapshot.js, not by the policy axes below; no-snapshot is the consistent
30
+ * alias for the original snapshot-remove.
31
+ *
32
+ * resolveRegionPolicy() walks an element's self-or-ancestor chain once and
33
+ * returns the four independent axes the rest of the framework keys off:
34
+ * { watched, autosaveTriggered, undoable, persist, extension }
35
+ */
36
+
37
+ import { EXTENSION_NODE_SELECTOR } from './extension-noise.js';
38
+
39
+ export const PERSIST = { FULL: 'full', FROZEN: 'frozen', NONE: 'none' };
40
+
41
+ // The canonical region tokens (spelled in the `clay` attribute or as bare attrs).
42
+ export const REGION_ATTRS = ['no-save', 'no-trigger-autosave', 'no-undo', 'no-watch', 'freeze'];
43
+
44
+ // Canonical tokens spellable inside the space-separated `clay` attribute.
45
+ const CLAY_TOKENS = ["no-save", "no-snapshot", "no-trigger-autosave", "no-watch", "no-undo", "freeze"];
46
+
47
+ // True when a region marker is present, whether spelled as a `clay` token
48
+ // (whitespace-token semantics, matching [clay~=token]) or a legacy bare attribute.
49
+ function hasRegionToken(el, token) {
50
+ const clay = el.getAttribute?.("clay");
51
+ if (clay && clay.split(/\s+/).includes(token)) return true; // whitespace-token semantics, matches [clay~=…]
52
+ return !!el.hasAttribute?.(token); // legacy bare attribute
53
+ }
54
+
55
+ // Serializer selectors (recognize the clay-token spelling FIRST, then new + legacy bare).
56
+ export const STRIP_FROM_SAVE = '[clay~="no-save"], [no-save], [save-remove]';
57
+ export const FREEZE_SELECTOR = '[clay~="freeze"], [freeze], [save-freeze]';
58
+ // forComparison additionally strips every region whose autosave-trigger is off,
59
+ // so their churn never marks the page dirty — including the no-watch /
60
+ // mutations-ignore footgun (their content stays in the saved file, but is no
61
+ // longer counted as a change).
62
+ export const STRIP_FROM_COMPARISON =
63
+ '[clay~="no-save"], [clay~="no-trigger-autosave"], [clay~="freeze"], [clay~="no-watch"], [no-save], [save-remove], [no-trigger-autosave], [save-ignore], [freeze], [save-freeze], [no-watch], [mutations-ignore]';
64
+
65
+ // Snapshot-layer marker: removed from EVERY snapshot (save, live-sync broadcast,
66
+ // dirty-comparison) in snapshot.js. `no-snapshot` is the consistent alias for the
67
+ // original `snapshot-remove`; hyper-morph treats both as sync-ignored so a
68
+ // live-sync receiver keeps its own local copy instead of deleting it.
69
+ export const SNAPSHOT_REMOVE_SELECTOR = '[clay~="no-snapshot"], [snapshot-remove], [no-snapshot]';
70
+
71
+ export function isSnapshotRemoved(el) {
72
+ return hasRegionToken(el, 'snapshot-remove') || hasRegionToken(el, 'no-snapshot');
73
+ }
74
+
75
+ const PERSIST_RANK = { full: 0, frozen: 1, none: 2 };
76
+ const RANK_PERSIST = ['full', 'frozen', 'none'];
77
+
78
+ function startElement(node) {
79
+ return node && node.nodeType !== 1 ? node.parentElement : node;
80
+ }
81
+
82
+ /**
83
+ * Walk an element's self-or-ancestor chain once and resolve its region axes.
84
+ *
85
+ * @param {Node} node
86
+ * @returns {{watched:boolean, autosaveTriggered:boolean, undoable:boolean, persist:string, extension:boolean}}
87
+ */
88
+ export function resolveRegionPolicy(node) {
89
+ let element = startElement(node);
90
+
91
+ // Browser-extension injected content is never page content, for any consumer.
92
+ if (element && element.closest && element.closest(EXTENSION_NODE_SELECTOR)) {
93
+ return { watched: false, autosaveTriggered: false, undoable: false, persist: PERSIST.FULL, extension: true };
94
+ }
95
+
96
+ let watched = true;
97
+ let undoable = true;
98
+ let autosaveOff = false;
99
+ let persistRank = 0;
100
+
101
+ while (element && element.nodeType === 1) {
102
+ if (element.hasAttribute) {
103
+ // new naked attributes
104
+ if (hasRegionToken(element, 'no-watch')) watched = false;
105
+ if (hasRegionToken(element, 'no-trigger-autosave')) autosaveOff = true;
106
+ if (hasRegionToken(element, 'no-undo')) undoable = false;
107
+ if (hasRegionToken(element, 'no-save')) persistRank = Math.max(persistRank, PERSIST_RANK.none);
108
+ if (hasRegionToken(element, 'freeze')) persistRank = Math.max(persistRank, PERSIST_RANK.frozen);
109
+ // legacy markers -> bundles
110
+ if (hasRegionToken(element, 'mutations-ignore')) watched = false;
111
+ if (hasRegionToken(element, 'save-remove')) { persistRank = Math.max(persistRank, PERSIST_RANK.none); undoable = false; }
112
+ if (hasRegionToken(element, 'save-ignore')) { autosaveOff = true; undoable = false; }
113
+ if (hasRegionToken(element, 'save-freeze')) { persistRank = Math.max(persistRank, PERSIST_RANK.frozen); undoable = false; }
114
+ }
115
+ element = element.parentElement;
116
+ }
117
+
118
+ // Implication rules (no-save wins over freeze automatically via Math.max above):
119
+ // no-watch ⟹ no autosave + no undo (can't track what isn't watched)
120
+ // no-save / freeze ⟹ no autosave (nothing live to persist)
121
+ if (!watched) { autosaveOff = true; undoable = false; }
122
+ if (persistRank > 0) autosaveOff = true;
123
+
124
+ return {
125
+ watched,
126
+ autosaveTriggered: !autosaveOff,
127
+ undoable,
128
+ persist: RANK_PERSIST[persistRank],
129
+ extension: false,
130
+ };
131
+ }
132
+
133
+ /**
134
+ * Cheap intake-level check: is a node invisible to EVERY consumer?
135
+ *
136
+ * Only no-watch / mutations-ignore (and extension noise) qualify — they're the
137
+ * one universal drop, so the observer can skip walking those subtrees entirely.
138
+ * All other region attributes are resolved per-consumer in Mutation._notify.
139
+ *
140
+ * @param {Node} node
141
+ * @returns {boolean}
142
+ */
143
+ export function isInert(node) {
144
+ let element = startElement(node);
145
+ if (element && element.closest && element.closest(EXTENSION_NODE_SELECTOR)) return true;
146
+ while (element && element.nodeType === 1) {
147
+ if (element.hasAttribute &&
148
+ (hasRegionToken(element, 'no-watch') || hasRegionToken(element, 'mutations-ignore'))) {
149
+ return true;
150
+ }
151
+ element = element.parentElement;
152
+ }
153
+ return false;
154
+ }
155
+
156
+ /**
157
+ * Combine two resolved policies into the stricter of each axis. Used to merge a
158
+ * removed (detached) element's own markers with its still-attached parent's.
159
+ */
160
+ export function strictestPolicy(a, b) {
161
+ return {
162
+ watched: a.watched && b.watched,
163
+ autosaveTriggered: a.autosaveTriggered && b.autosaveTriggered,
164
+ undoable: a.undoable && b.undoable,
165
+ persist: PERSIST_RANK[a.persist] >= PERSIST_RANK[b.persist] ? a.persist : b.persist,
166
+ extension: a.extension || b.extension,
167
+ };
168
+ }
169
+
170
+ // Literal `skip:[...]` escape-hatch tokens -> axis predicate.
171
+ const SKIP_TOKEN_PREDICATES = {
172
+ 'no-watch': p => !p.watched,
173
+ 'mutations-ignore': p => !p.watched,
174
+ 'no-save': p => p.persist === PERSIST.NONE,
175
+ 'save-remove': p => p.persist === PERSIST.NONE,
176
+ 'freeze': p => p.persist === PERSIST.FROZEN,
177
+ 'save-freeze': p => p.persist === PERSIST.FROZEN,
178
+ 'no-trigger-autosave': p => !p.autosaveTriggered,
179
+ 'save-ignore': p => !p.autosaveTriggered,
180
+ 'no-undo': p => !p.undoable,
181
+ };
182
+
183
+ /**
184
+ * Should a consumer skip a change in this region?
185
+ *
186
+ * @param {object} policy resolved region policy
187
+ * @param {string} [require] axis the consumer needs: 'observed' | 'autosave' | 'undo'
188
+ * @param {string[]} [skip] literal attribute escape-hatch (any match -> skip)
189
+ * @returns {boolean}
190
+ */
191
+ export function skipForPolicy(policy, require, skip) {
192
+ if (policy.extension) return true;
193
+ if (skip && skip.length) {
194
+ return skip.some(tok => SKIP_TOKEN_PREDICATES[tok]?.(policy) || false);
195
+ }
196
+ switch (require) {
197
+ case 'observed': return !policy.watched;
198
+ case 'autosave': return !policy.autosaveTriggered;
199
+ case 'undo': return !policy.undoable;
200
+ default:
201
+ // No require declared: preserve the legacy four-marker skip so unmodified
202
+ // consumers behave exactly as before. (Undo-only opt-outs still pass.)
203
+ return !policy.watched || !policy.autosaveTriggered || policy.persist !== PERSIST.FULL;
204
+ }
205
+ }
206
+
207
+ // The canonical region API the vendored hyper-undo (a separate bundle that can't
208
+ // import this module) delegates "is this undoable?" to via window.hyperclay.region,
209
+ // so the two can no longer drift. The loader assembles this onto the compat shim.
210
+ export const windowRegionShape = {
211
+ resolveRegionPolicy,
212
+ isInert,
213
+ skipForPolicy,
214
+ strictestPolicy,
215
+ PERSIST,
216
+ REGION_ATTRS,
217
+ STRIP_FROM_SAVE,
218
+ FREEZE_SELECTOR,
219
+ STRIP_FROM_COMPARISON,
220
+ };
@@ -0,0 +1,41 @@
1
+ function throttle(callback, delay, executeFirst = true) {
2
+ let lastCall = executeFirst ? 0 : Date.now();
3
+ let timeoutId = null;
4
+ let pendingResolvers = [];
5
+
6
+ return function (...args) {
7
+ const ctx = this;
8
+ const now = Date.now();
9
+ const remaining = delay - (now - lastCall);
10
+
11
+ return new Promise((resolve) => {
12
+ if (remaining <= 0) {
13
+ clearTimeout(timeoutId);
14
+ timeoutId = null;
15
+ lastCall = now;
16
+
17
+ const resolvers = pendingResolvers.concat(resolve);
18
+ pendingResolvers = [];
19
+
20
+ Promise.resolve(callback.apply(ctx, args))
21
+ .then(value => { for (const r of resolvers) r(value); });
22
+ } else {
23
+ pendingResolvers.push(resolve);
24
+
25
+ if (!timeoutId) {
26
+ timeoutId = setTimeout(() => {
27
+ lastCall = Date.now();
28
+ timeoutId = null;
29
+ const resolvers = pendingResolvers;
30
+ pendingResolvers = [];
31
+
32
+ Promise.resolve(callback.apply(ctx, args))
33
+ .then(value => { for (const r of resolvers) r(value); });
34
+ }, remaining);
35
+ }
36
+ }
37
+ });
38
+ };
39
+ }
40
+
41
+ export default throttle;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * user-gesture — the "was a human driving this write?" signal for the
3
+ * data-clobber guard. Small and dedicated (NOT behaviorCollector, which is the
4
+ * analytics collector).
5
+ *
6
+ * How it works: capture-phase listeners for trusted user-intent events stamp
7
+ * `lastTrustedGestureTs` (and set a same-turn `gestureTaskActive` flag, cleared
8
+ * on the next macrotask). The Mutation hub calls `isUserDrivenNow()`
9
+ * synchronously when it processes a DOM change. A change reads as user-driven if
10
+ * it lands in the same turn as a gesture OR within `RECENT_GESTURE_MS` of the
11
+ * last trusted gesture. The recency window is what attributes CROSS-TURN
12
+ * first-party edits: a button's `click` handler runs a turn after its
13
+ * `pointerdown`, and a confirm-modal delete runs a macrotask after the Confirm
14
+ * gesture (theModal defers its resolve via setTimeout) — both mutate a few ms
15
+ * after the gesture, inside the window. A background setTimeout / fetch().then()
16
+ * mutation with no recent gesture reads background. Synthetic dispatchEvent /
17
+ * el.click() are isTrusted=false, so a script can't fake a gesture.
18
+ *
19
+ * Why a bounded recency window and not `navigator.userActivation`: its transient
20
+ * window is ~5s and consumable — too loose for a recovery tool, since a
21
+ * background clobber within 5s of any click would read user-driven and suppress
22
+ * the chip (silent data loss). `RECENT_GESTURE_MS` is an owned, tight window
23
+ * stamped only by trusted gestures, so it's unspoofable and an order of
24
+ * magnitude less exposed.
25
+ *
26
+ * The accumulated bit rides every save the page makes; the server splits a
27
+ * UI-gestured save from a background-script save with it.
28
+ */
29
+
30
+ // `change` and `submit` are included intentionally. Both are reachable with
31
+ // isTrusted=true from script (checkbox.click(), form.requestSubmit(), a
32
+ // submit-button .click()), so a nefarious script could fake a gesture and suppress
33
+ // the chip. That is out of scope by design: this panel catches ACCIDENTAL
34
+ // deletion, not malicious intent, and we assume scripts are helpful. Listening for
35
+ // them attributes legitimate script- or control-driven form edits as driven, which
36
+ // avoids false-positive chips. (A synthetic dispatchEvent stays isTrusted=false.)
37
+ const GESTURE_EVENTS = ['pointerdown', 'pointerup', 'click', 'keydown', 'beforeinput', 'change', 'submit', 'paste', 'drop', 'cut'];
38
+
39
+ // A cross-turn first-party edit (click handler, confirm-modal delete) mutates
40
+ // within a few ms of its trusted gesture; 500ms is ample margin while keeping
41
+ // the false-negative window (a background clobber near a gesture) an order of
42
+ // magnitude tighter than navigator.userActivation's ~5s.
43
+ const RECENT_GESTURE_MS = 500;
44
+
45
+ let gestureTaskActive = false;
46
+ let lastTrustedGestureTs = -Infinity;
47
+ let userDrivenSinceLastSave = false;
48
+ let installed = false;
49
+
50
+ // Monotonic clock for the recency window — never moves backward, so a system
51
+ // clock change can't widen or break the window (Date.now() can jump).
52
+ function now() {
53
+ return (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
54
+ }
55
+
56
+ function markGestureTurn() {
57
+ gestureTaskActive = true;
58
+ lastTrustedGestureTs = now();
59
+ // Clear the same-turn flag on the next macrotask. The MutationObserver
60
+ // microtask for a change made synchronously in this turn drains BEFORE this
61
+ // fires; cross-turn changes fall back to the recency window above.
62
+ setTimeout(() => { gestureTaskActive = false; }, 0);
63
+ }
64
+
65
+ function onGesture(e) {
66
+ if (!e || !e.isTrusted) return;
67
+ markGestureTurn();
68
+ }
69
+
70
+ /**
71
+ * Install the capture-phase gesture listeners (idempotent). Call once in edit
72
+ * mode (autosave.js and data-loss-panel both do this).
73
+ */
74
+ export function initUserGesture() {
75
+ if (installed || typeof document === 'undefined') return;
76
+ installed = true;
77
+ for (const type of GESTURE_EVENTS) {
78
+ document.addEventListener(type, onGesture, true);
79
+ }
80
+ }
81
+
82
+ /**
83
+ * True iff a trusted gesture drove this exact turn, OR one happened within
84
+ * RECENT_GESTURE_MS. The Mutation hub calls this synchronously per change batch.
85
+ */
86
+ export function isUserDrivenNow() {
87
+ if (gestureTaskActive) return true;
88
+ return (now() - lastTrustedGestureTs) < RECENT_GESTURE_MS;
89
+ }
90
+
91
+ /**
92
+ * Record that an autosave-relevant change happened in a user-driven turn. Called
93
+ * by the Mutation hub (per autosave-filtered batch) and by the [persist] input
94
+ * path (a trusted input is itself the gesture+change).
95
+ */
96
+ export function markUserDriven() {
97
+ userDrivenSinceLastSave = true;
98
+ }
99
+
100
+ /**
101
+ * Read-and-reset the accumulated bit. Called at the ACTUAL save send (not on a
102
+ * save that never ships), so it survives the autosave debounce and coalescing.
103
+ * @returns {boolean}
104
+ */
105
+ export function consumeUserDriven() {
106
+ const v = userDrivenSinceLastSave;
107
+ userDrivenSinceLastSave = false;
108
+ return v;
109
+ }
110
+
111
+ /** Test seam: force-reset state. */
112
+ export function _resetUserGesture() {
113
+ gestureTaskActive = false;
114
+ lastTrustedGestureTs = -Infinity;
115
+ userDrivenSinceLastSave = false;
116
+ }
117
+
118
+ /**
119
+ * Test seam: open a gesture turn as a trusted event would, without a real
120
+ * trusted event (jsdom can't forge isTrusted). Same semantics as onGesture: the
121
+ * turn is active synchronously and cleared on the next macrotask, and the
122
+ * recency timestamp is stamped.
123
+ */
124
+ export function _simulateGestureTurn() {
125
+ markGestureTurn();
126
+ }
@@ -0,0 +1,62 @@
1
+ export const CORE_WAVES = {
2
+ always: [
3
+ "lib/mutation.js", // first: everything hangs off the hub; cms/sync need it in view mode too
4
+ "core/edit-mode.js", // both modes — matches today: 'edit-mode' is NOT in EDIT_MODE_ONLY
5
+ // (hyperclay.js:251-273 lists 'edit-mode-helpers', not 'edit-mode');
6
+ // toggleEditMode must exist in view mode, it's the way IN
7
+ ],
8
+ editOnly: [
9
+ "core/snapshot.js", "core/save-core.js", "core/save.js",
10
+ "core/unsaved-warning.js", "core/persist.js",
11
+ "core/admin-attrs.js", "core/autosave.js",
12
+ "attrs/save-freeze.js", "attrs/onaftersave.js", "attrs/refetch-on-save.js",
13
+ "lib/cache-bust.js",
14
+ ],
15
+ };
16
+
17
+ export const PLUGIN_PATHS = {
18
+ richclay: { path: "vendor/richclay.vendor.js", editOnly: true, default: true },
19
+ indicator: { path: "plugins/indicator.js", editOnly: true, default: false },
20
+ sync: { path: "sync/live-sync.js", editOnly: false, default: false },
21
+ sortable: { path: "plugins/sortable.js", editOnly: true, default: false },
22
+ undo: { path: "plugins/undo.js", editOnly: true, default: false },
23
+ cms: { path: "vendor/hypercms.vendor.js", editOnly: false, default: false },
24
+ };
25
+
26
+ const PLUGIN_ORDER = ["richclay", "indicator", "sortable", "undo", "cms", "sync"];
27
+
28
+ function parseCsv(params, key, enabled, apply) {
29
+ const raw = params.get(key);
30
+ if (!raw) return;
31
+ for (const token of raw.split(",")) {
32
+ const name = token.trim();
33
+ if (!name) continue;
34
+ if (!PLUGIN_PATHS[name]) {
35
+ console.warn(`clayjs: unknown plugin "${name}"`);
36
+ continue;
37
+ }
38
+ apply(enabled, name);
39
+ }
40
+ }
41
+
42
+ export function resolveModules(params, isEditMode) {
43
+ const core = [...CORE_WAVES.always];
44
+ if (isEditMode) core.push(...CORE_WAVES.editOnly);
45
+
46
+ const enabled = new Set();
47
+ for (const [name, spec] of Object.entries(PLUGIN_PATHS)) {
48
+ if (spec.default) enabled.add(name);
49
+ }
50
+ parseCsv(params, "plugins", enabled, (set, name) => set.add(name));
51
+ parseCsv(params, "exclude", enabled, (set, name) => set.delete(name));
52
+
53
+ const plugins = [];
54
+ for (const name of PLUGIN_ORDER) {
55
+ if (!enabled.has(name)) continue;
56
+ const spec = PLUGIN_PATHS[name];
57
+ if (spec.editOnly && !isEditMode) continue;
58
+ plugins.push(spec.path);
59
+ }
60
+
61
+ return { core, plugins };
62
+ }
package/src/loader.js ADDED
@@ -0,0 +1,123 @@
1
+ import { resolveModules } from "./loader-logic.js";
2
+ import onDomReady from "./lib/dom-ready.js";
3
+
4
+ function domReady() {
5
+ return new Promise((resolve) => onDomReady(resolve));
6
+ }
7
+
8
+ export async function boot(base, params, readyResolve) {
9
+ await domReady(); // Mutation observes document.body
10
+ // unconditionally, and a <head> placement
11
+ // would otherwise observe null
12
+
13
+ const editMode = await import(base + "/src/core/is-edit-mode.js");
14
+ const { isEditMode, isOwner } = editMode;
15
+
16
+ // richclay's vendor build detects edit mode via this legacy global; set it
17
+ // before any plugin import so its autoInit sees the right value. Overwrite
18
+ // even a pre-set value: resolution already consumed it (lowest precedence),
19
+ // and a conflicting leftover would make richclay disable itself.
20
+ window.__hyperclayEditMode = isEditMode;
21
+
22
+ const regionPolicy = await import(base + "/src/lib/region-policy.js");
23
+
24
+ const plan = resolveModules(params, isEditMode);
25
+ const loaded = {};
26
+
27
+ for (const path of plan.core) {
28
+ loaded[path] = await import(base + "/src/" + path); // sequential: order is load-bearing
29
+ }
30
+
31
+ assembleCore(loaded, { isEditMode, isOwner }, regionPolicy); // window.clay + shim MUST exist
32
+ // before any plugin import
33
+
34
+ for (const path of plan.plugins) {
35
+ const mod = await import(base + "/src/" + path);
36
+ loaded[path] = mod;
37
+ attachPluginMember(path, mod); // immediately, not after the loop: hypercms's ?cms=true
38
+ // auto-open runs as a microtask queued during ITS evaluation
39
+ // (before boot resumes) and reads window.hyperclay.RichClay
40
+ // and .undo, which earlier plugins must have mirrored by then
41
+ }
42
+
43
+ // Plugins with async setup (sortable's vendor fetch) export `ready`; hold
44
+ // clay.ready until they finish. allSettled: a failed plugin degrades, never blocks boot.
45
+ await Promise.allSettled(plan.plugins.map((path) => loaded[path]?.ready).filter(Boolean));
46
+
47
+ installViewModeNotice(isEditMode);
48
+ readyResolve(window.clay);
49
+ document.dispatchEvent(new CustomEvent("clay:ready", { detail: { clay: window.clay } }));
50
+ }
51
+
52
+ function assembleCore(loaded, { isEditMode, isOwner }, regionPolicy) {
53
+ const clay = window.clay;
54
+
55
+ const mutation = loaded["lib/mutation.js"];
56
+ const editModeMod = loaded["core/edit-mode.js"];
57
+
58
+ Object.assign(clay, {
59
+ toggleEditMode: editModeMod.toggleEditMode,
60
+ isEditMode,
61
+ isOwner,
62
+ Mutation: mutation.default,
63
+ });
64
+
65
+ const snapshot = loaded["core/snapshot.js"];
66
+ const save = loaded["core/save.js"];
67
+ const cacheBustMod = loaded["lib/cache-bust.js"];
68
+
69
+ if (save) {
70
+ const saveFn = save.savePage || save.default;
71
+ saveFn.force = save.savePageForce;
72
+ clay.save = saveFn;
73
+ }
74
+ if (snapshot) {
75
+ clay.getHTML = snapshot.getPageContents;
76
+ clay.beforeSave = snapshot.beforeSave;
77
+ clay.onSnapshot = snapshot.onSnapshot;
78
+ }
79
+ if (cacheBustMod) {
80
+ clay.cacheBust = cacheBustMod.default;
81
+ }
82
+
83
+ // vendor-compat shim: richclay, hypercms, and hyper-undo read window.hyperclay;
84
+ // remove when the vendors migrate to window.clay.
85
+ window.hyperclay = window.hyperclay || {};
86
+ Object.assign(window.hyperclay, {
87
+ Mutation: clay.Mutation,
88
+ isEditMode: clay.isEditMode,
89
+ region: regionPolicy.windowRegionShape,
90
+ });
91
+ if (clay.beforeSave) {
92
+ window.hyperclay.beforeSave = clay.beforeSave; // richclay's save-cleanup hook
93
+ window.hyperclay.onPrepareForSave = clay.beforeSave; // hypercms uses this name
94
+ }
95
+ if (clay.save) {
96
+ window.hyperclay.savePage = clay.save;
97
+ }
98
+ }
99
+
100
+ function attachPluginMember(path, mod) {
101
+ const clay = window.clay;
102
+
103
+ if (path === "plugins/undo.js") {
104
+ clay.undo = mod.undo || mod.default;
105
+ window.hyperclay.undo = clay.undo;
106
+ } else if (path === "sync/live-sync.js") {
107
+ clay.morph = mod.morph;
108
+ } else if (path === "vendor/hypercms.vendor.js") {
109
+ clay.cms = mod.cms || mod.default;
110
+ window.hyperclay.hypercms = clay.cms;
111
+ } else if (path === "vendor/richclay.vendor.js") {
112
+ window.hyperclay.RichClay = mod.RichClay || mod.default;
113
+ }
114
+ }
115
+
116
+ function installViewModeNotice(isEditMode) {
117
+ if (isEditMode) return;
118
+ document.addEventListener("click", (event) => {
119
+ if (!event.target.closest?.("[trigger-save]")) return;
120
+ console.info("clayjs: you're not the owner of this page; changes stay local");
121
+ document.dispatchEvent(new CustomEvent("clay:view-save-attempt"));
122
+ });
123
+ }
@@ -0,0 +1,51 @@
1
+ import { isEditMode } from "../core/is-edit-mode.js";
2
+ import onDomReady from "../lib/dom-ready.js";
3
+
4
+ const LABELS = {
5
+ saving: "Saving…",
6
+ saved: "Saved",
7
+ error: "Couldn't save",
8
+ offline: "Offline, not saved",
9
+ };
10
+
11
+ let el = null;
12
+ let hideTimer = null;
13
+
14
+ function ensure() {
15
+ if (el) return el;
16
+ el = document.createElement("div");
17
+ el.setAttribute("clay", "no-save no-watch no-snapshot");
18
+ el.setAttribute("data-clay-indicator", "");
19
+ el.setAttribute("role", "status");
20
+ el.style.cssText = [
21
+ "position:fixed", "right:16px", "bottom:16px", "z-index:2147483000",
22
+ "padding:4px 12px", "border-radius:999px",
23
+ "font:13px/1.6 system-ui,-apple-system,sans-serif",
24
+ "background:var(--clay-indicator-bg,#2e2b27)",
25
+ "color:var(--clay-indicator-ink,#f2f0eb)",
26
+ "opacity:0", "transition:opacity .25s", "pointer-events:none",
27
+ ].join(";");
28
+ if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) el.style.transition = "none";
29
+ document.body.appendChild(el);
30
+ return el;
31
+ }
32
+
33
+ function show(state) {
34
+ const node = ensure();
35
+ node.textContent = LABELS[state];
36
+ node.dataset.state = state;
37
+ node.style.background = state === "error" || state === "offline"
38
+ ? "var(--clay-indicator-error-bg,#7a3b28)" : "var(--clay-indicator-bg,#2e2b27)";
39
+ node.style.opacity = "1";
40
+ clearTimeout(hideTimer);
41
+ if (state !== "saving") hideTimer = setTimeout(() => { node.style.opacity = "0"; }, 2200);
42
+ }
43
+
44
+ function init() {
45
+ if (!isEditMode) return;
46
+ for (const state of Object.keys(LABELS)) {
47
+ document.addEventListener("clay:save-" + state, () => show(state));
48
+ }
49
+ }
50
+
51
+ onDomReady(init);