@panphora/clayjs 1.8.1 → 1.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panphora/clayjs",
3
- "version": "1.8.1",
3
+ "version": "1.9.0",
4
4
  "type": "module",
5
5
  "description": "clayjs: malleable HTML files. Save lifecycle for self-saving HTML.",
6
6
  "license": "MIT-0",
@@ -46,13 +46,13 @@
46
46
  "access": "public"
47
47
  },
48
48
  "scripts": {
49
- "test": "NODE_OPTIONS=--experimental-vm-modules jest",
49
+ "test": "node scripts/test-guard.mjs",
50
50
  "test:conformance": "UPDATE_GOLDENS=0 web-test-runner --config conformance/wtr.config.mjs",
51
51
  "conformance:update": "UPDATE_GOLDENS=1 web-test-runner --config conformance/wtr.config.mjs",
52
52
  "build:standalone": "node build.js --standalone",
53
53
  "prepack": "node build.js --standalone",
54
54
  "test:standalone": "node build.js --standalone && web-test-runner --config tests/browser/wtr.config.mjs",
55
- "prepublishOnly": "npm test && npm run test:conformance && npm run test:standalone",
55
+ "prepublishOnly": "testbox run",
56
56
  "dev": "node tests/stub-server.mjs",
57
57
  "build": "npm run build:bevel && npm run build:skins && node build.js",
58
58
  "propagate:foundation": "node scripts/propagate-foundation.mjs",
@@ -73,9 +73,13 @@
73
73
  "esbuild": "^0.28.2",
74
74
  "jest": "^29.7.0",
75
75
  "jest-environment-jsdom": "^29.7.0",
76
+ "jsdom": "^20.0.3",
77
+ "marked": "^18.0.14",
76
78
  "parse5": "^7.3.0",
77
79
  "playwright": "1.62.1",
78
- "postcss": "^8.5.28"
80
+ "postcss": "^8.5.28",
81
+ "turndown": "^7.2.4",
82
+ "turndown-plugin-gfm": "^1.0.2"
79
83
  },
