@tinkerlandai/indiana-iam 0.3.0 → 0.4.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 +56 -3
- package/assets/document-controller.js +18 -1
- package/assets/document-runtime.js +189 -1
- package/assets/viewer.js +19 -5
- package/package.json +5 -13
- package/src/analyze.mjs +799 -109
- package/src/build-options.d.ts +1 -1
- package/src/build-runtime.mjs +4 -1
- package/src/build-styles.mjs +49 -6
- package/src/build.mjs +3 -3
- package/src/component-registry.mjs +229 -0
- package/src/dependencies.mjs +2 -2
- package/src/framework-routes.mjs +3 -1
- package/src/generate.mjs +3 -1
- package/src/graph-sources.mjs +54 -0
- package/src/index.d.ts +2 -0
- package/src/library-entry.mjs +4 -0
- package/src/lucide-source.mjs +18 -0
- package/src/next-loader.cjs +7 -1
- package/src/next.cjs +5 -0
- package/src/node-hashes.mjs +9 -8
- package/src/route-lazy.mjs +80 -0
- package/src/route-tree.mjs +15 -3
- package/src/source-attributes.mjs +24 -0
- package/src/source-charts.mjs +38 -0
- package/src/source-collections.mjs +241 -0
- package/src/source-environment.mjs +26 -0
- package/src/source-media.mjs +21 -0
- package/src/source-query-evidence.mjs +64 -0
- package/src/source-redirects.mjs +388 -0
- package/src/static-primitives.mjs +48 -0
- package/src/static-query.mjs +39 -11
- package/src/tab-scenes.mjs +214 -0
- package/src/ui-preview.mjs +225 -0
- package/src/ui-state-machine.mjs +76 -0
- package/src/ui-structure.mjs +645 -0
- package/src/validate.mjs +5 -0
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
import { identity, canonical } from "./integrity.mjs";
|
|
2
|
+
import { documentDom } from "./dom-payload.mjs";
|
|
3
|
+
import { JSDOM, VirtualConsole } from "jsdom";
|
|
4
|
+
import { checkedCollectionFragment } from "./source-collections.mjs";
|
|
5
|
+
import {graphSource} from './graph-sources.mjs';
|
|
6
|
+
|
|
7
|
+
const text = { type: "string", minLength: 1, maxLength: 2048 };
|
|
8
|
+
const nullable = (shape) => ({ anyOf: [shape, { type: "null" }] });
|
|
9
|
+
const array = (items, maxItems = 20000) => ({ type: "array", items, maxItems });
|
|
10
|
+
const object = (properties) => ({
|
|
11
|
+
type: "object",
|
|
12
|
+
properties,
|
|
13
|
+
required: Object.keys(properties),
|
|
14
|
+
additionalProperties: false,
|
|
15
|
+
});
|
|
16
|
+
const source = object({
|
|
17
|
+
file: text,
|
|
18
|
+
symbol: text,
|
|
19
|
+
line: { type: "integer", minimum: 1 },
|
|
20
|
+
column: { type: "integer", minimum: 1 },
|
|
21
|
+
fileHash: { type: "string", pattern: "^[a-f0-9]{64}$" },
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/** Inert, source-derived presentation relationships. They never authorize an action. */
|
|
25
|
+
export const uiStructureSchema = object({
|
|
26
|
+
schemaVersion: { const: 1 },
|
|
27
|
+
conditions: array(object({ conditionKey: text, expression: text, source })),
|
|
28
|
+
tabs: array(
|
|
29
|
+
object({
|
|
30
|
+
tabKey: text,
|
|
31
|
+
routeKey: text,
|
|
32
|
+
renderKey: text,
|
|
33
|
+
nodeId: text,
|
|
34
|
+
variableKey: nullable(text),
|
|
35
|
+
initialValue: nullable(text),
|
|
36
|
+
items: array(object({ value: text, nodeId: text }), 100),
|
|
37
|
+
source,
|
|
38
|
+
scopeSource: source,
|
|
39
|
+
}),
|
|
40
|
+
),
|
|
41
|
+
navigation: array(
|
|
42
|
+
object({
|
|
43
|
+
relationKey: text,
|
|
44
|
+
routeKey: text,
|
|
45
|
+
targetRouteKey: text,
|
|
46
|
+
kind: { enum: ["login", "return", "redirect"] },
|
|
47
|
+
returnParameter: nullable(text),
|
|
48
|
+
condition: nullable(text),
|
|
49
|
+
activation: { enum: ["route-condition", "source-continuation"] },
|
|
50
|
+
sourceRefs: array(source, 16),
|
|
51
|
+
}),
|
|
52
|
+
),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
uiStructureSchema.properties.regions = array(
|
|
56
|
+
object({
|
|
57
|
+
regionKey: text,
|
|
58
|
+
routeKey: text,
|
|
59
|
+
renderKey: text,
|
|
60
|
+
nodeId: text,
|
|
61
|
+
expression: text,
|
|
62
|
+
variableKey: nullable(text),
|
|
63
|
+
initialValue: nullable(text),
|
|
64
|
+
options: array(
|
|
65
|
+
object({
|
|
66
|
+
value: text,
|
|
67
|
+
label: text,
|
|
68
|
+
nodeId: text,
|
|
69
|
+
componentKey: text,
|
|
70
|
+
source,
|
|
71
|
+
scope: nullable(text),
|
|
72
|
+
}),
|
|
73
|
+
32,
|
|
74
|
+
),
|
|
75
|
+
source,
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
uiStructureSchema.properties.tabs.items.properties.scopeNodeId = text;
|
|
80
|
+
uiStructureSchema.properties.tabs.items.properties.contentNodeIds = array(
|
|
81
|
+
text,
|
|
82
|
+
2000,
|
|
83
|
+
);
|
|
84
|
+
uiStructureSchema.properties.conditions.items.properties.observation = {
|
|
85
|
+
enum: ["remote", "unobserved-data"],
|
|
86
|
+
};
|
|
87
|
+
uiStructureSchema.properties.fragments = array(
|
|
88
|
+
object({
|
|
89
|
+
fragmentKey: text,
|
|
90
|
+
html: { type: "string", maxLength: 262144 },
|
|
91
|
+
elementKeys: array({ type: "integer", minimum: 0, maximum: 1999 }, 2000),
|
|
92
|
+
sourceElements: array(text, 2000),
|
|
93
|
+
classNames: array({ type: "string", maxLength: 4000 }, 128),
|
|
94
|
+
source,
|
|
95
|
+
itemCount: { type: "integer", minimum: 0, maximum: 128 },
|
|
96
|
+
}),
|
|
97
|
+
200,
|
|
98
|
+
);
|
|
99
|
+
uiStructureSchema.properties.collections = array(
|
|
100
|
+
object({
|
|
101
|
+
collectionKey: text,
|
|
102
|
+
routeKey: text,
|
|
103
|
+
renderKey: text,
|
|
104
|
+
nodeId: text,
|
|
105
|
+
variableKey: text,
|
|
106
|
+
requires: array(
|
|
107
|
+
object({
|
|
108
|
+
variableKey: text,
|
|
109
|
+
value: { type: ["string", "number", "boolean"] },
|
|
110
|
+
}),
|
|
111
|
+
16,
|
|
112
|
+
),
|
|
113
|
+
cases: array(object({ value: text, fragmentKey: text }), 8),
|
|
114
|
+
source,
|
|
115
|
+
}),
|
|
116
|
+
1000,
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
uiStructureSchema.properties.attributeSets = array(
|
|
120
|
+
object({
|
|
121
|
+
setKey: text,
|
|
122
|
+
property: { enum: ["class", "placeholder", "aria-label"] },
|
|
123
|
+
cases: array(
|
|
124
|
+
object({ value: text, text: { type: "string", maxLength: 4000 } }),
|
|
125
|
+
8,
|
|
126
|
+
),
|
|
127
|
+
source,
|
|
128
|
+
}),
|
|
129
|
+
500,
|
|
130
|
+
);
|
|
131
|
+
uiStructureSchema.properties.attributes = array(
|
|
132
|
+
object({
|
|
133
|
+
attributeKey: text,
|
|
134
|
+
routeKey: text,
|
|
135
|
+
renderKey: text,
|
|
136
|
+
nodeId: text,
|
|
137
|
+
variableKey: text,
|
|
138
|
+
setKey: text,
|
|
139
|
+
}),
|
|
140
|
+
2000,
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
export function attachUiStructure(graph, structure, documents) {
|
|
144
|
+
const nodeRenders = new Map(
|
|
145
|
+
documents.flatMap((d) =>
|
|
146
|
+
d.nodes.map((n) => [
|
|
147
|
+
n.nodeId,
|
|
148
|
+
graph.renders.find((r) => r.documentKey === d.documentKey)?.renderKey,
|
|
149
|
+
]),
|
|
150
|
+
),
|
|
151
|
+
);
|
|
152
|
+
// Conditions and adapted primitives can be synthetic and therefore are not SDK targets.
|
|
153
|
+
for (const row of [
|
|
154
|
+
...structure.tabs,
|
|
155
|
+
...(structure.regions ?? []),
|
|
156
|
+
...(structure.collections ?? []),
|
|
157
|
+
...(structure.attributes ?? []),
|
|
158
|
+
])
|
|
159
|
+
row.renderKey = nodeRenders.get(row.truthyNodeId ?? row.nodeId);
|
|
160
|
+
const allNodes = new Map(
|
|
161
|
+
documents.flatMap((d) => d.nodes.map((n) => [n.nodeId, n])),
|
|
162
|
+
);
|
|
163
|
+
const within = (id, root) => {
|
|
164
|
+
let n = allNodes.get(id),
|
|
165
|
+
count = 0;
|
|
166
|
+
while (n && count++ < 128) {
|
|
167
|
+
if (n.nodeId === root) return true;
|
|
168
|
+
n = allNodes.get(n.parentId);
|
|
169
|
+
}
|
|
170
|
+
return false;
|
|
171
|
+
};
|
|
172
|
+
const reads = (expr, key) =>
|
|
173
|
+
(expr?.op === "var" && expr.key === key) ||
|
|
174
|
+
[
|
|
175
|
+
expr?.arg,
|
|
176
|
+
expr?.left,
|
|
177
|
+
expr?.right,
|
|
178
|
+
...(expr?.args ?? []),
|
|
179
|
+
...(expr?.parts ?? []).map((p) => p.when),
|
|
180
|
+
].some((e) => e && reads(e, key));
|
|
181
|
+
for (const tab of structure.tabs) {
|
|
182
|
+
const chain = [];
|
|
183
|
+
let n = allNodes.get(tab.nodeId);
|
|
184
|
+
while (n && chain.length < 128) {
|
|
185
|
+
chain.push(n);
|
|
186
|
+
n = allNodes.get(n.parentId);
|
|
187
|
+
}
|
|
188
|
+
const local = chain.filter(
|
|
189
|
+
(n) =>
|
|
190
|
+
n.source.file === tab.scopeSource.file &&
|
|
191
|
+
n.source.symbol === tab.scopeSource.symbol,
|
|
192
|
+
);
|
|
193
|
+
tab.scopeNodeId =
|
|
194
|
+
chain.find((n) => n.attributes.role === "dialog")?.nodeId ??
|
|
195
|
+
local.at(-1)?.nodeId ??
|
|
196
|
+
tab.nodeId;
|
|
197
|
+
tab.contentNodeIds = [
|
|
198
|
+
...new Set(
|
|
199
|
+
graph.bindings
|
|
200
|
+
.filter(
|
|
201
|
+
(b) =>
|
|
202
|
+
b.renderKey === tab.renderKey &&
|
|
203
|
+
tab.variableKey &&
|
|
204
|
+
reads(b.value, tab.variableKey) &&
|
|
205
|
+
within(b.nodeId, tab.scopeNodeId) &&
|
|
206
|
+
!within(b.nodeId, tab.nodeId),
|
|
207
|
+
)
|
|
208
|
+
.map((b) => b.nodeId),
|
|
209
|
+
),
|
|
210
|
+
];
|
|
211
|
+
}
|
|
212
|
+
const {
|
|
213
|
+
conditions,
|
|
214
|
+
tabs,
|
|
215
|
+
navigation,
|
|
216
|
+
regions = [],
|
|
217
|
+
collections = [],
|
|
218
|
+
fragments = [],
|
|
219
|
+
attributes = [],
|
|
220
|
+
attributeSets = [],
|
|
221
|
+
} = structure;
|
|
222
|
+
// Analysis can visit JSX that a parent adapter does not project. Only the
|
|
223
|
+
// conditions attached to final concrete containers belong to this graph.
|
|
224
|
+
const projectedConditions = new Set();
|
|
225
|
+
for (const document of documents) {
|
|
226
|
+
const dom = new JSDOM(document.html, {
|
|
227
|
+
virtualConsole: new VirtualConsole(),
|
|
228
|
+
});
|
|
229
|
+
try {
|
|
230
|
+
for (const node of dom.window.document.querySelectorAll(
|
|
231
|
+
"[data-iam-condition]",
|
|
232
|
+
))
|
|
233
|
+
projectedConditions.add(node.getAttribute("data-iam-condition"));
|
|
234
|
+
} finally {
|
|
235
|
+
dom.window.close();
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
...graph,
|
|
240
|
+
ui: {
|
|
241
|
+
schemaVersion: 1,
|
|
242
|
+
conditions: conditions.filter((c) =>
|
|
243
|
+
projectedConditions.has(c.conditionKey),
|
|
244
|
+
),
|
|
245
|
+
tabs,
|
|
246
|
+
navigation,
|
|
247
|
+
regions,
|
|
248
|
+
collections,
|
|
249
|
+
fragments,
|
|
250
|
+
attributes,
|
|
251
|
+
attributeSets,
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function validateUiStructure(graph, manifest, documents) {
|
|
257
|
+
if (!graph.ui) return;
|
|
258
|
+
const nodes = new Map(
|
|
259
|
+
documents.flatMap((d) =>
|
|
260
|
+
d.nodes.map((n) => [
|
|
261
|
+
n.nodeId,
|
|
262
|
+
{ ...n, pageKey: d.pageKey, documentKey: d.documentKey },
|
|
263
|
+
]),
|
|
264
|
+
),
|
|
265
|
+
);
|
|
266
|
+
const renders = new Map(graph.renders.map((r) => [r.renderKey, r]));
|
|
267
|
+
const sourceCheck = (s) => {
|
|
268
|
+
if (
|
|
269
|
+
!manifest.sourceFiles.some(
|
|
270
|
+
(f) => f.file === s.file && f.sha256 === s.fileHash,
|
|
271
|
+
)
|
|
272
|
+
)
|
|
273
|
+
throw Error("UI structure source mismatch");
|
|
274
|
+
};
|
|
275
|
+
const seen = new Set();
|
|
276
|
+
function own(row, nodeId) {
|
|
277
|
+
const node = nodes.get(nodeId),
|
|
278
|
+
render = renders.get(row.renderKey);
|
|
279
|
+
if (
|
|
280
|
+
!node ||
|
|
281
|
+
!render ||
|
|
282
|
+
node.pageKey !== row.routeKey ||
|
|
283
|
+
render.routeKey !== row.routeKey ||
|
|
284
|
+
node.documentKey !== render.documentKey
|
|
285
|
+
)
|
|
286
|
+
throw Error("UI structure DOM scope mismatch");
|
|
287
|
+
return node;
|
|
288
|
+
}
|
|
289
|
+
const within = (id, root) => {
|
|
290
|
+
let node = nodes.get(id),
|
|
291
|
+
count = 0;
|
|
292
|
+
while (node && count++ < 128) {
|
|
293
|
+
if (node.nodeId === root) return true;
|
|
294
|
+
node = nodes.get(node.parentId);
|
|
295
|
+
}
|
|
296
|
+
return false;
|
|
297
|
+
};
|
|
298
|
+
for (const row of graph.ui.conditions) {
|
|
299
|
+
if (seen.has(row.conditionKey)) throw Error("Duplicate UI condition");
|
|
300
|
+
seen.add(row.conditionKey);
|
|
301
|
+
sourceCheck(row.source);
|
|
302
|
+
}
|
|
303
|
+
const conditions = new Map(
|
|
304
|
+
graph.ui.conditions.map((c) => [c.conditionKey, c]),
|
|
305
|
+
),
|
|
306
|
+
usedConditions = new Set(),
|
|
307
|
+
regionMarkers = new Map();
|
|
308
|
+
for (const document of documents) {
|
|
309
|
+
const dom = new JSDOM(
|
|
310
|
+
documentDom(
|
|
311
|
+
manifest.documents.find((d) => d.documentKey === document.documentKey),
|
|
312
|
+
).html,
|
|
313
|
+
{ virtualConsole: new VirtualConsole() },
|
|
314
|
+
);
|
|
315
|
+
try {
|
|
316
|
+
for (const node of dom.window.document.querySelectorAll(
|
|
317
|
+
"[data-iam-region],[data-iam-region-value],[data-iam-collection]",
|
|
318
|
+
))
|
|
319
|
+
regionMarkers.set(node.getAttribute("data-iam-node"), {
|
|
320
|
+
key: node.getAttribute("data-iam-region"),
|
|
321
|
+
value: node.getAttribute("data-iam-region-value"),
|
|
322
|
+
collection: node.getAttribute("data-iam-collection"),
|
|
323
|
+
});
|
|
324
|
+
for (const node of dom.window.document.querySelectorAll(
|
|
325
|
+
"[data-iam-condition]",
|
|
326
|
+
)) {
|
|
327
|
+
const key = node.getAttribute("data-iam-condition"),
|
|
328
|
+
condition = conditions.get(key),
|
|
329
|
+
id = node.getAttribute("data-iam-node"),
|
|
330
|
+
mapped = nodes.get(id);
|
|
331
|
+
if (
|
|
332
|
+
!condition ||
|
|
333
|
+
!mapped?.synthetic ||
|
|
334
|
+
!node.hasAttribute("hidden") ||
|
|
335
|
+
!["true", "false"].includes(node.getAttribute("data-iam-alternative"))
|
|
336
|
+
)
|
|
337
|
+
throw Error(
|
|
338
|
+
"Invalid source condition container: " +
|
|
339
|
+
JSON.stringify({
|
|
340
|
+
key,
|
|
341
|
+
id,
|
|
342
|
+
known: !!condition,
|
|
343
|
+
synthetic: mapped?.synthetic,
|
|
344
|
+
hidden: node.hasAttribute("hidden"),
|
|
345
|
+
source: mapped?.source,
|
|
346
|
+
}),
|
|
347
|
+
);
|
|
348
|
+
usedConditions.add(key);
|
|
349
|
+
}
|
|
350
|
+
} finally {
|
|
351
|
+
dom.window.close();
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (conditions.size !== usedConditions.size)
|
|
355
|
+
throw Error("Source condition has no concrete UI");
|
|
356
|
+
const fragments = new Map(),
|
|
357
|
+
fragmentElements = new Map(manifest.elements.map((e) => [e.elementKey, e]));
|
|
358
|
+
let fragmentBytes = 0;
|
|
359
|
+
for (const fragment of graph.ui.fragments ?? []) {
|
|
360
|
+
const { fragmentKey, ...body } = fragment;
|
|
361
|
+
sourceCheck(fragment.source);
|
|
362
|
+
fragmentBytes += Buffer.byteLength(fragment.html);
|
|
363
|
+
if (
|
|
364
|
+
fragmentBytes > 1_048_576 ||
|
|
365
|
+
fragments.has(fragmentKey) ||
|
|
366
|
+
identity("ui-fragment", body) !== fragmentKey
|
|
367
|
+
)
|
|
368
|
+
throw Error("Invalid shared source fragment");
|
|
369
|
+
const dom = new JSDOM("<body>" + fragment.html + "</body>", {
|
|
370
|
+
virtualConsole: new VirtualConsole(),
|
|
371
|
+
});
|
|
372
|
+
try {
|
|
373
|
+
const nodes = checkedCollectionFragment(
|
|
374
|
+
dom.window.document,
|
|
375
|
+
fragment,
|
|
376
|
+
).nodes;
|
|
377
|
+
if (nodes.length !== fragment.elementKeys.length)
|
|
378
|
+
throw Error("Shared source fragment mapping mismatch");
|
|
379
|
+
const allowed = new Set(
|
|
380
|
+
"div span p button input svg path circle rect line polyline polygon ellipse g title".split(
|
|
381
|
+
" ",
|
|
382
|
+
),
|
|
383
|
+
);
|
|
384
|
+
for (const [index, node] of nodes.entries()) {
|
|
385
|
+
const element = fragmentElements.get(
|
|
386
|
+
fragment.sourceElements[fragment.elementKeys[index]],
|
|
387
|
+
);
|
|
388
|
+
if (
|
|
389
|
+
!element ||
|
|
390
|
+
(/^[a-z]/.test(element.tag) &&
|
|
391
|
+
element.tag !== node.tagName.toLowerCase()) ||
|
|
392
|
+
!allowed.has(node.tagName.toLowerCase())
|
|
393
|
+
)
|
|
394
|
+
throw Error(
|
|
395
|
+
"Invalid shared source fragment element: " +
|
|
396
|
+
String(element?.tag) +
|
|
397
|
+
" / " +
|
|
398
|
+
node.tagName.toLowerCase() +
|
|
399
|
+
" / " +
|
|
400
|
+
fragment.source.file +
|
|
401
|
+
":" +
|
|
402
|
+
fragment.source.line,
|
|
403
|
+
);
|
|
404
|
+
sourceCheck(element.source);
|
|
405
|
+
for (const attr of node.attributes)
|
|
406
|
+
if (
|
|
407
|
+
/^on/i.test(attr.name) ||
|
|
408
|
+
(attr.name.startsWith("data-") && !/^data-(?:slot|state|active|disabled|pressed)$/.test(attr.name)) ||
|
|
409
|
+
[
|
|
410
|
+
"id",
|
|
411
|
+
"href",
|
|
412
|
+
"src",
|
|
413
|
+
"srcset",
|
|
414
|
+
"srcdoc",
|
|
415
|
+
"action",
|
|
416
|
+
"formaction",
|
|
417
|
+
"name",
|
|
418
|
+
"autofocus",
|
|
419
|
+
].includes(attr.name) ||
|
|
420
|
+
(attr.name === "style" &&
|
|
421
|
+
/(?:url\(|@import|expression\()/i.test(attr.value))
|
|
422
|
+
)
|
|
423
|
+
throw Error("Active shared source fragment attribute");
|
|
424
|
+
}
|
|
425
|
+
} finally {
|
|
426
|
+
dom.window.close();
|
|
427
|
+
}
|
|
428
|
+
fragments.set(fragmentKey, fragment);
|
|
429
|
+
}
|
|
430
|
+
const usedFragments = new Set();
|
|
431
|
+
const sets = new Map();
|
|
432
|
+
for (const set of graph.ui.attributeSets ?? []) {
|
|
433
|
+
sourceCheck(set.source);
|
|
434
|
+
const { setKey, ...body } = set;
|
|
435
|
+
if (
|
|
436
|
+
sets.has(setKey) ||
|
|
437
|
+
setKey !== identity("ui-attribute-set", body) ||
|
|
438
|
+
set.cases.length < 2 ||
|
|
439
|
+
new Set(set.cases.map((c) => c.value)).size !== set.cases.length
|
|
440
|
+
)
|
|
441
|
+
throw Error("Invalid source attribute set");
|
|
442
|
+
sets.set(setKey, set);
|
|
443
|
+
}
|
|
444
|
+
const usedSets = new Set();
|
|
445
|
+
for (const row of graph.ui.attributes ?? []) {
|
|
446
|
+
const node = own(row, row.nodeId),
|
|
447
|
+
set = sets.get(row.setKey),
|
|
448
|
+
variable = graph.variables.find(
|
|
449
|
+
(v) =>
|
|
450
|
+
v.variableKey === row.variableKey && v.renderKey === row.renderKey,
|
|
451
|
+
);
|
|
452
|
+
if (
|
|
453
|
+
!set ||
|
|
454
|
+
!variable ||
|
|
455
|
+
!graph.ui.tabs.some(
|
|
456
|
+
(t) =>
|
|
457
|
+
t.variableKey === row.variableKey &&
|
|
458
|
+
set.cases.every((c) => t.items.some((i) => i.value === c.value)),
|
|
459
|
+
)
|
|
460
|
+
)
|
|
461
|
+
throw Error("Source attribute scope mismatch");
|
|
462
|
+
const source = node.source ?? fragmentElements.get(node.elementKey)?.source;
|
|
463
|
+
const forwardedWithinTab=graph.ui.tabs.some(t=>t.variableKey===row.variableKey&&t.scopeSource.file===set.source.file&&t.scopeSource.symbol===set.source.symbol&&within(row.nodeId,t.scopeNodeId??t.nodeId));
|
|
464
|
+
if ((source.file !== set.source.file || source.symbol !== set.source.symbol)&&!forwardedWithinTab)
|
|
465
|
+
throw Error("Source attribute owner mismatch");
|
|
466
|
+
if (seen.has(row.attributeKey)) throw Error("Duplicate source attribute");
|
|
467
|
+
seen.add(row.attributeKey);
|
|
468
|
+
usedSets.add(row.setKey);
|
|
469
|
+
}
|
|
470
|
+
if (usedSets.size !== sets.size) throw Error("Unused source attribute set");
|
|
471
|
+
for (const row of graph.ui.collections ?? []) {
|
|
472
|
+
if (seen.has(row.collectionKey)) throw Error("Duplicate source collection");
|
|
473
|
+
seen.add(row.collectionKey);
|
|
474
|
+
sourceCheck(row.source);
|
|
475
|
+
const root = own(row, row.nodeId);
|
|
476
|
+
if (
|
|
477
|
+
!root.synthetic ||
|
|
478
|
+
!Object.hasOwn(root.attributes, "hidden") ||
|
|
479
|
+
regionMarkers.get(row.nodeId)?.collection !== row.collectionKey
|
|
480
|
+
)
|
|
481
|
+
throw Error("Invalid source collection mount");
|
|
482
|
+
const variable = graph.variables.find(
|
|
483
|
+
(v) =>
|
|
484
|
+
v.variableKey === row.variableKey &&
|
|
485
|
+
v.routeKey === row.routeKey &&
|
|
486
|
+
v.renderKey === row.renderKey,
|
|
487
|
+
);
|
|
488
|
+
if (
|
|
489
|
+
!variable ||
|
|
490
|
+
variable.valueType !== "string" ||
|
|
491
|
+
!graph.ui.tabs.some(
|
|
492
|
+
(t) =>
|
|
493
|
+
t.variableKey === row.variableKey && t.renderKey === row.renderKey,
|
|
494
|
+
)
|
|
495
|
+
)
|
|
496
|
+
throw Error("Source collection has no controlled Tab");
|
|
497
|
+
for (const required of row.requires)
|
|
498
|
+
if (
|
|
499
|
+
!graph.variables.some(
|
|
500
|
+
(v) =>
|
|
501
|
+
v.variableKey === required.variableKey &&
|
|
502
|
+
v.routeKey === row.routeKey &&
|
|
503
|
+
v.renderKey === row.renderKey &&
|
|
504
|
+
v.valueType === typeof required.value,
|
|
505
|
+
)
|
|
506
|
+
)
|
|
507
|
+
throw Error("Source collection constraint mismatch");
|
|
508
|
+
const values = new Set();
|
|
509
|
+
for (const item of row.cases) {
|
|
510
|
+
if (
|
|
511
|
+
values.has(item.value) ||
|
|
512
|
+
!fragments.has(item.fragmentKey) ||
|
|
513
|
+
!graph.ui.tabs.some(
|
|
514
|
+
(t) =>
|
|
515
|
+
t.variableKey === row.variableKey &&
|
|
516
|
+
t.items.some((i) => i.value === item.value),
|
|
517
|
+
)
|
|
518
|
+
)
|
|
519
|
+
throw Error("Invalid source collection case");
|
|
520
|
+
values.add(item.value);
|
|
521
|
+
usedFragments.add(item.fragmentKey);
|
|
522
|
+
}
|
|
523
|
+
if (values.size < 2) throw Error("Source collection needs distinct cases");
|
|
524
|
+
}
|
|
525
|
+
if (usedFragments.size !== fragments.size)
|
|
526
|
+
throw Error("Unreferenced shared source fragment");
|
|
527
|
+
for (const row of graph.ui.tabs) {
|
|
528
|
+
if (seen.has(row.tabKey)) throw Error("Duplicate UI Tab");
|
|
529
|
+
seen.add(row.tabKey);
|
|
530
|
+
sourceCheck(row.source);
|
|
531
|
+
sourceCheck(row.scopeSource);
|
|
532
|
+
const root = own(row, row.nodeId);
|
|
533
|
+
if (
|
|
534
|
+
canonical(
|
|
535
|
+
root.source ??
|
|
536
|
+
manifest.elements.find((e) => e.elementKey === root.elementKey)
|
|
537
|
+
?.source,
|
|
538
|
+
) !== canonical(row.source)
|
|
539
|
+
)
|
|
540
|
+
throw Error("UI Tab source mismatch");
|
|
541
|
+
if (
|
|
542
|
+
row.variableKey &&
|
|
543
|
+
!graph.variables.some(
|
|
544
|
+
(v) =>
|
|
545
|
+
v.variableKey === row.variableKey &&
|
|
546
|
+
v.routeKey === row.routeKey &&
|
|
547
|
+
v.renderKey === row.renderKey &&
|
|
548
|
+
canonical(graphSource(graph,v)) === canonical(row.scopeSource),
|
|
549
|
+
)
|
|
550
|
+
)
|
|
551
|
+
throw Error("UI Tab variable scope mismatch");
|
|
552
|
+
if (row.scopeNodeId) {
|
|
553
|
+
own(row, row.scopeNodeId);
|
|
554
|
+
if (!within(row.nodeId, row.scopeNodeId))
|
|
555
|
+
throw Error("UI Tab scope is not an ancestor");
|
|
556
|
+
}
|
|
557
|
+
for (const id of row.contentNodeIds ?? []) {
|
|
558
|
+
own(row, id);
|
|
559
|
+
if (!within(id, row.scopeNodeId ?? row.nodeId))
|
|
560
|
+
throw Error("UI Tab content lies outside its scope");
|
|
561
|
+
}
|
|
562
|
+
const values = new Set();
|
|
563
|
+
for (const item of row.items) {
|
|
564
|
+
const node = own(row, item.nodeId);
|
|
565
|
+
if (
|
|
566
|
+
node.tag !== "button" ||
|
|
567
|
+
node.attributes.role !== "tab" ||
|
|
568
|
+
!within(item.nodeId, row.nodeId) ||
|
|
569
|
+
values.has(item.value)
|
|
570
|
+
)
|
|
571
|
+
throw Error("Invalid UI Tab item");
|
|
572
|
+
values.add(item.value);
|
|
573
|
+
}
|
|
574
|
+
if (row.initialValue !== null && !values.has(row.initialValue))
|
|
575
|
+
throw Error("UI Tab initial value is absent");
|
|
576
|
+
}
|
|
577
|
+
for (const region of graph.ui.regions ?? []) {
|
|
578
|
+
if (seen.has(region.regionKey)) throw Error("Duplicate component region");
|
|
579
|
+
seen.add(region.regionKey);
|
|
580
|
+
sourceCheck(region.source);
|
|
581
|
+
const root = own(region, region.nodeId);
|
|
582
|
+
if (regionMarkers.get(root.nodeId)?.key !== region.regionKey)
|
|
583
|
+
throw Error("Source region marker mismatch");
|
|
584
|
+
if (
|
|
585
|
+
region.variableKey &&
|
|
586
|
+
!graph.variables.some(
|
|
587
|
+
(v) =>
|
|
588
|
+
v.variableKey === region.variableKey &&
|
|
589
|
+
v.routeKey === region.routeKey &&
|
|
590
|
+
v.renderKey === region.renderKey,
|
|
591
|
+
)
|
|
592
|
+
)
|
|
593
|
+
throw Error("Region variable scope mismatch");
|
|
594
|
+
const values = new Set();
|
|
595
|
+
for (const option of region.options) {
|
|
596
|
+
sourceCheck(option.source);
|
|
597
|
+
const node = own(region, option.nodeId);
|
|
598
|
+
if (
|
|
599
|
+
!node.synthetic ||
|
|
600
|
+
!Object.hasOwn(node.attributes, "hidden") ||
|
|
601
|
+
node.parentId !== region.nodeId ||
|
|
602
|
+
regionMarkers.get(node.nodeId)?.value !== option.value ||
|
|
603
|
+
values.has(option.value) ||
|
|
604
|
+
!manifest.components.some((c) => c.componentKey === option.componentKey)
|
|
605
|
+
)
|
|
606
|
+
throw Error("Invalid source component alternative");
|
|
607
|
+
values.add(option.value);
|
|
608
|
+
}
|
|
609
|
+
if (
|
|
610
|
+
region.options.length < 2 ||
|
|
611
|
+
(region.initialValue !== null && !values.has(region.initialValue))
|
|
612
|
+
)
|
|
613
|
+
throw Error("Invalid source registry selection");
|
|
614
|
+
}
|
|
615
|
+
for (const row of graph.ui.navigation) {
|
|
616
|
+
if (seen.has(row.relationKey)) throw Error("Duplicate UI navigation");
|
|
617
|
+
seen.add(row.relationKey);
|
|
618
|
+
if (
|
|
619
|
+
!graph.routes.some((r) => r.routeKey === row.routeKey) ||
|
|
620
|
+
!graph.routes.some((r) => r.routeKey === row.targetRouteKey)
|
|
621
|
+
)
|
|
622
|
+
throw Error("UI navigation route mismatch");
|
|
623
|
+
row.sourceRefs.forEach(sourceCheck);
|
|
624
|
+
const { relationKey, ...data } = row;
|
|
625
|
+
if (uiRelation(data).relationKey !== relationKey || !row.sourceRefs.length)
|
|
626
|
+
throw Error("UI relation identity mismatch");
|
|
627
|
+
if (
|
|
628
|
+
row.kind === "login" &&
|
|
629
|
+
!graph.ui.navigation.some(
|
|
630
|
+
(r) =>
|
|
631
|
+
r.kind === "return" &&
|
|
632
|
+
r.routeKey === row.targetRouteKey &&
|
|
633
|
+
r.targetRouteKey === row.routeKey &&
|
|
634
|
+
r.returnParameter === row.returnParameter,
|
|
635
|
+
)
|
|
636
|
+
)
|
|
637
|
+
throw Error("Login return consumer is absent");
|
|
638
|
+
if (["login", "return"].includes(row.kind) && !row.returnParameter)
|
|
639
|
+
throw Error("UI return parameter is missing");
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
export function uiRelation(row) {
|
|
644
|
+
return { relationKey: identity("ui-relation", row), ...row };
|
|
645
|
+
}
|
package/src/validate.mjs
CHANGED
|
@@ -171,6 +171,11 @@ export async function validate(manifest) {
|
|
|
171
171
|
)
|
|
172
172
|
throw new Error("DOM tag/locator mismatch");
|
|
173
173
|
for (const attr of node.attributes) {
|
|
174
|
+
if(node.tagName.toLowerCase()==='use'&&['href','xlink:href'].includes(attr.name)){
|
|
175
|
+
if(attr.name!=='href'||!/^#iam-glyph-[a-f0-9]{24}$/.test(attr.value))throw Error('Unsafe SVG glyph reference');
|
|
176
|
+
const glyphs=[...document.querySelectorAll('[id]')].filter(el=>el.id===attr.value.slice(1));
|
|
177
|
+
if(glyphs.length!==1||glyphs[0].tagName.toLowerCase()!=='g'||glyphs[0].parentElement?.tagName.toLowerCase()!=='defs'||[...glyphs[0].querySelectorAll('*')].some(el=>!['path','circle','rect','line','polyline','polygon','ellipse','g'].includes(el.tagName.toLowerCase())))throw Error('Invalid SVG glyph definition');
|
|
178
|
+
}
|
|
174
179
|
if (
|
|
175
180
|
/^on/i.test(attr.name) ||
|
|
176
181
|
["srcdoc", "formaction", "action"].includes(attr.name)
|