@panphora/clayjs 1.1.0 → 1.3.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.
- package/README.md +4 -1
- package/THIRD-PARTY-NOTICES.md +10 -0
- package/dist/clay.standalone.js +21751 -13942
- package/entries/clay-data.js +1 -1
- package/package.json +7 -2
- package/packed-contract.json +17 -0
- package/src/attrs/save-freeze.js +10 -18
- package/src/core/admin-contenteditable.js +5 -6
- package/src/core/etag.js +17 -4
- package/src/core/host-attrs.js +52 -2
- package/src/core/is-edit-mode.js +14 -5
- package/src/core/persist.js +5 -10
- package/src/core/save-conflict-notice.js +22 -27
- package/src/core/save-core.js +326 -26
- package/src/core/save.js +19 -4
- package/src/core/snapshot.js +148 -15
- package/src/core/source-map.js +817 -0
- package/src/core/stale-host-notice.js +95 -0
- package/src/core/unsaved-warning.js +3 -0
- package/src/dom/dom-helpers.js +5 -1
- package/src/lib/content-dom.js +108 -0
- package/src/lib/hostile-css.js +49 -0
- package/src/lib/mutation.js +26 -3
- package/src/lib/region-capabilities.js +69 -0
- package/src/lib/region-policy.js +18 -13
- package/src/lib/root-attrs.js +52 -13
- package/src/loader-logic.js +25 -4
- package/src/loader.js +4 -0
- package/src/plugins/demo.js +3 -0
- package/src/plugins/sortable.js +6 -1
- package/src/plugins/source.js +326 -0
- package/src/plugins/wire.js +248 -47
- package/src/sync/live-sync.js +275 -63
- package/src/sync/presence.js +303 -0
- package/src/sync/section-notice.js +230 -0
- package/src/sync/splice-merge.js +7 -10
- package/src/sync/stream.js +190 -0
- package/src/vendor/hyper-morph.vendor.js +2 -2
- package/src/vendor/hyper-undo.vendor.js +1 -1
- package/src/vendor/hypercms.vendor.js +438 -45
- package/src/vendor/parse5.vendor.js +3 -0
- package/src/vendor/quickcrop.vendor.js +1 -1
- package/src/vendor/richclay.vendor.js +22 -15
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { servedStaleToken } from "./host-attrs.js";
|
|
2
|
+
import { isEditMode } from "./is-edit-mode.js";
|
|
3
|
+
import onDomReady from "../lib/dom-ready.js";
|
|
4
|
+
import { set, make } from "../lib/hostile-css.js";
|
|
5
|
+
|
|
6
|
+
// The page's only visible word on a host too old to save to.
|
|
7
|
+
//
|
|
8
|
+
// This library reads one save-token name. A host older than that rename sends the
|
|
9
|
+
// other one, so there is no token here, and it also sets the owner cookie, so without
|
|
10
|
+
// help the page would open fully editable and 404 every save against a route that no
|
|
11
|
+
// longer matches. is-edit-mode.js takes editing away for that reason. This says why.
|
|
12
|
+
//
|
|
13
|
+
// It loads in the ALWAYS wave, not the edit-only one, which is the whole point: the
|
|
14
|
+
// case it exists for is precisely the case where edit mode is off, so a module gated on
|
|
15
|
+
// edit mode could never run in it. host-attrs.js also logs a line, and that line is for
|
|
16
|
+
// a developer. Somebody editing a document in a desktop app has no console open, and
|
|
17
|
+
// "why can I not edit this any more" is the question they are actually holding.
|
|
18
|
+
//
|
|
19
|
+
// One line, no choice to make: nothing on this page can fix it, so offering a button
|
|
20
|
+
// would be a lie. Dismissable, because after you have read it, it is only in the way.
|
|
21
|
+
|
|
22
|
+
const BG = "var(--clay-notice-bg,#222)";
|
|
23
|
+
const INK = "var(--clay-notice-ink,#fff)";
|
|
24
|
+
const EDGE = "var(--clay-notice-edge,rgba(255,255,255,.28))";
|
|
25
|
+
const FONT = "14px/1.45 system-ui,-apple-system,'Segoe UI',sans-serif";
|
|
26
|
+
|
|
27
|
+
const MESSAGE =
|
|
28
|
+
"This page can't be edited: the app serving it is out of date. " +
|
|
29
|
+
"Update HTML Clay to 1.9.0 or newer.";
|
|
30
|
+
|
|
31
|
+
let root = null;
|
|
32
|
+
|
|
33
|
+
// A phone keyboard shrinks the visual viewport but leaves fixed elements pinned to the
|
|
34
|
+
// layout viewport, so a bottom-anchored bar parks itself behind the keyboard. Same fix
|
|
35
|
+
// as the conflict notice.
|
|
36
|
+
function place() {
|
|
37
|
+
if (!root) return;
|
|
38
|
+
const vv = window.visualViewport;
|
|
39
|
+
const lift = vv ? Math.max(0, window.innerHeight - vv.height - vv.offsetTop) : 0;
|
|
40
|
+
set(root, "bottom", `calc(${16 + lift}px + env(safe-area-inset-bottom,0px))`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function dismiss() {
|
|
44
|
+
if (!root) return;
|
|
45
|
+
set(root, "display", "none");
|
|
46
|
+
window.visualViewport?.removeEventListener("resize", place);
|
|
47
|
+
window.visualViewport?.removeEventListener("scroll", place);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function build() {
|
|
51
|
+
root = make("div", [
|
|
52
|
+
"position:fixed", "left:50%", "transform:translateX(-50%)",
|
|
53
|
+
"z-index:2147483001", "display:flex", "align-items:center", "gap:10px",
|
|
54
|
+
"max-width:calc(100vw - 24px)", "flex-wrap:wrap", "justify-content:center",
|
|
55
|
+
"padding:9px 12px", "border-radius:10px",
|
|
56
|
+
`background:${BG}`, `color:${INK}`, `border:1px solid ${EDGE}`,
|
|
57
|
+
"box-shadow:0 6px 24px rgba(0,0,0,.32),0 1px 2px rgba(0,0,0,.24)",
|
|
58
|
+
`font:${FONT}`, "text-align:left",
|
|
59
|
+
]);
|
|
60
|
+
// Three markers, and each is load-bearing on a page that CAN save: this element is
|
|
61
|
+
// injected, so it is in no document on disk and must never reach one, never wake the
|
|
62
|
+
// watcher, and never ride out in a snapshot to somebody else's browser.
|
|
63
|
+
root.setAttribute("clay", "no-save no-watch no-snapshot");
|
|
64
|
+
root.setAttribute("data-clay-stale-host", "");
|
|
65
|
+
root.setAttribute("role", "alert");
|
|
66
|
+
|
|
67
|
+
root.append(make("span", ["margin-right:2px"], MESSAGE));
|
|
68
|
+
|
|
69
|
+
const close = make("button", [
|
|
70
|
+
"all:initial", "box-sizing:border-box", "cursor:pointer", `font:${FONT}`,
|
|
71
|
+
"color:" + INK, "opacity:.72", "padding:2px 6px", "border-radius:6px", "flex:none",
|
|
72
|
+
], "Dismiss");
|
|
73
|
+
close.type = "button";
|
|
74
|
+
close.setAttribute("aria-label", "Dismiss this message");
|
|
75
|
+
close.addEventListener("click", dismiss);
|
|
76
|
+
root.append(close);
|
|
77
|
+
|
|
78
|
+
document.body.appendChild(root);
|
|
79
|
+
place();
|
|
80
|
+
window.visualViewport?.addEventListener("resize", place);
|
|
81
|
+
window.visualViewport?.addEventListener("scroll", place);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function init() {
|
|
85
|
+
if (!servedStaleToken()) return;
|
|
86
|
+
// ?editmode=true outranks the stale-host check by design: that is a person at the
|
|
87
|
+
// keyboard asking for editing on this load, and is-edit-mode.js gives it to them.
|
|
88
|
+
// This notice never consulted that, so it appeared on a page that IS editable and
|
|
89
|
+
// told its reader the opposite. The message is only true while editing is actually
|
|
90
|
+
// off, so it is shown only then.
|
|
91
|
+
if (isEditMode) return;
|
|
92
|
+
build();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
onDomReady(init);
|
|
@@ -31,6 +31,9 @@ preloadIfEnabled();
|
|
|
31
31
|
// the person editing it deserves the warning.
|
|
32
32
|
window.addEventListener('beforeunload', (event) => {
|
|
33
33
|
if (!isEditMode) return;
|
|
34
|
+
// The demo plugin saves into this browser's own storage, so leaving the page
|
|
35
|
+
// loses nothing a prompt could protect.
|
|
36
|
+
if (window.clay?.demo) return;
|
|
34
37
|
|
|
35
38
|
// The DIRTY domain, not the autosave domain. An edit inside a
|
|
36
39
|
// no-trigger-autosave region never starts a save by itself, which is exactly
|
package/src/dom/dom-helpers.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import nearest from "./nearest.js";
|
|
2
2
|
import pipe from "./pipe.js";
|
|
3
|
+
import { createContentView, cleanContentClone } from '../lib/content-dom.js';
|
|
3
4
|
|
|
4
5
|
function init () {
|
|
5
6
|
|
|
7
|
+
window.clay = window.clay || {};
|
|
8
|
+
window.clay.dom = Object.assign(window.clay.dom || {}, { createContentView, cleanContentClone });
|
|
9
|
+
|
|
6
10
|
// Bail if already initialized
|
|
7
11
|
if (HTMLElement.prototype.hasOwnProperty('nearest')) {
|
|
8
12
|
return;
|
|
@@ -214,5 +218,5 @@ function init () {
|
|
|
214
218
|
// Auto-init when module is imported
|
|
215
219
|
init();
|
|
216
220
|
|
|
217
|
-
export { init };
|
|
221
|
+
export { init, createContentView, cleanContentClone };
|
|
218
222
|
export default init;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { capabilitySelector, hasCapability } from './region-capabilities.js'
|
|
2
|
+
|
|
3
|
+
const UNSUPPORTED_SELECTOR = /:(?:focus(?:-within|-visible)?|hover|active|visited|defined)\b/i
|
|
4
|
+
|
|
5
|
+
function copyControlState(source, copy) {
|
|
6
|
+
if (!/^(INPUT|TEXTAREA|SELECT|OPTION)$/.test(source.tagName || '')) return
|
|
7
|
+
const type = (source.getAttribute?.('type') || '').toLowerCase()
|
|
8
|
+
if ('value' in source && 'value' in copy && source.tagName !== 'OPTION' && type !== 'checkbox' && type !== 'radio') copy.value = source.value
|
|
9
|
+
if ('checked' in source && 'checked' in copy) copy.checked = source.checked
|
|
10
|
+
if ('selected' in source && 'selected' in copy) copy.selected = source.selected
|
|
11
|
+
if (source.tagName === 'SELECT') {
|
|
12
|
+
for (let i = 0; i < source.options.length; i++) copy.options[i].selected = source.options[i].selected
|
|
13
|
+
}
|
|
14
|
+
if ('indeterminate' in source && 'indeterminate' in copy) copy.indeterminate = source.indeterminate
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function matchesWithin(node, selector, boundary, inherit) {
|
|
18
|
+
if (inherit) return !!node.closest?.(selector)
|
|
19
|
+
let current = node
|
|
20
|
+
while (current?.nodeType === 1) {
|
|
21
|
+
if (current.matches(selector)) return true
|
|
22
|
+
if (current === boundary) break
|
|
23
|
+
current = current.parentElement
|
|
24
|
+
}
|
|
25
|
+
return false
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function importTree(source, targetDocument, capability, capabilityMatch, exclude, boundary, inherit, maps) {
|
|
29
|
+
if (source.nodeType === 1 && (
|
|
30
|
+
(inherit ? hasCapability(source, capability) : matchesWithin(source, capabilityMatch, boundary, false)) ||
|
|
31
|
+
(exclude && matchesWithin(source, exclude, boundary, inherit))
|
|
32
|
+
)) return null
|
|
33
|
+
const copy = targetDocument.importNode(source, false)
|
|
34
|
+
maps.cloneToLive.set(copy, source)
|
|
35
|
+
maps.liveToClone.set(source, copy)
|
|
36
|
+
const sourceChildren = source.nodeType === 1 && source.tagName === 'TEMPLATE' ? source.content : source
|
|
37
|
+
const copyChildren = copy.nodeType === 1 && copy.tagName === 'TEMPLATE' ? copy.content : copy
|
|
38
|
+
if (sourceChildren !== source) {
|
|
39
|
+
maps.cloneToLive.set(copyChildren, sourceChildren)
|
|
40
|
+
maps.liveToClone.set(sourceChildren, copyChildren)
|
|
41
|
+
}
|
|
42
|
+
for (const child of sourceChildren.childNodes || []) {
|
|
43
|
+
const childCopy = importTree(child, targetDocument, capability, capabilityMatch, exclude, boundary, inherit, maps)
|
|
44
|
+
if (childCopy) {
|
|
45
|
+
copyChildren.appendChild(childCopy)
|
|
46
|
+
if (child.nodeType === 1) copyControlState(child, childCopy)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (source.nodeType === 1) copyControlState(source, copy)
|
|
50
|
+
return copy
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function createContentView(context, { capability = 'data', exclude = null, inherit = true } = {}) {
|
|
54
|
+
if (!context) throw new TypeError('createContentView requires a DOM context')
|
|
55
|
+
const sourceDocument = context.nodeType === 9 ? context : context.ownerDocument
|
|
56
|
+
if (!sourceDocument?.implementation?.createHTMLDocument) {
|
|
57
|
+
throw new TypeError('createContentView requires an HTML DOM implementation')
|
|
58
|
+
}
|
|
59
|
+
const inertDocument = sourceDocument.implementation.createHTMLDocument('')
|
|
60
|
+
const cloneToLive = new WeakMap()
|
|
61
|
+
const liveToClone = new WeakMap()
|
|
62
|
+
const sourceRoot = context.nodeType === 9 ? context.documentElement : context
|
|
63
|
+
if (exclude) inertDocument.documentElement.matches(exclude)
|
|
64
|
+
const capabilityMatch = capabilitySelector(capability)
|
|
65
|
+
const root = importTree(sourceRoot, inertDocument, capability, capabilityMatch, exclude, sourceRoot, inherit, { cloneToLive, liveToClone })
|
|
66
|
+
if (context.nodeType === 9 && root) {
|
|
67
|
+
inertDocument.replaceChild(root, inertDocument.documentElement)
|
|
68
|
+
liveToClone.set(context, inertDocument)
|
|
69
|
+
cloneToLive.set(inertDocument, context)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const assertSelector = selector => {
|
|
73
|
+
if (UNSUPPORTED_SELECTOR.test(selector)) {
|
|
74
|
+
throw new Error(`Filtered content queries do not support stateful selector: ${selector}`)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
root,
|
|
80
|
+
document: inertDocument,
|
|
81
|
+
capability,
|
|
82
|
+
selector: capabilitySelector(capability),
|
|
83
|
+
cloneToLive,
|
|
84
|
+
liveToClone,
|
|
85
|
+
original(node) { return cloneToLive.get(node) || null },
|
|
86
|
+
cloneOf(node) { return liveToClone.get(node) || null },
|
|
87
|
+
query(selector, queryRoot = root) {
|
|
88
|
+
assertSelector(selector)
|
|
89
|
+
return Array.from(queryRoot.querySelectorAll(selector), node => cloneToLive.get(node) || node)
|
|
90
|
+
},
|
|
91
|
+
text(node = root) {
|
|
92
|
+
const projected = node === root ? root : liveToClone.get(node)
|
|
93
|
+
return projected?.textContent || ''
|
|
94
|
+
},
|
|
95
|
+
html(node = root) {
|
|
96
|
+
const projected = node === root ? root : liveToClone.get(node)
|
|
97
|
+
return projected?.innerHTML ?? ''
|
|
98
|
+
},
|
|
99
|
+
clone(node = root) {
|
|
100
|
+
const projected = node === root ? root : liveToClone.get(node)
|
|
101
|
+
return projected ? inertDocument.importNode(projected, true) : null
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function cleanContentClone(node, options) {
|
|
107
|
+
return createContentView(node, options).root
|
|
108
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Styling that survives somebody else's stylesheet.
|
|
2
|
+
//
|
|
3
|
+
// Every declaration goes on as !important, and that is the whole reason anything this
|
|
4
|
+
// library draws on a stranger's page looks the way it was written. A plain inline style
|
|
5
|
+
// loses to an author rule carrying !important, and `button { ... !important }` is a
|
|
6
|
+
// thing real pages do: the first browser run of the conflict notice had both of its
|
|
7
|
+
// controls repainted in the host page's colours and font. Only an inline !important
|
|
8
|
+
// outranks an author !important.
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Apply `prop:value` rules to an element, each one !important.
|
|
12
|
+
* @param {Element} el
|
|
13
|
+
* @param {string[]} rules
|
|
14
|
+
*/
|
|
15
|
+
export function style(el, rules) {
|
|
16
|
+
for (const rule of rules) {
|
|
17
|
+
const at = rule.indexOf(":");
|
|
18
|
+
el.style.setProperty(rule.slice(0, at).trim(), rule.slice(at + 1).trim(), "important");
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Set one property, !important.
|
|
24
|
+
*
|
|
25
|
+
* Same reason as above, from the other direction: a later assignment has to be able to
|
|
26
|
+
* beat the !important already sitting on the element, and a plain `el.style.foo = x`
|
|
27
|
+
* silently cannot.
|
|
28
|
+
*
|
|
29
|
+
* @param {Element} el
|
|
30
|
+
* @param {string} prop
|
|
31
|
+
* @param {string} value
|
|
32
|
+
*/
|
|
33
|
+
export function set(el, prop, value) {
|
|
34
|
+
el.style.setProperty(prop, value, "important");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Build an element with those rules already on it.
|
|
39
|
+
* @param {string} tag
|
|
40
|
+
* @param {string[]} rules
|
|
41
|
+
* @param {string} [text]
|
|
42
|
+
* @returns {HTMLElement}
|
|
43
|
+
*/
|
|
44
|
+
export function make(tag, rules, text) {
|
|
45
|
+
const el = document.createElement(tag);
|
|
46
|
+
style(el, rules);
|
|
47
|
+
if (text) el.textContent = text;
|
|
48
|
+
return el;
|
|
49
|
+
}
|
package/src/lib/mutation.js
CHANGED
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
|
|
69
69
|
import { EXTENSION_ATTR_PATTERN } from './extension-noise.js';
|
|
70
70
|
import { resolveRegionPolicy, isInert, strictestPolicy, skipForPolicy } from './region-policy.js';
|
|
71
|
+
import { isPolicyAttribute } from './region-capabilities.js';
|
|
71
72
|
import { ROOT_LIBRARY_ATTRS } from './root-attrs.js';
|
|
72
73
|
|
|
73
74
|
import { isUserDrivenNow, markUserDriven } from './user-gesture.js';
|
|
@@ -186,7 +187,12 @@ const localMutation = {
|
|
|
186
187
|
// doesn't participate in (no-save / no-trigger-autosave / no-undo / freeze
|
|
187
188
|
// and their legacy equivalents), resolved against the callback's `require`.
|
|
188
189
|
let filteredChanges = changes.filter(
|
|
189
|
-
change =>
|
|
190
|
+
change => {
|
|
191
|
+
const currentSkipped = skipForPolicy(this._policyForChange(change), require, skip);
|
|
192
|
+
if (!change.policyTransition) return !currentSkipped;
|
|
193
|
+
const previousSkipped = skipForPolicy(change.previousPolicy, require, skip);
|
|
194
|
+
return !(currentSkipped && previousSkipped);
|
|
195
|
+
}
|
|
190
196
|
);
|
|
191
197
|
if (!filteredChanges.length) {
|
|
192
198
|
this._log('No changes passed the region policy, skipping callback');
|
|
@@ -478,7 +484,8 @@ const localMutation = {
|
|
|
478
484
|
// Intake drop: a no-watch / mutations-ignore subtree (or extension noise)
|
|
479
485
|
// is invisible to every consumer, so skip it without walking. All other
|
|
480
486
|
// region attributes are resolved per-consumer in _notify.
|
|
481
|
-
|
|
487
|
+
const policyTransition = mutation.type === 'attributes' && isPolicyAttribute(mutation.attributeName);
|
|
488
|
+
if (!policyTransition && isInert(mutation.target)) {
|
|
482
489
|
continue;
|
|
483
490
|
}
|
|
484
491
|
|
|
@@ -586,7 +593,9 @@ const localMutation = {
|
|
|
586
593
|
element: mutation.target,
|
|
587
594
|
attribute: mutation.attributeName,
|
|
588
595
|
oldValue: mutation.oldValue,
|
|
589
|
-
newValue: mutation.target.getAttribute(mutation.attributeName)
|
|
596
|
+
newValue: mutation.target.getAttribute(mutation.attributeName),
|
|
597
|
+
policyTransition,
|
|
598
|
+
previousPolicy: policyTransition ? policyBeforeMutation(mutation) : null,
|
|
590
599
|
};
|
|
591
600
|
changes.push(change);
|
|
592
601
|
changesByType.attribute.push(change);
|
|
@@ -783,3 +792,17 @@ if (typeof window !== 'undefined' && !existingHub) {
|
|
|
783
792
|
}
|
|
784
793
|
|
|
785
794
|
export default Mutation;
|
|
795
|
+
function policyBeforeMutation(record) {
|
|
796
|
+
let source = record.target;
|
|
797
|
+
let clone = source.cloneNode(false);
|
|
798
|
+
const leaf = clone;
|
|
799
|
+
while (source.parentElement) {
|
|
800
|
+
source = source.parentElement;
|
|
801
|
+
const parent = source.cloneNode(false);
|
|
802
|
+
parent.appendChild(clone);
|
|
803
|
+
clone = parent;
|
|
804
|
+
}
|
|
805
|
+
if (record.oldValue == null) leaf.removeAttribute(record.attributeName);
|
|
806
|
+
else leaf.setAttribute(record.attributeName, record.oldValue);
|
|
807
|
+
return resolveRegionPolicy(leaf);
|
|
808
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export const CAPABILITIES = Object.freeze({
|
|
2
|
+
data: Object.freeze({ tokens: ['no-data'], bundles: ['editor-ui'] }),
|
|
3
|
+
save: Object.freeze({ tokens: ['no-save'], bundles: ['editor-ui'] }),
|
|
4
|
+
snapshot: Object.freeze({ tokens: ['no-snapshot'], bundles: ['editor-ui'] }),
|
|
5
|
+
watch: Object.freeze({ tokens: ['no-watch'], bundles: ['editor-ui'] }),
|
|
6
|
+
undo: Object.freeze({ tokens: ['no-undo'], bundles: ['editor-ui'] }),
|
|
7
|
+
history: Object.freeze({ tokens: [], bundles: ['editor-ui'] }),
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
export const BUNDLES = Object.freeze({
|
|
11
|
+
'editor-ui': Object.freeze(['no-data', 'no-save', 'no-snapshot', 'no-watch', 'no-undo']),
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
export const POLICY_TOKENS = Object.freeze([
|
|
15
|
+
'no-save',
|
|
16
|
+
'no-snapshot',
|
|
17
|
+
'no-trigger-autosave',
|
|
18
|
+
'no-dirty',
|
|
19
|
+
'no-watch',
|
|
20
|
+
'no-undo',
|
|
21
|
+
'no-data',
|
|
22
|
+
'freeze',
|
|
23
|
+
'editor-ui',
|
|
24
|
+
])
|
|
25
|
+
|
|
26
|
+
export function hasPolicyToken(element, token) {
|
|
27
|
+
if (!element || element.nodeType !== 1) return false
|
|
28
|
+
const clay = element.getAttribute?.('clay')
|
|
29
|
+
return !!(
|
|
30
|
+
(clay && clay.split(/\s+/).includes(token)) ||
|
|
31
|
+
element.hasAttribute?.(token)
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function expandsTo(element, token) {
|
|
36
|
+
if (hasPolicyToken(element, token)) return true
|
|
37
|
+
for (const [bundle, members] of Object.entries(BUNDLES)) {
|
|
38
|
+
if (members.includes(token) && hasPolicyToken(element, bundle)) return true
|
|
39
|
+
}
|
|
40
|
+
return false
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function capabilitySelector(capability) {
|
|
44
|
+
const definition = CAPABILITIES[capability]
|
|
45
|
+
if (!definition) throw new Error(`Unknown region capability: ${capability}`)
|
|
46
|
+
return [...definition.tokens, ...definition.bundles]
|
|
47
|
+
.flatMap(token => [`[clay~="${token}"]`, `[${token}]`])
|
|
48
|
+
.join(', ')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function closestWithCapability(node, capability) {
|
|
52
|
+
let element = node && node.nodeType === 1 ? node : node?.parentElement
|
|
53
|
+
while (element && element.nodeType === 1) {
|
|
54
|
+
const definition = CAPABILITIES[capability]
|
|
55
|
+
if (!definition) throw new Error(`Unknown region capability: ${capability}`)
|
|
56
|
+
if (definition.tokens.some(token => expandsTo(element, token)) ||
|
|
57
|
+
definition.bundles.some(bundle => hasPolicyToken(element, bundle))) return element
|
|
58
|
+
element = element.parentElement
|
|
59
|
+
}
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function hasCapability(node, capability) {
|
|
64
|
+
return !!closestWithCapability(node, capability)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function isPolicyAttribute(name) {
|
|
68
|
+
return name === 'clay' || POLICY_TOKENS.includes(name)
|
|
69
|
+
}
|
package/src/lib/region-policy.js
CHANGED
|
@@ -44,24 +44,21 @@
|
|
|
44
44
|
*/
|
|
45
45
|
|
|
46
46
|
import { EXTENSION_NODE_SELECTOR } from './extension-noise.js';
|
|
47
|
+
import { capabilitySelector, expandsTo, hasCapability, hasPolicyToken, POLICY_TOKENS } from './region-capabilities.js';
|
|
47
48
|
|
|
48
49
|
export const PERSIST = { FULL: 'full', FROZEN: 'frozen', NONE: 'none' };
|
|
49
50
|
|
|
50
51
|
// The canonical region tokens (spelled in the `clay` attribute or as bare attrs).
|
|
51
|
-
export const REGION_ATTRS = ['no-save', 'no-trigger-autosave', 'no-dirty', 'no-undo', 'no-watch', 'freeze'];
|
|
52
|
+
export const REGION_ATTRS = ['no-save', 'no-trigger-autosave', 'no-dirty', 'no-undo', 'no-watch', 'no-data', 'freeze', 'editor-ui'];
|
|
52
53
|
|
|
53
54
|
// Every canonical token spellable inside the space-separated `clay` attribute.
|
|
54
55
|
// Exported: this list used to exist as a private CLAY_TOKENS that nothing read,
|
|
55
56
|
// while callers hardcoded their own copies.
|
|
56
|
-
export const TOKENS = [
|
|
57
|
+
export const TOKENS = [...POLICY_TOKENS];
|
|
57
58
|
|
|
58
59
|
// True when a region marker is present, whether spelled as a `clay` token
|
|
59
60
|
// (whitespace-token semantics, matching [clay~=token]) or a legacy bare attribute.
|
|
60
|
-
|
|
61
|
-
const clay = el.getAttribute?.("clay");
|
|
62
|
-
if (clay && clay.split(/\s+/).includes(token)) return true; // whitespace-token semantics, matches [clay~=…]
|
|
63
|
-
return !!el.hasAttribute?.(token); // legacy bare attribute
|
|
64
|
-
}
|
|
61
|
+
const hasRegionToken = hasPolicyToken;
|
|
65
62
|
|
|
66
63
|
/**
|
|
67
64
|
* Add a canonical region token to an element's `clay` attribute, preserving any
|
|
@@ -79,7 +76,7 @@ export function addRegionToken(el, token) {
|
|
|
79
76
|
}
|
|
80
77
|
|
|
81
78
|
// Serializer selectors (recognize the clay-token spelling FIRST, then new + legacy bare).
|
|
82
|
-
export const STRIP_FROM_SAVE = '
|
|
79
|
+
export const STRIP_FROM_SAVE = `${capabilitySelector('save')}, [save-remove]`;
|
|
83
80
|
export const FREEZE_SELECTOR = '[clay~="freeze"], [freeze], [save-freeze]';
|
|
84
81
|
// forComparison additionally strips every region whose autosave-trigger is off,
|
|
85
82
|
// so their churn never marks the page dirty — including the no-watch /
|
|
@@ -117,7 +114,8 @@ export const STRIP_FROM_DIRTY_CHECK =
|
|
|
117
114
|
// dirty-comparison) in snapshot.js. `no-snapshot` is the consistent alias for the
|
|
118
115
|
// original `snapshot-remove`; hyper-morph treats both as sync-ignored so a
|
|
119
116
|
// live-sync receiver keeps its own local copy instead of deleting it.
|
|
120
|
-
export const SNAPSHOT_REMOVE_SELECTOR = '
|
|
117
|
+
export const SNAPSHOT_REMOVE_SELECTOR = `${capabilitySelector('snapshot')}, [snapshot-remove]`;
|
|
118
|
+
export const NO_DATA_SELECTOR = capabilitySelector('data');
|
|
121
119
|
|
|
122
120
|
// Ancestor-aware, because the strip removes a marked element together with its
|
|
123
121
|
// whole subtree: a child of a no-snapshot region is just as absent from every
|
|
@@ -128,6 +126,10 @@ export function isSnapshotRemoved(node) {
|
|
|
128
126
|
return !!(element && element.closest && element.closest(SNAPSHOT_REMOVE_SELECTOR));
|
|
129
127
|
}
|
|
130
128
|
|
|
129
|
+
export function isDataExcluded(node) {
|
|
130
|
+
return hasCapability(node, 'data');
|
|
131
|
+
}
|
|
132
|
+
|
|
131
133
|
const PERSIST_RANK = { full: 0, frozen: 1, none: 2 };
|
|
132
134
|
const RANK_PERSIST = ['full', 'frozen', 'none'];
|
|
133
135
|
|
|
@@ -158,11 +160,11 @@ export function resolveRegionPolicy(node) {
|
|
|
158
160
|
while (element && element.nodeType === 1) {
|
|
159
161
|
if (element.hasAttribute) {
|
|
160
162
|
// new naked attributes
|
|
161
|
-
if (
|
|
163
|
+
if (expandsTo(element, 'no-watch')) watched = false;
|
|
162
164
|
if (hasRegionToken(element, 'no-trigger-autosave')) autosaveOff = true;
|
|
163
165
|
if (hasRegionToken(element, 'no-dirty')) { autosaveOff = true; dirtyOff = true; }
|
|
164
|
-
if (
|
|
165
|
-
if (
|
|
166
|
+
if (expandsTo(element, 'no-undo')) undoable = false;
|
|
167
|
+
if (expandsTo(element, 'no-save')) persistRank = Math.max(persistRank, PERSIST_RANK.none);
|
|
166
168
|
if (hasRegionToken(element, 'freeze')) persistRank = Math.max(persistRank, PERSIST_RANK.frozen);
|
|
167
169
|
// legacy markers -> bundles
|
|
168
170
|
if (hasRegionToken(element, 'mutations-ignore')) watched = false;
|
|
@@ -209,7 +211,7 @@ export function isInert(node) {
|
|
|
209
211
|
if (element && element.closest && element.closest(EXTENSION_NODE_SELECTOR)) return true;
|
|
210
212
|
while (element && element.nodeType === 1) {
|
|
211
213
|
if (element.hasAttribute &&
|
|
212
|
-
(
|
|
214
|
+
(expandsTo(element, 'no-watch') || hasRegionToken(element, 'mutations-ignore'))) {
|
|
213
215
|
return true;
|
|
214
216
|
}
|
|
215
217
|
element = element.parentElement;
|
|
@@ -283,6 +285,7 @@ export const regionShape = {
|
|
|
283
285
|
resolveRegionPolicy,
|
|
284
286
|
isInert,
|
|
285
287
|
isSnapshotRemoved,
|
|
288
|
+
isDataExcluded,
|
|
286
289
|
skipForPolicy,
|
|
287
290
|
strictestPolicy,
|
|
288
291
|
addRegionToken,
|
|
@@ -297,6 +300,7 @@ export const regionShape = {
|
|
|
297
300
|
NO_TRIGGER_AUTOSAVE_SELECTOR,
|
|
298
301
|
NO_DIRTY_SELECTOR,
|
|
299
302
|
SNAPSHOT_REMOVE_SELECTOR,
|
|
303
|
+
NO_DATA_SELECTOR,
|
|
300
304
|
FREEZE_SELECTOR,
|
|
301
305
|
|
|
302
306
|
// Nested camelCase: what clay.internals.region has always published.
|
|
@@ -307,6 +311,7 @@ export const regionShape = {
|
|
|
307
311
|
noTriggerAutosave: NO_TRIGGER_AUTOSAVE_SELECTOR,
|
|
308
312
|
noDirty: NO_DIRTY_SELECTOR,
|
|
309
313
|
snapshotRemove: SNAPSHOT_REMOVE_SELECTOR,
|
|
314
|
+
noData: NO_DATA_SELECTOR,
|
|
310
315
|
freeze: FREEZE_SELECTOR,
|
|
311
316
|
},
|
|
312
317
|
};
|
package/src/lib/root-attrs.js
CHANGED
|
@@ -8,31 +8,70 @@
|
|
|
8
8
|
* peer's copy of them must never be applied.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
// The
|
|
12
|
-
// and makes the host strip it before writing, so it never reaches
|
|
13
|
-
// bounds only the save path, and §10 fans a snapshot out to other editors'
|
|
11
|
+
// The save token, in the order a reader tries the two spellings. Spec §9 bounds it to
|
|
12
|
+
// per-file and per-tab and makes the host strip it before writing, so it never reaches
|
|
13
|
+
// disk. But §9 bounds only the save path, and §10 fans a snapshot out to other editors'
|
|
14
14
|
// browsers, which is the hole this module closes.
|
|
15
15
|
//
|
|
16
16
|
// Save tokens ONLY. host-attrs.js returns the first name it finds here and puts it
|
|
17
17
|
// straight into the save URL, so anything added to this list becomes a credential
|
|
18
|
-
// in a path. Durable identities go in
|
|
19
|
-
|
|
18
|
+
// in a path. Durable identities go in HOST_IDENTITY_ATTRS, never in this one.
|
|
19
|
+
//
|
|
20
|
+
// One name, deliberately. §9 names exactly one save-token attribute.
|
|
21
|
+
//
|
|
22
|
+
// This is a knowing break, not an oversight. htmlclay is the only host that mints a save
|
|
23
|
+
// token at all (hyperclay and Hyperclay Local authorize by cookie and inject nothing),
|
|
24
|
+
// it serves both spellings only from 1.9.0, and a document loads this library from a
|
|
25
|
+
// rolling URL. So a document served by an htmlclay at or below 1.8.0 keeps edit mode,
|
|
26
|
+
// because that host also sets the isAdminOfCurrentResource cookie, and every save 404s,
|
|
27
|
+
// because that host registers only `POST /_/save/{token}`.
|
|
28
|
+
//
|
|
29
|
+
// Taken on purpose, and taken now because the cost only grows: htmlclay 1.8.0 is days
|
|
30
|
+
// old, the product is pre-launch, and the alternative is carrying a second credential
|
|
31
|
+
// name in the save path permanently, since "wait until the old hosts are gone" is a
|
|
32
|
+
// condition nobody ever measures. host-attrs.js says so plainly in the console when it
|
|
33
|
+
// finds the old name, so the failure names its own cause and its own fix.
|
|
34
|
+
//
|
|
35
|
+
// ⚠️ RELEASE ORDER: htmlclay 1.9.0, which injects both names, must publish BEFORE this.
|
|
36
|
+
export const SAVE_TOKEN_ATTRS = ["savetoken"];
|
|
37
|
+
|
|
38
|
+
// The pre-rename spelling. Never read as a credential, which is the point of it being
|
|
39
|
+
// here rather than above, and never removed from HOST_TOKEN_ATTRS below, which is a
|
|
40
|
+
// different job: a name a host may inject has to go on being stripped before a save and
|
|
41
|
+
// kept out of a peer's morph, or a live token gets written into a document or handed to
|
|
42
|
+
// another tab. htmlclay injects it forever, because documents frozen against an older
|
|
43
|
+
// client read it, so this name never leaves this file.
|
|
44
|
+
export const LEGACY_SAVE_TOKEN_ATTRS = ["htmlclaytoken"];
|
|
20
45
|
|
|
21
|
-
// Host-injected, but NOT credentials.
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
46
|
+
// Host-injected, but NOT credentials. This is htmlclay's durable file identity,
|
|
47
|
+
// stamped on every serve and absent from disk bytes, so it rides in the morph
|
|
48
|
+
// protection below for the same reason a token does: a morph of raw disk content
|
|
49
|
+
// would otherwise strip this tab's copy, and a peer's copy must never be applied.
|
|
50
|
+
//
|
|
51
|
+
// Two spellings, permanently, mirroring the token list above. htmlclay serves
|
|
52
|
+
// `documentid` and reads either, but a document saved before that rename holds
|
|
53
|
+
// `htmlclayid` on disk forever, and this list is what a morph consults. Knowing
|
|
54
|
+
// only the current name would leave every pre-rename file's identity unprotected,
|
|
55
|
+
// which is the failure this list exists to prevent.
|
|
26
56
|
//
|
|
27
57
|
// It was previously in the token list, where saveToken() returned it whenever a
|
|
28
58
|
// host minted no token of its own. That is reachable: htmlclay stamps the id on
|
|
29
59
|
// every serve but injects a token only for top-level document loads, and its save
|
|
30
60
|
// route strips only the token, so the id reaches disk. Any such file hosted
|
|
31
61
|
// somewhere tokenless made this library POST to /_/save/{id} with no cookie and
|
|
32
|
-
// hand edit mode to every visitor.
|
|
33
|
-
|
|
62
|
+
// hand edit mode to every visitor. Splitting this list off from the token list is
|
|
63
|
+
// what fixed that, and the split holds whatever token spellings are read above.
|
|
64
|
+
export const HOST_IDENTITY_ATTRS = ["documentid", "htmlclayid"];
|
|
34
65
|
|
|
35
|
-
|
|
66
|
+
// What a host may have injected, and therefore what has to be stripped before a save
|
|
67
|
+
// and kept out of an incoming morph. Wider than what is READ, on purpose: the old token
|
|
68
|
+
// spelling is still injected by every htmlclay, so it still has to be stripped, whether
|
|
69
|
+
// or not this library will accept it as a credential.
|
|
70
|
+
export const HOST_TOKEN_ATTRS = [
|
|
71
|
+
...SAVE_TOKEN_ATTRS,
|
|
72
|
+
...LEGACY_SAVE_TOKEN_ATTRS,
|
|
73
|
+
...HOST_IDENTITY_ATTRS,
|
|
74
|
+
];
|
|
36
75
|
|
|
37
76
|
// This library's own root state, and this tab's UI truth.
|
|
38
77
|
export const ROOT_LIBRARY_ATTRS = ["savestatus", "editmode", "pageowner"];
|