80
84
  "hyper": {
81
85
  "kind": "lib",
@@ -23,6 +23,17 @@ export const servedDocumentEtag =
23
23
  ? null
24
24
  : document.documentElement.getAttribute("documentetag") || null;
25
25
 
26
+ // Whether the host serves this site privately, captured ONCE, for the same reason as
27
+ // the etag above: it describes this response, and a morph may later rewrite the root.
28
+ // null when the host said nothing (HTML Clay, Hyperclay Local, a downloaded copy) or
29
+ // said something this library does not recognize.
30
+ const SITE_VISIBILITIES = new Set(["private", "public"]);
31
+ export const servedSiteVisibility = (() => {
32
+ if (typeof document === "undefined") return null;
33
+ const value = document.documentElement.getAttribute("sitevisibility");
34
+ return SITE_VISIBILITIES.has(value) ? value : null;
35
+ })();
36
+
26
37
  let warnedAboutLegacyToken = false;
27
38
 
28
39
  /**
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * host-meta.js — what this host can do (spec §5).
3
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.
4
+ * The first discovery client clayjs has had. Not fetched at boot by core: the first
5
+ * caller is the first file pick, or the people plugin when a page asks for it, so a
6
+ * page that uses neither never asks.
6
7
  *
7
8
  * The spec's own rule about what counts as an answer is deliberately strict on
8
9
  * the way in and forgiving on the way out. Only a 2xx carrying a JSON object with
@@ -17,6 +18,29 @@ import { saveToken } from "./host-attrs.js";
17
18
  const META_PATH = "/_/meta";
18
19
  const META_TIMEOUT_MS = 6000;
19
20
 
21
+ // How each answer came about, kept beside the answer rather than on it so its
22
+ // shape never changes. "ok": a capability document. "none": no discovery on offer
23
+ // (a 404, a file nobody is serving, a server that has never heard of the spec).
24
+ // "failed": a host that should have answered did not (a 5xx, a timeout, a dropped
25
+ // connection). Save and upload treat the last two alike; people must not, because
26
+ // a host that failed to say who you are has not said you are nobody.
27
+ const outcomes = new WeakMap();
28
+
29
+ function settle(outcome, meta = bareHost()) {
30
+ outcomes.set(meta, outcome);
31
+ return meta;
32
+ }
33
+
34
+ /**
35
+ * How the discovery that produced `meta` ended: "ok", "none" or "failed".
36
+ *
37
+ * @param {Object} meta - an answer from hostMeta()
38
+ * @returns {"ok"|"none"|"failed"}
39
+ */
40
+ export function hostMetaOutcome(meta) {
41
+ return outcomes.get(meta) ?? "none";
42
+ }
43
+
20
44
  // The bare-core-host answer, which is also every failure's answer.
21
45
  function bareHost() {
22
46
  return { spec: null, extensions: [], document: null };
@@ -34,8 +58,9 @@ let refreshing = null;
34
58
  * `spec` and `extensions` describe the host and never change under a loaded
35
59
  * document, which is why one answer serves the whole page. The `document` block
36
60
  * does change: its `etag` ticks on every save, by anyone. A caller that needs a
37
- * current one passes `fresh`, which asks again and replaces the memoized answer,
38
- * and concurrent fresh callers still share one request.
61
+ * current one passes `fresh`, which asks again and replaces the memoized answer
62
+ * unless it failed where an earlier one succeeded, and concurrent fresh callers
63
+ * still share one request.
39
64
  *
40
65
  * @param {Object} [options]
41
66
  * @param {boolean} [options.fresh]
@@ -47,8 +72,15 @@ export function hostMeta({ fresh = false } = {}) {
47
72
  return inFlight;
48
73
  }
49
74
  if (!refreshing) {
75
+ const previous = inFlight;
50
76
  refreshing = fetchMeta().catch(bareHost).finally(() => { refreshing = null; });
51
- inFlight = refreshing;
77
+ // The caller that asked for a fresh answer gets it, failure and all. The page keeps its
78
+ // last good answer: one 502 during a deploy must not turn uploads off for the session.
79
+ inFlight = refreshing.then(async (meta) => {
80
+ if (hostMetaOutcome(meta) === "ok" || !previous) return meta;
81
+ const last = await previous;
82
+ return hostMetaOutcome(last) === "ok" ? last : meta;
83
+ });
52
84
  }
53
85
  return refreshing;
54
86
  }
@@ -74,6 +106,9 @@ export function resetHostMeta() {
74
106
  }
75
107
 
76
108
  async function fetchMeta() {
109
+ // A file opened from disk has no host to ask, and fetch would only throw.
110
+ if (!/^https?:$/.test(window.location.protocol)) return settle("none");
111
+
77
112
  const token = saveToken();
78
113
  // A host that mints tokens answers discovery per token, because on a sandboxed
79
114
  // document the token is the only identity there is: the browser gives it an
@@ -99,25 +134,25 @@ async function fetchMeta() {
99
134
  redirect: "manual",
100
135
  signal: controller.signal
101
136
  });
102
- if (!res.ok) return bareHost();
137
+ if (!res.ok) return settle(res.status >= 500 ? "failed" : "none");
103
138
  const text = await res.text();
104
- if (!text) return bareHost();
139
+ if (!text) return settle("none");
105
140
 
106
141
  let body;
107
142
  try {
108
143
  body = JSON.parse(text);
109
144
  } catch (_) {
110
- return bareHost();
145
+ return settle("none");
111
146
  }
112
- if (!body || typeof body !== "object" || typeof body.spec !== "number") return bareHost();
147
+ if (!body || typeof body !== "object" || typeof body.spec !== "number") return settle("none");
113
148
 
114
- return {
149
+ return settle("ok", {
115
150
  spec: body.spec,
116
151
  extensions: Array.isArray(body.extensions) ? body.extensions : [],
117
152
  document: body.document && typeof body.document === "object" ? body.document : null
118
- };
153
+ });
119
154
  } catch (_) {
120
- return bareHost();
155
+ return settle("failed");
121
156
  } finally {
122
157
  clearTimeout(timeoutId);
123
158
  }
