@panphora/clayjs 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panphora/clayjs",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "description": "clayjs: malleable HTML files. Save lifecycle for self-saving HTML.",
6
6
  "license": "MIT",
@@ -28,10 +28,23 @@
28
28
  },
29
29
  "scripts": {
30
30
  "test": "NODE_OPTIONS=--experimental-vm-modules jest",
31
+ "test:conformance": "UPDATE_GOLDENS=0 web-test-runner",
32
+ "conformance:update": "UPDATE_GOLDENS=1 web-test-runner",
33
+ "prepublishOnly": "npm test && npm run test:conformance",
31
34
  "dev": "node tests/stub-server.mjs"
32
35
  },
33
36
  "devDependencies": {
37
+ "@esm-bundle/chai": "^4.3.4-fix.0",
38
+ "@web/test-runner": "^0.20.2",
39
+ "@web/test-runner-commands": "^0.9.0",
40
+ "@web/test-runner-playwright": "^0.11.1",
34
41
  "jest": "^29.7.0",
35
- "jest-environment-jsdom": "^29.7.0"
42
+ "jest-environment-jsdom": "^29.7.0",
43
+ "playwright": "1.56.1"
44
+ },
45
+ "hyper": {
46
+ "kind": "lib",
47
+ "status": "active",
48
+ "url": "https://clayjs.com"
36
49
  }
37
50
  }
package/src/core/save.js CHANGED
@@ -38,8 +38,9 @@ let savingTimeout = null;
38
38
  *
39
39
  * @param {string} state - One of: 'saving', 'saved', 'offline', 'error'
40
40
  * @param {string} msg - Optional message (e.g., error details)
41
+ * @param {string} msgType - Optional severity from the server (e.g., 'warning')
41
42
  */
