@panphora/clayjs 0.7.4 → 1.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 (39) hide show
  1. package/README.md +99 -85
  2. package/THIRD-PARTY-NOTICES.md +2 -4
  3. package/dist/clay.standalone.js +22030 -0
  4. package/{all.js → entries/all.js} +7 -1
  5. package/entries/clay-data.js +16 -0
  6. package/{clay-dom.js → entries/clay-dom.js} +7 -1
  7. package/{clay-events.js → entries/clay-events.js} +7 -1
  8. package/{clay-internals.js → entries/clay-internals.js} +7 -1
  9. package/{clay-options.js → entries/clay-options.js} +7 -1
  10. package/{clay-ui.js → entries/clay-ui.js} +7 -1
  11. package/{clay-utils.js → entries/clay-utils.js} +7 -1
  12. package/{clay.js → entries/clay.js} +15 -1
  13. package/package.json +46 -15
  14. package/src/core/autosave.js +3 -2
  15. package/src/core/etag.js +114 -0
  16. package/src/core/host-attrs.js +7 -2
  17. package/src/core/host-meta.js +20 -3
  18. package/src/core/persist.js +3 -2
  19. package/src/core/save-conflict-notice.js +196 -0
  20. package/src/core/save-core.js +60 -3
  21. package/src/core/save.js +80 -1
  22. package/src/core/snapshot.js +34 -17
  23. package/src/lib/attr-aliases.js +13 -0
  24. package/src/lib/dirty-gate.js +2 -1
  25. package/src/lib/root-attrs.js +23 -7
  26. package/src/loader-logic.js +39 -0
  27. package/src/loader.js +11 -5
  28. package/src/plugins/demo.js +2 -2
  29. package/src/plugins/indicator.js +13 -2
  30. package/src/plugins/sortable.js +5 -5
  31. package/src/standalone.js +98 -0
  32. package/src/sync/live-sync.js +42 -0
  33. package/src/ui/index.js +7 -0
  34. package/src/vendor/hypercms.vendor.js +13 -13
  35. package/src/vendor/richclay.vendor.js +15 -15
  36. package/_headers +0 -14
  37. package/clay-data.js +0 -16
  38. package/src/lib/load-vendor-script.js +0 -57
  39. /package/{sap.js → entries/sap.js} +0 -0
package/src/loader.js CHANGED
@@ -1,16 +1,21 @@
1
- import { resolveModules } from "./loader-logic.js";
1
+ import { resolveModules, MODULES } from "./loader-logic.js";
2
2
  import onDomReady from "./lib/dom-ready.js";
3
3
 
4
4
  function domReady() {
5
5
  return new Promise((resolve) => onDomReady(resolve));
6
6
  }
7
7
 