@@ -1,6 +1,6 @@
1
1
  import cookie from "../lib/cookie.js";
2
2
  import query from "../lib/query.js";
3
- import { hasSaveToken, servedStaleToken } from "./host-attrs.js";
3
+ import { hasSaveToken, servedStaleToken, servedSiteVisibility } from "./host-attrs.js";
4
4
 
5
5
  // Edit-mode precedence: an explicit ?editmode=true|false URL param wins, then an
6
6
  // authored `viewonly` attribute on <html> (a tool page that must never save its
@@ -47,7 +47,10 @@ const isEditMode = query.editmode
47
47
 
48
48
  const isOwner = Boolean(cookie.get("isAdminOfCurrentResource"));
49
49
 
50
+ const siteVisibility = servedSiteVisibility;
51
+
50
52
  export {
51
53
  isEditMode,
52
- isOwner
54
+ isOwner,
55
+ siteVisibility
53
56
  }
@@ -2,8 +2,10 @@
2
2
  [onpagemutation] / [onglobalmutation] - Trigger code when ANY element on the page changes
3
3
 
4
4
  Usage:
5
- <span onglobalmutation="this.textContent = All('li').length">0</span>
6
- <span onpagemutation="this.textContent = All('li').length">0</span>
5
+ <span onglobalmutation="const n = String(All('li').length); if (this.textContent !== n) this.textContent = n">0</span>
6
+ <span onpagemutation="const n = String(All('li').length); if (this.textContent !== n) this.textContent = n">0</span>
7
+
8
+ Guard every write: writing into the page, even the same text, is a mutation and re-runs the handler.
7
9
 
8
10
  Both attributes are equivalent - onglobalmutation is the preferred name for clarity.
9
11
  */
@@ -74,7 +74,12 @@ export const HOST_IDENTITY_ATTRS = ["documentid", "htmlclayid"];
74
74
  // Kept out of SAVE_TOKEN_ATTRS on purpose: host-attrs.js returns the first name it
75
75
  // finds in that list straight into the save URL, and a version stamp is not a
76
76
  // credential.
77
- export const HOST_RESPONSE_ATTRS = ["documentetag"];
77
+ //
78
+ // `sitevisibility` is the host telling the owner's tab whether this site is served
79
+ // privately or publicly ("private" | "public"). It describes the response, not the
80
+ // file: the same bytes are private today and public tomorrow, so it must never be
81
+ // written to disk, and a peer tab's copy says nothing about what this tab was served.
82
+ export const HOST_RESPONSE_ATTRS = ["documentetag", "sitevisibility"];
78
83
 
79
84
  // What a host may have injected, and therefore what has to be stripped before a save
80
85
  // and kept out of an incoming morph. Wider than what is READ, on purpose: the old token
