@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,282 @@
1
+ /**
2
+ * snapshot.js — The source of truth for page state
3
+ *
4
+ * THE SAVE/SYNC PIPELINE:
5
+ *
6
+ * ┌─────────────────────────────────────────────────────────┐
7
+ * │ 1. CLONE document.documentElement.cloneNode() │
8
+ * └─────────────────────────────────────────────────────────┘
9
+ * │
10
+ * ▼
11
+ * ┌─────────────────────────────────────────────────────────┐
12
+ * │ 2. SNAPSHOT HOOKS onSnapshot callbacks │
13
+ * │ (form value sync) │
14
+ * │ │
15
+ * │ ✓ Used by: SAVE and LIVE-SYNC │
16
+ * └─────────────────────────────────────────────────────────┘
17
+ * │
18
+ * ┌───────────────┴───────────────┐
19
+ * ▼ ▼
20
+ * ┌─────────────────────────┐ ┌─────────────────────────┐
21
+ * │ 3a. PREPARE HOOKS │ │ 3b. DONE │
22
+ * │ onPrepareForSave │ │ (live-sync stops here) │
23
+ * │ [onbeforesave] │ │ │
24
+ * │ [save-remove] │ │ → emits snapshot-ready │
25
+ * │ │ └─────────────────────────┘
26
+ * │ ✓ Used by: SAVE only │
27
+ * └─────────────────────────┘
28
+ * │
29
+ * ▼
30
+ * ┌─────────────────────────┐
31
+ * │ 4. SERIALIZE │
32
+ * │ "<!DOCTYPE html>" │
33
+ * │ + outerHTML │
34
+ * │ │
35
+ * │ → sent to server │
36
+ * └─────────────────────────┘
37
+ */
38
+
39
+ import { stripExtensionNoise } from '../lib/extension-noise.js';
40
+ import { STRIP_FROM_SAVE, STRIP_FROM_COMPARISON, SNAPSHOT_REMOVE_SELECTOR } from '../lib/region-policy.js';
41
+
42
+ // =============================================================================
43
+ // HOOK REGISTRIES
44
+ // =============================================================================
45
+
46
+ const snapshotHooks = []; // Phase 2: Always run (form sync)
47
+ const prepareForSaveHooks = []; // Phase 3a: Save only (strip admin)
48
+
49
+ /**
50
+ * Register a hook that runs on EVERY snapshot (save AND sync).
51
+ * Use for: syncing form values to the clone.
52
+ *
53
+ * @param {Function} callback - Receives the cloned document element
54
+ */
55
+ export function onSnapshot(callback) {
56
+ snapshotHooks.push(callback);
57
+ }
58
+
59
+ /**
60
+ * Register a hook that runs ONLY when preparing for save.
61
+ * Use for: stripping admin elements, cleanup.
62
+ *
63
+ * @param {Function} callback - Receives the cloned document element
64
+ */
65
+ export function onPrepareForSave(callback) {
66
+ prepareForSaveHooks.push(callback);
67
+ }
68
+
69
+ // Backwards compat alias
70
+ export const beforeSave = onPrepareForSave;
71
+
72
+ // =============================================================================
73
+ // CAPTURE FUNCTIONS
74
+ // =============================================================================
75
+
76
+ /**
77
+ * PHASE 1-2: Clone the DOM and run snapshot hooks.
78
+ *
79
+ * This is the "canonical" state — form values synced, nothing stripped.
80
+ * Used as the base for both saving and syncing.
81
+ *
82
+ * @returns {HTMLElement} Cloned document element with snapshot hooks applied
83
+ */
84
+ function clonePreventingOnclone(node) {
85
+ const prev = window.__preventOnclone;
86
+ window.__preventOnclone = true;
87
+ try { return node.cloneNode(true); }
88
+ finally { window.__preventOnclone = prev; }
89
+ }
90
+
91
+ export function captureSnapshot() {
92
+ // Force-close any pending undo idle batch BEFORE cloning the DOM, so the
93
+ // snapshot reflects a clean undo boundary. Without this, a save that fires
94
+ // mid-typing would leave the idle batch open across the save boundary, and
95
+ // Cmd+Z after save would restore to a state earlier than the last save.
96
+ // No-op when undo isn't loaded or no batch is pending.
97
+ if (typeof window !== 'undefined' && window.hyperclay && window.hyperclay.undo && window.hyperclay.undo.flush) {
98
+ window.hyperclay.undo.flush();
99
+ }
100
+
101
+ const clone = clonePreventingOnclone(document.documentElement);
102
+
103
+ for (const hook of snapshotHooks) {
104
+ hook(clone);
105
+ }
106
+
107
+ for (const el of clone.querySelectorAll('[onbeforesnapshot]')) {
108
+ new Function(el.getAttribute('onbeforesnapshot')).call(el);
109
+ }
110
+
111
+ for (const el of clone.querySelectorAll(SNAPSHOT_REMOVE_SELECTOR)) {
112
+ el.remove();
113
+ }
114
+
115
+ // Browser-extension noise (password-manager menus, Grammarly overlays, and
116
+ // marker attributes on real inputs) is not page content. Drop it from every
117
+ // snapshot so it never reaches a save, a comparison, or a live-sync broadcast.
118
+ stripExtensionNoise(clone);
119
+
120
+ return clone;
121
+ }
122
+
123
+ /**
124
+ * Prepare an already-captured snapshot for saving.
125
+ * Mutates the clone — only call once per snapshot.
126
+ *
127
+ * @param {HTMLElement} clone - A snapshot from captureSnapshot()
128
+ * @returns {string} Full HTML string ready for server
129
+ */
130
+ function prepareCloneForSave(clone) {
131
+ // Run inline [onbeforesave] handlers
132
+ for (const el of clone.querySelectorAll('[onbeforesave]')) {
133
+ new Function(el.getAttribute('onbeforesave')).call(el);
134
+ }
135
+
136
+ // Remove elements that shouldn't be saved ([no-save] / legacy [save-remove])
137
+ for (const el of clone.querySelectorAll(STRIP_FROM_SAVE)) {
138
+ el.remove();
139
+ }
140
+
141
+ // Run registered prepare hooks ([freeze]/[save-freeze] innerHTML restore lives here)
142
+ for (const hook of prepareForSaveHooks) {
143
+ hook(clone);
144
+ }
145
+
146
+ return "<!DOCTYPE html>" + clone.outerHTML;
147
+ }
148
+
149
+ /**
150
+ * Capture snapshot prepared for dirty/change comparison.
151
+ *
152
+ * Like captureForSave but also strips every region whose autosave-trigger is off
153
+ * (no-trigger-autosave, freeze, no-watch, plus the legacy equivalents), so their
154
+ * churn never marks the page dirty.
155
+ *
156
+ * @returns {string} HTML string with all autosave-off regions stripped
157
+ */
158
+ export function captureForComparison() {
159
+ const clone = captureSnapshot();
160
+
161
+ // Run inline [onbeforesave] handlers
162
+ for (const el of clone.querySelectorAll('[onbeforesave]')) {
163
+ new Function(el.getAttribute('onbeforesave')).call(el);
164
+ }
165
+
166
+ // Strip before hooks (hooks see the "final" state)
167
+ for (const el of clone.querySelectorAll(STRIP_FROM_COMPARISON)) {
168
+ el.remove();
169
+ }
170
+
171
+ // Run registered prepare hooks
172
+ for (const hook of prepareForSaveHooks) {
173
+ hook(clone);
174
+ }
175
+
176
+ return "<!DOCTYPE html>" + clone.outerHTML;
177
+ }
178
+
179
+ /**
180
+ * Single-capture function for both saving and comparison.
181
+ *
182
+ * Clones the DOM once, then clones that clone for comparison.
183
+ * More efficient than calling captureForSave() and captureForComparison() separately.
184
+ *
185
+ * @param {Object} options
186
+ * @param {boolean} options.emitForSync - Whether to emit snapshot-ready event (default: true)
187
+ * @returns {{ forSave: string, forComparison: string }}
188
+ */
189
+ export function captureForSaveAndComparison({ emitForSync = true } = {}) {
190
+ const clone = captureSnapshot();
191
+
192
+ // Emit for live-sync before any stripping
193
+ if (emitForSync) {
194
+ document.dispatchEvent(new CustomEvent('clay:snapshot-ready', {
195
+ detail: { documentElement: clone }
196
+ }));
197
+
198
+ // Store snapshot HTML for Hyperclay Local platform sync
199
+ // This allows the save system to send both stripped and full versions
200
+ const isHyperclayLocal = window.location.hostname === 'localhost' ||
201
+ window.location.hostname === '127.0.0.1';
202
+ if (isHyperclayLocal) {
203
+ window.__hyperclaySnapshotHtml = '<!DOCTYPE html>' + clone.outerHTML;
204
+ }
205
+ }
206
+
207
+ // Run inline [onbeforesave] handlers
208
+ for (const el of clone.querySelectorAll('[onbeforesave]')) {
209
+ new Function(el.getAttribute('onbeforesave')).call(el);
210
+ }
211
+
212
+ // Clone for comparison before stripping (cheaper than cloning live DOM)
213
+ const compareClone = clonePreventingOnclone(clone);
214
+
215
+ // Save clone: strip [no-save]/[save-remove], then run hooks (freeze restore in hooks)
216
+ for (const el of clone.querySelectorAll(STRIP_FROM_SAVE)) {
217
+ el.remove();
218
+ }
219
+ for (const hook of prepareForSaveHooks) {
220
+ hook(clone);
221
+ }
222
+ const forSave = "<!DOCTYPE html>" + clone.outerHTML;
223
+
224
+ // Compare clone: strip every autosave-off region, then run hooks
225
+ for (const el of compareClone.querySelectorAll(STRIP_FROM_COMPARISON)) {
226
+ el.remove();
227
+ }
228
+ for (const hook of prepareForSaveHooks) {
229
+ hook(compareClone);
230
+ }
231
+ const forComparison = "<!DOCTYPE html>" + compareClone.outerHTML;
232
+
233
+ return { forSave, forComparison };
234
+ }
235
+
236
+ /**
237
+ * PHASE 1-4: Full pipeline for saving to server.
238
+ *
239
+ * Captures snapshot, emits for live-sync, then prepares for save.
240
+ * This is the main entry point for the save process.
241
+ *
242
+ * @param {Object} options
243
+ * @param {boolean} options.emitForSync - Whether to emit snapshot-ready event (default: true)
244
+ * @returns {string} Full HTML string ready for server
245
+ */
246
+ export function captureForSave({ emitForSync = true } = {}) {
247
+ const clone = captureSnapshot();
248
+
249
+ // Emit for live-sync before stripping admin elements
250
+ // Sends full cloned documentElement so live-sync can extract head and body
251
+ if (emitForSync) {
252
+ document.dispatchEvent(new CustomEvent('clay:snapshot-ready', {
253
+ detail: { documentElement: clone }
254
+ }));
255
+ }
256
+
257
+ return prepareCloneForSave(clone);
258
+ }
259
+
260
+ /**
261
+ * PHASE 1-2 (body only): For live-sync between admin users.
262
+ *
263
+ * Includes admin elements — no stripping.
264
+ * Note: Prefer listening to 'clay:snapshot-ready' event instead,
265
+ * which reuses the save's clone.
266
+ *
267
+ * @returns {string} Body innerHTML with form values synced
268
+ */
269
+ export function captureBodyForSync() {
270
+ const clone = captureSnapshot();
271
+ return clone.querySelector('body').innerHTML;
272
+ }
273
+
274
+ /**
275
+ * Get page contents for change detection.
276
+ * Does NOT emit snapshot-ready event (safe for comparison).
277
+ *
278
+ * Returns full HTML via snapshot pipeline.
279
+ */
280
+ export function getPageContents() {
281
+ return captureForSave({ emitForSync: false });
282
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Unsaved Warning Module
3
+ *
4
+ * Warns users before leaving the page if there are unsaved changes.
5
+ * Self-contained: compares current page content to last saved content on beforeunload.
6
+ *
7
+ * Works independently of autosave - no mutation observer needed during editing,
8
+ * just a single comparison when the user tries to leave.
9
+ *
10
+ * Both current and stored content have [save-remove] and [save-ignore] stripped,
11
+ * so comparison is direct with no parsing needed.
12
+ *
13
+ * Requires the 'save-system' module (automatically included as dependency).
14
+ */
15
+
16
+ import { isOwner, isEditMode } from "./is-edit-mode.js";
17
+ import { captureForComparison } from "./snapshot.js";
18
+ import { getLastSavedContents } from "./save.js";
19
+ import { logUnloadDiffSync, preloadIfEnabled } from "../lib/autosave-debug.js";
20
+
21
+ // Pre-load diff library if debug mode is on (so it's ready for unload)
22
+ preloadIfEnabled();
23
+
24
+ window.addEventListener('beforeunload', (event) => {
25
+ if (!isOwner || !isEditMode) return;
26
+
27
+ // Compare directly - both are already stripped
28
+ const currentForCompare = captureForComparison();
29
+ const lastSaved = getLastSavedContents();
30
+
31
+ if (currentForCompare !== lastSaved) {
32
+ // Debug: log what's different before showing the warning
33
+ logUnloadDiffSync(currentForCompare, lastSaved);
34
+
35
+ event.preventDefault();
36
+ event.returnValue = '';
37
+ }
38
+ });
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Autosave Debug Utility
3
+ *
4
+ * Provides conditional logging for the autosave system.
5
+ * Enable by setting localStorage.setItem('clay:debug:autosave', 'true')
6
+ *
7
+ * When enabled, dynamically imports a diff library to show exactly
8
+ * what changed between DOM states.
9
+ */
10
+
11
+ const DEBUG_KEY = 'clay:debug:autosave';
12
+
13
+ let diffModule = null;
14
+ let diffLoadPromise = null;
15
+
16
+ /**
17
+ * Check if autosave debug mode is enabled
18
+ */
19
+ export function isDebugEnabled() {
20
+ try {
21
+ return localStorage.getItem(DEBUG_KEY) === 'true';
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Lazily load the diff library only when needed
29
+ * Uses esm.sh for zero-install dynamic import
30
+ */
31
+ async function loadDiff() {
32
+ if (diffModule) return diffModule;
33
+ if (diffLoadPromise) return diffLoadPromise;
34
+
35
+ diffLoadPromise = import('https://esm.sh/diff@5.2.0')
36
+ .then(mod => {
37
+ diffModule = mod;
38
+ return mod;
39
+ })
40
+ .catch(err => {
41
+ console.warn('[autosave-debug] Failed to load diff library:', err);
42
+ return null;
43
+ });
44
+
45
+ return diffLoadPromise;
46
+ }
47
+
48
+ /**
49
+ * Log a brief one-liner for save operations
50
+ */
51
+ export function logSaveCheck(label, matches) {
52
+ if (!isDebugEnabled()) return;
53
+
54
+ const status = matches ? '✓ matches' : '✗ differs';
55
+ console.log(`[autosave] ${label}: ${status}`);
56
+ }
57
+
58
+ /**
59
+ * Log when baseline is captured/updated
60
+ */
61
+ export function logBaseline(event, details = '') {
62
+ if (!isDebugEnabled()) return;
63
+
64
+ console.log(`[autosave] baseline ${event}${details ? `: ${details}` : ''}`);
65
+ }
66
+
67
+ /**
68
+ * Log and diff two HTML strings (async, for unload warning)
69
+ * Returns a promise that resolves when logging is complete
70
+ */
71
+ export async function logUnloadDiff(currentContents, lastSaved) {
72
+ if (!isDebugEnabled()) return;
73
+
74
+ console.group('[autosave] Unload warning triggered - content differs from last save');
75
+ console.log('Current length:', currentContents.length);
76
+ console.log('Last saved length:', lastSaved.length);
77
+ console.log('Difference:', currentContents.length - lastSaved.length, 'characters');
78
+
79
+ const diff = await loadDiff();
80
+
81
+ if (diff) {
82
+ const changes = diff.diffLines(lastSaved, currentContents);
83
+
84
+ console.log('\n--- DIFF (last saved → current) ---');
85
+
86
+ let hasChanges = false;
87
+ changes.forEach(part => {
88
+ if (part.added || part.removed) {
89
+ hasChanges = true;
90
+ const prefix = part.added ? '+++ ' : '--- ';
91
+ const lines = part.value.split('\n').filter(l => l.length > 0);
92
+
93
+ // Truncate very long diffs
94
+ const maxLines = 20;
95
+ const displayLines = lines.slice(0, maxLines);
96
+
97
+ displayLines.forEach(line => {
98
+ // Truncate very long lines
99
+ const displayLine = line.length > 200
100
+ ? line.substring(0, 200) + '...[truncated]'
101
+ : line;
102
+ console.log(prefix + displayLine);
103
+ });
104
+
105
+ if (lines.length > maxLines) {
106
+ console.log(`... and ${lines.length - maxLines} more lines`);
107
+ }
108
+ }
109
+ });
110
+
111
+ if (!hasChanges) {
112
+ console.log('(No line-level differences found - may be whitespace or inline changes)');
113
+ // Fall back to character diff for small differences
114
+ if (Math.abs(currentContents.length - lastSaved.length) < 500) {
115
+ const charChanges = diff.diffChars(lastSaved, currentContents);
116
+ console.log('\n--- CHARACTER DIFF ---');
117
+ charChanges.forEach(part => {
118
+ if (part.added || part.removed) {
119
+ const prefix = part.added ? '+++ ' : '--- ';
120
+ const value = part.value.length > 100
121
+ ? part.value.substring(0, 100) + '...[truncated]'
122
+ : part.value;
123
+ console.log(prefix + JSON.stringify(value));
124
+ }
125
+ });
126
+ }
127
+ }
128
+ } else {
129
+ console.log('(diff library not available - showing raw comparison)');
130
+ console.log('First 500 chars of current:', currentContents.substring(0, 500));
131
+ console.log('First 500 chars of last saved:', lastSaved.substring(0, 500));
132
+ }
133
+
134
+ console.groupEnd();
135
+ }
136
+
137
+ /**
138
+ * Synchronous version for beforeunload (can't await in event handler)
139
+ * Logs what it can immediately, diff loads async but may not complete before unload
140
+ */
141
+ export function logUnloadDiffSync(currentContents, lastSaved) {
142
+ if (!isDebugEnabled()) return;
143
+
144
+ console.group('[autosave] Unload warning triggered - content differs from last save');
145
+ console.log('Current length:', currentContents.length);
146
+ console.log('Last saved length:', lastSaved.length);
147
+ console.log('Difference:', currentContents.length - lastSaved.length, 'characters');
148
+
149
+ // If diff is already loaded, use it synchronously
150
+ if (diffModule) {
151
+ const changes = diffModule.diffLines(lastSaved, currentContents);
152
+
153
+ console.log('\n--- DIFF (last saved → current) ---');
154
+
155
+ let hasChanges = false;
156
+ changes.forEach(part => {
157
+ if (part.added || part.removed) {
158
+ hasChanges = true;
159
+ const prefix = part.added ? '+++ ' : '--- ';
160
+ const lines = part.value.split('\n').filter(l => l.length > 0);
161
+
162
+ const maxLines = 20;
163
+ const displayLines = lines.slice(0, maxLines);
164
+
165
+ displayLines.forEach(line => {
166
+ const displayLine = line.length > 200
167
+ ? line.substring(0, 200) + '...[truncated]'
168
+ : line;
169
+ console.log(prefix + displayLine);
170
+ });
171
+
172
+ if (lines.length > maxLines) {
173
+ console.log(`... and ${lines.length - maxLines} more lines`);
174
+ }
175
+ }
176
+ });
177
+
178
+ if (!hasChanges) {
179
+ console.log('(No line-level differences - checking character diff)');
180
+ if (Math.abs(currentContents.length - lastSaved.length) < 500) {
181
+ const charChanges = diffModule.diffChars(lastSaved, currentContents);
182
+ charChanges.forEach(part => {
183
+ if (part.added || part.removed) {
184
+ const prefix = part.added ? '+++ ' : '--- ';
185
+ const value = part.value.length > 100
186
+ ? part.value.substring(0, 100) + '...[truncated]'
187
+ : part.value;
188
+ console.log(prefix + JSON.stringify(value));
189
+ }
190
+ });
191
+ }
192
+ }
193
+ } else {
194
+ // Kick off async load for next time, show basic info now
195
+ loadDiff();
196
+ console.log('(diff library loading - run localStorage.setItem("clay:debug:autosave", "true") and reload for full diff)');
197
+
198
+ // Find first difference position
199
+ let diffPos = 0;
200
+ const minLen = Math.min(currentContents.length, lastSaved.length);
201
+ while (diffPos < minLen && currentContents[diffPos] === lastSaved[diffPos]) {
202
+ diffPos++;
203
+ }
204
+
205
+ console.log('First difference at position:', diffPos);
206
+ console.log('Context around difference:');
207
+ console.log(' Last saved:', JSON.stringify(lastSaved.substring(Math.max(0, diffPos - 50), diffPos + 100)));
208
+ console.log(' Current:', JSON.stringify(currentContents.substring(Math.max(0, diffPos - 50), diffPos + 100)));
209
+ }
210
+
211
+ console.groupEnd();
212
+ }
213
+
214
+ /**
215
+ * Pre-load the diff library if debug is enabled
216
+ * Call this early to ensure diff is ready for unload events
217
+ */
218
+ export function preloadIfEnabled() {
219
+ if (isDebugEnabled()) {
220
+ loadDiff();
221
+ console.log('[autosave-debug] Debug mode enabled - diff library loading');
222
+ }
223
+ }
@@ -0,0 +1,12 @@
1
+ // cacheBust.js
2
+ // Cache-bust an element's href or src attribute by adding/updating a version query param
3
+
4
+ function cacheBust(el) {
5
+ const attr = el.hasAttribute('href') ? 'href' : 'src';
6
+ const currentValue = el.getAttribute(attr);
7
+ const url = new URL(currentValue, location.href);
8
+ url.searchParams.set('v', Date.now());
9
+ el.setAttribute(attr, url.href);
10
+ }
11
+
12
+ export default cacheBust;
@@ -0,0 +1,37 @@
1
+ // e.g. Cookie.get("isAdminOfCurrentResource")
2
+ function get (cookieName) {
3
+ const cookies = document.cookie.split('; ');
4
+ const cookie = cookies.find(row => row.startsWith(`${cookieName}=`));
5
+ if (!cookie) return null;
6
+ const cookieValue = cookie.slice(cookieName.length + 1);
7
+ try {
8
+ return JSON.parse(decodeURIComponent(cookieValue));
9
+ } catch (err) {
10
+ return decodeURIComponent(cookieValue);
11
+ }
12
+ }
13
+
14
+ // e.g. Cookie.remove("isAdminOfCurrentResource")
15
+ function remove(name) {
16
+ // Clear from current path
17
+ document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/;`
18
+
19
+ // Clear from current domain
20
+ document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; domain=${window.location.hostname};`
21
+
22
+ // Clear from apex domain (e.g., .hyperclay.com or .localhyperclay.com)
23
+ const hostname = window.location.hostname;
24
+ if (hostname.includes('.')) {
25
+ // Get the last two parts for the apex domain (handles .com, .co.uk, etc)
26
+ const parts = hostname.split('.');
27
+ const apexDomain = '.' + parts.slice(-2).join('.');
28
+ document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; domain=${apexDomain};`
29
+ }
30
+ }
31
+
32
+ const cookie = {
33
+ get,
34
+ remove
35
+ };
36
+
37
+ export default cookie;
@@ -0,0 +1,9 @@
1
+ function onDomReady (callback) {
2
+ if (document.readyState === 'loading') {
3
+ document.addEventListener('DOMContentLoaded', callback);
4
+ } else {
5
+ callback();
6
+ }
7
+ }
8
+
9
+ export default onDomReady;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * extension-noise.js — browser-extension DOM noise the page should never own.
3
+ *
4
+ * Extensions touch the live page in two distinct ways, handled differently so a
5
+ * save never loses real content:
6
+ * 1. Standalone elements they own (password-manager menus, Grammarly overlays,
7
+ * translate banners, extension iframes/scripts) — remove them entirely.
8
+ * 2. Marker attributes they stamp onto YOUR real inputs/forms — strip just the
9
+ * attribute and keep the element.
10
+ *
11
+ * The selectors are not stable public APIs; extend both lists from real captures.
12
+ * Keep this file byte-identical to the copy in the other package (hyperclayjs and
13
+ * hyper-undo each carry one) — their save/undo observers must agree, or one will
14
+ * record/save what the other ignores.
15
+ */
16
+
17
+ // Standalone elements browser extensions inject into the page — remove them entirely.
18
+ export const EXTENSION_NODE_SELECTORS = [
19
+ '[src^="chrome-extension://"]', '[href^="chrome-extension://"]',
20
+ '[src^="moz-extension://"]', '[href^="moz-extension://"]',
21
+ '[src^="safari-web-extension://"]', '[href^="safari-web-extension://"]',
22
+ '[id="1p-menu-live-region"]',
23
+ 'com-1password-button', 'com-1password-menu', 'com-1password-notification',
24
+ 'grammarly-extension', 'grammarly-desktop-integration',
25
+ 'lt-div', 'lt-card', 'lt-toolbar', 'lt-highlighter',
26
+ '[data-lastpass-root]', '[data-lastpass-icon-root]', '[data-dashlane-shadowhost]',
27
+ '[data-klarna-extension]', '[class*="klarna-extension"]',
28
+ '.skiptranslate', '#goog-gt-tt', '.goog-te-banner-frame', '.goog-te-balloon-frame',
29
+ 'style.darkreader', 'style[data-darkreader-mode]', 'style[data-darkreader-scheme]',
30
+ '#loom-companion-mv3', 'loom-container',
31
+ '[ext-id]',
32
+ ]
33
+
34
+ // Attribute-name prefixes extensions stamp onto YOUR real elements — strip the attribute, keep the element.
35
+ export const EXTENSION_ATTR_PREFIXES = [
36
+ 'data-grammarly', 'data-lt', 'data-1p', 'data-com-onepassword', 'data-lastpass', 'data-lp', 'data-kwimpala',
37
+ 'data-bitwarden', 'data-bw', 'data-dashlane', 'data-np', 'data-protonpass', 'data-proton-pass',
38
+ 'data-keeper', 'data-rf', 'data-roboform', 'data-honey', 'data-rakuten', 'data-capital-one',
39
+ 'data-wikibuy', 'data-klarna', 'data-darkreader', 'data-evernote', 'data-pocket', 'data-pin',
40
+ ]
41
+
42
+ // Pre-joined once so hot paths call matches()/closest()/querySelectorAll() without re-joining.
43
+ export const EXTENSION_NODE_SELECTOR = EXTENSION_NODE_SELECTORS.join(',')
44
+
45
+ // One compiled test for "is this an extension marker attribute". The (?:-|$) boundary means a
46
+ // prefix like `data-lt` matches `data-lt` / `data-lt-foo` but never an app attribute like `data-ltr`.
47
+ export const EXTENSION_ATTR_PATTERN = new RegExp('^(?:' + EXTENSION_ATTR_PREFIXES.join('|') + ')(?:-|$)')
48
+
49
+ // Remove extension-injected elements and strip extension marker attributes from a (cloned) subtree, in place.
50
+ // Guarded so a malformed denylist entry degrades to "noise not stripped" rather than breaking the save.
51
+ export function stripExtensionNoise(root) {
52
+ if (!root || !root.querySelectorAll) return
53
+ try {
54
+ for (const el of root.querySelectorAll(EXTENSION_NODE_SELECTOR)) el.remove()
55
+ for (const el of root.querySelectorAll('*')) {
56
+ for (const attr of [...el.attributes]) {
57
+ if (EXTENSION_ATTR_PATTERN.test(attr.name.toLowerCase())) el.removeAttribute(attr.name)
58
+ }
59
+ }
60
+ } catch (e) {
61
+ if (typeof console !== 'undefined') console.warn('[clay] stripExtensionNoise skipped:', e)
62
+ }
63
+ }