@panphora/clayjs 0.6.0 → 0.7.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/LICENSE +12 -17
- package/README.md +33 -15
- package/THIRD-PARTY-NOTICES.md +40 -0
- package/_headers +12 -0
- package/clay-events.js +9 -0
- package/package.json +3 -3
- package/src/attrs/onaftersave.js +23 -9
- package/src/attrs/refetch-on-save.js +38 -18
- package/src/core/autosave.js +11 -4
- package/src/core/host-attrs.js +1 -16
- package/src/core/host-meta.js +107 -0
- package/src/core/save-core.js +26 -27
- package/src/core/save.js +119 -28
- package/src/core/snapshot.js +121 -26
- package/src/core/unsaved-warning.js +11 -7
- package/src/internals/index.js +3 -26
- package/src/lib/authored-url.js +96 -0
- package/src/lib/cache-bust.js +16 -12
- package/src/lib/dirty-gate.js +28 -10
- package/src/lib/mutation.js +5 -3
- package/src/lib/region-policy.js +98 -18
- package/src/lib/root-attrs.js +0 -5
- package/src/lib/user-gesture.js +33 -1
- package/src/loader-logic.js +29 -1
- package/src/loader.js +4 -0
- package/src/plugins/demo.js +14 -8
- package/src/plugins/upload.js +185 -0
- package/src/sync/live-sync.js +157 -12
- package/src/sync/splice-merge.js +21 -7
- package/src/vendor/hypercms.vendor.js +53 -11
- package/src/vendor/quickcrop.vendor.js +401 -0
- package/NOTICE +0 -19
package/LICENSE
CHANGED
|
@@ -1,21 +1,16 @@
|
|
|
1
|
-
MIT
|
|
1
|
+
MIT No Attribution
|
|
2
2
|
|
|
3
3
|
Copyright (c) 2026 David Miranda
|
|
4
4
|
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this
|
|
6
|
+
software and associated documentation files (the "Software"), to deal in the Software
|
|
7
|
+
without restriction, including without limitation the rights to use, copy, modify,
|
|
8
|
+
merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
|
|
9
|
+
permit persons to whom the Software is furnished to do so.
|
|
11
10
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
11
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
|
12
|
+
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
|
13
|
+
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
14
|
+
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
|
|
15
|
+
CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
|
|
16
|
+
OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# clayjs
|
|
1
|
+
# clayjs™
|
|
2
2
|
|
|
3
3
|
**Site, tutorial, and docs: [clayjs.com](https://clayjs.com)** · hosted platform: [hyperclay.com](https://hyperclay.com)
|
|
4
4
|
|
|
@@ -19,8 +19,10 @@ query params on the script URL:
|
|
|
19
19
|
<script src="/clay.js?plugins=sync,cms&exclude=indicator"></script>
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
- `?plugins=` — add optional plugins: `sync`, `cms`
|
|
23
|
-
|
|
22
|
+
- `?plugins=` — add optional plugins: `sync`, `cms`, `undo`, `sortable`, `indicator`, `quickcrop`, `upload`, `wire`, `demo`.
|
|
23
|
+
Only `richclay` loads by default, and only in edit mode. `cms` brings `quickcrop` with it, because
|
|
24
|
+
the CMS uses it for `data-hcms-crop` image fields.
|
|
25
|
+
- `?exclude=` — drop a plugin that would otherwise load (a default, or one another plugin pulled in).
|
|
24
26
|
- `?editmode=false` — force view mode (URL param wins over everything below).
|
|
25
27
|
|
|
26
28
|
## Host attributes
|
|
@@ -33,10 +35,6 @@ never reach the file on disk.
|
|
|
33
35
|
`/_/save/{token}` and sends no cookies, because the token is the credential.
|
|
34
36
|
A token also implies edit mode, which is the only such signal a sandboxed
|
|
35
37
|
document can see. `htmlclaytoken` is the older spelling of the same thing.
|
|
36
|
-
- `clay-save-transport="desktop-json-v1"` — send the save as
|
|
37
|
-
`{content, snapshotHtml, userDriven}` JSON instead of plain text. Only declare
|
|
38
|
-
it on a host whose save lane reads that envelope.
|
|
39
|
-
|
|
40
38
|
Edit mode is decided in this order: the `?editmode` param, then
|
|
41
39
|
`window.clayEditMode`, then a save token, then the platform's owner cookie.
|
|
42
40
|
|
|
@@ -52,8 +50,8 @@ clay.save();
|
|
|
52
50
|
|
|
53
51
|
Edit mode exposes `clay.save()` (+ `clay.save.force()`), `clay.getHTML()`, `clay.addDocumentTransform(fn)`,
|
|
54
52
|
`clay.onSnapshot(fn)`, `clay.toggleEditMode()`, `clay.isEditMode`, `clay.isOwner`, `clay.Mutation`,
|
|
55
|
-
`clay.region`, `clay.cacheBust(el)`, plus `clay.undo` / `clay.cms` / `clay.morph` / `clay.RichClay`
|
|
56
|
-
when those plugins load. View mode keeps only the always-available members (`toggleEditMode`, `isEditMode`, `isOwner`,
|
|
53
|
+
`clay.region`, `clay.cacheBust(el)`, plus `clay.undo` / `clay.cms` / `clay.morph` / `clay.RichClay` /
|
|
54
|
+
`clay.quickcrop` when those plugins load. View mode keeps only the always-available members (`toggleEditMode`, `isEditMode`, `isOwner`,
|
|
57
55
|
`Mutation`, `region`, `ready`); edit-only members are simply absent.
|
|
58
56
|
|
|
59
57
|
## API
|
|
@@ -70,12 +68,18 @@ The contract starts at **0.3.0**: no name below changes without a major version.
|
|
|
70
68
|
|
|
71
69
|
**`clay.js`** — `ready`, `save()`, `save.force()`, `getHTML()`, `addDocumentTransform(fn)`, `onSnapshot(fn)`,
|
|
72
70
|
`toggleEditMode()`, `isEditMode`, `isOwner`, `Mutation`, `region`, `cacheBust(el)`, plus `undo` / `cms` / `morph` /
|
|
73
|
-
`RichClay` when those plugins load. View mode keeps only the always-available members, as above.
|
|
71
|
+
`RichClay` / `quickcrop` when those plugins load. View mode keeps only the always-available members, as above.
|
|
74
72
|
|
|
75
73
|
`addDocumentTransform(fn)` runs your callback over a detached clone whenever the page
|
|
76
74
|
prepares to save AND whenever it checks whether anything changed. Keep it pure and
|
|
77
75
|
repeatable: it is a transform, not a "a save is happening" event.
|
|
78
76
|
|
|
77
|
+
`quickcrop(file, options)` opens a crop modal over a File or Blob and resolves
|
|
78
|
+
`{ blob, dataURL, width, height }`, or `null` if the person cancels. `aspect` (a number,
|
|
79
|
+
or null for freeform), `type`, `quality`, `maxWidth`/`maxHeight` and `labels.confirm` are
|
|
80
|
+
the options worth knowing. Its modal and injected stylesheet carry `save-remove`, so they
|
|
81
|
+
never reach the saved file.
|
|
82
|
+
|
|
79
83
|
`region` is the region-policy model: `resolveRegionPolicy`, `isInert`, `skipForPolicy`,
|
|
80
84
|
`strictestPolicy`, the `PERSIST` and `REGION_ATTRS` token constants, and the
|
|
81
85
|
`STRIP_FROM_SAVE`, `FREEZE_SELECTOR` and `STRIP_FROM_COMPARISON` selectors.
|
|
@@ -103,8 +107,9 @@ lifecycle.
|
|
|
103
107
|
satellite with no core loaded.
|
|
104
108
|
- `region.addRegionToken(el, token)`, `region.resolveRegionPolicy(node)`, `region.isInert(node)`,
|
|
105
109
|
`region.isSnapshotRemoved(el)`, `region.PERSIST`, `region.REGION_ATTRS`, and
|
|
106
|
-
`region.selectors.stripFromSave` / `.stripFromComparison` / `.
|
|
107
|
-
|
|
110
|
+
`region.selectors.stripFromSave` / `.stripFromComparison` / `.stripFromDirtyCheck` /
|
|
111
|
+
`.noTriggerAutosave` / `.noDirty` / `.snapshotRemove` / `.freeze` — write your own attribute
|
|
112
|
+
without hardcoding our selectors.
|
|
108
113
|
- `save.saveHtml(html, cb, opts)`, `save.replacePageWith(url, cb)`, `save.isSaveInProgress()` — the save
|
|
109
114
|
lane under `clay.save`. **`saveHtml` writes the bytes you hand it straight to the file**, bypassing the
|
|
110
115
|
snapshot pipeline entirely; check `isSaveInProgress()` first.
|
|
@@ -118,8 +123,15 @@ attribute:
|
|
|
118
123
|
<div clay="no-save no-snapshot freeze">…</div>
|
|
119
124
|
```
|
|
120
125
|
|
|
121
|
-
Tokens: `no-save`, `no-snapshot`, `no-trigger-autosave`, `no-watch`, `no-undo`,
|
|
122
|
-
Add `autosave` to `<html>` to save automatically on change.
|
|
126
|
+
Tokens: `no-save`, `no-snapshot`, `no-trigger-autosave`, `no-dirty`, `no-watch`, `no-undo`,
|
|
127
|
+
`freeze`. Add `autosave` to `<html>` to save automatically on change.
|
|
128
|
+
|
|
129
|
+
`no-trigger-autosave` and `no-dirty` are the pair worth getting right. Both are saved in full and
|
|
130
|
+
neither starts an autosave. The difference is whether their content is *work*: a
|
|
131
|
+
`no-trigger-autosave` region holds real edits waiting for a manual save, so it warns you on close
|
|
132
|
+
and live sync will not overwrite it, while a `no-dirty` region renders itself from something else,
|
|
133
|
+
so it never warns and an incoming sync frame may replace it. Use `no-dirty` for filter bars,
|
|
134
|
+
projections and drag previews; use `no-trigger-autosave` for a heavy editor you save by hand.
|
|
123
135
|
|
|
124
136
|
## Develop
|
|
125
137
|
|
|
@@ -143,7 +155,7 @@ Never edit `public/` by hand, and never copy a file into it. Its contents are
|
|
|
143
155
|
**derived**:
|
|
144
156
|
|
|
145
157
|
```
|
|
146
|
-
public/ = package.json "files" (minus
|
|
158
|
+
public/ = package.json "files" (minus THIRD-PARTY-NOTICES.md, which nothing requests)
|
|
147
159
|
+ website/* (flattened to the root, so /docs.html works)
|
|
148
160
|
```
|
|
149
161
|
|
|
@@ -153,3 +165,9 @@ reach npm and miss the site. Before this script existed the mirror was hand-copi
|
|
|
153
165
|
and it drifted exactly that way: `src/core/host-attrs.js` never made it across, and
|
|
154
166
|
since `loader.js` imports `is-edit-mode.js`, which imports it, the deployed
|
|
155
167
|
`clay.js` could not boot at all.
|
|
168
|
+
|
|
169
|
+
## License
|
|
170
|
+
|
|
171
|
+
Our code is MIT-0 (MIT No Attribution): use it, remix it, ship it, no attribution
|
|
172
|
+
needed. Vendored third-party files keep their original permissive licenses; see
|
|
173
|
+
[THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md).
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Third-party notices
|
|
2
|
+
|
|
3
|
+
Our own code in this repository is licensed under MIT-0 (see LICENSE): use it,
|
|
4
|
+
remix it, ship it, no attribution needed. The components below are third-party,
|
|
5
|
+
or carry third-party code, and keep their original licenses.
|
|
6
|
+
|
|
7
|
+
## SortableJS 1.15.6 (MIT)
|
|
8
|
+
|
|
9
|
+
- File: src/vendor/Sortable.vendor.js
|
|
10
|
+
- Copyright (c) SortableJS contributors
|
|
11
|
+
- https://github.com/SortableJS/Sortable
|
|
12
|
+
|
|
13
|
+
## Squire 2.4.8 (MIT)
|
|
14
|
+
|
|
15
|
+
- Carried inside src/vendor/richclay.vendor.js and website/vendor/richclay.min.js
|
|
16
|
+
(richclay bundles Squire)
|
|
17
|
+
- Copyright (c) 2011-2023 by Neil Jenkins
|
|
18
|
+
- https://github.com/fastmail/Squire
|
|
19
|
+
|
|
20
|
+
## DOMPurify 3.4.11 (Apache-2.0 OR MPL-2.0)
|
|
21
|
+
|
|
22
|
+
- Carried inside src/vendor/richclay.vendor.js and website/vendor/richclay.min.js
|
|
23
|
+
(richclay bundles DOMPurify)
|
|
24
|
+
- (c) Cure53 and other contributors
|
|
25
|
+
- https://github.com/cure53/DOMPurify
|
|
26
|
+
|
|
27
|
+
## MicroModal (MIT)
|
|
28
|
+
|
|
29
|
+
- The modal component in src/ui/modal.js derives its structure and class naming
|
|
30
|
+
from MicroModal.
|
|
31
|
+
- Copyright (c) 2017 Indrashish Ghosh
|
|
32
|
+
- https://github.com/ghosh/Micromodal
|
|
33
|
+
|
|
34
|
+
## First-party vendored modules
|
|
35
|
+
|
|
36
|
+
The remaining files in src/vendor/ (hypercms, hyper-morph, hyper-undo, quickcrop,
|
|
37
|
+
control-serialize) and the generated sap.js and clay-data.js are our own sibling
|
|
38
|
+
libraries. clayjs also derives from our own hyperclayjs library (MIT); as the
|
|
39
|
+
copyright holder of those portions we license them here under MIT-0 with the rest
|
|
40
|
+
of our code.
|
package/_headers
CHANGED
|
@@ -1,2 +1,14 @@
|
|
|
1
|
+
# Cache-Control is declared once, on /*. Two matching blocks do not override,
|
|
2
|
+
# they join: repeating this line under /src/* served every module a doubled
|
|
3
|
+
# max-age, which a cache is free to treat as stale. /src/* inherits it and adds
|
|
4
|
+
# only CORS.
|
|
5
|
+
#
|
|
6
|
+
# No stale-while-revalidate: clay.js imports ~49 unversioned module URLs, each
|
|
7
|
+
# with its own freshness clock, and a view-mode load fetches a different subset
|
|
8
|
+
# than an edit-mode load. A stale window lets one browser hold half the graph
|
|
9
|
+
# from before a deploy and half from after, which fails silently.
|
|
10
|
+
/*
|
|
11
|
+
Cache-Control: public, max-age=600
|
|
12
|
+
|
|
1
13
|
/src/*
|
|
2
14
|
Access-Control-Allow-Origin: *
|
package/clay-events.js
CHANGED
|
@@ -20,7 +20,16 @@
|
|
|
20
20
|
});
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
// [onrender] sweeps the whole document the moment that import resolves, and those
|
|
24
|
+
// handlers routinely reach for clay-dom's element helpers (this.val, this.exec,
|
|
25
|
+
// this.nearest). The two satellites are independent dynamic imports with nothing
|
|
26
|
+
// ordering them, so on a cold load events can win and every handler throws
|
|
27
|
+
// "Cannot read properties of undefined". By DOM ready every satellite tag has run,
|
|
28
|
+
// so clay.loaded.dom is present exactly when clay-dom.js is on the page: wait for it
|
|
29
|
+
// then, proceed when it is absent. A clay-dom that failed to load must not take
|
|
30
|
+
// events down with it, so its rejection is swallowed here rather than chained.
|
|
23
31
|
clay.loaded.events = domReady()
|
|
32
|
+
.then(function () { return clay.loaded.dom && clay.loaded.dom.catch(function () {}); })
|
|
24
33
|
.then(function () { return import(base + "/src/events/index.js"); })
|
|
25
34
|
.catch(function (err) { console.error("clay-events failed to load:", err); throw err; });
|
|
26
35
|
// Mark handled: see clay-ui.js.
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@panphora/clayjs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "clayjs: malleable HTML files. Save lifecycle for self-saving HTML.",
|
|
6
|
-
"license": "MIT",
|
|
6
|
+
"license": "MIT-0",
|
|
7
7
|
"homepage": "https://clayjs.com",
|
|
8
8
|
"repository": {
|
|
9
9
|
"type": "git",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"sap.js",
|
|
22
22
|
"clay-data.js",
|
|
23
23
|
"_headers",
|
|
24
|
-
"
|
|
24
|
+
"THIRD-PARTY-NOTICES.md",
|
|
25
25
|
"src/"
|
|
26
26
|
],
|
|
27
27
|
"publishConfig": {
|
package/src/attrs/onaftersave.js
CHANGED
|
@@ -5,16 +5,30 @@
|
|
|
5
5
|
* Only fires on 'clay:save-saved' events (not on error/offline).
|
|
6
6
|
*
|
|
7
7
|
* Usage:
|
|
8
|
-
* <span clay="no-
|
|
9
|
-
* <link href="styles.css" onaftersave="cacheBust(this)">
|
|
8
|
+
* <span clay="no-save" onaftersave="this.innerText = event.detail.msg"></span>
|
|
9
|
+
* <link href="styles.css" onaftersave="clay.cacheBust(this)">
|
|
10
10
|
*
|
|
11
|
-
* MARK WHAT YOU MUTATE `no-
|
|
12
|
-
* These handlers run after the save baseline has been taken, so anything they
|
|
13
|
-
* to the live DOM reads as a change the
|
|
14
|
-
* dirty:
|
|
15
|
-
* the element
|
|
16
|
-
*
|
|
17
|
-
*
|
|
11
|
+
* MARK WHAT YOU MUTATE `no-save` (or `freeze`).
|
|
12
|
+
* These handlers run after the save baseline has been taken, so anything they
|
|
13
|
+
* write to the live DOM reads as a change the person made, and the page stays
|
|
14
|
+
* dirty: the close-tab warning never clears, and on an autosave page it loops.
|
|
15
|
+
* `no-save` keeps the element out of the file entirely, which is what you want
|
|
16
|
+
* for a status chip or any other runtime-only chrome. `freeze` is the choice
|
|
17
|
+
* when the element must exist in the saved file, but as authored rather than as
|
|
18
|
+
* the handler left it.
|
|
19
|
+
*
|
|
20
|
+
* CHANGED IN 0.7: `no-trigger-autosave` is no longer enough on its own.
|
|
21
|
+
* It stops the handler's write from STARTING a save, and it still does that. It
|
|
22
|
+
* no longer hides the write from an explicit save or from the close warning,
|
|
23
|
+
* because those two now deliberately see batching regions — an edit you make
|
|
24
|
+
* inside one is real work that nothing else is going to write. A handler whose
|
|
25
|
+
* output is marked only `no-trigger-autosave` will leave the page reporting
|
|
26
|
+
* unsaved changes after every save. Change those markers to `no-save`, or to
|
|
27
|
+
* `freeze` if the element belongs in the file.
|
|
28
|
+
*
|
|
29
|
+
* `clay.cacheBust()` and `[refetch-on-save]` need no marker at all: they
|
|
30
|
+
* remember the authored URL and restore it on every snapshot (authored-url.js).
|
|
31
|
+
* A handler that mutates some OTHER element has to mark that element itself.
|
|
18
32
|
*
|
|
19
33
|
* (The alternative — re-reading the whole live DOM after handlers run — is what
|
|
20
34
|
* clayjs used to do, and it silently discarded anything typed during a save.)
|
|
@@ -1,28 +1,48 @@
|
|
|
1
|
-
import
|
|
1
|
+
import Mutation from '../lib/mutation.js';
|
|
2
|
+
import { urlAttrFor, writeRuntimeUrl, markSuperseded, isSuperseded } from '../lib/authored-url.js';
|
|
3
|
+
|
|
4
|
+
/** Remove an element without letting the removal read as a user edit. */
|
|
5
|
+
function removeQuietly(el) {
|
|
6
|
+
if (!el.parentNode) return;
|
|
7
|
+
Mutation.pause();
|
|
8
|
+
try {
|
|
9
|
+
el.remove();
|
|
10
|
+
} finally {
|
|
11
|
+
Mutation.resume();
|
|
12
|
+
}
|
|
13
|
+
}
|
|
2
14
|
|
|
3
15
|
function swapElement(el) {
|
|
4
|
-
const attr =
|
|
16
|
+
const attr = urlAttrFor(el);
|
|
5
17
|
if (!attr) return;
|
|
6
18
|
|
|
7
|
-
|
|
8
|
-
|
|
19
|
+
// A second save landing inside the two-second overlap would otherwise swap the
|
|
20
|
+
// element that is already on its way out, and clone the clone.
|
|
21
|
+
if (isSuperseded(el)) return;
|
|
22
|
+
|
|
23
|
+
const url = new URL(el.getAttribute(attr), location.href);
|
|
9
24
|
url.searchParams.set('v', Date.now());
|
|
10
25
|
const isSameOrigin = url.origin === location.origin;
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
26
|
+
const runtimeValue = isSameOrigin ? url.pathname + url.search : url.href;
|
|
27
|
+
|
|
28
|
+
Mutation.pause();
|
|
29
|
+
try {
|
|
30
|
+
const newEl = document.createElement(el.tagName);
|
|
31
|
+
for (const { name, value } of el.attributes) {
|
|
32
|
+
newEl.setAttribute(name, value);
|
|
33
|
+
}
|
|
34
|
+
// Record-if-absent carries over any authored URL the copied attributes
|
|
35
|
+
// already held, so cacheBust and refetch on one element keep the original.
|
|
36
|
+
writeRuntimeUrl(newEl, attr, runtimeValue);
|
|
37
|
+
|
|
38
|
+
markSuperseded(el);
|
|
39
|
+
el.insertAdjacentElement('afterend', newEl);
|
|
40
|
+
|
|
41
|
+
newEl.onload = () => removeQuietly(el);
|
|
42
|
+
setTimeout(() => removeQuietly(el), 2000);
|
|
43
|
+
} finally {
|
|
44
|
+
Mutation.resume();
|
|
15
45
|
}
|
|
16
|
-
newEl.setAttribute(attr, isSameOrigin ? url.pathname + url.search : url.href);
|
|
17
|
-
addRegionToken(newEl, "no-trigger-autosave");
|
|
18
|
-
addRegionToken(newEl, "no-undo");
|
|
19
|
-
|
|
20
|
-
el.insertAdjacentElement('afterend', newEl);
|
|
21
|
-
|
|
22
|
-
newEl.onload = () => el.remove();
|
|
23
|
-
setTimeout(() => {
|
|
24
|
-
if (el.parentNode) el.remove();
|
|
25
|
-
}, 2000);
|
|
26
46
|
}
|
|
27
47
|
|
|
28
48
|
function init() {
|
package/src/core/autosave.js
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
import Mutation from "../lib/mutation.js";
|
|
14
14
|
import { isEditMode } from "./is-edit-mode.js";
|
|
15
15
|
import { savePageThrottled } from "./save.js";
|
|
16
|
-
import {
|
|
16
|
+
import { markUserDriven } from "../lib/user-gesture.js";
|
|
17
|
+
import { resolveRegionPolicy, skipForPolicy } from "../lib/region-policy.js";
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Initialize auto-save on DOM changes
|
|
@@ -48,8 +49,13 @@ function initSaveOnPersistInput() {
|
|
|
48
49
|
document.addEventListener('input', (e) => {
|
|
49
50
|
if (!e.target.closest('[persist]')) return;
|
|
50
51
|
// A trusted input on a [persist] field is itself a user-driven change the
|
|
51
|
-
// Mutation hub can't see (form values aren't DOM mutations). Attribute it
|
|
52
|
-
|
|
52
|
+
// Mutation hub can't see (form values aren't DOM mutations). Attribute it,
|
|
53
|
+
// but only when the field is inside the dirty domain: a [persist] control in
|
|
54
|
+
// a no-save or freeze region contributes nothing to the saved bytes, so
|
|
55
|
+
// typing in one must not arm provenance for a later background write.
|
|
56
|
+
if (e.isTrusted && !skipForPolicy(resolveRegionPolicy(e.target), 'dirty')) {
|
|
57
|
+
markUserDriven();
|
|
58
|
+
}
|
|
53
59
|
clearTimeout(inputSaveTimer);
|
|
54
60
|
inputSaveTimer = setTimeout(savePageThrottled, 1500);
|
|
55
61
|
}, true);
|
|
@@ -58,7 +64,8 @@ function initSaveOnPersistInput() {
|
|
|
58
64
|
function init() {
|
|
59
65
|
if (!document.documentElement.hasAttribute("autosave")) return;
|
|
60
66
|
if (!isEditMode) return;
|
|
61
|
-
initUserGesture
|
|
67
|
+
// initUserGesture moved to save.js's init: gesture provenance belongs to every
|
|
68
|
+
// editable page, not only the ones with <html autosave>.
|
|
62
69
|
initSavePageOnChange();
|
|
63
70
|
initSaveOnPersistInput();
|
|
64
71
|
}
|
package/src/core/host-attrs.js
CHANGED
|
@@ -7,13 +7,7 @@
|
|
|
7
7
|
* cannot drift between the edit-mode ladder and the save lane.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { HOST_TOKEN_ATTRS
|
|
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";
|
|
10
|
+
import { HOST_TOKEN_ATTRS } from "../lib/root-attrs.js";
|
|
17
11
|
|
|
18
12
|
/**
|
|
19
13
|
* The per-document save token this response carries, or null.
|
|
@@ -35,12 +29,3 @@ export function saveToken() {
|
|
|
35
29
|
export function hasSaveToken() {
|
|
36
30
|
return saveToken() !== null;
|
|
37
31
|
}
|
|
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
|
-
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* host-meta.js — what this host can do (spec §5).
|
|
3
|
+
*
|
|
4
|
+
* The first discovery client clayjs has had. Not fetched at boot: the first
|
|
5
|
+
* caller is the first file pick, so a page that never uploads never asks.
|
|
6
|
+
*
|
|
7
|
+
* The spec's own rule about what counts as an answer is deliberately strict on
|
|
8
|
+
* the way in and forgiving on the way out. Only a 2xx carrying a JSON object with
|
|
9
|
+
* a numeric `spec` is a capability document. A 404, an HTML error page from a
|
|
10
|
+
* proxy, a redirect, a body that will not parse: all of them mean "bare core
|
|
11
|
+
* host", and a bare core host is fully conforming. Discovery failing must never
|
|
12
|
+
* cost a person their save, so this never throws and never rejects.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { saveToken } from "./host-attrs.js";
|
|
16
|
+
|
|
17
|
+
const META_PATH = "/_/meta";
|
|
18
|
+
const META_TIMEOUT_MS = 6000;
|
|
19
|
+
|
|
20
|
+
// The bare-core-host answer, which is also every failure's answer.
|
|
21
|
+
function bareHost() {
|
|
22
|
+
return { spec: null, extensions: [], document: null };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let inFlight = null;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Ask the host what it supports, once per page.
|
|
29
|
+
*
|
|
30
|
+
* Memoizes the PROMISE rather than the value, so two pickers opened together
|
|
31
|
+
* make one request instead of two and the second waits on the first.
|
|
32
|
+
*
|
|
33
|
+
* @returns {Promise<{spec: ?number, extensions: string[], document: ?Object}>}
|
|
34
|
+
*/
|
|
35
|
+
export function hostMeta() {
|
|
36
|
+
if (!inFlight) inFlight = fetchMeta().catch(bareHost);
|
|
37
|
+
return inFlight;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* True when the host announced this capability by name.
|
|
42
|
+
*
|
|
43
|
+
* §5: a client must not infer a host's capabilities any other way. Not from the
|
|
44
|
+
* hostname, not from the port, not by probing a route to see whether it answers.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} name
|
|
47
|
+
* @returns {Promise<boolean>}
|
|
48
|
+
*/
|
|
49
|
+
export async function hostSupports(name) {
|
|
50
|
+
const meta = await hostMeta();
|
|
51
|
+
return meta.extensions.includes(name);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Drop the memoized answer. Tests only. */
|
|
55
|
+
export function resetHostMeta() {
|
|
56
|
+
inFlight = null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function fetchMeta() {
|
|
60
|
+
const token = saveToken();
|
|
61
|
+
// A host that mints tokens answers discovery per token, because on a sandboxed
|
|
62
|
+
// document the token is the only identity there is: the browser gives it an
|
|
63
|
+
// opaque origin, so it holds no cookie and gets the answer any stranger would.
|
|
64
|
+
// Without this the `document` block, which carries whether this person may
|
|
65
|
+
// upload and how large a file the host takes, is unreachable on exactly the
|
|
66
|
+
// hosts that mint tokens.
|
|
67
|
+
const path = token ? `${META_PATH}/${token}` : META_PATH;
|
|
68
|
+
|
|
69
|
+
const controller = new AbortController();
|
|
70
|
+
const timeoutId = setTimeout(() => controller.abort(), META_TIMEOUT_MS);
|
|
71
|
+
try {
|
|
72
|
+
const res = await fetch(new URL(path, window.location.origin).href, {
|
|
73
|
+
method: "GET",
|
|
74
|
+
// Same rule as the save lane. The token IS the credential, and a host that
|
|
75
|
+
// mints per-document tokens must never send Access-Control-Allow-Credentials
|
|
76
|
+
// back, so asking for cookies gets the answer blocked before it is read.
|
|
77
|
+
credentials: token ? "omit" : "same-origin",
|
|
78
|
+
headers: { "Document-URL": window.location.href },
|
|
79
|
+
cache: "no-store",
|
|
80
|
+
// A redirect is not an answer (§5), and following one would send the
|
|
81
|
+
// Document-URL header somewhere this host did not choose.
|
|
82
|
+
redirect: "manual",
|
|
83
|
+
signal: controller.signal
|
|
84
|
+
});
|
|
85
|
+
if (!res.ok) return bareHost();
|
|
86
|
+
const text = await res.text();
|
|
87
|
+
if (!text) return bareHost();
|
|
88
|
+
|
|
89
|
+
let body;
|
|
90
|
+
try {
|
|
91
|
+
body = JSON.parse(text);
|
|
92
|
+
} catch (_) {
|
|
93
|
+
return bareHost();
|
|
94
|
+
}
|
|
95
|
+
if (!body || typeof body !== "object" || typeof body.spec !== "number") return bareHost();
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
spec: body.spec,
|
|
99
|
+
extensions: Array.isArray(body.extensions) ? body.extensions : [],
|
|
100
|
+
document: body.document && typeof body.document === "object" ? body.document : null
|
|
101
|
+
};
|
|
102
|
+
} catch (_) {
|
|
103
|
+
return bareHost();
|
|
104
|
+
} finally {
|
|
105
|
+
clearTimeout(timeoutId);
|
|
106
|
+
}
|
|
107
|
+
}
|
package/src/core/save-core.js
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { isEditMode } from "./is-edit-mode.js";
|
|
11
|
-
import { consumeUserDriven, markUserDriven } from "../lib/user-gesture.js";
|
|
12
|
-
import { saveToken
|
|
11
|
+
import { consumeUserDriven, consumeExplicitSave, markUserDriven } from "../lib/user-gesture.js";
|
|
12
|
+
import { saveToken } from "./host-attrs.js";
|
|
13
13
|
import {
|
|
14
14
|
getPageContents,
|
|
15
15
|
onSnapshot,
|
|
@@ -103,13 +103,19 @@ function skippedResult(msg) {
|
|
|
103
103
|
* the save — and the per-document token in its path — to an origin the document
|
|
104
104
|
* itself chose.
|
|
105
105
|
*
|
|
106
|
+
* The body is the document, as text, always. Spec §3: this route has exactly one
|
|
107
|
+
* body shape. Everything else about the save travels in a header, which is how
|
|
108
|
+
* §6's `conditional` capability added `If-Match` without touching the body, and
|
|
109
|
+
* how anything later will be added too. There used to be a JSON envelope here
|
|
110
|
+
* carrying the document alongside an unstripped snapshot; the snapshot's home is
|
|
111
|
+
* the §10 relay, which the live-sync plugin already posts it to.
|
|
112
|
+
*
|
|
106
113
|
* @param {string} content - HTML to save
|
|
107
|
-
* @param {?string} snapshotHtml - unstripped snapshot, for the desktop envelope
|
|
108
114
|
* @param {boolean} userDriven - Whether a human gesture is behind this save
|
|
109
115
|
* @param {AbortSignal} signal
|
|
110
116
|
* @returns {{url: string, options: Object}}
|
|
111
117
|
*/
|
|
112
|
-
function buildSaveRequest(content,
|
|
118
|
+
function buildSaveRequest(content, userDriven, signal) {
|
|
113
119
|
const token = saveToken();
|
|
114
120
|
const path = token ? `${SAVE_PATH}/${token}` : SAVE_PATH;
|
|
115
121
|
const options = {
|
|
@@ -120,23 +126,14 @@ function buildSaveRequest(content, snapshotHtml, userDriven, signal) {
|
|
|
120
126
|
'Document-URL': window.location.href,
|
|
121
127
|
// The older spelling of the same header, which htmlclay reads today.
|
|
122
128
|
'Page-URL': window.location.href,
|
|
123
|
-
'X-Hyperclay-User-Driven
|
|
124
|
-
|
|
129
|
+
// Spec §9's name for the provenance bit. `X-Hyperclay-User-Driven` was the
|
|
130
|
+
// pre-spec spelling; every host reads Save-Trigger first and falls back to
|
|
131
|
+
// it, so stored documents running an older client keep working.
|
|
132
|
+
'Save-Trigger': userDriven ? 'user' : 'auto'
|
|
133
|
+
},
|
|
134
|
+
body: content
|
|
125
135
|
};
|
|
126
136
|
|
|
127
|
-
// The desktop JSON envelope carries the unstripped snapshot alongside the
|
|
128
|
-
// document, so a sync engine can replay what the browser actually had. It goes
|
|
129
|
-
// out only when the served document DECLARES that transport, and it is passed in
|
|
130
|
-
// from the capture that produced `content` — it used to be left on a window
|
|
131
|
-
// global that was cleared only on success, so two captures without an intervening
|
|
132
|
-
// successful save would pair a stale snapshot with fresh content.
|
|
133
|
-
if (saveTransport() === DESKTOP_JSON && snapshotHtml) {
|
|
134
|
-
options.headers['Content-Type'] = 'application/json';
|
|
135
|
-
options.body = JSON.stringify({ content, snapshotHtml, userDriven });
|
|
136
|
-
} else {
|
|
137
|
-
options.body = content;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
137
|
return { url: new URL(path, window.location.origin).href, options };
|
|
141
138
|
}
|
|
142
139
|
|
|
@@ -153,18 +150,22 @@ function buildSaveRequest(content, snapshotHtml, userDriven, signal) {
|
|
|
153
150
|
* instead of the status.
|
|
154
151
|
*
|
|
155
152
|
* @param {string} content - HTML to save
|
|
156
|
-
* @param {?string} snapshotHtml
|
|
157
153
|
* @returns {Promise<Object>} The server's response body
|
|
158
154
|
*/
|
|
159
|
-
function sendSave(content
|
|
155
|
+
function sendSave(content) {
|
|
160
156
|
const controller = new AbortController();
|
|
161
157
|
const timeoutId = setTimeout(() => controller.abort(), SAVE_TIMEOUT_MS);
|
|
162
158
|
|
|
163
159
|
// Read-and-reset the data-guard provenance bit at the ACTUAL send (past the
|
|
164
160
|
// early returns in the callers), so it's never consumed on a save that never
|
|
165
161
|
// ships.
|
|
166
|
-
|
|
167
|
-
|
|
162
|
+
// Two independent ways a save is human: an edit made in a trusted turn, or a
|
|
163
|
+
// person pressing Save. Both are consumed here, at the one point past every
|
|
164
|
+
// early return, so neither can survive into an unrelated later save.
|
|
165
|
+
const gestureDriven = consumeUserDriven();
|
|
166
|
+
const explicitlyAsked = consumeExplicitSave();
|
|
167
|
+
const userDriven = gestureDriven || explicitlyAsked;
|
|
168
|
+
const { url, options } = buildSaveRequest(content, userDriven, controller.signal);
|
|
168
169
|
|
|
169
170
|
return fetch(url, options)
|
|
170
171
|
.then(res => res.text().then(text => {
|
|
@@ -208,15 +209,13 @@ function sendSave(content, snapshotHtml) {
|
|
|
208
209
|
*
|
|
209
210
|
* @param {string} html - HTML string to save
|
|
210
211
|
* @param {Function} callback - Called with the result on completion
|
|
211
|
-
* @param {Object} [options]
|
|
212
|
-
* @param {?string} [options.snapshotHtml] - unstripped snapshot from the same capture
|
|
213
212
|
* @returns {Promise<{msg: string, msgType: string, code: ?string, etag: ?string}>}
|
|
214
213
|
*
|
|
215
214
|
* @example
|
|
216
215
|
* const {msg, msgType} = await saveHtml(myHtml);
|
|
217
216
|
* if (msgType === 'error') console.error('Save failed:', msg);
|
|
218
217
|
*/
|
|
219
|
-
export function saveHtml(html, callback = () => {}
|
|
218
|
+
export function saveHtml(html, callback = () => {}) {
|
|
220
219
|
return new Promise((resolve) => {
|
|
221
220
|
const done = (result) => {
|
|
222
221
|
if (typeof callback === 'function') callback(result);
|
|
@@ -241,7 +240,7 @@ export function saveHtml(html, callback = () => {}, { snapshotHtml = null } = {}
|
|
|
241
240
|
return;
|
|
242
241
|
}
|
|
243
242
|
|
|
244
|
-
sendSave(html
|
|
243
|
+
sendSave(html)
|
|
245
244
|
.then(successResult, errorResult)
|
|
246
245
|
// Clear the flag BEFORE handing the result back, so a caller that queues a
|
|
247
246
|
// follow-up save can start it immediately rather than being told the lane
|