@optiaxiom/proteus 3.0.6 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. package/dist/esm/assets/src/proteus-chart/{ProteusChart.css.ts.vanilla-ByPSKREp.css → ProteusChart.css.ts.vanilla-BcGBUwbD.css} +2 -2
  2. package/dist/esm/assets/src/proteus-chart/{ProteusChartTooltipContent.css.ts.vanilla-DnR_C5ly.css → ProteusChartTooltipContent.css.ts.vanilla-BthlShlk.css} +2 -2
  3. package/dist/esm/assets/src/proteus-document/{ProteusDocumentShell.css.ts.vanilla-CVOes7es.css → ProteusDocumentShell.css.ts.vanilla-BugeP59E.css} +2 -2
  4. package/dist/esm/assets/src/proteus-image-carousel/{ProteusImageCarousel.css.ts.vanilla-DXF3wHYt.css → ProteusImageCarousel.css.ts.vanilla-DhXdg25-.css} +2 -2
  5. package/dist/esm/assets/src/proteus-markdown/{ProteusMarkdown.css.ts.vanilla-Cjmk3X5z.css → ProteusMarkdown.css.ts.vanilla-BM9-lPyj.css} +2 -2
  6. package/dist/esm/assets/src/proteus-question/{ProteusQuestion.css.ts.vanilla-DVFRsIPp.css → ProteusQuestion.css.ts.vanilla-1u353vmb.css} +2 -2
  7. package/dist/esm/proteus-action/ProteusAction.js +1 -1
  8. package/dist/esm/proteus-chart/ProteusChart-css.js +1 -1
  9. package/dist/esm/proteus-chart/ProteusChartTooltipContent-css.js +1 -1
  10. package/dist/esm/proteus-document/ProteusDocumentShell-css.js +1 -1
  11. package/dist/esm/proteus-document/ProteusDocumentShell.js +70 -49
  12. package/dist/esm/proteus-document/isSafeUrl.js +22 -0
  13. package/dist/esm/proteus-element/ProteusElement.js +10 -1
  14. package/dist/esm/proteus-image-carousel/ProteusImageCarousel-css.js +1 -1
  15. package/dist/esm/proteus-markdown/ProteusMarkdown-css.js +1 -1
  16. package/dist/esm/proteus-question/ProteusQuestion-css.js +1 -1
  17. package/dist/esm/proteus-script/useProteusScripts.js +164 -0
  18. package/dist/esm/proteus-script/workerScript.js +149 -0
  19. package/dist/esm/schema/public-schema.js +66 -0
  20. package/dist/esm/schema/runtime-schema.js +64 -0
  21. package/dist/index.d.ts +11 -0
  22. package/package.json +2 -2