42
- function setSaveState(state, msg = '') {
43
+ function setSaveState(state, msg = '', msgType = '') {
43
44
  if (savingTimeout) {
44
45
  clearTimeout(savingTimeout);
45
46
  savingTimeout = null;
@@ -48,7 +49,7 @@ function setSaveState(state, msg = '') {
48
49
  document.documentElement.setAttribute('savestatus', state);
49
50
 
50
51
  const event = new CustomEvent(`clay:save-${state}`, {
51
- detail: { msg, timestamp: Date.now() }
52
+ detail: { msg, msgType, timestamp: Date.now() }
52
53
  });
53
54
  document.dispatchEvent(event);
54
55
  }
@@ -187,7 +188,7 @@ export function savePage(callback = () => {}) {
187
188
  // SUCCESS - store stripped version for future comparisons
188
189
  lastSavedContents = forComparison;
189
190
  unsavedChanges = false;
190
- setSaveState('saved', data?.msg || 'Saved');
191
+ setSaveState('saved', data?.msg || 'Saved', data?.msgType);
191
192
  logBaseline('updated after save', `${lastSavedContents.length} chars`);
192
193
  } else {
193
194
  // FAILED - determine if it's offline or server error
@@ -255,7 +256,7 @@ export function savePageForce(callback = () => {}) {
255
256
  if (!err) {
256
257
  lastSavedContents = forComparison;
257
258
  unsavedChanges = false;
258
- setSaveState('saved', data?.msg || 'Saved');
259
+ setSaveState('saved', data?.msg || 'Saved', data?.msgType);
259
260
  logBaseline('updated after force save', `${lastSavedContents.length} chars`);
260
261
  } else {
261
262
  if (!navigator.onLine) {
@@ -133,16 +133,18 @@ function prepareCloneForSave(clone) {
133
133
  new Function(el.getAttribute('onbeforesave')).call(el);
134
134
  }
135
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
136
  // Run registered prepare hooks ([freeze]/[save-freeze] innerHTML restore lives here)
142
137
  for (const hook of prepareForSaveHooks) {
143
138
  hook(clone);
144
139
  }
145
140
 
141
+ // Strip [no-save] / legacy [save-remove] LAST (snapshot-algorithm step 7): a
142
+ // prepare hook (freeze restore) can re-inject [no-save] content into the clone,
143
+ // so the strip must run after the hooks or that content leaks to disk.
144
+ for (const el of clone.querySelectorAll(STRIP_FROM_SAVE)) {
145
+ el.remove();
146
+ }
147
+
146
148
  return "<!DOCTYPE html>" + clone.outerHTML;
147
149
  }
148
150
 
@@ -212,13 +214,14 @@ export function captureForSaveAndComparison({ emitForSync = true } = {}) {
212
214
  // Clone for comparison before stripping (cheaper than cloning live DOM)
213
215
  const compareClone = clonePreventingOnclone(clone);
214
216
 
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
- }
217
+ // Save clone: run hooks (freeze restore lives here), THEN strip [no-save]/[save-remove]
218
+ // LAST (snapshot-algorithm step 7) so freeze-restored [no-save] content can't leak to disk.
219
219
  for (const hook of prepareForSaveHooks) {
220
220
  hook(clone);
221
221
  }
222
+ for (const el of clone.querySelectorAll(STRIP_FROM_SAVE)) {
223
+ el.remove();
224
+ }
222
225
  const forSave = "<!DOCTYPE html>" + clone.outerHTML;
223
226
 
224
227
  // Compare clone: strip every autosave-off region, then run hooks
@@ -47,6 +47,15 @@ class LiveSync {
47
47
  this.currentFile = null;
48
48
  this.lastHtml = null;
49
49
  this.clientId = this.generateClientId();
50
+
51
+ // Per-stream resume id for the htmlclay replay server's wire contract.
52
+ // Regenerated on every start() (see below) so an explicit stop/start is a
53
+ // brand-new stream that never inherits the old baseline. Native
54
+ // EventSource reuses the same URL — and thus this id — on its own
55
+ // automatic reconnect. Deliberately NOT the sender clientId, which is the
56
+ // durable per-tab identity used for echo suppression.
57
+ this.resumeId = null;
58
+
50
59
  this.debounceMs = 150;
51
60
  this.debounceTimer = null;
52
61
  this.isPaused = false;
@@ -133,6 +142,21 @@ class LiveSync {
133
142
  return id;
134
143
  }
135
144
 
145
+ /**
146
+ * Generate a fresh per-stream resume id (URL-safe, well under the 128-byte
147
+ * wire limit). Minted on every start(), never persisted: an explicit
148
+ * stop/start must not reuse the previous stream's baseline.
149
+ */
150
+ generateResumeId() {
151
+ try {
152
+ const bytes = new Uint8Array(16);
153
+ (globalThis.crypto || window.crypto).getRandomValues(bytes);
154
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
155
+ } catch (e) {
156
+ return Math.random().toString(36).slice(2) + Date.now().toString(36);
157
+ }
158
+ }
159
+
136
160
  /**
137
161
  * Start the LiveSync system
138
162
  * Can be called after stop() to restart with a new file
@@ -153,9 +177,11 @@ class LiveSync {
153
177
  return;
154
178
  }
155
179
 
156
- // Reset state for new connection
180
+ // Reset state for new connection. Resetting lastSeenSeq is a new baseline,
181
+ // so mint a fresh resume id — this stream must not resume the previous one.
157
182
  this.lastHtml = null;
158
183
  this.lastSeenSeq = 0;
184
+ this.resumeId = this.generateResumeId();
159
185
 
160
186
  console.log(`[LiveSync] Starting for: ${this.currentFile} (lane=${this.lane})`);
161
187
  this.connect();
@@ -361,7 +387,7 @@ class LiveSync {
361
387
  if (this.isDestroyed) return;
362
388
 
363
389
  const pageUrl = encodeURIComponent(window.location.href);
364
- const url = `/_/live-sync/stream?page-url=${pageUrl}&lane=${this.lane}`;
390
+ const url = `/_/live-sync/stream?page-url=${pageUrl}&lane=${this.lane}&resume-id=${this.resumeId}`;
365
391
  this.sse = new EventSource(url);
366
392
 
367
393
  this.sse.onopen = () => {
@@ -372,6 +398,26 @@ class LiveSync {
372
398
  this.sse.onmessage = (event) => {
373
399
  const data = JSON.parse(event.data);
374
400
 
401
+ // Staleness check + watermark advance run FIRST — before the notification
402
+ // and error branches — against the high-water mark of seqs we've seen
403
+ // (own echoes count too, see below). Notification frames now carry a seq,
404
+ // so a conservative-cursor replay after reconnect must not duplicate a
405
+ // toast or let a later stale data frame apply an older snapshot. `seq` is
406
+ // optional for back-compat with older servers that don't stamp it.
407
+ const { seq } = data;
408
+ if (typeof seq === 'number' && seq <= this.lastSeenSeq) {
409
+ this._log(`Dropping stale message: seq=${seq}, lastSeen=${this.lastSeenSeq}`);
410
+ return;
411
+ }
412
+
413
+ // Advance the watermark before the notification/sender branches so that
414
+ // our own save echoes count toward "seen". Without this, a later
415
+ // buffered/replayed message with a smaller seq could rewind us past our
416
+ // local edit.
417
+ if (typeof seq === 'number') {
418
+ this.lastSeenSeq = seq;
419
+ }
420
+
375
421
  // Handle notifications (show toast, don't morph)
376
422
  if (data.type === "notification") {
377
423
  this.handleNotification(data);
@@ -385,22 +431,7 @@ class LiveSync {
385
431
  return;
386
432
  }
387
433
 
388
- const { html, sender, seq, identityMap } = data;
389
-
390
- // Staleness check runs FIRST — compared against the high-water mark of
391
- // seqs we've seen (own echoes count too, see below). `seq` is optional
392
- // for back-compat with older server builds that don't stamp it.
393
- if (typeof seq === 'number' && seq <= this.lastSeenSeq) {
394
- this._log(`Dropping stale message: seq=${seq}, lastSeen=${this.lastSeenSeq}`);
395
- return;
396
- }
397
-
398
- // Advance the watermark before the sender filter so that our own save
399
- // echoes count toward "seen". Without this, a later buffered/replayed
400
- // peer message with a smaller seq could rewind us past our local edit.
401
- if (typeof seq === 'number') {
402
- this.lastSeenSeq = seq;
403
- }
434
+ const { html, sender, identityMap } = data;
404
435
 
405
436
  // Ignore own changes — already reflected in the DOM, nothing to morph
406
437
  if (sender === this.clientId) {
package/src/ui/index.js CHANGED
@@ -30,7 +30,8 @@ else document.addEventListener("clay:mutation-ready", mirrorConsent, { once: tru
30
30
 
31
31
  // Automatic save feedback. clay:save-saving stays deliberately silent (a toast for
32
32
  // a sub-second transient is noise). Keep your own toast lib? The events are public.
33
- document.addEventListener("clay:save-saved", () => toast("Saved", "success"));
33
+ document.addEventListener("clay:save-saved", (e) =>
34
+ toast(e.detail?.msg || "Saved", e.detail?.msgType === "warning" ? "warning" : "success"));
34
35
  document.addEventListener("clay:save-error", () => toastPersistent("Couldn't save", "error"));
35
36
  document.addEventListener("clay:save-offline", () => toastPersistent("Offline, not saved", "warning"));
36
37
 
package/src/ui/toast.js CHANGED
@@ -280,11 +280,14 @@ export function toastCore(message, messageType = "success", config = {}) {
280
280
  const template = templates.toast[messageType] || templates.toast.success;
281
281
  const toastHtml = template
282
282
  .replace('{icon}', icon)
283
- .replace('{message}', message);
283
+ .replace('{message}', '');
284
284
 
285
285
  const tempDiv = document.createElement('div');
286
286
  tempDiv.innerHTML = toastHtml.trim();
287
287
  const toastElement = tempDiv.firstElementChild;
288
+ // A message can carry a filename, and a filename is whatever whoever wrote the
289
+ // file chose. Assign it as text so markup in a name can never become markup here.
290
+ toastElement.querySelector('.toast-message, .message').textContent = message;
288
291
 
289
292
  // Add click handler
290
293
  toastElement.addEventListener('click', () => {
@@ -393,11 +396,14 @@ function toastPersistent(message, messageType = "warning") {
393
396
  const template = templates.toast[messageType] || templates.toast.warning;
394
397
  const toastHtml = template
395
398
  .replace('{icon}', icon)
396
- .replace('{message}', message);
399
+ .replace('{message}', '');
397
400
 
398
401
  const tempDiv = document.createElement('div');
399
402
  tempDiv.innerHTML = toastHtml.trim();
400
403
  const toastElement = tempDiv.firstElementChild;
404
+ // A message can carry a filename, and a filename is whatever whoever wrote the
405
+ // file chose. Assign it as text so markup in a name can never become markup here.
406
+ toastElement.querySelector('.toast-message, .message').textContent = message;
401
407
  toastElement.classList.add('toast-persistent');
402
408
 
403
409
  // Add close button as sibling (not inside message)