@@ -36,6 +36,8 @@ export const PLUGIN_PATHS = {
36
36
  // injects its own editing toggle there and clayjs's edit-mode signal is a
37
37
  // superset of the cms's, so the plugin is present exactly when it can be used.
38
38
  upload: { path: "plugins/upload.js", editOnly: true, default: false },
39
+ // Who is editing and who wrote what. Not editOnly: a reader still sees names.
40
+ people: { path: "plugins/people.js", editOnly: false, default: false },
39
41
  wire: { path: "plugins/wire.js", editOnly: false, default: false },
40
42
  // Edit mode only: the AI comment box is an editing gesture. Opt in with
41
43
  // `plugins=ai-edit`; it stays dormant on any host that does not list a ready
@@ -89,6 +91,7 @@ export const MODULES = {
89
91
  "plugins/cms.js": () => import("./plugins/cms.js"),
90
92
  "plugins/quickcrop.js": () => import("./plugins/quickcrop.js"),
91
93
  "plugins/upload.js": () => import("./plugins/upload.js"),
94
+ "plugins/people.js": () => import("./plugins/people.js"),
92
95
  "plugins/wire.js": () => import("./plugins/wire.js"),
93
96
  "plugins/ai-edit.js": () => import("./plugins/ai-edit.js"),
94
97
  "plugins/demo.js": () => import("./plugins/demo.js"),
@@ -97,7 +100,7 @@ export const MODULES = {
97
100
 
98
101
  // `source` is last on purpose: its install captures a save clone, so it wants every
99
102
  // plugin that registers a document transform to have registered it first.
100
- const PLUGIN_ORDER = ["richclay", "indicator", "sortable", "undo", "quickcrop", "upload", "cms", "sync", "wire", "ai-edit", "demo", "source"];
103
+ const PLUGIN_ORDER = ["richclay", "indicator", "sortable", "undo", "quickcrop", "upload", "people", "cms", "sync", "wire", "ai-edit", "demo", "source"];
101
104
 
102
105
  // A plugin that cannot do its whole job alone. hypercms reads the cropper through
103
106
  // a capability lookup (`clay.quickcrop`) and silently uploads the raw file when it
package/src/loader.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { resolveModules, MODULES } from "./loader-logic.js";
2
2
  import onDomReady from "./lib/dom-ready.js";
3
+ import { initTheme } from "./ui/theme.js";
3
4
 
4
5
  function domReady() {
5
6
  return new Promise((resolve) => onDomReady(resolve));
@@ -11,12 +12,14 @@ function domReady() {
11
12
  // previous release. A call shape that differs between them leaves the page with
12
13
  // no clayjs at all.
13
14
  export async function boot(base, params, readyResolve) {
15
+ initTheme();
16
+
14
17
  await domReady(); // Mutation observes document.body
15
18
  // unconditionally, and a <head> placement
16
19
  // would otherwise observe null
17
20
 
18
21
  const editMode = await MODULES["core/is-edit-mode.js"]();
19
- const { isEditMode, isOwner } = editMode;
22
+ const { isEditMode, isOwner, siteVisibility } = editMode;
20
23
 
21
24
  // richclay's vendor build detects edit mode via this legacy global; set it
22
25
  // before any plugin import so its autoInit sees the right value. Overwrite
@@ -33,7 +36,7 @@ export async function boot(base, params, readyResolve) {
33
36
  loaded[path] = await MODULES[path](); // sequential: order is load-bearing
34
37
  }
35
38
 
36
- assembleCore(loaded, { isEditMode, isOwner }, regionPolicy); // window.clay MUST be assembled
39
+ assembleCore(loaded, { isEditMode, isOwner, siteVisibility }, regionPolicy); // window.clay MUST be assembled
37
40
  // before any plugin import
38
41
 
39
42
  for (const path of plan.plugins) {
@@ -65,7 +68,7 @@ export async function boot(base, params, readyResolve) {
65
68
  document.dispatchEvent(new CustomEvent("clay:ready", { detail: { clay: window.clay } }));
66
69
  }
67
70
 
68
- function assembleCore(loaded, { isEditMode, isOwner }, regionPolicy) {
71
+ function assembleCore(loaded, { isEditMode, isOwner, siteVisibility }, regionPolicy) {
69
72
  const clay = window.clay;
70
73
 
71
74
  const mutation = loaded["lib/mutation.js"];
@@ -75,6 +78,7 @@ function assembleCore(loaded, { isEditMode, isOwner }, regionPolicy) {
75
78
  toggleEditMode: editModeMod.toggleEditMode,
76
79
  isEditMode,
77
80
  isOwner,
81
+ siteVisibility,
78
82
  Mutation: mutation.default,
79
83
  region: regionPolicy.windowRegionShape,
80
84
  });
@@ -124,6 +128,10 @@ function attachPluginMember(path, mod, loaded) {
124
128
  clay.cms = mod.cms || mod.default;
125
129
  } else if (path === "plugins/upload.js") {
126
130
  clay.upload = mod.upload || mod.default;
131
+ } else if (path === "plugins/people.js") {
132
+ clay.people = mod.people;
133
+ clay.author = mod.author;
134
+ Object.defineProperty(clay, "me", { get: mod.currentMe, configurable: true, enumerable: true });
127
135
  } else if (path === "plugins/wire.js") {
128
136
  clay.wire = mod.wire || mod.default;
129
137
  } else if (path === "plugins/ai-edit.js") {