@@ -1,5 +1,5 @@
1
- @layer optiaxiom._1dfr2ib;
2
- @layer optiaxiom._1dfr2ib {
1
+ @layer optiaxiom.st6wia;
2
+ @layer optiaxiom.st6wia {
3
3
  .ProteusChart__jmlqij1 {
4
4
  border-radius: 16px;
5
5
  }
@@ -1,5 +1,5 @@
1
- @layer optiaxiom._1dfr2ib;
2
- @layer optiaxiom._1dfr2ib {
1
+ @layer optiaxiom.st6wia;
2
+ @layer optiaxiom.st6wia {
3
3
  .ProteusChartTooltipContent__1gsvq810 {
4
4
  min-width: 128px;
5
5
  position: absolute;
@@ -1,5 +1,5 @@
1
- @layer optiaxiom._1dfr2ib;
2
- @layer optiaxiom._1dfr2ib {
1
+ @layer optiaxiom.st6wia;
2
+ @layer optiaxiom.st6wia {
3
3
  .ProteusDocumentShell__vpuvfj1 {
4
4
  margin: -4px;
5
5
  }
@@ -1,5 +1,5 @@
1
- @layer optiaxiom._1dfr2ib;
2
- @layer optiaxiom._1dfr2ib {
1
+ @layer optiaxiom.st6wia;
2
+ @layer optiaxiom.st6wia {
3
3
  .ProteusImageCarousel__1t6qej70 {
4
4
  outline: none;
5
5
  user-select: none;
@@ -1,5 +1,5 @@
1
- @layer optiaxiom._1dfr2ib;
2
- @layer optiaxiom._1dfr2ib {
1
+ @layer optiaxiom.st6wia;
2
+ @layer optiaxiom.st6wia {
3
3
  .ProteusMarkdown__1ibq5ae0 > * {
4
4
  margin-bottom: 1em;
5
5
  margin-top: 0;
@@ -1,5 +1,5 @@
1
- @layer optiaxiom._1dfr2ib;
2
- @layer optiaxiom._1dfr2ib {
1
+ @layer optiaxiom.st6wia;
2
+ @layer optiaxiom.st6wia {
3
3
  .ProteusQuestion__8f590p0 {
4
4
  outline: none;
5
5
  }
@@ -33,7 +33,7 @@ function ProteusAction({ children, onClick, type = "button", ...props }) {
33
33
  * available — so the document-root event handler can apply it unambiguously.
34
34
  */
35
35
  function resolveEventPath(event, parentPath) {
36
- if (event && typeof event === "object" && "action" in event && (event.action === "pushValue" || event.action === "removeValue")) {
36
+ if (event && typeof event === "object" && "action" in event && (event.action === "pushValue" || event.action === "removeValue" || event.action === "setValue")) {
37
37
  const { path } = event;
38
38
  const resolved = path === "" ? parentPath : path.startsWith("/") ? path : `${parentPath}/${path}`;
39
39
  return {
@@ -1,4 +1,4 @@
1
- import "./../assets/src/proteus-chart/ProteusChart.css.ts.vanilla-ByPSKREp.css";
1
+ import "./../assets/src/proteus-chart/ProteusChart.css.ts.vanilla-BcGBUwbD.css";
2
2
  import { recipe } from "@optiaxiom/react/css-runtime";
3
3
  //#region src/proteus-chart/ProteusChart.css.ts
4
4
  var chart = recipe({ base: [
@@ -1,5 +1,5 @@
1
1
  import { recipe } from "@optiaxiom/react/css-runtime";
2
- import "./../assets/src/proteus-chart/ProteusChartTooltipContent.css.ts.vanilla-DnR_C5ly.css";
2
+ import "./../assets/src/proteus-chart/ProteusChartTooltipContent.css.ts.vanilla-BthlShlk.css";
3
3
  //#region src/proteus-chart/ProteusChartTooltipContent.css.ts
4
4
  var tooltip = recipe({ base: [{
5
5
  bg: "bg.default",
@@ -1,5 +1,5 @@
1
1
  import { recipe } from "@optiaxiom/react/css-runtime";
2
- import "./../assets/src/proteus-document/ProteusDocumentShell.css.ts.vanilla-CVOes7es.css";
2
+ import "./../assets/src/proteus-document/ProteusDocumentShell.css.ts.vanilla-BugeP59E.css";
3
3
  //#region src/proteus-document/ProteusDocumentShell.css.ts
4
4
  var body = recipe({
5
5
  base: [{
@@ -3,6 +3,8 @@ import { ProteusDocumentProvider } from "./ProteusDocumentContext.js";
3
3
  import { resolveProteusValue } from "./resolveProteusValue.js";
4
4
  import { useEffectEvent } from "../hooks/useEffectEvent.js";
5
5
  import { downloadFile } from "../proteus-image/downloadFile.js";
6
+ import { useProteusScripts } from "../proteus-script/useProteusScripts.js";
7
+ import { isSafeUrl } from "./isSafeUrl.js";
6
8
  import { body, scrollIndicator } from "./ProteusDocumentShell-css.js";
7
9
  import { Box, Disclosure, DisclosureContent, DisclosureTrigger, Group, Heading, Text, Tooltip } from "@optiaxiom/react";
8
10
  import { useEffect, useRef, useState } from "react";
@@ -41,6 +43,73 @@ function ProteusDocumentShell({ collapsible: collapsibleProp, data = {}, default
41
43
  const collapsible = collapsibleProp && element.appName;
42
44
  const Trigger = collapsible ? DisclosureTrigger : Box;
43
45
  const inline = resolveProteusValue(element.appearance, data, "", []) === "inline";
46
+ const onEvent = useEffectEvent(async (event) => {
47
+ if ("interaction" in event) return await onInteraction?.(event.interaction, event.params);
48
+ else if ("message" in event) await onMessage?.(event.message);
49
+ else if ("script" in event) return await runScript(event.script, event.params);
50
+ else if (event.action === "download") {
51
+ const urls = [];
52
+ if (typeof event.url === "string") urls.push(event.url);
53
+ else if (Array.isArray(event.url)) for (const u of event.url) {
54
+ if (typeof u !== "string") throw new Error("Invalid URL in download array");
55
+ urls.push(u);
56
+ }
57
+ else throw new Error("Invalid URL for download action");
58
+ for (const u of urls) if (!isSafeUrl(u)) {
59
+ if (strict) throw new Error(`download: unsafe URL "${u}"`);
60
+ return;
61
+ }
62
+ if (onDownload) await onDownload(urls);
63
+ else await Promise.all(urls.map((u) => downloadFile(u)));
64
+ } else if (event.action === "openLink") {
65
+ if (isSafeUrl(event.url)) window.open(event.url, "_blank", "noopener,noreferrer");
66
+ else if (strict) throw new Error(`openLink: unsafe URL "${String(event.url)}"`);
67
+ } else if (event.action === "preview") await onPreview?.(event.file);
68
+ else if (event.action === "pushValue") {
69
+ const { path, value } = event;
70
+ onDataChange?.((prev) => {
71
+ const next = structuredClone(prev);
72
+ const current = get(next, path);
73
+ if (current !== void 0 && !Array.isArray(current)) {
74
+ if (strict) throw new Error(`pushValue: expected array at "${path}"`);
75
+ return prev;
76
+ }
77
+ set(next, path, [...current ?? [], value]);
78
+ return next;
79
+ });
80
+ } else if (event.action === "removeValue") {
81
+ const { path } = event;
82
+ const slash = path.lastIndexOf("/");
83
+ const parent = slash > 0 ? path.slice(0, slash) : "";
84
+ const index = Number(path.slice(slash + 1));
85
+ if (!parent || !Number.isInteger(index)) {
86
+ if (strict) throw new Error(`removeValue: "${path}" is not an array index`);
87
+ return;
88
+ }
89
+ onDataChange?.((prev) => {
90
+ const next = structuredClone(prev);
91
+ const arr = get(next, parent);
92
+ if (!Array.isArray(arr)) {
93
+ if (strict) throw new Error(`removeValue: "${parent}" is not an array`);
94
+ return prev;
95
+ }
96
+ set(next, parent, arr.filter((_, i) => i !== index));
97
+ return next;
98
+ });
99
+ } else if (event.action === "setValue") {
100
+ const { path, value } = event;
101
+ onDataChange?.((prev) => {
102
+ const next = structuredClone(prev);
103
+ set(next, path, value);
104
+ return next;
105
+ });
106
+ }
107
+ });
108
+ const runScript = useProteusScripts({
109
+ data,
110
+ onEmit: onEvent,
111
+ scripts: element.scripts
112
+ });
44
113
  return /* @__PURE__ */ jsx(ProteusDocumentProvider, {
45
114
  data,
46
115
  icons,
@@ -51,55 +120,7 @@ function ProteusDocumentShell({ collapsible: collapsibleProp, data = {}, default
51
120
  return next;
52
121
  });
53
122
  }),
54
- onEvent: useEffectEvent(async (event) => {
55
- if ("interaction" in event) return await onInteraction?.(event.interaction, event.params);
56
- else if ("message" in event) await onMessage?.(event.message);
57
- else if (event.action === "download") {
58
- const urls = [];
59
- if (typeof event.url === "string") urls.push(event.url);
60
- else if (Array.isArray(event.url)) for (const u of event.url) {
61
- if (typeof u !== "string") throw new Error("Invalid URL in download array");
62
- urls.push(u);
63
- }
64
- else throw new Error("Invalid URL for download action");
65
- if (onDownload) await onDownload(urls);
66
- else await Promise.all(urls.map((u) => downloadFile(u)));
67
- } else if (event.action === "openLink") {
68
- if (typeof event.url === "string") window.open(event.url, "_blank", "noopener,noreferrer");
69
- } else if (event.action === "preview") await onPreview?.(event.file);
70
- else if (event.action === "pushValue") {
71
- const { path, value } = event;
72
- onDataChange?.((prev) => {
73
- const next = structuredClone(prev);
74
- const current = get(next, path);
75
- if (current !== void 0 && !Array.isArray(current)) {
76
- if (strict) throw new Error(`pushValue: expected array at "${path}"`);
77
- return prev;
78
- }
79
- set(next, path, [...current ?? [], value]);
80
- return next;
81
- });
82
- } else if (event.action === "removeValue") {
83
- const { path } = event;
84
- const slash = path.lastIndexOf("/");
85
- const parent = slash > 0 ? path.slice(0, slash) : "";
86
- const index = Number(path.slice(slash + 1));
87
- if (!parent || !Number.isInteger(index)) {
88
- if (strict) throw new Error(`removeValue: "${path}" is not an array index`);
89
- return;
90
- }
91
- onDataChange?.((prev) => {
92
- const next = structuredClone(prev);
93
- const arr = get(next, parent);
94
- if (!Array.isArray(arr)) {
95
- if (strict) throw new Error(`removeValue: "${parent}" is not an array`);
96
- return prev;
97
- }
98
- set(next, parent, arr.filter((_, i) => i !== index));
99
- return next;
100
- });
101
- }
102
- }),
123
+ onEvent,
103
124
  onTrack: useEffectEvent((event, properties) => {
104
125
  onTrack?.(event, properties);
105
126
  }),
@@ -0,0 +1,22 @@
1
+ //#region src/proteus-document/isSafeUrl.ts
2
+ /**
3
+ * Whether a URL is safe to hand to `window.open` from a document-triggered
4
+ * action (`openLink` / `download`).
5
+ *
6
+ * Documents — and now scripts, via `ctx.emit` — supply these URLs, so we only
7
+ * allow navigable web schemes and reject dangerous ones like `javascript:`
8
+ * (script injection) and `data:` (can host arbitrary HTML/JS in a new tab).
9
+ * Relative URLs are resolved against the current document.
10
+ */
11
+ function isSafeUrl(url) {
12
+ if (typeof url !== "string") return false;
13
+ let parsed;
14
+ try {
15
+ parsed = new URL(url, window.location.href);
16
+ } catch {
17
+ return false;
18
+ }
19
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
20
+ }
21
+ //#endregion
22
+ export { isSafeUrl };
@@ -82,7 +82,16 @@ const ProteusElement = ({ element: elementProp }) => {
82
82
  const { filled, name, ...rest } = resolve(element);
83
83
  const IconComp = icons?.[name];
84
84
  if (!IconComp) {
85
- if (strict) throw new Error(`Icon "${name}" not registered. Pass it via the \`icons\` prop on ProteusDocumentRenderer.`);
85
+ if (typeof name === "string" && /^(?:https?:\/\/|data:)/.test(name)) return /* @__PURE__ */ jsx(Box, {
86
+ asChild: true,
87
+ ...rest,
88
+ children: /* @__PURE__ */ jsx("img", {
89
+ alt: "",
90
+ "aria-hidden": "true",
91
+ src: name
92
+ })
93
+ });
94
+ if (strict) throw new Error(`Icon "${name}" not registered and not a URL. Pass it via the \`icons\` prop on ProteusDocumentRenderer, or use an http(s):// or data: URL.`);
86
95
  return null;
87
96
  }
88
97
  return /* @__PURE__ */ jsx(Box, {
@@ -1,5 +1,5 @@
1
1
  import { recipe } from "@optiaxiom/react/css-runtime";
2
- import "./../assets/src/proteus-image-carousel/ProteusImageCarousel.css.ts.vanilla-DXF3wHYt.css";
2
+ import "./../assets/src/proteus-image-carousel/ProteusImageCarousel.css.ts.vanilla-DhXdg25-.css";
3
3
  //#region src/proteus-image-carousel/ProteusImageCarousel.css.ts
4
4
  var carousel = recipe({ base: [{
5
5
  flexDirection: "column",
@@ -1,5 +1,5 @@
1
1
  import { recipe } from "@optiaxiom/react/css-runtime";
2
- import "./../assets/src/proteus-markdown/ProteusMarkdown.css.ts.vanilla-Cjmk3X5z.css";
2
+ import "./../assets/src/proteus-markdown/ProteusMarkdown.css.ts.vanilla-BM9-lPyj.css";
3
3
  //#region src/proteus-markdown/ProteusMarkdown.css.ts
4
4
  var code = recipe({ base: [{
5
5
  bg: "bg.secondary",
@@ -1,5 +1,5 @@
1
1
  import { recipe } from "@optiaxiom/react/css-runtime";
2
- import "./../assets/src/proteus-question/ProteusQuestion.css.ts.vanilla-DVFRsIPp.css";
2
+ import "./../assets/src/proteus-question/ProteusQuestion.css.ts.vanilla-1u353vmb.css";
3
3
  //#region src/proteus-question/ProteusQuestion.css.ts
4
4
  var addon = recipe({
5
5
  base: [{
@@ -0,0 +1,164 @@
1
+ "use client";
2
+ "use client";
3
+ import { useEffectEvent } from "../hooks/useEffectEvent.js";
4
+ import { WORKER_SCRIPT } from "./workerScript.js";
5
+ import { useEffect, useRef } from "react";
6
+ import { get } from "jsonpointer";
7
+ //#region src/proteus-script/useProteusScripts.ts
8
+ /**
9
+ * How long a single handler run may take before we give up on it. A script
10
+ * with an infinite loop cannot block the main thread (it runs in the worker),
11
+ * but its `invoke` promise would otherwise never resolve — wedging the
12
+ * triggering control in a `loading` state forever. On timeout we resolve the
13
+ * run to `undefined` (the fail-silent default) and terminate the worker so the
14
+ * runaway handler stops burning a core; the next run lazily respawns it.
15
+ */
16
+ const INVOKE_TIMEOUT_MS = 5e3;
17
+ /**
18
+ * Spins up a single sandbox Web Worker for the document's `scripts` and returns
19
+ * a `runScript(handler, params)` that invokes a named handler inside it. Events
20
+ * a handler or watcher emits are routed back through `onEmit` (the shell
21
+ * dispatcher) and their results returned to the worker, so `ctx.emit`
22
+ * round-trips work.
23
+ *
24
+ * Watchers (`watch(path, fn)` in a script) are driven from here: the worker
25
+ * reports its watched paths after `init`, and this hook edge-detects each path
26
+ * against `data` on every change, posting `runWatcher` when the value there
27
+ * changes. A watcher does NOT re-fire on data changes caused by a script's own
28
+ * `emit` (see `emitDepthRef`) — watchers observe user/host state changes, which
29
+ * both prevents emit→watcher→emit loops and gives a predictable "reacts to what
30
+ * the user did" semantic.
31
+ *
32
+ * Returns a no-op runner when the document ships no scripts — the worker is
33
+ * spawned eagerly when scripts exist (watchers can fire before any interaction).
34
+ */
35
+ function useProteusScripts({ data, onEmit, scripts }) {
36
+ const dataRef = useRef(data);
37
+ dataRef.current = data;
38
+ const emit = useEffectEvent(onEmit);
39
+ const hasScripts = !!scripts && Object.keys(scripts).length > 0;
40
+ const pending = useRef(/* @__PURE__ */ new Map()).current;
41
+ const nextId = useRef(1);
42
+ const workerRef = useRef(null);
43
+ const watchedPaths = useRef([]);
44
+ const lastEval = useRef([]);
45
+ const emitDepth = useRef(0);
46
+ /** Snapshot every watched path from `data` into `lastEval` without firing. */
47
+ const seedWatchers = useEffectEvent((data) => {
48
+ lastEval.current = watchedPaths.current.map((path) => JSON.stringify(getByPointer(data, path) ?? null));
49
+ });
50
+ /** Resolve a pending run (if still pending) and clear its timeout. */
51
+ const settle = useEffectEvent((invokeId, result) => {
52
+ const entry = pending.get(invokeId);
53
+ if (!entry) return;
54
+ clearTimeout(entry.timer);
55
+ pending.delete(invokeId);
56
+ entry.resolve(result);
57
+ });
58
+ const spawnWorker = useEffectEvent(() => {
59
+ const url = URL.createObjectURL(new Blob([WORKER_SCRIPT], { type: "text/javascript" }));
60
+ const instance = new Worker(url);
61
+ URL.revokeObjectURL(url);
62
+ instance.postMessage({
63
+ scripts,
64
+ type: "init"
65
+ });
66
+ instance.addEventListener("message", async (e) => {
67
+ const msg = e.data;
68
+ if (msg.type === "emit") {
69
+ emitDepth.current += 1;
70
+ let result;
71
+ try {
72
+ result = await emit(msg.event);
73
+ } finally {
74
+ emitDepth.current -= 1;
75
+ }
76
+ instance.postMessage({
77
+ emitId: msg.emitId,
78
+ result,
79
+ type: "emitResult"
80
+ });
81
+ } else if (msg.type === "invokeResult") settle(msg.invokeId, msg.result);
82
+ else if (msg.type === "watchers") {
83
+ watchedPaths.current = msg.paths;
84
+ seedWatchers(dataRef.current);
85
+ }
86
+ });
87
+ return instance;
88
+ });
89
+ /** Ensure the worker exists, spawning it on first need. */
90
+ const ensureWorker = useEffectEvent(() => {
91
+ if (!workerRef.current) workerRef.current = spawnWorker();
92
+ return workerRef.current;
93
+ });
94
+ /** Tear down the current worker and abandon every in-flight run. */
95
+ const teardown = useEffectEvent(() => {
96
+ workerRef.current?.terminate();
97
+ workerRef.current = null;
98
+ watchedPaths.current = [];
99
+ lastEval.current = [];
100
+ for (const [invokeId] of pending) settle(invokeId, void 0);
101
+ });
102
+ useEffect(() => {
103
+ teardown();
104
+ if (hasScripts) ensureWorker();
105
+ return teardown;
106
+ }, [scripts, hasScripts]);
107
+ useEffect(() => {
108
+ const worker = workerRef.current;
109
+ if (!worker || watchedPaths.current.length === 0) return;
110
+ if (emitDepth.current > 0) {
111
+ seedWatchers(data);
112
+ return;
113
+ }
114
+ watchedPaths.current.forEach((path, watchId) => {
115
+ const current = getByPointer(data, path) ?? null;
116
+ const serialized = JSON.stringify(current);
117
+ const prevSerialized = lastEval.current[watchId];
118
+ if (serialized !== prevSerialized) {
119
+ lastEval.current[watchId] = serialized;
120
+ const previous = prevSerialized === void 0 ? void 0 : JSON.parse(prevSerialized);
121
+ worker.postMessage({
122
+ current,
123
+ data,
124
+ previous,
125
+ type: "runWatcher",
126
+ watchId
127
+ });
128
+ }
129
+ });
130
+ }, [data]);
131
+ return useEffectEvent((handler, params) => {
132
+ if (!hasScripts) return Promise.resolve(void 0);
133
+ const worker = ensureWorker();
134
+ const invokeId = nextId.current++;
135
+ return new Promise((resolve) => {
136
+ const timer = setTimeout(() => {
137
+ settle(invokeId, void 0);
138
+ teardown();
139
+ }, INVOKE_TIMEOUT_MS);
140
+ pending.set(invokeId, {
141
+ resolve,
142
+ timer
143
+ });
144
+ worker.postMessage({
145
+ data: dataRef.current,
146
+ handler,
147
+ invokeId,
148
+ params: params ?? {},
149
+ type: "invoke"
150
+ });
151
+ });
152
+ });
153
+ }
154
+ /** Read a JSON-pointer slice, matching the worker's getByPointer semantics. */
155
+ function getByPointer(data, path) {
156
+ if (!path || path === "/") return data;
157
+ try {
158
+ return get(data, path);
159
+ } catch {
160
+ return;
161
+ }
162
+ }
163
+ //#endregion
164
+ export { useProteusScripts };
@@ -0,0 +1,149 @@
1
+ //#region src/proteus-script/workerScript.ts
2
+ /**
3
+ * Source of the sandbox Web Worker, authored as a self-contained string so it
4
+ * can be turned into a Blob URL and run without a separate chunk or same-origin
5
+ * file (mirrors how `ProteusBridge` inlines its shim script).
6
+ *
7
+ * The worker has no DOM, no `window.parent`, and communicates only via
8
+ * structured-clone `postMessage` — a strictly tighter sandbox than an iframe.
9
+ * A handler's sole capability is `ctx.emit`, which posts an existing Proteus
10
+ * event back to the host to be re-dispatched. See `protocol.ts` for the message
11
+ * shapes and `ScriptContext`.
12
+ *
13
+ * INVARIANT: script isolation depends on this worker holding no privileged
14
+ * globals. We `new Function(...)` the document's source below, which is only
15
+ * safe because there is nothing sensitive in scope. Do NOT add `importScripts`,
16
+ * pass host capabilities in via `postMessage`, or expose any API to handler
17
+ * scope — doing so turns `new Function` into an escape hatch.
18
+ */
19
+ const WORKER_SCRIPT = `
20
+ "use strict";
21
+
22
+ const handlers = new Map();
23
+ const watchers = [];
24
+ const pendingEmits = new Map();
25
+ let nextEmitId = 1;
26
+
27
+ // Minimal JSON-pointer getter (same semantics as the host's getProteusValue).
28
+ function getByPointer(data, path) {
29
+ if (!path || path === "/") return data;
30
+ const parts = path.replace(/^\\//, "").split("/");
31
+ let current = data;
32
+ for (const raw of parts) {
33
+ if (current == null) return undefined;
34
+ const key = raw.replace(/~1/g, "/").replace(/~0/g, "~");
35
+ current = current[key];
36
+ }
37
+ return current;
38
+ }
39
+
40
+ function register(name, fn) {
41
+ if (typeof name !== "string" || typeof fn !== "function") {
42
+ throw new Error("register(name, fn) requires a string name and a function");
43
+ }
44
+ handlers.set(name, fn);
45
+ }
46
+
47
+ function watch(path, fn) {
48
+ if (typeof path !== "string" || typeof fn !== "function") {
49
+ throw new Error("watch(path, fn) requires a string path and a function");
50
+ }
51
+ watchers.push({ fn, path });
52
+ }
53
+
54
+ // Evaluate one module's source in a scope that exposes \`register\` and \`watch\`.
55
+ // Handlers are keyed by "moduleName:handlerName" via a per-module register
56
+ // wrapper; watchers are collected globally (order = watch id).
57
+ function loadModule(moduleName, source) {
58
+ const scopedRegister = (name, fn) => register(moduleName + ":" + name, fn);
59
+ // eslint-disable-next-line no-new-func
60
+ const factory = new Function("register", "watch", source);
61
+ factory(scopedRegister, watch);
62
+ }
63
+
64
+ function emit(event) {
65
+ const emitId = nextEmitId++;
66
+ return new Promise((resolve) => {
67
+ pendingEmits.set(emitId, resolve);
68
+ self.postMessage({ emitId, event, type: "emit" });
69
+ });
70
+ }
71
+
72
+ self.onmessage = async (e) => {
73
+ const msg = e.data;
74
+
75
+ if (msg.type === "init") {
76
+ for (const [moduleName, source] of Object.entries(msg.scripts ?? {})) {
77
+ try {
78
+ loadModule(moduleName, source);
79
+ } catch (err) {
80
+ // A broken module leaves its handlers unregistered; invokes for them
81
+ // surface a "not found" error at call time.
82
+ }
83
+ }
84
+ // Report watched paths so the host can edge-detect and drive runWatcher.
85
+ self.postMessage({ paths: watchers.map((w) => w.path), type: "watchers" });
86
+ return;
87
+ }
88
+
89
+ if (msg.type === "emitResult") {
90
+ const resolve = pendingEmits.get(msg.emitId);
91
+ if (resolve) {
92
+ pendingEmits.delete(msg.emitId);
93
+ resolve(msg.result);
94
+ }
95
+ return;
96
+ }
97
+
98
+ if (msg.type === "invoke") {
99
+ const { data, handler, invokeId, params } = msg;
100
+ const fn = handlers.get(handler);
101
+ if (!fn) {
102
+ self.postMessage({
103
+ error: 'No script handler registered for "' + handler + '"',
104
+ invokeId,
105
+ result: undefined,
106
+ type: "invokeResult",
107
+ });
108
+ return;
109
+ }
110
+ const ctx = {
111
+ emit,
112
+ getValue: (path) => getByPointer(data, path),
113
+ };
114
+ try {
115
+ const result = await fn(ctx, params ?? {});
116
+ self.postMessage({ invokeId, result, type: "invokeResult" });
117
+ } catch (err) {
118
+ self.postMessage({
119
+ error: err && err.message ? String(err.message) : String(err),
120
+ invokeId,
121
+ result: undefined,
122
+ type: "invokeResult",
123
+ });
124
+ }
125
+ return;
126
+ }
127
+
128
+ if (msg.type === "runWatcher") {
129
+ const { current, data, previous, watchId } = msg;
130
+ const watcher = watchers[watchId];
131
+ if (!watcher) {
132
+ return;
133
+ }
134
+ const ctx = {
135
+ emit,
136
+ getValue: (path) => getByPointer(data, path),
137
+ };
138
+ try {
139
+ // Fire-and-forget: watchers return nothing to any caller. Errors are
140
+ // swallowed to match the fail-silent default of the other ops.
141
+ await watcher.fn(ctx, current, previous);
142
+ } catch (err) {
143
+ // Intentionally ignored.
144
+ }
145
+ }
146
+ };
147
+ `;
148
+ //#endregion
149
+ export { WORKER_SCRIPT };
@@ -1210,6 +1210,32 @@ var public_schema_default = {
1210
1210
  "gap": "16"
1211
1211
  }],
1212
1212
  "title": "Create Test Plan"
1213
+ },
1214
+ {
1215
+ "$type": "Document",
1216
+ "actions": [{
1217
+ "$type": "Action",
1218
+ "appearance": "primary",
1219
+ "children": "Greet",
1220
+ "onClick": {
1221
+ "params": { "name": {
1222
+ "$type": "Value",
1223
+ "path": "/name"
1224
+ } },
1225
+ "script": "main:greet"
1226
+ }
1227
+ }],
1228
+ "appName": "Opal",
1229
+ "body": [{
1230
+ "$type": "Field",
1231
+ "children": {
1232
+ "$type": "Input",
1233
+ "name": "name"
1234
+ },
1235
+ "label": "Your name"
1236
+ }],
1237
+ "scripts": { "main": "register('greet', (ctx) => ctx.emit({ message: `Hello, ${ctx.params.name}!` }))" },
1238
+ "title": "Scripted Greeting"
1213
1239
  }
1214
1240
  ],
1215
1241
  "properties": {
@@ -1252,6 +1278,11 @@ var public_schema_default = {
1252
1278
  "type": "object"
1253
1279
  },
1254
1280
  "meta": { "description": "Additional metadata not directly consumed by Proteus. Use this to pass along any extra data." },
1281
+ "scripts": {
1282
+ "additionalProperties": { "type": "string" },
1283
+ "description": "Map of module name to JavaScript source. Each module runs in a sandboxed Web Worker and registers named handlers via `register(name, fn)`. Trigger a handler from any event source with `{ script: 'module:handler', params }`; handlers receive a single `ctx` ({ emit, getValue, params }) and can only affect the document by emitting the existing Proteus events through `ctx.emit`.",
1284
+ "type": "object"
1285
+ },
1255
1286
  "subtitle": {
1256
1287
  "$ref": "#/definitions/ProteusNode",
1257
1288
  "description": "A brief description or tagline that provides additional context about the Proteus document's purpose."
@@ -1338,6 +1369,23 @@ var public_schema_default = {
1338
1369
  "required": ["interaction"],
1339
1370
  "type": "object"
1340
1371
  },
1372
+ {
1373
+ "additionalProperties": false,
1374
+ "description": "Scripted action - runs a named handler from the document's `scripts` map in a sandboxed Web Worker. The handler can emit the other Proteus events via `ctx.emit`.",
1375
+ "properties": {
1376
+ "params": {
1377
+ "additionalProperties": {},
1378
+ "description": "Parameters passed to the handler as `ctx.params`. Values can be ProteusExpressions that resolve at call time.",
1379
+ "type": "object"
1380
+ },
1381
+ "script": {
1382
+ "description": "Handler to run, addressed as `module:handler` (the module key comes from the document's `scripts` map).",
1383
+ "type": "string"
1384
+ }
1385
+ },
1386
+ "required": ["script"],
1387
+ "type": "object"
1388
+ },
1341
1389
  {
1342
1390
  "additionalProperties": false,
1343
1391
  "description": "Client-side message action",
@@ -1441,6 +1489,24 @@ var public_schema_default = {
1441
1489
  },
1442
1490
  "required": ["action", "path"],
1443
1491
  "type": "object"
1492
+ },
1493
+ {
1494
+ "additionalProperties": false,
1495
+ "description": "Runtime data operation - writes `value` at `path` in form data, replacing any existing value (e.g. `/tags/2`). Path resolves like `Value` (absolute `/x`, relative to current parentPath, or `''` for parentPath itself).",
1496
+ "properties": {
1497
+ "action": {
1498
+ "const": "setValue",
1499
+ "description": "The action type",
1500
+ "type": "string"
1501
+ },
1502
+ "path": {
1503
+ "description": "JSON pointer path to write to",
1504
+ "type": "string"
1505
+ },
1506
+ "value": { "description": "The value to write; primitives or objects" }
1507
+ },
1508
+ "required": ["action", "path"],
1509
+ "type": "object"
1444
1510
  }
1445
1511
  ],
1446
1512
  "description": "Handler for user interactions - a server-side interaction call, client-side message, or client-side component action"
@@ -1197,6 +1197,32 @@ var definitions = {
1197
1197
  "gap": "16"
1198
1198
  }],
1199
1199
  "title": "Create Test Plan"
1200
+ },
1201
+ {
1202
+ "$type": "Document",
1203
+ "actions": [{
1204
+ "$type": "Action",
1205
+ "appearance": "primary",
1206
+ "children": "Greet",
1207
+ "onClick": {
1208
+ "params": { "name": {
1209
+ "$type": "Value",
1210
+ "path": "/name"
1211
+ } },
1212
+ "script": "main:greet"
1213
+ }
1214
+ }],
1215
+ "appName": "Opal",
1216
+ "body": [{
1217
+ "$type": "Field",
1218
+ "children": {
1219
+ "$type": "Input",
1220
+ "name": "name"
1221
+ },
1222
+ "label": "Your name"
1223
+ }],
1224
+ "scripts": { "main": "register('greet', (ctx) => ctx.emit({ message: `Hello, ${ctx.params.name}!` }))" },
1225
+ "title": "Scripted Greeting"
1200
1226
  }
1201
1227
  ],
1202
1228
  "properties": {
@@ -1239,6 +1265,11 @@ var definitions = {
1239
1265
  "type": "object"
1240
1266
  },
1241
1267
  "meta": { "description": "Additional metadata not directly consumed by Proteus. Use this to pass along any extra data." },
1268
+ "scripts": {
1269
+ "additionalProperties": { "type": "string" },
1270
+ "description": "Map of module name to JavaScript source. Each module runs in a sandboxed Web Worker and registers named handlers via `register(name, fn)`. Trigger a handler from any event source with `{ script: 'module:handler', params }`; handlers receive a single `ctx` ({ emit, getValue, params }) and can only affect the document by emitting the existing Proteus events through `ctx.emit`.",
1271
+ "type": "object"
1272
+ },
1242
1273
  "subtitle": {
1243
1274
  "$ref": "#/definitions/ProteusNode",
1244
1275
  "description": "A brief description or tagline that provides additional context about the Proteus document's purpose."
@@ -1324,6 +1355,22 @@ var definitions = {
1324
1355
  "required": ["interaction"],
1325
1356
  "type": "object"
1326
1357
  },
1358
+ {
1359
+ "description": "Scripted action - runs a named handler from the document's `scripts` map in a sandboxed Web Worker. The handler can emit the other Proteus events via `ctx.emit`.",
1360
+ "properties": {
1361
+ "params": {
1362
+ "additionalProperties": {},
1363
+ "description": "Parameters passed to the handler as `ctx.params`. Values can be ProteusExpressions that resolve at call time.",
1364
+ "type": "object"
1365
+ },
1366
+ "script": {
1367
+ "description": "Handler to run, addressed as `module:handler` (the module key comes from the document's `scripts` map).",
1368
+ "type": "string"
1369
+ }
1370
+ },
1371
+ "required": ["script"],
1372
+ "type": "object"
1373
+ },
1327
1374
  {
1328
1375
  "description": "Client-side message action",
1329
1376
  "properties": { "message": {
@@ -1421,6 +1468,23 @@ var definitions = {
1421
1468
  },
1422
1469
  "required": ["action", "path"],
1423
1470
  "type": "object"
1471
+ },
1472
+ {
1473
+ "description": "Runtime data operation - writes `value` at `path` in form data, replacing any existing value (e.g. `/tags/2`). Path resolves like `Value` (absolute `/x`, relative to current parentPath, or `''` for parentPath itself).",
1474
+ "properties": {
1475
+ "action": {
1476
+ "const": "setValue",
1477
+ "description": "The action type",
1478
+ "type": "string"
1479
+ },
1480
+ "path": {
1481
+ "description": "JSON pointer path to write to",
1482
+ "type": "string"
1483
+ },
1484
+ "value": { "description": "The value to write; primitives or objects" }
1485
+ },
1486
+ "required": ["action", "path"],
1487
+ "type": "object"
1424
1488
  }
1425
1489
  ],
1426
1490
  "description": "Handler for user interactions - a server-side interaction call, client-side message, or client-side component action"
package/dist/index.d.ts CHANGED
@@ -432,6 +432,8 @@ type ProteusDocumentShellProps = Pick<ComponentPropsWithoutRef<typeof Disclosure
432
432
  element: ProteusDocument$2;
433
433
  /**
434
434
  * Map of icon name to React component. Referenced by `{ $type: "Icon", name }` elements.
435
+ * If `name` is not in the map but starts with `http(s)://` or `data:`, it is
436
+ * rendered as `<img src={name}>` instead.
435
437
  */
436
438
  icons?: ProteusIconMap;
437
439
  /**
@@ -492,6 +494,7 @@ type ProteusDocument$2 = {
492
494
  blocking?: boolean;
493
495
  body: ReactNode;
494
496
  compact?: boolean;
497
+ scripts?: Record<string, string>;
495
498
  subtitle?: ReactNode;
496
499
  title?: ReactNode;
497
500
  titleIcon?: string;
@@ -540,11 +543,18 @@ type ProteusEventHandler = {
540
543
  } | {
541
544
  action: "removeValue";
542
545
  path: string;
546
+ } | {
547
+ action: "setValue";
548
+ path: string;
549
+ value?: unknown;
543
550
  } | {
544
551
  interaction: string;
545
552
  params?: Record<string, unknown>;
546
553
  } | {
547
554
  message: string | StructuredMessage;
555
+ } | {
556
+ params?: Record<string, unknown>;
557
+ script: string;
548
558
  };
549
559
  type ProteusPreviewFile = {
550
560
  extension: string;
@@ -619,6 +629,7 @@ type ProteusDocument = {
619
629
  blocking?: boolean;
620
630
  body: unknown;
621
631
  compact?: boolean;
632
+ scripts?: Record<string, string>;
622
633
  subtitle?: unknown;
623
634
  title?: unknown;
624
635
  titleIcon?: string;
package/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "url": "git+https://github.com/optimizely-axiom/optiaxiom.git"
8
8
  },
9
9
  "type": "module",
10
- "version": "3.0.6",
10
+ "version": "3.1.0",
11
11
  "files": [
12
12
  "dist/**",
13
13
  "LICENSE"
@@ -42,7 +42,7 @@
42
42
  "recharts": "^3.8.1",
43
43
  "remark-gfm": "^4.0.1",
44
44
  "@optiaxiom/icons": "^1.3.0",
45
- "@optiaxiom/react": "^3.1.2"
45
+ "@optiaxiom/react": "^3.1.4"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@emotion/hash": "^0.9.2",