8
+ // `base` has been unused since 1.1.0 (the loader imports through MODULES) and
9
+ // stays anyway: /v1/clay.js and /v1/src/loader.js are cached independently, so
10
+ // for a while after a deploy a browser can pair either file with the other's
11
+ // previous release. A call shape that differs between them leaves the page with
12
+ // no clayjs at all.
8
13
  export async function boot(base, params, readyResolve) {
9
14
  await domReady(); // Mutation observes document.body
10
15
  // unconditionally, and a <head> placement
11
16
  // would otherwise observe null
12
17
 
13
- const editMode = await import(base + "/src/core/is-edit-mode.js");
18
+ const editMode = await MODULES["core/is-edit-mode.js"]();
14
19
  const { isEditMode, isOwner } = editMode;
15
20
 
16
21
  // richclay's vendor build detects edit mode via this legacy global; set it
@@ -19,13 +24,13 @@ export async function boot(base, params, readyResolve) {
19
24
  // and a conflicting leftover would make richclay disable itself.
20
25
  window.__hyperclayEditMode = isEditMode;
21
26
 
22
- const regionPolicy = await import(base + "/src/lib/region-policy.js");
27
+ const regionPolicy = await MODULES["lib/region-policy.js"]();
23
28
 
24
29
  const plan = resolveModules(params, isEditMode);
25
30
  const loaded = {};
26
31
 
27
32
  for (const path of plan.core) {
28
- loaded[path] = await import(base + "/src/" + path); // sequential: order is load-bearing
33
+ loaded[path] = await MODULES[path](); // sequential: order is load-bearing
29
34
  }
30
35
 
31
36
  assembleCore(loaded, { isEditMode, isOwner }, regionPolicy); // window.clay MUST be assembled
@@ -39,7 +44,7 @@ export async function boot(base, params, readyResolve) {
39
44
  // `ready` exports, never their evaluation.)
40
45
  let mod;
41
46
  try {
42
- mod = await import(base + "/src/" + path);
47
+ mod = await MODULES[path]();
43
48
  } catch (err) {
44
49
  console.error(`clayjs: plugin "${path}" failed to load, continuing without it:`, err);
45
50
  continue;
@@ -81,6 +86,7 @@ function assembleCore(loaded, { isEditMode, isOwner }, regionPolicy) {
81
86
  if (save) {
82
87
  const saveFn = save.savePage || save.default;
83
88
  saveFn.force = save.savePageForce;
89
+ saveFn.overwrite = save.saveOverwritingConflict;
84
90
  clay.save = saveFn;
85
91
  }
86
92
  if (snapshot) {
@@ -3,7 +3,7 @@
3
3
  //
4
4
  // <html autosave demo-key="my-page-v1"> <!-- both attributes optional -->
5
5
  // <script>window.clayEditMode = true;</script> <!-- before the clay.js tag -->
6
- // <script src="https://clayjs.com/clay.js?plugins=demo"></script>
6
+ // <script src="https://clayjs.com/v1/clay.js?plugins=demo"></script>
7
7
  //
8
8
  // The fetch shim answers POST /_/save the way a clayjs server would, so the
9
9
  // whole real pipeline (savestatus, events, autosave, ⌘S) runs unchanged. The
@@ -112,7 +112,7 @@ async function restore() {
112
112
  // attribute, never the element: [contenteditable] cannot join CHROME_SELECTOR,
113
113
  // whose matches are removed outright, and doing that deletes real content.
114
114
  if (!isEditMode) {
115
- for (const el of doc.querySelectorAll("[editable][contenteditable]")) {
115
+ for (const el of doc.querySelectorAll(":is([editable], [clay-editable])[contenteditable]")) {
116
116
  el.removeAttribute("contenteditable");
117
117
  }
118
118
  }
@@ -1,6 +1,12 @@
1
1
  import { isEditMode } from "../core/is-edit-mode.js";
2
2
  import onDomReady from "../lib/dom-ready.js";
3
3
 
4
+ // No 'conflict' here on purpose. core/save-conflict-notice.js owns that state now,
5
+ // and it ships in every document rather than only the ones that turned this chip on.
6
+ // Both listen to clay:save-conflict, so keeping a label here put a chip in the corner
7
+ // saying the same thing as the bar at the same moment. Dropping it from this side
8
+ // leaves core unaware that this plugin exists, which is the direction that
9
+ // dependency has to point.
4
10
  const LABELS = {
5
11
  saving: "Saving…",
6
12
  saved: "Saved",
@@ -8,6 +14,11 @@ const LABELS = {
8
14
  offline: "Offline, not saved",
9
15
  };
10
16
 
17
+ // States that stay on screen instead of fading, because 'saving' is still in flight.
18
+ const STICKY = new Set(["saving"]);
19
+
20
+ const ALARMING = new Set(["error", "offline"]);
21
+
11
22
  let el = null;
12
23
  let hideTimer = null;
13
24
 
@@ -34,11 +45,11 @@ function show(state) {
34
45
  const node = ensure();
35
46
  node.textContent = LABELS[state];
36
47
  node.dataset.state = state;
37
- node.style.background = state === "error" || state === "offline"
48
+ node.style.background = ALARMING.has(state)
38
49
  ? "var(--clay-indicator-error-bg,#7a3b28)" : "var(--clay-indicator-bg,#2e2b27)";
39
50
  node.style.opacity = "1";
40
51
  clearTimeout(hideTimer);
41
- if (state !== "saving") hideTimer = setTimeout(() => { node.style.opacity = "0"; }, 2200);
52
+ if (!STICKY.has(state)) hideTimer = setTimeout(() => { node.style.opacity = "0"; }, 2200);
42
53
  }
43
54
 
44
55
  function init() {
@@ -16,7 +16,6 @@
16
16
  */
17
17
  import { isEditMode } from "../core/is-edit-mode.js";
18
18
  import Mutation from "../lib/mutation.js";
19
- import { getVendorUrl } from "../lib/load-vendor-script.js";
20
19
 
21
20
  function makeSortable(sortableElem, Sortable) {
22
21
  let options = {};
@@ -89,10 +88,11 @@ function makeSortable(sortableElem, Sortable) {
89
88
  async function init() {
90
89
  if (!isEditMode) return;
91
90
 
92
- // Load the vendor script
93
- const vendorUrl = getVendorUrl(import.meta.url, '../vendor/Sortable.vendor.js');
94
- await import(vendorUrl);
95
- const Sortable = window.Sortable;
91
+ // Sortable's UMD header picks its target at run time: window.Sortable when no
92
+ // module system is present (a module load, and the single-file build), a
93
+ // default export when a bundler hands it one. Cover both.
94
+ const mod = await import('../vendor/Sortable.vendor.js');
95
+ const Sortable = window.Sortable || mod.default;
96
96
 
97
97
  // Set up sortable on page load
98
98
  document.querySelectorAll('[sortable]').forEach(el => makeSortable(el, Sortable));
@@ -0,0 +1,98 @@
1
+ /* clayjs standalone: the entry of the single-file build. https://clayjs.com/offline
2
+
3
+ Twin of entries/clay.js. That bootstrap derives a base URL from its own script
4
+ tag and imports the loader at runtime; this one is bundled together with the
5
+ loader and every module it can ask for (see MODULES in loader-logic.js), so
6
+ `plugins=` decides what runs, never what downloads. The bootstrap logic below
7
+ (window.clay, `ready`, `__booted`, the retry path) is the same code as clay.js
8
+ and must stay in step with it; tests/unit/bootstrap-twins.test.js compares the
9
+ shared pieces. */
10
+ import { boot } from "./loader.js";
11
+ import onDomReady from "./lib/dom-ready.js";
12
+
13
+ (function () {
14
+ // Suppress every vendored bundle's window auto-export; the loader assembles
15
+ // window.clay explicitly.
16
+ window.__hyperclayNoAutoExport = true;
17
+
18
+ // Merge into any window.clay a satellite already created; never replace it.
19
+ var clay = window.clay = window.clay || {};
20
+ // A second tag is a no-op: the original boot's `ready` promise survives.
21
+ // A FAILED boot resets the sentinel so a corrected tag can retry; the retry
22
+ // resolves the ORIGINAL `ready` promise via the stashed resolver.
23
+ if (clay.__booted) return;
24
+
25
+ function mintReady() {
26
+ clay.ready = new Promise(function (res, rej) {
27
+ clay.__readyResolve = res;
28
+ clay.__readyReject = rej;
29
+ });
30
+ // Nobody may be awaiting a failed boot's promise, and an unhandled rejection
31
+ // in the console is noise on top of the error we already logged.
32
+ clay.ready.catch(function () {});
33
+ }
34
+
35
+ if (!clay.ready) mintReady();
36
+
37
+ // clay.js needs its own URL to find the loader; this file needs it only for the
38
+ // query string, so pasted inline into the page it boots with the defaults.
39
+ var script = document.currentScript;
40
+ var params = script && script.src ? new URL(script.src, location.href).searchParams : new URLSearchParams();
41
+ clay.__booted = true;
42
+
43
+ function domReady() {
44
+ return new Promise(function (r) { onDomReady(r); });
45
+ }
46
+
47
+ // The module satellites, on the terms of their own entry scripts: each is a
48
+ // promise on clay.loaded, the ones that touch document.body wait for DOM
49
+ // ready, and events waits for dom because [onrender] handlers reach for its
50
+ // element helpers the moment events lands.
51
+ function satellite(name, promise) {
52
+ var p = promise.catch(function (err) {
53
+ console.error("clayjs: " + name + " failed to load:", err);
54
+ throw err;
55
+ });
56
+ // Mark handled: a failed satellite must not emit an unhandled rejection.
57
+ // Consumers who await clay.loaded[name] still get the error.
58
+ p.catch(function () {});
59
+ clay.loaded[name] = p;
60
+ }
61
+
62
+ clay.loaded = clay.loaded || {};
63
+ // The two generated satellites are classic scripts that assemble themselves
64
+ // onto window.clay when they run, and they carry no guard of their own, so
65
+ // they run here, behind the sentinel, and not as hoisted static imports: a
66
+ // duplicate tag would otherwise mount a second Sap runtime on the page. For
67
+ // the same reason a tag that already registered one (a leftover
68
+ // <script src="sap.js"> above this one) wins, and the copy in here stays idle.
69
+ if (!clay.loaded.data) satellite("data", import("../entries/clay-data.js"));
70
+ if (!clay.loaded.sap) satellite("sap", import("../entries/sap.js"));
71
+ satellite("dom", import("./dom/dom-helpers.js"));
72
+ satellite("utils", import("./utils/index.js"));
73
+ satellite("internals", import("./internals/index.js"));
74
+ satellite("all", import("./dom/all.js").then(function (m) {
75
+ window.All = m.default; // interop carve-out, as in entries/all.js
76
+ clay.All = m.default;
77
+ return m.default;
78
+ }));
79
+ satellite("ui", domReady().then(function () { return import("./ui/index.js"); }));
80
+ satellite("options", domReady().then(function () { return import("./options/options.js"); }));
81
+ satellite("events", domReady()
82
+ .then(function () { return clay.loaded.dom && clay.loaded.dom.catch(function () {}); })
83
+ .then(function () { return import("./events/index.js"); }));
84
+
85
+ // The first argument is the loader's unused `base`; see boot() in loader.js.
86
+ boot(null, params, clay.__readyResolve).catch(function (err) {
87
+ clay.__booted = false;
88
+ console.error("clayjs failed to load:", err);
89
+ // Settle the promise so `await clay.ready` fails loudly instead of hanging,
90
+ // then mint a fresh one for a corrected retry tag. See entries/clay.js. A
91
+ // retry re-evaluates this whole file, satellites included; the path is
92
+ // reachable only when a bundled module throws on evaluation, which is a
93
+ // clayjs bug, not a page condition.
94
+ var reject = clay.__readyReject;
95
+ mintReady();
96
+ if (reject) reject(err);
97
+ });
98
+ })();
@@ -44,6 +44,7 @@ import { serializeForSync, captureForComparisonAndDirty, captureSnapshot } from
44
44
  import { isTabLocalRootAttr } from '../lib/root-attrs.js';
45
45
  import { protectPeerDoc, protectDiskDoc, activateIncomingDoc } from './splice-merge.js';
46
46
  import { hostMeta } from '../core/host-meta.js';
47
+ import { recordEtag, seedEtag } from '../core/etag.js';
47
48
  import { pageMaybeDirty, pauseGate, resumeGate } from '../lib/dirty-gate.js';
48
49
 
49
50
  // The page just took a frame verified clean against its baseline, so it now IS
@@ -517,11 +518,50 @@ class LiveSync {
517
518
  const key = lane === 'live' ? '_heldLive' : '_heldExt';
518
519
  if (this[key] === isHeld) return;
519
520
  this[key] = isHeld;
521
+ // A hold that clears leaves this tab back in step, but every stamp frame that
522
+ // arrived while it was held was refused and nothing re-sends them: stamps are
523
+ // broadcast on a save, not on a merge. So the tab would go on holding a stamp
524
+ // from before the hold and have its next save refused over a conflict that has
525
+ // already resolved itself, which is this notice crying wolf. Ask the host for
526
+ // the current one instead. Only once BOTH lanes are clear, because one lane
527
+ // resuming while the other still holds means the tab is still behind.
528
+ if (!isHeld && !this._heldLive && !this._heldExt) {
529
+ seedEtag({ fresh: true, clearIfMissing: false });
530
+ }
520
531
  document.dispatchEvent(new CustomEvent(isHeld ? 'clay:sync-held' : 'clay:sync-resumed', {
521
532
  detail: { lane, el: el || null },
522
533
  }));
523
534
  }
524
535
 
536
+ /**
537
+ * Take a stamp that arrived with no document (spec §6).
538
+ *
539
+ * The host sends one whenever the file on disk changes, because an editing tab
540
+ * never receives the saved document itself: that lane is for viewers, and
541
+ * pushing a saved document onto an editor would replace work in progress.
542
+ *
543
+ * Taking it is what makes live sync and the conflict check agree. An editor
544
+ * whose peer frames are merging is already looking at the other tab's content,
545
+ * so refusing its next save would be a conflict about nothing.
546
+ *
547
+ * But only while this tab is in step. A held lane means live sync saw an
548
+ * incoming change, could not merge it into an unsaved local edit, and kept this
549
+ * tab's version instead, so the DOM here is knowingly missing what disk holds.
550
+ * Taking the new stamp there would let this tab's next save replace that change
551
+ * with nobody seeing it. That is the one loss live sync cannot prevent on its
552
+ * own: a held tab "converges through its own next save", and that convergence
553
+ * IS the overwrite. Refusing the stamp turns it into a conflict somebody is
554
+ * told about.
555
+ *
556
+ * @param {Object} data - The decoded frame
557
+ * @returns {boolean} True when this was a stamp frame and nothing should morph
558
+ */
559
+ _applyEtagFrame(data) {
560
+ if (typeof data.etag !== 'string' || typeof data.html === 'string') return false;
561
+ if (!this._heldLive && !this._heldExt) recordEtag(data.etag);
562
+ return true;
563
+ }
564
+
525
565
  /**
526
566
  * Connect to the SSE endpoint
527
567
  * Uses native EventSource reconnection behavior
@@ -609,6 +649,8 @@ class LiveSync {
609
649
  return;
610
650
  }
611
651
 
652
+ if (this._applyEtagFrame(data)) return;
653
+
612
654
  const { html, sender, identityMap } = data;
613
655
 
614
656
  // Ignore own changes — already reflected in the DOM, nothing to morph
package/src/ui/index.js CHANGED
@@ -23,6 +23,13 @@ if (typeof window.toastPersistent === "undefined") window.toastPersistent = toas
23
23
  document.addEventListener("clay:save-saved", (e) =>
24
24
  toast(e.detail?.msg || "Saved", e.detail?.msgType === "warning" ? "warning" : "success"));
25
25
  document.addEventListener("clay:save-error", () => toastPersistent("Couldn't save", "error"));
26
+ // Spec §6. Persistent, and carrying the host's own words, because this is the one
27
+ // save outcome the person has to act on: their edits are safe and unsaved, and
28
+ // autosave has stopped until they choose. Deliberately a toast and not a dialog:
29
+ // most conflicts surface on an autosave nobody asked for, and a modal thrown over
30
+ // the page a person is typing into is worse than the problem it reports.
31
+ document.addEventListener("clay:save-conflict", (e) =>
32
+ toastPersistent(e.detail?.msg || "This document changed since you opened it", "warning"));
26
33
  document.addEventListener("clay:save-offline", () => toastPersistent("Offline, not saved", "warning"));
27
34
 
28
35
  export { toast, toastPersistent, ask, consent, tell, snippet, themodal };