@tinkerlandai/indiana-iam 0.0.0-stage → 0.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.
- package/README.md +92 -2
- package/assets/document-controller.js +215 -0
- package/assets/document-runtime.js +295 -0
- package/assets/preview-keyboard.js +12 -0
- package/assets/state-machine.js +75 -0
- package/assets/viewer.css +735 -0
- package/assets/viewer.html +140 -0
- package/assets/viewer.js +859 -0
- package/package.json +62 -4
- package/src/analyze.mjs +1759 -0
- package/src/build-options.d.ts +24 -0
- package/src/build-runtime.mjs +10 -0
- package/src/build-styles.mjs +155 -0
- package/src/build.mjs +135 -0
- package/src/cli.mjs +120 -0
- package/src/compact-dom.mjs +9 -0
- package/src/dependencies.mjs +19 -0
- package/src/framework-routes.mjs +619 -0
- package/src/generate.mjs +126 -0
- package/src/index.d.ts +116 -0
- package/src/index.mjs +7 -0
- package/src/instrument.mjs +95 -0
- package/src/integrity.mjs +85 -0
- package/src/json.mjs +37 -0
- package/src/library-entry.mjs +82 -0
- package/src/next.d.ts +15 -0
- package/src/next.mjs +51 -0
- package/src/node-hashes.mjs +45 -0
- package/src/privacy.mjs +37 -0
- package/src/release-metadata.mjs +12 -0
- package/src/render-index.mjs +193 -0
- package/src/route-composition.mjs +90 -0
- package/src/route-tree.mjs +166 -0
- package/src/routing.mjs +176 -0
- package/src/runtime-asset.mjs +3 -0
- package/src/schema-definition.mjs +178 -0
- package/src/shared-scripts.mjs +22 -0
- package/src/static-query.mjs +145 -0
- package/src/upload.mjs +81 -0
- package/src/validate.mjs +284 -0
- package/src/viewer-assets.mjs +5 -0
- package/src/vite.d.ts +10 -0
- package/src/vite.mjs +54 -0
package/README.md
CHANGED
|
@@ -1,3 +1,93 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @tinkerlandai/indiana-iam
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Build-time generator for the Indiana App Manifest (IAM). On every production build it scans your App's source, generates
|
|
4
|
+
and validates the IAM, and writes the release descriptor your pages use to bind visual changes to that exact source.
|
|
5
|
+
Indiana's App page map edits pages from these files; the browser SDK applies the published changes.
|
|
6
|
+
|
|
7
|
+
Install it as a development dependency (Node.js 22 or later):
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install --save-dev @tinkerlandai/indiana-iam
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Next.js (15.4.1 or later)
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
// next.config.mjs
|
|
17
|
+
import { withIndianaIam } from "@tinkerlandai/indiana-iam/next";
|
|
18
|
+
|
|
19
|
+
export default withIndianaIam({
|
|
20
|
+
// your Next.js config
|
|
21
|
+
});
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`next build` then:
|
|
25
|
+
|
|
26
|
+
1. scans the source and exposes `process.env.INDIANA_IAM_RELEASE_URL`;
|
|
27
|
+
2. after compilation (`compiler.runAfterProductionCompile`), packages the compiled CSS, validates the IAM, writes it
|
|
28
|
+
to a temporary folder outside your checkout and serves `public/.well-known/indiana/releases/<id>.json`.
|
|
29
|
+
|
|
30
|
+
Point the visual SDK at the descriptor:
|
|
31
|
+
|
|
32
|
+
```tsx
|
|
33
|
+
<script
|
|
34
|
+
type="module"
|
|
35
|
+
src="https://tinker.cash/web-view/visual-sdk"
|
|
36
|
+
data-indiana-environment="<environment ID>"
|
|
37
|
+
data-indiana-manifest-url={process.env.INDIANA_IAM_RELEASE_URL}
|
|
38
|
+
/>
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`next dev` and other phases leave your config unchanged. Add `public/.well-known/indiana/` to `.gitignore`.
|
|
42
|
+
|
|
43
|
+
## Vite and React Router
|
|
44
|
+
|
|
45
|
+
```js
|
|
46
|
+
// vite.config.mjs
|
|
47
|
+
import { indianaIam } from "@tinkerlandai/indiana-iam/vite";
|
|
48
|
+
|
|
49
|
+
export default { plugins: [indianaIam()] };
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Client builds expose `import.meta.env.INDIANA_IAM_RELEASE_URL` and write the descriptor into the build output folder.
|
|
53
|
+
Server builds are skipped.
|
|
54
|
+
|
|
55
|
+
## Options
|
|
56
|
+
|
|
57
|
+
Both integrations accept `{ enabled, name, strict, rootFont, omitSvgResources, logger, apiKey, baseUrl, upload }`;
|
|
58
|
+
`withIndianaIam` also takes `root`. By default a source the scanner cannot read prints a warning and the build continues without an IAM;
|
|
59
|
+
`strict: true` fails the build instead. The same source always produces the same release ID and IAM bytes.
|
|
60
|
+
|
|
61
|
+
## Other builds
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
indiana-iam generate --root . --out <new folder outside the checkout>
|
|
65
|
+
indiana-iam validate --file <folder>/indiana-app-manifest.json
|
|
66
|
+
indiana-iam prepare --root .
|
|
67
|
+
indiana-iam anchor --root . --out <plan.json> [--apply]
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`anchor` adds reviewable `data-indiana-id` attributes to JSX elements so saved changes keep targeting the same elements
|
|
71
|
+
across releases.
|
|
72
|
+
|
|
73
|
+
## Scope
|
|
74
|
+
|
|
75
|
+
Supported source: Next.js App Router and Pages Router, and React Router / single-root React apps built with Vite.
|
|
76
|
+
The scanner reads source files only; it never runs your build, imports your code or reads env files. Only the upload
|
|
77
|
+
step calls Indiana, and only when a key is set.
|
|
78
|
+
Values that look like credentials, tokens, emails or private URLs are removed, but review what your pages contain.
|
|
79
|
+
|
|
80
|
+
## Upload
|
|
81
|
+
|
|
82
|
+
Create a Key with only the `app-manifests:write` permission (Console → developer settings) and set it as
|
|
83
|
+
`INDIANA_IAM_KEY` in your build environment. Each production build then registers the IAM, stores it through a
|
|
84
|
+
server-signed URL and asks Indiana to validate it; the validated version becomes the environment's current version.
|
|
85
|
+
Rebuilding unchanged source only makes the existing version current again.
|
|
86
|
+
|
|
87
|
+
- Uploads go to `https://tinker.cash`; set `INDIANA_IAM_BASE_URL` (or the `baseUrl` option) for another Indiana
|
|
88
|
+
environment.
|
|
89
|
+
- On Vercel only production deployments upload by default; `upload: "always"` uploads every build and
|
|
90
|
+
`upload: false` never uploads.
|
|
91
|
+
- A failed upload prints a warning and the build continues, unless `strict: true`.
|
|
92
|
+
- The App's Owner or Admin must turn on the App page map first.
|
|
93
|
+
- `INDIANA_IAM_KEY=<key> indiana-iam upload --file <manifest>` uploads a file generated elsewhere.
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/* Indiana UI simulation runtime 0.2.0. No merchant code, requests, authentication or purchases. */
|
|
2
|
+
(() => {
|
|
3
|
+
"use strict";
|
|
4
|
+
// Modelled html/body remain mapped wrappers. Mirror their classes onto the
|
|
5
|
+
// actual document roots so UA color-scheme and viewport backgrounds agree.
|
|
6
|
+
for (const [name, root] of [["html", document.documentElement], ["body", document.body]]) {
|
|
7
|
+
const model = document.querySelector('[data-iam-adapter="' + name + '"]');
|
|
8
|
+
if (model) root.className = model.getAttribute("class") || "";
|
|
9
|
+
}
|
|
10
|
+
let machine = null;
|
|
11
|
+
const patches = new Map();
|
|
12
|
+
const node = id => [...document.querySelectorAll("[data-iam-node]")].find(e => e.dataset.iamNode === id);
|
|
13
|
+
function renderState() {
|
|
14
|
+
if (!machine) return;
|
|
15
|
+
for (const binding of machine.bindings()) {
|
|
16
|
+
const el = node(binding.nodeId); if (!el) continue;
|
|
17
|
+
if (binding.property === "visible") el.hidden = !binding.value;
|
|
18
|
+
else if (binding.property === "className") el.setAttribute('class',binding.value);
|
|
19
|
+
else if (binding.property === "text") el.textContent = String(binding.value);
|
|
20
|
+
else el[binding.property] = binding.value;
|
|
21
|
+
}
|
|
22
|
+
for (const patch of patches.values()) applyPatch(patch);
|
|
23
|
+
}
|
|
24
|
+
function applyPatch(data) {
|
|
25
|
+
const el = node(data.nodeId); if (!el) return;
|
|
26
|
+
if (data.property === "text" && typeof data.value === "string" && data.value.length <= 4000 && el.children.length === 0) el.textContent = data.value;
|
|
27
|
+
if (data.property === "fontSize" && Number.isFinite(data.value) && data.value >= 8 && data.value <= 120) el.style.fontSize = data.value + "px";
|
|
28
|
+
if (data.property === "color" && /^#[0-9a-f]{6}$/i.test(data.value)) el.style.color = data.value;
|
|
29
|
+
}
|
|
30
|
+
function uiEvent(el, event, payload = {}) {
|
|
31
|
+
if (!machine || !el || el.disabled || el.closest("[hidden]")) return false;
|
|
32
|
+
try {
|
|
33
|
+
const result = machine.dispatch(el.dataset.iamNode, event, payload);
|
|
34
|
+
if (!result) return false;
|
|
35
|
+
renderState(); send("stateChanged", result); return true;
|
|
36
|
+
} catch { send("simulated", { message: "此状态变化无法安全模拟,已保留原状态。" }); return true; }
|
|
37
|
+
}
|
|
38
|
+
let channel = null,
|
|
39
|
+
selecting = true,
|
|
40
|
+
selected = null;
|
|
41
|
+
const send = (type, payload = {}) => {
|
|
42
|
+
if (channel)
|
|
43
|
+
parent.postMessage(
|
|
44
|
+
{ channel: "iam-preview-v1", nonce: channel, type, ...payload },
|
|
45
|
+
"*",
|
|
46
|
+
);
|
|
47
|
+
};
|
|
48
|
+
const describe = (el) => {
|
|
49
|
+
const style = getComputedStyle(el);
|
|
50
|
+
return {
|
|
51
|
+
nodeId: el.dataset.iamNode,
|
|
52
|
+
tag: el.tagName.toLowerCase(),
|
|
53
|
+
text: (el.textContent || "").slice(0, 4000),
|
|
54
|
+
canEditText: el.children.length === 0,
|
|
55
|
+
fontSize: parseFloat(style.fontSize),
|
|
56
|
+
color: style.color,
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
const choose = (el) => {
|
|
60
|
+
if (selected) {
|
|
61
|
+
selected.style.outline = "";
|
|
62
|
+
selected.style.outlineOffset = "";
|
|
63
|
+
}
|
|
64
|
+
selected = el;
|
|
65
|
+
selected.style.outline = "2px solid #1c7258";
|
|
66
|
+
selected.style.outlineOffset = "3px";
|
|
67
|
+
send("selected", { element: describe(el) });
|
|
68
|
+
};
|
|
69
|
+
addEventListener("message", (event) => {
|
|
70
|
+
if (
|
|
71
|
+
event.source !== parent ||
|
|
72
|
+
!event.data ||
|
|
73
|
+
event.data.channel !== "iam-preview-v1"
|
|
74
|
+
)
|
|
75
|
+
return;
|
|
76
|
+
const data = event.data;
|
|
77
|
+
if (!channel && data.type === "init" && typeof data.nonce === "string") {
|
|
78
|
+
channel = data.nonce;
|
|
79
|
+
if (data.graph && typeof data.routeKey === "string") {
|
|
80
|
+
machine = createStateMachine(data.graph, data.routeKey, data.renderKey);
|
|
81
|
+
const renderKey = data.renderKey ?? data.graph.routes.find(route => route.routeKey === data.routeKey)?.initialRenderKey;
|
|
82
|
+
activeTransitions = data.graph.transitions.filter(t => t.routeKey === data.routeKey && t.renderKey === renderKey);
|
|
83
|
+
renderState();
|
|
84
|
+
}
|
|
85
|
+
send("ready");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (!channel || data.nonce !== channel) return;
|
|
89
|
+
if (data.type === "previewValues" && machine) {
|
|
90
|
+
try { const result = machine.preview(data.values); renderState(); send("stateChanged", result); }
|
|
91
|
+
catch { send("simulated", { message: "界面预览值无效,已保留原状态。" }); }
|
|
92
|
+
}
|
|
93
|
+
if (data.type === "mode") {
|
|
94
|
+
selecting = data.selecting === true;
|
|
95
|
+
document.body.style.cursor = selecting ? "crosshair" : "";
|
|
96
|
+
}
|
|
97
|
+
if (data.type === "anchor" && typeof data.id === "string")
|
|
98
|
+
document.getElementById(data.id)?.scrollIntoView({ behavior: "smooth" });
|
|
99
|
+
if (data.type === "select" && typeof data.nodeId === "string") {
|
|
100
|
+
const el = node(data.nodeId);
|
|
101
|
+
if (el) {
|
|
102
|
+
choose(el);
|
|
103
|
+
el.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (data.type === "patch" && typeof data.nodeId === "string") {
|
|
107
|
+
const el = [...document.querySelectorAll("[data-iam-node]")].find(
|
|
108
|
+
(e) => e.dataset.iamNode === data.nodeId,
|
|
109
|
+
);
|
|
110
|
+
if (!el) return;
|
|
111
|
+
patches.set(data.nodeId + ":" + data.property, data);
|
|
112
|
+
if (
|
|
113
|
+
data.property === "text" &&
|
|
114
|
+
typeof data.value === "string" &&
|
|
115
|
+
data.value.length <= 4000 &&
|
|
116
|
+
el.children.length === 0
|
|
117
|
+
)
|
|
118
|
+
el.textContent = data.value;
|
|
119
|
+
if (
|
|
120
|
+
data.property === "fontSize" &&
|
|
121
|
+
Number.isFinite(data.value) &&
|
|
122
|
+
data.value >= 8 &&
|
|
123
|
+
data.value <= 120
|
|
124
|
+
)
|
|
125
|
+
el.style.fontSize = data.value + "px";
|
|
126
|
+
if (data.property === "color" && /^#[0-9a-f]{6}$/i.test(data.value))
|
|
127
|
+
el.style.color = data.value;
|
|
128
|
+
send("patched", { element: describe(el) });
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
for (const event of ["input", "change"]) document.addEventListener(event, e => {
|
|
132
|
+
if (selecting) return;
|
|
133
|
+
const match = eventTarget(e.target, event);
|
|
134
|
+
if (!match) return;
|
|
135
|
+
const { el, transition } = match;
|
|
136
|
+
const payload = {};
|
|
137
|
+
for (const key of Object.keys(transition.trigger.payload)) payload[key] = key === "checked" ? el.checked : el.value;
|
|
138
|
+
uiEvent(el, event, payload);
|
|
139
|
+
}, true);
|
|
140
|
+
let activeTransitions = [];
|
|
141
|
+
function eventTarget(target, event) {
|
|
142
|
+
for (let el = target; el?.dataset; el = el.parentElement) {
|
|
143
|
+
const transition = activeTransitions.find(t => t.trigger.nodeId === el.dataset.iamNode && t.trigger.event === event);
|
|
144
|
+
if (transition) return { el, transition };
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
document.addEventListener(
|
|
149
|
+
"submit",
|
|
150
|
+
(event) => {
|
|
151
|
+
event.preventDefault();
|
|
152
|
+
send("simulated", { message: "此界面仅作模拟,表单不会提交。" });
|
|
153
|
+
},
|
|
154
|
+
true,
|
|
155
|
+
);
|
|
156
|
+
document.addEventListener(
|
|
157
|
+
"click",
|
|
158
|
+
(event) => {
|
|
159
|
+
const el = event.target.closest?.("[data-iam-node]");
|
|
160
|
+
if (!el) return;
|
|
161
|
+
if (selecting) {
|
|
162
|
+
event.preventDefault();
|
|
163
|
+
event.stopImmediatePropagation();
|
|
164
|
+
choose(el);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const link = event.target.closest("a");
|
|
168
|
+
const action = eventTarget(event.target, "click");
|
|
169
|
+
if (action) {
|
|
170
|
+
const payload = {};
|
|
171
|
+
for (const key of Object.keys(action.transition.trigger.payload)) payload[key] = key === "checked" ? action.el.checked : action.el.value;
|
|
172
|
+
event.preventDefault();
|
|
173
|
+
uiEvent(action.el, "click", payload);
|
|
174
|
+
// A UI-only handler (for example closing a mobile menu) does not
|
|
175
|
+
// cancel the link's separately declared navigation.
|
|
176
|
+
if (!link) return;
|
|
177
|
+
}
|
|
178
|
+
if (link) {
|
|
179
|
+
event.preventDefault();
|
|
180
|
+
const href = link.getAttribute("href") || "";
|
|
181
|
+
if (href.startsWith("#")) {
|
|
182
|
+
document
|
|
183
|
+
.getElementById(href.slice(1))
|
|
184
|
+
?.scrollIntoView({ behavior: "smooth" });
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
send("navigate", { href, nodeId: link.dataset.iamNode });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const button = event.target.closest("button");
|
|
191
|
+
if (button) {
|
|
192
|
+
event.preventDefault();
|
|
193
|
+
const state = button.dataset.iamToggle;
|
|
194
|
+
if (state) {
|
|
195
|
+
const branches = [
|
|
196
|
+
...document.querySelectorAll("[data-iam-state]"),
|
|
197
|
+
].filter((e) => e.dataset.iamState === state),
|
|
198
|
+
active = branches.find((e) => e.dataset.iamBranch === "true");
|
|
199
|
+
if (active) {
|
|
200
|
+
const next = active.hidden;
|
|
201
|
+
for (const branch of branches)
|
|
202
|
+
branch.hidden = branch.dataset.iamBranch !== String(next);
|
|
203
|
+
button.setAttribute("aria-expanded", String(next));
|
|
204
|
+
send("simulated", {
|
|
205
|
+
message: "已按源码模拟界面状态,未执行业务。",
|
|
206
|
+
});
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
send("simulated", { message: "按钮仅展示界面,不执行业务操作。" });
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
true,
|
|
214
|
+
);
|
|
215
|
+
})();
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/* Generated by scripts/build-runtime.mjs. */
|
|
2
|
+
(() => {
|
|
3
|
+
/* Indiana declarative UI state machine. Pure computation; no merchant callbacks. */
|
|
4
|
+
function createStateMachine(graph, routeKey, renderKey) {
|
|
5
|
+
{
|
|
6
|
+
renderKey ??= graph.routes.find(route => route.routeKey === routeKey)?.initialRenderKey;
|
|
7
|
+
if (!graph.renders.some(render => render.renderKey === renderKey && render.routeKey === routeKey)) throw new Error("Unknown UI render");
|
|
8
|
+
}
|
|
9
|
+
const owns = row => row.routeKey === routeKey && row.renderKey === renderKey;
|
|
10
|
+
const variables = new Map(graph.variables.filter(owns).map(v => [v.variableKey, v]));
|
|
11
|
+
const transitions = graph.transitions.filter(t => owns(t) && t.simulation === "implemented");
|
|
12
|
+
let values = Object.fromEntries([...variables].map(([key, v]) => [key, v.initialValue]));
|
|
13
|
+
const read = (expression, payload, snapshot, depth = 0) => {
|
|
14
|
+
if (depth > 24) throw new Error("Expression budget exceeded");
|
|
15
|
+
const next = e => read(e, payload, snapshot, depth + 1);
|
|
16
|
+
switch (expression.op) {
|
|
17
|
+
case "const": return expression.value;
|
|
18
|
+
case "var": {
|
|
19
|
+
if (!variables.has(expression.key)) throw new Error("Unknown variable");
|
|
20
|
+
return snapshot[expression.key];
|
|
21
|
+
}
|
|
22
|
+
case "event": {
|
|
23
|
+
if (!Object.hasOwn(payload, expression.key)) throw new Error("Missing event input");
|
|
24
|
+
return payload[expression.key];
|
|
25
|
+
}
|
|
26
|
+
case "not": return !next(expression.arg);
|
|
27
|
+
case "eq": return next(expression.left) === next(expression.right);
|
|
28
|
+
case "ne": return next(expression.left) !== next(expression.right);
|
|
29
|
+
case "add":
|
|
30
|
+
case "subtract": {
|
|
31
|
+
const left = next(expression.left), right = next(expression.right);
|
|
32
|
+
if (typeof left !== "number" || typeof right !== "number" || !Number.isFinite(left) || !Number.isFinite(right)) throw new Error("Invalid numeric expression");
|
|
33
|
+
const value = expression.op === "add" ? left + right : left - right;
|
|
34
|
+
if (!Number.isFinite(value)) throw new Error("Invalid numeric result");
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
case "all": return expression.args.every(e => next(e) === true);
|
|
38
|
+
case "any": return expression.args.some(e => next(e) === true);
|
|
39
|
+
case "classes": return expression.parts.filter(part=>next(part.when)===true).map(part=>part.value).join(' ');
|
|
40
|
+
default: throw new Error("Unsupported expression");
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
return {
|
|
44
|
+
snapshot: () => ({ ...values }),
|
|
45
|
+
preview(input) {
|
|
46
|
+
if (!input || Array.isArray(input) || typeof input !== "object" || Object.keys(input).length > variables.size) throw new Error("Invalid preview values");
|
|
47
|
+
const writes = Object.create(null);
|
|
48
|
+
for (const [key, value] of Object.entries(input)) {
|
|
49
|
+
const variable = variables.get(key);
|
|
50
|
+
if (!variable || typeof value !== variable.valueType || typeof value === "string" && value.length > 4000 || typeof value === "number" && !Number.isFinite(value)) throw new Error("Invalid preview value");
|
|
51
|
+
writes[key] = value;
|
|
52
|
+
}
|
|
53
|
+
values = { ...values, ...writes };
|
|
54
|
+
return { values: { ...values }, evidence: "manual-ui-preview" };
|
|
55
|
+
},
|
|
56
|
+
reset: () => { values = Object.fromEntries([...variables].map(([key, v]) => [key, v.initialValue])); },
|
|
57
|
+
bindings: () => graph.bindings.filter(owns).map(b => ({ ...b, value: read(b.value, {}, values) })),
|
|
58
|
+
dispatch(nodeId, event, payload = {}) {
|
|
59
|
+
const candidates = transitions.filter(t => t.trigger.nodeId === nodeId && t.trigger.event === event);
|
|
60
|
+
for (const t of candidates) {
|
|
61
|
+
const expected = Object.keys(t.trigger.payload);
|
|
62
|
+
if (Object.keys(payload).some(key => !expected.includes(key)) || expected.some(key => typeof payload[key] !== t.trigger.payload[key] || typeof payload[key] === "string" && payload[key].length > 4000)) throw new Error("Invalid event input");
|
|
63
|
+
}
|
|
64
|
+
const eligible = candidates.filter(t => read(t.guard, payload, values) === true);
|
|
65
|
+
if (eligible.length > 1) throw new Error("Ambiguous UI transition");
|
|
66
|
+
if (!eligible.length) return null;
|
|
67
|
+
const t = eligible[0], writes = Object.create(null);
|
|
68
|
+
for (const w of t.stateWrites) {
|
|
69
|
+
const value = read(w.value, payload, values), variable = variables.get(w.variableKey);
|
|
70
|
+
if (!variable || typeof value !== variable.valueType || typeof value === "string" && value.length > 4000 || typeof value === "number" && !Number.isFinite(value)) throw new Error("Invalid state write");
|
|
71
|
+
writes[w.variableKey] = value;
|
|
72
|
+
}
|
|
73
|
+
values = { ...values, ...writes };
|
|
74
|
+
return { transitionKey: t.transitionKey, values: { ...values } };
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* Indiana UI simulation runtime 0.2.0. No merchant code, requests, authentication or purchases. */
|
|
80
|
+
(() => {
|
|
81
|
+
"use strict";
|
|
82
|
+
// Modelled html/body remain mapped wrappers. Mirror their classes onto the
|
|
83
|
+
// actual document roots so UA color-scheme and viewport backgrounds agree.
|
|
84
|
+
for (const [name, root] of [["html", document.documentElement], ["body", document.body]]) {
|
|
85
|
+
const model = document.querySelector('[data-iam-adapter="' + name + '"]');
|
|
86
|
+
if (model) root.className = model.getAttribute("class") || "";
|
|
87
|
+
}
|
|
88
|
+
let machine = null;
|
|
89
|
+
const patches = new Map();
|
|
90
|
+
const node = id => [...document.querySelectorAll("[data-iam-node]")].find(e => e.dataset.iamNode === id);
|
|
91
|
+
function renderState() {
|
|
92
|
+
if (!machine) return;
|
|
93
|
+
for (const binding of machine.bindings()) {
|
|
94
|
+
const el = node(binding.nodeId); if (!el) continue;
|
|
95
|
+
if (binding.property === "visible") el.hidden = !binding.value;
|
|
96
|
+
else if (binding.property === "className") el.setAttribute('class',binding.value);
|
|
97
|
+
else if (binding.property === "text") el.textContent = String(binding.value);
|
|
98
|
+
else el[binding.property] = binding.value;
|
|
99
|
+
}
|
|
100
|
+
for (const patch of patches.values()) applyPatch(patch);
|
|
101
|
+
}
|
|
102
|
+
function applyPatch(data) {
|
|
103
|
+
const el = node(data.nodeId); if (!el) return;
|
|
104
|
+
if (data.property === "text" && typeof data.value === "string" && data.value.length <= 4000 && el.children.length === 0) el.textContent = data.value;
|
|
105
|
+
if (data.property === "fontSize" && Number.isFinite(data.value) && data.value >= 8 && data.value <= 120) el.style.fontSize = data.value + "px";
|
|
106
|
+
if (data.property === "color" && /^#[0-9a-f]{6}$/i.test(data.value)) el.style.color = data.value;
|
|
107
|
+
}
|
|
108
|
+
function uiEvent(el, event, payload = {}) {
|
|
109
|
+
if (!machine || !el || el.disabled || el.closest("[hidden]")) return false;
|
|
110
|
+
try {
|
|
111
|
+
const result = machine.dispatch(el.dataset.iamNode, event, payload);
|
|
112
|
+
if (!result) return false;
|
|
113
|
+
renderState(); send("stateChanged", result); return true;
|
|
114
|
+
} catch { send("simulated", { message: "此状态变化无法安全模拟,已保留原状态。" }); return true; }
|
|
115
|
+
}
|
|
116
|
+
let channel = null,
|
|
117
|
+
selecting = true,
|
|
118
|
+
selected = null;
|
|
119
|
+
const send = (type, payload = {}) => {
|
|
120
|
+
if (channel)
|
|
121
|
+
parent.postMessage(
|
|
122
|
+
{ channel: "iam-preview-v1", nonce: channel, type, ...payload },
|
|
123
|
+
"*",
|
|
124
|
+
);
|
|
125
|
+
};
|
|
126
|
+
const describe = (el) => {
|
|
127
|
+
const style = getComputedStyle(el);
|
|
128
|
+
return {
|
|
129
|
+
nodeId: el.dataset.iamNode,
|
|
130
|
+
tag: el.tagName.toLowerCase(),
|
|
131
|
+
text: (el.textContent || "").slice(0, 4000),
|
|
132
|
+
canEditText: el.children.length === 0,
|
|
133
|
+
fontSize: parseFloat(style.fontSize),
|
|
134
|
+
color: style.color,
|
|
135
|
+
};
|
|
136
|
+
};
|
|
137
|
+
const choose = (el) => {
|
|
138
|
+
if (selected) {
|
|
139
|
+
selected.style.outline = "";
|
|
140
|
+
selected.style.outlineOffset = "";
|
|
141
|
+
}
|
|
142
|
+
selected = el;
|
|
143
|
+
selected.style.outline = "2px solid #1c7258";
|
|
144
|
+
selected.style.outlineOffset = "3px";
|
|
145
|
+
send("selected", { element: describe(el) });
|
|
146
|
+
};
|
|
147
|
+
addEventListener("message", (event) => {
|
|
148
|
+
if (
|
|
149
|
+
event.source !== parent ||
|
|
150
|
+
!event.data ||
|
|
151
|
+
event.data.channel !== "iam-preview-v1"
|
|
152
|
+
)
|
|
153
|
+
return;
|
|
154
|
+
const data = event.data;
|
|
155
|
+
if (!channel && data.type === "init" && typeof data.nonce === "string") {
|
|
156
|
+
channel = data.nonce;
|
|
157
|
+
if (data.graph && typeof data.routeKey === "string") {
|
|
158
|
+
machine = createStateMachine(data.graph, data.routeKey, data.renderKey);
|
|
159
|
+
const renderKey = data.renderKey ?? data.graph.routes.find(route => route.routeKey === data.routeKey)?.initialRenderKey;
|
|
160
|
+
activeTransitions = data.graph.transitions.filter(t => t.routeKey === data.routeKey && t.renderKey === renderKey);
|
|
161
|
+
renderState();
|
|
162
|
+
}
|
|
163
|
+
send("ready");
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (!channel || data.nonce !== channel) return;
|
|
167
|
+
if (data.type === "previewValues" && machine) {
|
|
168
|
+
try { const result = machine.preview(data.values); renderState(); send("stateChanged", result); }
|
|
169
|
+
catch { send("simulated", { message: "界面预览值无效,已保留原状态。" }); }
|
|
170
|
+
}
|
|
171
|
+
if (data.type === "mode") {
|
|
172
|
+
selecting = data.selecting === true;
|
|
173
|
+
document.body.style.cursor = selecting ? "crosshair" : "";
|
|
174
|
+
}
|
|
175
|
+
if (data.type === "anchor" && typeof data.id === "string")
|
|
176
|
+
document.getElementById(data.id)?.scrollIntoView({ behavior: "smooth" });
|
|
177
|
+
if (data.type === "select" && typeof data.nodeId === "string") {
|
|
178
|
+
const el = node(data.nodeId);
|
|
179
|
+
if (el) {
|
|
180
|
+
choose(el);
|
|
181
|
+
el.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (data.type === "patch" && typeof data.nodeId === "string") {
|
|
185
|
+
const el = [...document.querySelectorAll("[data-iam-node]")].find(
|
|
186
|
+
(e) => e.dataset.iamNode === data.nodeId,
|
|
187
|
+
);
|
|
188
|
+
if (!el) return;
|
|
189
|
+
patches.set(data.nodeId + ":" + data.property, data);
|
|
190
|
+
if (
|
|
191
|
+
data.property === "text" &&
|
|
192
|
+
typeof data.value === "string" &&
|
|
193
|
+
data.value.length <= 4000 &&
|
|
194
|
+
el.children.length === 0
|
|
195
|
+
)
|
|
196
|
+
el.textContent = data.value;
|
|
197
|
+
if (
|
|
198
|
+
data.property === "fontSize" &&
|
|
199
|
+
Number.isFinite(data.value) &&
|
|
200
|
+
data.value >= 8 &&
|
|
201
|
+
data.value <= 120
|
|
202
|
+
)
|
|
203
|
+
el.style.fontSize = data.value + "px";
|
|
204
|
+
if (data.property === "color" && /^#[0-9a-f]{6}$/i.test(data.value))
|
|
205
|
+
el.style.color = data.value;
|
|
206
|
+
send("patched", { element: describe(el) });
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
for (const event of ["input", "change"]) document.addEventListener(event, e => {
|
|
210
|
+
if (selecting) return;
|
|
211
|
+
const match = eventTarget(e.target, event);
|
|
212
|
+
if (!match) return;
|
|
213
|
+
const { el, transition } = match;
|
|
214
|
+
const payload = {};
|
|
215
|
+
for (const key of Object.keys(transition.trigger.payload)) payload[key] = key === "checked" ? el.checked : el.value;
|
|
216
|
+
uiEvent(el, event, payload);
|
|
217
|
+
}, true);
|
|
218
|
+
let activeTransitions = [];
|
|
219
|
+
function eventTarget(target, event) {
|
|
220
|
+
for (let el = target; el?.dataset; el = el.parentElement) {
|
|
221
|
+
const transition = activeTransitions.find(t => t.trigger.nodeId === el.dataset.iamNode && t.trigger.event === event);
|
|
222
|
+
if (transition) return { el, transition };
|
|
223
|
+
}
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
document.addEventListener(
|
|
227
|
+
"submit",
|
|
228
|
+
(event) => {
|
|
229
|
+
event.preventDefault();
|
|
230
|
+
send("simulated", { message: "此界面仅作模拟,表单不会提交。" });
|
|
231
|
+
},
|
|
232
|
+
true,
|
|
233
|
+
);
|
|
234
|
+
document.addEventListener(
|
|
235
|
+
"click",
|
|
236
|
+
(event) => {
|
|
237
|
+
const el = event.target.closest?.("[data-iam-node]");
|
|
238
|
+
if (!el) return;
|
|
239
|
+
if (selecting) {
|
|
240
|
+
event.preventDefault();
|
|
241
|
+
event.stopImmediatePropagation();
|
|
242
|
+
choose(el);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const link = event.target.closest("a");
|
|
246
|
+
const action = eventTarget(event.target, "click");
|
|
247
|
+
if (action) {
|
|
248
|
+
const payload = {};
|
|
249
|
+
for (const key of Object.keys(action.transition.trigger.payload)) payload[key] = key === "checked" ? action.el.checked : action.el.value;
|
|
250
|
+
event.preventDefault();
|
|
251
|
+
uiEvent(action.el, "click", payload);
|
|
252
|
+
// A UI-only handler (for example closing a mobile menu) does not
|
|
253
|
+
// cancel the link's separately declared navigation.
|
|
254
|
+
if (!link) return;
|
|
255
|
+
}
|
|
256
|
+
if (link) {
|
|
257
|
+
event.preventDefault();
|
|
258
|
+
const href = link.getAttribute("href") || "";
|
|
259
|
+
if (href.startsWith("#")) {
|
|
260
|
+
document
|
|
261
|
+
.getElementById(href.slice(1))
|
|
262
|
+
?.scrollIntoView({ behavior: "smooth" });
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
send("navigate", { href, nodeId: link.dataset.iamNode });
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
const button = event.target.closest("button");
|
|
269
|
+
if (button) {
|
|
270
|
+
event.preventDefault();
|
|
271
|
+
const state = button.dataset.iamToggle;
|
|
272
|
+
if (state) {
|
|
273
|
+
const branches = [
|
|
274
|
+
...document.querySelectorAll("[data-iam-state]"),
|
|
275
|
+
].filter((e) => e.dataset.iamState === state),
|
|
276
|
+
active = branches.find((e) => e.dataset.iamBranch === "true");
|
|
277
|
+
if (active) {
|
|
278
|
+
const next = active.hidden;
|
|
279
|
+
for (const branch of branches)
|
|
280
|
+
branch.hidden = branch.dataset.iamBranch !== String(next);
|
|
281
|
+
button.setAttribute("aria-expanded", String(next));
|
|
282
|
+
send("simulated", {
|
|
283
|
+
message: "已按源码模拟界面状态,未执行业务。",
|
|
284
|
+
});
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
send("simulated", { message: "按钮仅展示界面,不执行业务操作。" });
|
|
289
|
+
}
|
|
290
|
+
},
|
|
291
|
+
true,
|
|
292
|
+
);
|
|
293
|
+
})();
|
|
294
|
+
|
|
295
|
+
})();
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/* Console keyboard bridge, independent of the immutable merchant projection runtime. */
|
|
2
|
+
(() => {
|
|
3
|
+
let nonce;
|
|
4
|
+
addEventListener("message", event => {
|
|
5
|
+
const data = event.data;
|
|
6
|
+
if (event.source === parent && data?.channel === "iam-preview-v1" && data.type === "init" && typeof data.nonce === "string" && !nonce) nonce = data.nonce;
|
|
7
|
+
});
|
|
8
|
+
addEventListener("keydown", event => {
|
|
9
|
+
if (!nonce || event.key !== "Escape" || event.defaultPrevented || event.isComposing || event.target?.tagName === "SELECT") return;
|
|
10
|
+
parent.postMessage({ channel: "iam-preview-v1", nonce, type: "escape" }, "*");
|
|
11
|
+
});
|
|
12
|
+
})();
|