@panphora/clayjs 0.2.0 → 0.4.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/README.md +99 -7
  2. package/clay-internals.js +19 -0
  3. package/clay.js +18 -2
  4. package/package.json +19 -3
  5. package/sap.js +5 -5
  6. package/src/attrs/onaftersave.js +12 -1
  7. package/src/attrs/refetch-on-save.js +4 -4
  8. package/src/attrs/save-freeze.js +47 -19
  9. package/src/core/admin-contenteditable.js +2 -2
  10. package/src/core/admin-inputs.js +2 -2
  11. package/src/core/admin-onclick.js +2 -2
  12. package/src/core/admin-resources.js +23 -14
  13. package/src/core/autosave.js +11 -1
  14. package/src/core/edit-mode.js +2 -2
  15. package/src/core/host-attrs.js +46 -0
  16. package/src/core/is-edit-mode.js +14 -6
  17. package/src/core/persist.js +8 -1
  18. package/src/core/save-core.js +199 -290
  19. package/src/core/save.js +124 -84
  20. package/src/core/snapshot.js +99 -43
  21. package/src/core/unsaved-warning.js +8 -2
  22. package/src/dom/all.js +4 -29
  23. package/src/dom/form-data.js +1 -1
  24. package/src/dom/nearest.js +11 -9
  25. package/src/internals/index.js +64 -0
  26. package/src/lib/cache-bust.js +10 -0
  27. package/src/lib/cookie.js +50 -15
  28. package/src/lib/extension-noise.js +5 -1
  29. package/src/lib/mutation.js +65 -18
  30. package/src/lib/region-policy.js +17 -2
  31. package/src/lib/root-attrs.js +45 -0
  32. package/src/lib/throttle.js +25 -13
  33. package/src/loader.js +18 -24
  34. package/src/options/options.js +1 -3
  35. package/src/plugins/demo.js +7 -1
  36. package/src/sync/live-sync.js +121 -89
  37. package/src/ui/dialogs.js +10 -1
  38. package/src/ui/index.js +2 -11
  39. package/src/ui/toast.js +8 -2
  40. package/src/utils/debounce.js +24 -7
  41. package/src/vendor/hyper-undo.vendor.js +1 -1
  42. package/src/vendor/hypercms.vendor.js +29 -16
  43. package/src/vendor/richclay.vendor.js +29 -22
@@ -16,7 +16,7 @@
16
16
  * Changes inside freeze elements do not trigger autosave dirty checks.
17
17
  */
18
18
 
19
- import { onSnapshot, onPrepareForSave } from "../core/snapshot.js";
19
+ import { onSnapshot, addDocumentTransform } from "../core/snapshot.js";
20
20
  import { isEditMode } from "../core/is-edit-mode.js";
21
21
  import { FREEZE_SELECTOR } from "../lib/region-policy.js";
22
22
  import Mutation from "../lib/mutation.js";
@@ -44,22 +44,51 @@ function captureAll() {
44
44
  }
45
45
  }
46
46
 
47
- function freezeClone(clone) {
47
+ // Which cloned element holds which authored innerHTML, for the current snapshot.
48
+ // Keyed by the cloned element itself: the prepare phase runs on the same clone
49
+ // object the snapshot phase saw, so identity is the pairing. An attribute marker
50
+ // would work too, right up until clay:snapshot-ready — which fires BETWEEN the two
51
+ // phases — hands the still-marked clone to live-sync and demo mode, both of which
52
+ // serialize it on the spot.
53
+ let pending = new WeakMap();
54
+
55
+ // Phase 2. The clone is a verbatim cloneNode of the live root and nothing has been
56
+ // stripped from it yet, so pairing by position is sound HERE and nowhere else.
57
+ function freezeSnapshot(clone) {
58
+ pending = new WeakMap();
59
+
48
60
  const liveElements = document.querySelectorAll(FREEZE_SELECTOR);
49
61
  const cloneElements = clone.querySelectorAll(FREEZE_SELECTOR);
50
62
 
51
63
  log('freezing clone — live:', liveElements.length, 'clone:', cloneElements.length);
52
64
 
65
+ // Counts can only differ if an earlier onSnapshot hook added or removed
66
+ // elements, which means alignment is already gone. Restoring nothing beats
67
+ // restoring one element's authored content into a different element.
68
+ if (liveElements.length !== cloneElements.length) {
69
+ console.warn('[save-freeze] live/clone freeze counts differ, skipping freeze restore');
70
+ return;
71
+ }
72
+
53
73
  for (let i = 0; i < cloneElements.length; i++) {
54
- const liveEl = liveElements[i];
55
- const hasOriginal = liveEl && originals.has(liveEl);
56
- if (hasOriginal) {
57
- const original = originals.get(liveEl);
58
- const current = cloneElements[i].innerHTML;
59
- if (original !== current) {
60
- log('element', i, '— restoring original');
61
- cloneElements[i].innerHTML = original;
62
- }
74
+ if (!originals.has(liveElements[i])) continue;
75
+ const original = originals.get(liveElements[i]);
76
+ pending.set(cloneElements[i], original);
77
+ if (cloneElements[i].innerHTML !== original) {
78
+ log('element', i, '— restoring original');
79
+ cloneElements[i].innerHTML = original;
80
+ }
81
+ }
82
+ }
83
+
84
+ // Phase 3a. Re-freeze anything an [onbeforesave] handler changed between the two
85
+ // phases. Nothing to clean up: the pairing never entered the document.
86
+ function freezePrepare(clone) {
87
+ for (const el of clone.querySelectorAll(FREEZE_SELECTOR)) {
88
+ const original = pending.get(el);
89
+ if (original !== undefined && el.innerHTML !== original) {
90
+ log('re-restoring original after prepare-phase change');
91
+ el.innerHTML = original;
63
92
  }
64
93
  }
65
94
  }
@@ -75,10 +104,8 @@ function init() {
75
104
  // debounce:0 so we snapshot the authored content before any runtime edit
76
105
  // mutates it; the callback is a pure read into a WeakMap (no DOM writes).
77
106
  // require:'observed' (skip only no-watch); pausable:false so a morphed-in
78
- // freeze region is still captured. NOTE: the old observer watched
79
- // document.documentElement; Mutation watches document.body, so a [freeze] added
80
- // directly to <head> at runtime is no longer captured (authored <head> freeze
81
- // regions are still handled by captureAll() on load) — an accepted edge.
107
+ // freeze region is still captured. Mutation observes document.documentElement,
108
+ // so a [freeze] added directly to <head> at runtime is captured like any other.
82
109
  Mutation.onAddElement({
83
110
  selectorFilter: FREEZE_SELECTOR,
84
111
  require: 'observed',
@@ -90,11 +117,12 @@ function init() {
90
117
 
91
118
  // Phase 2: Freeze in snapshot — before snapshot-ready fires.
92
119
  // This prevents live-sync from writing unfrozen content to disk.
93
- onSnapshot(freezeClone);
120
+ onSnapshot(freezeSnapshot);
94
121
 
95
- // Phase 3a: Freeze again in prepare — belt-and-suspenders.
96
- // Catches any modifications made between phase 2 and 3a (e.g., onbeforesave handlers).
97
- onPrepareForSave(freezeClone);
122
+ // Phase 3a: Freeze again in prepare, for anything changed between the phases
123
+ // (e.g. onbeforesave handlers). Pairs by the WeakMap phase 2 filled, because by
124
+ // now the clone has lost its [no-snapshot] subtrees and position means nothing.
125
+ addDocumentTransform(freezePrepare);
98
126
  }
99
127
 
100
128
  init();
@@ -1,11 +1,11 @@
1
1
  import { isEditMode, isOwner } from "./is-edit-mode.js";
2
2
  import onDomReady from "../lib/dom-ready.js";
3
- import { beforeSave } from "./snapshot.js";
3
+ import { addDocumentTransform } from "./snapshot.js";
4
4
 
5
5
  export const SELECTOR = '[editmode\\:contenteditable]';
6
6
 
7
7
  export function disableContentEditableBeforeSave () {
8
- beforeSave(docElem => {
8
+ addDocumentTransform(docElem => {
9
9
  docElem.querySelectorAll(SELECTOR).forEach(resource => {
10
10
  const originalValue = resource.getAttribute("contenteditable");
11
11
  resource.setAttribute("inert-contenteditable", originalValue);
@@ -1,12 +1,12 @@
1
1
  import { isEditMode, isOwner } from "./is-edit-mode.js";
2
2
  import onDomReady from "../lib/dom-ready.js";
3
- import { beforeSave } from "./snapshot.js";
3
+ import { addDocumentTransform } from "./snapshot.js";
4
4
 
5
5
  export const SELECTOR_DISABLED = '[viewmode\\:disabled]';
6
6
  export const SELECTOR_READONLY = '[viewmode\\:readonly]';
7
7
 
8
8
  export function disableAdminInputsBeforeSave() {
9
- beforeSave(docElem => {
9
+ addDocumentTransform(docElem => {
10
10
  docElem.querySelectorAll(SELECTOR_DISABLED).forEach(input => {
11
11
  input.setAttribute('disabled', '');
12
12
  });
@@ -1,11 +1,11 @@
1
1
  import { isEditMode, isOwner } from "./is-edit-mode.js";
2
2
  import onDomReady from "../lib/dom-ready.js";
3
- import { beforeSave } from "./snapshot.js";
3
+ import { addDocumentTransform } from "./snapshot.js";
4
4
 
5
5
  export const SELECTOR = '[editmode\\:onclick]';
6
6
 
7
7
  export function disableOnClickBeforeSave () {
8
- beforeSave(docElem => {
8
+ addDocumentTransform(docElem => {
9
9
  docElem.querySelectorAll(SELECTOR).forEach(resource => {
10
10
  const originalValue = resource.getAttribute("onclick");
11
11
  resource.setAttribute("inert-onclick", originalValue);
@@ -1,18 +1,31 @@
1
1
  import { isEditMode, isOwner } from "./is-edit-mode.js";
2
2
  import onDomReady from "../lib/dom-ready.js";
3
- import { beforeSave } from "./snapshot.js";
3
+ import { addDocumentTransform } from "./snapshot.js";
4
4
 
5
5
  export const SELECTOR = '[editmode\\:resource]:is(style, link, script)';
6
6
  export const SELECTOR_INERT = '[editmode\\:resource]:is(style, link, script)[type^="inert/"]';
7
7
 
8
+ const INERT_PREFIX = 'inert/';
9
+
10
+ // An absent type is recorded as an empty remainder, so enabling restores absence
11
+ // rather than inventing text/javascript. A <style> or a stylesheet <link> handed a
12
+ // JavaScript MIME on the way back never applies again.
13
+ function makeInert(resource) {
14
+ const current = resource.getAttribute('type');
15
+ if (current && current.startsWith(INERT_PREFIX)) return false;
16
+ resource.setAttribute('type', INERT_PREFIX + (current || ''));
17
+ return true;
18
+ }
19
+
20
+ function makeActive(resource) {
21
+ const original = resource.getAttribute('type').slice(INERT_PREFIX.length);
22
+ if (original) resource.setAttribute('type', original);
23
+ else resource.removeAttribute('type');
24
+ }
25
+
8
26
  export function disableAdminResourcesBeforeSave () {
9
- beforeSave(docElem => {
10
- docElem.querySelectorAll(SELECTOR).forEach(resource => {
11
- const currentType = resource.getAttribute('type') || 'text/javascript';
12
- if (!currentType.startsWith('inert/')) {
13
- resource.setAttribute('type', `inert/${currentType}`);
14
- }
15
- });
27
+ addDocumentTransform(docElem => {
28
+ docElem.querySelectorAll(SELECTOR).forEach(makeInert);
16
29
  });
17
30
  }
18
31
 
@@ -27,18 +40,14 @@ export function enableAdminResourcesOnPageLoad () {
27
40
  // Runtime toggle functions
28
41
  export function enableAdminResources() {
29
42
  document.querySelectorAll(SELECTOR_INERT).forEach(resource => {
30
- resource.type = resource.type.replace(/inert\//g, '');
43
+ makeActive(resource);
31
44
  resource.replaceWith(resource.cloneNode(true));
32
45
  });
33
46
  }
34
47
 
35
48
  export function disableAdminResources() {
36
49
  document.querySelectorAll(SELECTOR).forEach(resource => {
37
- const currentType = resource.getAttribute('type') || 'text/javascript';
38
- if (!currentType.startsWith('inert/')) {
39
- resource.setAttribute('type', `inert/${currentType}`);
40
- resource.replaceWith(resource.cloneNode(true));
41
- }
50
+ if (makeInert(resource)) resource.replaceWith(resource.cloneNode(true));
42
51
  });
43
52
  }
44
53
 
@@ -19,9 +19,19 @@ import { initUserGesture, markUserDriven } from "../lib/user-gesture.js";
19
19
  * Initialize auto-save on DOM changes
20
20
  * Uses debounced mutation observer
21
21
  */
22
+ // A bare debounce resets on every change, so a page with anything that mutates
23
+ // faster than the delay — a clock, a countdown, a polling counter — pushes autosave
24
+ // into the future forever and it never fires, with nothing in the UI saying so.
25
+ // maxWait bounds that: however long the churn lasts, a save happens within
26
+ // AUTOSAVE_MAX_WAIT_MS of the first change it was waiting on. (The settled baseline
27
+ // capture in save.js already solved this for itself with MAX_SETTLE_MS.)
28
+ const AUTOSAVE_DEBOUNCE_MS = 1500;
29
+ const AUTOSAVE_MAX_WAIT_MS = 10000;
30
+
22
31
  function initSavePageOnChange() {
23
32
  Mutation.onAnyChange({
24
- debounce: 1500,
33
+ debounce: AUTOSAVE_DEBOUNCE_MS,
34
+ maxWait: AUTOSAVE_MAX_WAIT_MS,
25
35
  omitChangeDetails: true,
26
36
  require: 'autosave'
27
37
  }, () => {
@@ -3,7 +3,7 @@
3
3
  // (it's the way IN), and setPageTypeOnPageLoad marks <html> in either mode.
4
4
  import { isEditMode, isOwner } from "./is-edit-mode.js";
5
5
  import onDomReady from "../lib/dom-ready.js";
6
- import { beforeSave } from "./snapshot.js";
6
+ import { addDocumentTransform } from "./snapshot.js";
7
7
 
8
8
  export function toggleEditMode() {
9
9
  const url = new URL(window.location.href);
@@ -13,7 +13,7 @@ export function toggleEditMode() {
13
13
  }
14
14
 
15
15
  export function setViewerPageTypeBeforeSave () {
16
- beforeSave(docElem => {
16
+ addDocumentTransform(docElem => {
17
17
  docElem.setAttribute("editmode", "false");
18
18
  docElem.setAttribute("pageowner", "false");
19
19
  });
@@ -0,0 +1,46 @@
1
+ /**
2
+ * host-attrs.js — the attributes a host puts on <html>, and what they mean.
3
+ *
4
+ * These are ephemeral: the host injects them into the response and strips them
5
+ * back out of whatever the client saves, so they never reach disk. clayjs only
6
+ * ever reads them, and reads them here so the two spellings of the save token
7
+ * cannot drift between the edit-mode ladder and the save lane.
8
+ */
9
+
10
+ import { HOST_TOKEN_ATTRS, SAVE_TRANSPORT_ATTR } from "../lib/root-attrs.js";
11
+
12
+ // A host that wants the desktop JSON envelope on its save lane declares it on
13
+ // the root. This replaced a `location.hostname === 'localhost'` sniff, which sent
14
+ // the envelope to every host that happened to be local, including ones whose
15
+ // save lane takes text and answers 415.
16
+ export const DESKTOP_JSON = "desktop-json-v1";
17
+
18
+ /**
19
+ * The per-document save token this response carries, or null.
20
+ * @returns {?string}
21
+ */
22
+ export function saveToken() {
23
+ if (typeof document === "undefined") return null;
24
+ for (const attr of HOST_TOKEN_ATTRS) {
25
+ const value = document.documentElement.getAttribute(attr);
26
+ if (value) return value;
27
+ }
28
+ return null;
29
+ }
30
+
31
+ /**
32
+ * True when the host handed this response a save token of either spelling.
33
+ * @returns {boolean}
34
+ */
35
+ export function hasSaveToken() {
36
+ return saveToken() !== null;
37
+ }
38
+
39
+ /**
40
+ * The save transport the served document declares, or null.
41
+ * @returns {?string}
42
+ */
43
+ export function saveTransport() {
44
+ if (typeof document === "undefined") return null;
45
+ return document.documentElement.getAttribute(SAVE_TRANSPORT_ATTR);
46
+ }
@@ -1,12 +1,20 @@
1
1
  import cookie from "../lib/cookie.js";
2
2
  import query from "../lib/query.js";
3
+ import { hasSaveToken } from "./host-attrs.js";
3
4
 
4
5
  // Edit-mode precedence: an explicit ?editmode=true|false URL param wins, then an
5
6
  // opt-in window.clayEditMode global (with the legacy window.__hyperclayEditMode
6
- // still honored as a fallback — htmlclay injects it today), then the platform's
7
- // isAdminOfCurrentResource cookie. The global is for standalone uses (demos,
8
- // htmlclay, any self-saving file) that are always editable and have no owner
9
- // cookie; setting it before clayjs loads turns on the edit-only modules.
7
+ // still honored as a fallback — htmlclay injects it today), then a save token the
8
+ // host put on the root, then the platform's isAdminOfCurrentResource cookie. The
9
+ // global is for standalone uses (demos, htmlclay, any self-saving file) that are
10
+ // always editable and have no owner cookie; setting it before clayjs loads turns
11
+ // on the edit-only modules.
12
+ //
13
+ // The token sits above the cookie because it is the stronger claim and often the
14
+ // only one visible: the host minted it for this response and nothing else, while
15
+ // the cookie is ambient. A sandboxed document cannot read the cookie at all, so
16
+ // without this rung a host that sandboxes its own documents can hand one a save
17
+ // token and still watch it render read-only.
10
18
  let forcedEditMode = null;
11
19
  if (typeof window !== "undefined") {
12
20
  if (window.clayEditMode != null) {
@@ -17,10 +25,10 @@ if (typeof window !== "undefined") {
17
25
  }
18
26
 
19
27
  const isEditMode = query.editmode
20
- ? query.editmode === "true" // takes precedence over the global and cookie
28
+ ? query.editmode === "true" // takes precedence over the global, token and cookie
21
29
  : forcedEditMode != null
22
30
  ? forcedEditMode
23
- : Boolean(cookie.get("isAdminOfCurrentResource"));
31
+ : hasSaveToken() || Boolean(cookie.get("isAdminOfCurrentResource"));
24
32
 
25
33
  const isOwner = Boolean(cookie.get("isAdminOfCurrentResource"));
26
34
 
@@ -86,7 +86,14 @@ export default function enablePersistentFormInputValues(filterBySelector = "[per
86
86
  const finalize = (selector) => {
87
87
  const live = document.querySelectorAll(selector);
88
88
  const cloned = doc.querySelectorAll(selector);
89
- cloned.forEach((c, i) => { if (live[i]) finalizeControlForSave(c, live[i]); });
89
+ // Index pairing only holds while the two lists describe the same tree. If a
90
+ // hook ever diverges them, writing one control's value into another's is
91
+ // worse than writing none, so fail loudly instead of silently.
92
+ if (live.length !== cloned.length) {
93
+ console.warn('[persist] live/clone counts differ for', selector, '— skipping');
94
+ return;
95
+ }
96
+ cloned.forEach((c, i) => finalizeControlForSave(c, live[i]));
90
97
  };
91
98
  finalize(inputSelector);
92
99
  finalize(textareaSelector);