@tinkerlandai/indiana-iam 0.2.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 +127 -5
- package/assets/document-controller.js +18 -1
- package/assets/document-runtime.js +189 -1
- package/assets/viewer.js +74 -7
- package/package.json +6 -13
- package/src/analyze.mjs +897 -123
- package/src/anchors.mjs +5 -0
- package/src/build-options.d.ts +6 -1
- package/src/build-runtime.mjs +4 -1
- package/src/build-styles.mjs +49 -6
- package/src/build.mjs +13 -7
- package/src/cli.mjs +10 -4
- package/src/component-registry.mjs +229 -0
- package/src/dependencies.mjs +2 -2
- package/src/dom-payload-browser.mjs +28 -0
- package/src/dom-payload-json.mjs +12 -0
- package/src/dom-payload.mjs +50 -0
- package/src/framework-routes.mjs +44 -89
- package/src/generate.mjs +20 -7
- package/src/graph-sources.mjs +54 -0
- package/src/index.d.ts +13 -1
- package/src/index.mjs +1 -1
- package/src/integrity.mjs +4 -2
- package/src/library-entry.mjs +5 -0
- package/src/lucide-source.mjs +18 -0
- package/src/next-loader.cjs +7 -1
- package/src/next.cjs +5 -0
- package/src/next.mjs +4 -3
- package/src/node-hashes.mjs +13 -9
- package/src/render-index.mjs +6 -4
- package/src/route-lazy.mjs +80 -0
- package/src/route-tree.mjs +15 -3
- package/src/routing.mjs +1 -1
- package/src/schema-definition.mjs +8 -1
- package/src/source-attributes.mjs +24 -0
- package/src/source-charts.mjs +38 -0
- package/src/source-collections.mjs +241 -0
- package/src/source-entry.mjs +50 -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/source-resolution.mjs +268 -0
- package/src/static-primitives.mjs +48 -0
- package/src/static-query.mjs +39 -11
- package/src/tab-scenes.mjs +214 -0
- package/src/tanstack-routes.mjs +143 -0
- package/src/transform.mjs +2 -2
- package/src/ui-preview.mjs +225 -0
- package/src/ui-state-machine.mjs +76 -0
- package/src/ui-structure.mjs +645 -0
- package/src/upload.mjs +28 -6
- package/src/validate.mjs +19 -4
- package/src/vite.d.ts +4 -1
- package/src/vite.mjs +25 -10
package/src/render-index.mjs
CHANGED
|
@@ -5,7 +5,7 @@ export function flattenComposition(root) {
|
|
|
5
5
|
const entries = [];
|
|
6
6
|
function visit(branch) {
|
|
7
7
|
if (!branch) return;
|
|
8
|
-
entries.push({ lane: branch.lane, file: branch.file, layers: branch.layers.map(layer => ({ file: layer.file, slots: layer.slots.map(slot => ({ name: slot.name, lane: slot.branch?.lane ?? null })) })) });
|
|
8
|
+
entries.push({ lane: branch.lane, file: branch.file, ...(branch.symbol ? { symbol: branch.symbol } : {}), layers: branch.layers.map(layer => ({ file: layer.file, ...(layer.symbol ? { symbol: layer.symbol } : {}), slots: layer.slots.map(slot => ({ name: slot.name, lane: slot.branch?.lane ?? null })) })) });
|
|
9
9
|
for (const layer of branch.layers) for (const slot of layer.slots) visit(slot.branch);
|
|
10
10
|
}
|
|
11
11
|
visit(root);
|
|
@@ -36,6 +36,7 @@ export function renderTreeSchema(base) {
|
|
|
36
36
|
p.renderNavigation=array(object({fromRenderKey:str,toRenderKey:str}),512);
|
|
37
37
|
p.guards = array(object({ routeKey: str, selectorKeys: array({type:"integer",minimum:0,maximum:511}, 512) }), 200);
|
|
38
38
|
p.compositions = array(object({ compositionKey: str, entries: array(object({ lane: str, file: str, layers: array(object({ file: str, slots: array(object({ name: str, lane: nullable(str) }), 32) }), 32) }), 128) }), 1000);
|
|
39
|
+
for (const shape of [p.compositions.items.properties.entries.items, p.compositions.items.properties.entries.items.properties.layers.items]) shape.properties.symbol = str;
|
|
39
40
|
schema.required.push("renders", "guards", "compositions", "selectorPool", "renderNavigation");
|
|
40
41
|
return schema;
|
|
41
42
|
}
|
|
@@ -85,7 +86,7 @@ export function attachRenderIndex(graph, manifest, definitions, navigation=[]) {
|
|
|
85
86
|
}
|
|
86
87
|
|
|
87
88
|
export function validateRenderIndex(graph, manifest) {
|
|
88
|
-
const contained = (file, lane) => lane === "." && ["react-router", "react-spa"].includes(manifest.routing.framework)
|
|
89
|
+
const contained = (file, lane) => lane === "." && ["react-router", "react-spa", "tanstack-router"].includes(manifest.routing.framework)
|
|
89
90
|
? !file.startsWith("/") && !file.split("/").includes("..")
|
|
90
91
|
: file.startsWith(lane + "/");
|
|
91
92
|
const unique = (rows, key) => {
|
|
@@ -95,14 +96,15 @@ export function validateRenderIndex(graph, manifest) {
|
|
|
95
96
|
};
|
|
96
97
|
const renders = unique(graph.renders, "renderKey"), compositions = unique(graph.compositions, "compositionKey"), guards = unique(graph.guards, "routeKey");
|
|
97
98
|
const files = new Set(manifest.sourceFiles.map(file => file.file));
|
|
99
|
+
const knownSymbol = entry => !entry.symbol || manifest.components.some(component => component.file === entry.file && component.name === entry.symbol);
|
|
98
100
|
for (const composition of compositions.values()) {
|
|
99
101
|
if (composition.compositionKey !== compositionIdentity(composition.entries)) throw new Error("Composition identity mismatch");
|
|
100
102
|
const lanes = new Set();
|
|
101
103
|
for (const entry of composition.entries) {
|
|
102
|
-
if (lanes.has(entry.lane) || !files.has(entry.file) || !contained(entry.file, entry.lane)) throw new Error("Invalid composition entry");
|
|
104
|
+
if (lanes.has(entry.lane) || !files.has(entry.file) || !contained(entry.file, entry.lane) || !knownSymbol(entry)) throw new Error("Invalid composition entry");
|
|
103
105
|
lanes.add(entry.lane);
|
|
104
106
|
for (const layer of entry.layers) {
|
|
105
|
-
if (!files.has(layer.file) || !contained(layer.file, entry.lane)) throw new Error("Invalid composition layout");
|
|
107
|
+
if (!files.has(layer.file) || !contained(layer.file, entry.lane) || !knownSymbol(layer)) throw new Error("Invalid composition layout");
|
|
106
108
|
const folder = layer.file.slice(0, layer.file.lastIndexOf("/"));
|
|
107
109
|
for (const slot of layer.slots) if (slot.lane !== null && slot.lane !== folder + "/@" + slot.name) throw new Error("Invalid composition slot");
|
|
108
110
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/** Resolve a lazy route's component by AST proof only. No importer, mapper or loader is called. */
|
|
2
|
+
export function routeLazyComponent(node, module, { ts, resolution, resolveModule }) {
|
|
3
|
+
const unwrap = node => {
|
|
4
|
+
while (node && (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isSatisfiesExpression(node) || ts.isAwaitExpression(node))) node = node.expression;
|
|
5
|
+
return node;
|
|
6
|
+
};
|
|
7
|
+
const fn = node => {
|
|
8
|
+
node = unwrap(node);
|
|
9
|
+
return node && !node.asteriskToken && (ts.isArrowFunction(node) || ts.isFunctionExpression(node) || ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node)) ? node : null;
|
|
10
|
+
};
|
|
11
|
+
const returned = node => node?.body && (ts.isBlock(node.body)
|
|
12
|
+
? node.body.statements.length === 1 && ts.isReturnStatement(node.body.statements[0]) ? node.body.statements[0].expression : null
|
|
13
|
+
: node.body);
|
|
14
|
+
const plainParameter = parameter => parameter && ts.isIdentifier(parameter.name) && !parameter.initializer && !parameter.dotDotDotToken;
|
|
15
|
+
const reference = resolution.reference(node, module);
|
|
16
|
+
const lazy = reference && fn(reference.node);
|
|
17
|
+
if (!lazy || lazy.parameters.length) return null;
|
|
18
|
+
module = reference.module;
|
|
19
|
+
let body = unwrap(returned(lazy)), mapper;
|
|
20
|
+
if (body && ts.isCallExpression(body) && ts.isPropertyAccessExpression(body.expression) && body.expression.name.text === "then") {
|
|
21
|
+
if (body.arguments.length !== 1) return null;
|
|
22
|
+
mapper = resolution.reference(body.arguments[0], module);
|
|
23
|
+
if (!mapper) return null;
|
|
24
|
+
body = unwrap(body.expression.expression);
|
|
25
|
+
}
|
|
26
|
+
if (!body || !ts.isCallExpression(body) || body.expression.kind !== ts.SyntaxKind.ImportKeyword || body.arguments.length !== 1 || !ts.isStringLiteral(body.arguments[0])) return null;
|
|
27
|
+
const target = resolveModule(module.file, body.arguments[0].text);
|
|
28
|
+
if (!target) return null;
|
|
29
|
+
let exported = "Component";
|
|
30
|
+
if (mapper) {
|
|
31
|
+
let callback = fn(mapper.node);
|
|
32
|
+
if (!callback && ts.isCallExpression(unwrap(mapper.node))) {
|
|
33
|
+
const call = unwrap(mapper.node), factory = resolution.reference(call.expression, mapper.module), outer = factory && fn(factory.node);
|
|
34
|
+
if (!outer || outer.parameters.length !== call.arguments.length || outer.parameters.some(parameter => !plainParameter(parameter)) || outer.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword)) return null;
|
|
35
|
+
callback = fn(returned(outer));
|
|
36
|
+
}
|
|
37
|
+
if (!callback || callback.parameters.length !== 1 || !plainParameter(callback.parameters[0]) || callback.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword)) return null;
|
|
38
|
+
const namespace = callback.parameters[0].name.text, aliases = new Map();
|
|
39
|
+
let result = callback.body;
|
|
40
|
+
if (ts.isBlock(result)) {
|
|
41
|
+
const statements = result.statements;
|
|
42
|
+
if (!statements.length || !ts.isReturnStatement(statements.at(-1))) return null;
|
|
43
|
+
for (const statement of statements.slice(0, -1)) {
|
|
44
|
+
if (!ts.isVariableStatement(statement) || !(statement.declarationList.flags & ts.NodeFlags.Const)) return null;
|
|
45
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
46
|
+
const value = unwrap(declaration.initializer);
|
|
47
|
+
if (!ts.isObjectBindingPattern(declaration.name) || !value || !ts.isIdentifier(value) || value.text !== namespace) return null;
|
|
48
|
+
for (const element of declaration.name.elements) {
|
|
49
|
+
if (!ts.isIdentifier(element.name) || element.initializer || element.name.text === namespace || aliases.has(element.name.text) || element.propertyName && ts.isComputedPropertyName(element.propertyName)) return null;
|
|
50
|
+
aliases.set(element.name.text, element.dotDotDotToken ? null : (element.propertyName?.text ?? element.name.text));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
result = statements.at(-1).expression;
|
|
55
|
+
}
|
|
56
|
+
result = unwrap(result);
|
|
57
|
+
if (!result || !ts.isObjectLiteralExpression(result)) return null;
|
|
58
|
+
let selected, captured = false, unsafeBefore = false;
|
|
59
|
+
const effectful = node => {
|
|
60
|
+
if (ts.isCallExpression(node) || ts.isNewExpression(node) || ts.isAwaitExpression(node) || ts.isBinaryExpression(node) && node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && node.operatorToken.kind <= ts.SyntaxKind.LastAssignment || ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) return true;
|
|
61
|
+
return Boolean(ts.forEachChild(node, effectful));
|
|
62
|
+
};
|
|
63
|
+
for (const property of result.properties) {
|
|
64
|
+
if (ts.isSpreadAssignment(property)) { if (selected) return null; unsafeBefore = true; continue; }
|
|
65
|
+
if (!(ts.isPropertyAssignment(property) || ts.isShorthandPropertyAssignment(property)) || ts.isComputedPropertyName(property.name)) return null;
|
|
66
|
+
if (property.name.text !== "Component") { if (!selected && ts.isPropertyAssignment(property) && effectful(property.initializer)) unsafeBefore = true; continue; }
|
|
67
|
+
if (selected) return null;
|
|
68
|
+
const value = unwrap(ts.isShorthandPropertyAssignment(property) ? property.name : property.initializer);
|
|
69
|
+
if (!value) return null;
|
|
70
|
+
if (ts.isIdentifier(value)) { selected = aliases.get(value.text); captured = Boolean(selected); }
|
|
71
|
+
else if (ts.isPropertyAccessExpression(value) && ts.isIdentifier(value.expression) && value.expression.text === namespace && !value.questionDotToken) selected = value.name.text;
|
|
72
|
+
if (!selected || unsafeBefore && !captured) return null;
|
|
73
|
+
}
|
|
74
|
+
if (!selected) return null;
|
|
75
|
+
exported = selected;
|
|
76
|
+
}
|
|
77
|
+
const local = exported === "default" ? target.defaultName : target.exports.get(exported);
|
|
78
|
+
const component = local && resolution.component(target, local);
|
|
79
|
+
return component ? { file: component.module.file, symbol: component.name } : null;
|
|
80
|
+
}
|
package/src/route-tree.mjs
CHANGED
|
@@ -4,6 +4,8 @@ import { dependency } from "./dependencies.mjs";
|
|
|
4
4
|
import { canonical } from "./integrity.mjs";
|
|
5
5
|
import { documentNodes } from './node-hashes.mjs';
|
|
6
6
|
import { renderTreeSchema, attachRenderIndex, validateRenderIndex, routeIndexDigest } from "./render-index.mjs";
|
|
7
|
+
import { uiStructureSchema, validateUiStructure } from './ui-structure.mjs';
|
|
8
|
+
import {graphSource} from './graph-sources.mjs';
|
|
7
9
|
const str = { type: "string", minLength: 1, maxLength: 2048 };
|
|
8
10
|
const arr = (items, maxItems = 20000) => ({ type: "array", items, maxItems });
|
|
9
11
|
const obj = properties => ({ type: "object", properties, required: Object.keys(properties), additionalProperties: false });
|
|
@@ -36,6 +38,12 @@ const routeTreeShape = obj({
|
|
|
36
38
|
routeTreeShape.properties.targets.items.required=routeTreeShape.properties.targets.items.required.filter(key=>key!=='source');
|
|
37
39
|
routeTreeShape.properties.targets.items.properties.subtreeHash = { type: "string", pattern: "^[a-f0-9]{64}$" };
|
|
38
40
|
export const routeTreeSchema = renderTreeSchema(routeTreeShape);
|
|
41
|
+
routeTreeSchema.properties.sourcePool=arr(source,20000);
|
|
42
|
+
routeTreeSchema.properties.scopePool=arr(str,20000);
|
|
43
|
+
routeTreeSchema.properties.variables.items.properties.scopeKey={anyOf:[str,{type:'integer',minimum:0,maximum:19999}]};
|
|
44
|
+
for(const field of ['variables','bindings','transitions','states','navigation','uncovered'])routeTreeSchema.properties[field].items.properties.source={anyOf:[source,{type:'integer',minimum:0,maximum:19999}]};
|
|
45
|
+
routeTreeSchema.properties.navigation.items.properties.interactionKey={anyOf:[str,{type:'integer',minimum:0,maximum:19999}]};
|
|
46
|
+
routeTreeSchema.properties.ui=uiStructureSchema;
|
|
39
47
|
const Ajv = dependency("ajv").value;
|
|
40
48
|
const check = new Ajv({ allErrors: true }).compile({ ...routeTreeSchema, definitions: { uiExpression } });
|
|
41
49
|
export function validateRouteTree(graph, manifest, verifiedSelectors, concreteDocuments = manifest.documents.map(d => ({...d, nodes:documentNodes(d)}))) {
|
|
@@ -54,7 +62,8 @@ export function validateRouteTree(graph, manifest, verifiedSelectors, concreteDo
|
|
|
54
62
|
return node;
|
|
55
63
|
}
|
|
56
64
|
function sourceCheck(row) {
|
|
57
|
-
|
|
65
|
+
if(typeof row.source==='number'&&(!Number.isInteger(row.source)||!graph.sourcePool?.[row.source]))throw Error('Invalid pooled graph source');
|
|
66
|
+
const node=nodes.get(row.nodeId),source=graphSource(graph,row)??node?.source??manifest.elements.find(e=>e.elementKey===node?.elementKey)?.source;
|
|
58
67
|
if (!source||!manifest.sourceFiles.some(file => file.file === source.file && file.sha256 === source.fileHash)) throw new Error("Route tree source mismatch");
|
|
59
68
|
}
|
|
60
69
|
for (const route of graph.routes) {
|
|
@@ -106,8 +115,8 @@ export function validateRouteTree(graph, manifest, verifiedSelectors, concreteDo
|
|
|
106
115
|
}
|
|
107
116
|
for (const navigation of graph.navigation) {
|
|
108
117
|
mapped(navigation); require(maps.routes, navigation.targetRouteKey);
|
|
109
|
-
const original = manifest.interactions.find(i => i.interactionKey === navigation.interactionKey);
|
|
110
|
-
if (!original || original.pageKey !== navigation.routeKey || original.targetPageKey !== navigation.targetRouteKey || original.targetUrl !== navigation.targetUrl || canonical(original.targetParams) !== canonical(navigation.targetParams)) throw new Error("Route tree navigation mismatch");
|
|
118
|
+
const original = typeof navigation.interactionKey==='number'?manifest.interactions[navigation.interactionKey]:manifest.interactions.find(i => i.interactionKey === navigation.interactionKey);
|
|
119
|
+
if (!original || original.pageKey !== navigation.routeKey || original.nodeId !== navigation.nodeId || original.targetPageKey !== navigation.targetRouteKey || original.targetUrl !== navigation.targetUrl || canonical(original.targetParams) !== canonical(navigation.targetParams)) throw new Error("Route tree navigation mismatch");
|
|
111
120
|
}
|
|
112
121
|
const { JSDOM } = dependency("jsdom").value, documents = new Map(), targetIds = new Set();
|
|
113
122
|
try {
|
|
@@ -124,7 +133,10 @@ export function validateRouteTree(graph, manifest, verifiedSelectors, concreteDo
|
|
|
124
133
|
}
|
|
125
134
|
} finally { for (const dom of documents.values()) dom.window.close(); }
|
|
126
135
|
for (const collection of [graph.routes, graph.variables, graph.states, graph.bindings, graph.transitions, graph.navigation, graph.uncovered, graph.targets]) for (const row of collection) sourceCheck(row);
|
|
136
|
+
const refs=new Set([...graph.variables,...graph.bindings,...graph.transitions,...graph.states,...graph.navigation,...graph.uncovered].map(r=>r.source).filter(s=>typeof s==='number'));if(refs.size!==(graph.sourcePool?.length??0)||new Set((graph.sourcePool??[]).map(canonical)).size!==(graph.sourcePool?.length??0))throw Error('Invalid graph source pool');
|
|
137
|
+
const scopeRefs=new Set(graph.variables.map(v=>v.scopeKey).filter(k=>typeof k==='number'));if(scopeRefs.size!==(graph.scopePool?.length??0)||[...scopeRefs].some(k=>!Number.isInteger(k)||!graph.scopePool?.[k])||new Set(graph.scopePool??[]).size!==(graph.scopePool?.length??0))throw Error('Invalid graph scope pool');
|
|
127
138
|
validateRenderIndex(graph, {...manifest, documents:concreteDocuments});
|
|
139
|
+
validateUiStructure(graph,manifest,concreteDocuments);
|
|
128
140
|
return { routes: graph.routes.length, renders: graph.renders.length, transitions: graph.transitions.length, bindings: graph.bindings.length, digest: routeIndexDigest(graph) };
|
|
129
141
|
}
|
|
130
142
|
|
package/src/routing.mjs
CHANGED
|
@@ -26,7 +26,7 @@ export function pagesRoute(file) {
|
|
|
26
26
|
|
|
27
27
|
/** Router configuration provenance is explicit for source-declared React routes. */
|
|
28
28
|
export function sourcePageRoute(page, routing) {
|
|
29
|
-
if (["react-router", "react-spa"].includes(routing.framework)) {
|
|
29
|
+
if (["react-router", "react-spa", "tanstack-router"].includes(routing.framework)) {
|
|
30
30
|
const rows = routing.routeSources?.filter(
|
|
31
31
|
(row) =>
|
|
32
32
|
row.path === page.route &&
|
|
@@ -39,7 +39,7 @@ export const schema = {
|
|
|
39
39
|
platforms: array({ enum: ["web", "ios", "android"] }, 3),
|
|
40
40
|
}),
|
|
41
41
|
routing: object({
|
|
42
|
-
framework: { enum: ["next-app-router", "next-pages-router", "react-router", "react-spa"] },
|
|
42
|
+
framework: { enum: ["next-app-router", "next-pages-router", "react-router", "react-spa", "tanstack-router"] },
|
|
43
43
|
basePath: str,
|
|
44
44
|
trailingSlash: { type: "boolean" },
|
|
45
45
|
routeSources: array(object({ path: str, source, componentFile: str, componentSymbol: str }), 200),
|
|
@@ -176,3 +176,10 @@ schema.properties.styles=array(object({styleKey:{type:'string',pattern:'^style_[
|
|
|
176
176
|
schema.properties.documents.items.properties.styleKeys=array({type:'string',pattern:'^style_[a-f0-9]{64}$'},16);
|
|
177
177
|
schema.properties.scripts=array(object({scriptKey:{type:'string',pattern:'^script_[a-f0-9]{64}$'},sha256:hash,js:{type:'string',maxLength:100000}}),8);
|
|
178
178
|
schema.properties.documents.items.properties.scriptKey={type:'string',pattern:'^script_[a-f0-9]{64}$'};
|
|
179
|
+
|
|
180
|
+
// HTML and preorder mappings may use bounded lossless storage compression.
|
|
181
|
+
schema.properties.documents.items.properties.domPayload = object({
|
|
182
|
+
encoding: { const: "gzip-json-v1" },
|
|
183
|
+
bytes: { type: "integer", minimum: 1, maximum: 8_388_608 },
|
|
184
|
+
data: { type: "string", minLength: 1, maxLength: 11_184_812 },
|
|
185
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Finite source-derived attributes. Only presentation text and classes can change. */
|
|
2
|
+
export function applySourceAttributes(doc, ui, renderKey, values) {
|
|
3
|
+
const sets = new Map((ui?.attributeSets ?? []).map((s) => [s.setKey, s])),
|
|
4
|
+
nodes = new Map(
|
|
5
|
+
[...doc.querySelectorAll("[data-iam-node]")].map((n) => [
|
|
6
|
+
n.getAttribute("data-iam-node"),
|
|
7
|
+
n,
|
|
8
|
+
]),
|
|
9
|
+
);
|
|
10
|
+
for (const row of ui?.attributes ?? []) {
|
|
11
|
+
if (row.renderKey !== renderKey) continue;
|
|
12
|
+
const set = sets.get(row.setKey),
|
|
13
|
+
node = nodes.get(row.nodeId),
|
|
14
|
+
chosen = set?.cases.find((c) => c.value === values[row.variableKey]);
|
|
15
|
+
if (!node || !chosen) continue;
|
|
16
|
+
if (
|
|
17
|
+
!["class", "placeholder", "aria-label"].includes(set.property) ||
|
|
18
|
+
typeof chosen.text !== "string" ||
|
|
19
|
+
chosen.text.length > 4000
|
|
20
|
+
)
|
|
21
|
+
throw Error("Invalid source presentation attribute");
|
|
22
|
+
node.setAttribute(set.property, chosen.text);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Bounded inert geometry from source data. Never imports a chart library or runs callbacks.
|
|
2
|
+
const number=v=>typeof v==='number'&&Number.isFinite(v)&&Math.abs(v)<=1e12;
|
|
3
|
+
const color=(v,fallback)=>typeof v==='string'&&/^(?:#[\da-f]{3,8}|[a-z]{1,24}|var\(--[a-z0-9-]+\)|rgba?\([\d.,% /]+\))$/i.test(v)?v:fallback;
|
|
4
|
+
const palette=['#8b5cf6','#94a3b8','#38bdf8','#22c55e','#f59e0b','#ef4444'];
|
|
5
|
+
const shape=(tag,attrs={},children=[])=>({tag,attrs,children});
|
|
6
|
+
export function chartGeometry({type,data,width=640,height=280,axes=true}){
|
|
7
|
+
width=number(width)&&width>=80&&width<=4096?width:640;height=number(height)&&height>=40&&height<=2048?height:280;
|
|
8
|
+
const labels=Array.isArray(data?.labels)?data.labels:[],sets=Array.isArray(data?.datasets)?data.datasets:[];
|
|
9
|
+
const valid=labels.length>0&&labels.length<=256&&sets.length>0&&sets.length<=16&&labels.every(v=>typeof v==='string'||number(v))&&sets.every(s=>Array.isArray(s?.data)&&s.data.length===labels.length&&s.data.every(number));
|
|
10
|
+
const children=[],text=(x,y,value,extra={})=>shape('text',{x,y,fill:'#888888','font-size':11,'font-family':'sans-serif',...extra},[String(value)]);
|
|
11
|
+
const pad=axes?{l:52,r:16,t:16,b:30}:{l:20,r:20,t:16,b:16},w=width-pad.l-pad.r,h=height-pad.t-pad.b;
|
|
12
|
+
if(!valid){children.push(shape('rect',{x:1,y:1,width:width-2,height:height-2,fill:'none',stroke:'#cbd5e1','stroke-dasharray':'5 5'}),text(width/2,height/2,'数据未观测 / Unobserved data',{'text-anchor':'middle'}));return {width,height,children,unobserved:true};}
|
|
13
|
+
if(!['line','bar','doughnut','pie','area'].includes(type))return null;
|
|
14
|
+
if(type==='doughnut'||type==='pie'){
|
|
15
|
+
const values=sets[0].data,total=values.reduce((a,b)=>a+b,0);if(total<=0||values.some(v=>v<0))return null;
|
|
16
|
+
const cx=width/2,cy=height/2,r=Math.max(1,Math.min(width,height)/2-16),inner=type==='pie'?0:r*.7;let start=-Math.PI/2;
|
|
17
|
+
values.forEach((v,i)=>{if(!v)return;const end=start+v/total*Math.PI*2,angle=Math.min(end-start,Math.PI*2-1e-7),e=start+angle,p=(radius,a)=>[cx+Math.cos(a)*radius,cy+Math.sin(a)*radius].map(n=>+n.toFixed(3)).join(' '),d=inner?`M ${p(r,start)} A ${r} ${r} 0 ${angle>Math.PI?1:0} 1 ${p(r,e)} L ${p(inner,e)} A ${inner} ${inner} 0 ${angle>Math.PI?1:0} 0 ${p(inner,start)} Z`:`M ${cx} ${cy} L ${p(r,start)} A ${r} ${r} 0 ${angle>Math.PI?1:0} 1 ${p(r,e)} Z`;children.push(shape('path',{d,fill:color(sets[0].backgroundColor?.[i],palette[i%palette.length])}));start=end;});
|
|
18
|
+
}else{
|
|
19
|
+
const values=sets.flatMap(s=>s.data),min=Math.min(0,...values),max=Math.max(1,...values),y=v=>pad.t+(max-v)/(max-min)*h,x=i=>pad.l+i/Math.max(1,labels.length-1)*w;
|
|
20
|
+
if(axes){for(let i=0;i<=4;i++){const v=min+(max-min)*i/4,at=y(v);children.push(shape('line',{x1:pad.l,x2:width-pad.r,y1:at,y2:at,stroke:'#e2e8f0','stroke-width':.5}),text(pad.l-8,at+4,Math.round(v),{'text-anchor':'end'}));}labels.forEach((label,i)=>{if(i%Math.max(1,Math.ceil(labels.length/12))===0)children.push(text(type==='bar'?pad.l+(i+.5)*w/labels.length:x(i),height-8,String(label).slice(0,18),{'text-anchor':'middle'}));});}
|
|
21
|
+
sets.forEach((set,j)=>{const stroke=color(set.borderColor,palette[j%palette.length]),fill=color(set.backgroundColor,stroke);if(type==='bar'){const band=w/labels.length,bw=band*.7/sets.length;set.data.forEach((v,i)=>children.push(shape('rect',{x:pad.l+i*band+band*.15+j*bw,y:Math.min(y(v),y(0)),width:bw,height:Math.abs(y(v)-y(0)),fill,rx:2})));}else{const points=set.data.map((v,i)=>`${+x(i).toFixed(3)},${+y(v).toFixed(3)}`).join(' ');if(set.fill===true||type==='area')children.push(shape('polygon',{points:`${pad.l},${y(0)} ${points} ${x(labels.length-1)},${y(0)}`,fill:stroke,'fill-opacity':.1}));children.push(shape('polyline',{points,fill:'none',stroke,'stroke-width':number(set.borderWidth)?Math.max(.5,Math.min(8,set.borderWidth)):2,'stroke-linejoin':'round'}));}});
|
|
22
|
+
}
|
|
23
|
+
return {width,height,children,unobserved:false};
|
|
24
|
+
}
|
|
25
|
+
export function sourceChart({ts,node,tag,owner,context,evaluate,imported}){
|
|
26
|
+
const module=owner.module,ev=n=>evaluate(n,module,context),primitive=imported(module,tag),props=opening=>Object.fromEntries(opening.attributes.properties.filter(a=>ts.isJsxAttribute(a)).map(a=>[a.name.text,!a.initializer?true:ts.isStringLiteral(a.initializer)?a.initializer.text:ts.isJsxExpression(a.initializer)?ev(a.initializer.expression):undefined]));
|
|
27
|
+
if(tag==='canvas'){
|
|
28
|
+
const configs=[];function visit(n){if(ts.isNewExpression(n)&&imported(module,n.expression.getText(module.source))?.specifier==='chart.js'&&imported(module,n.expression.getText(module.source))?.name==='Chart'&&n.arguments?.length===2&&ts.isObjectLiteralExpression(n.arguments[1]))configs.push(n.arguments[1]);ts.forEachChild(n,visit);}visit(owner.fn);
|
|
29
|
+
if(configs.length!==1)return null;
|
|
30
|
+
const config=ev(configs[0]),p=props(node),type=config?.type;if(typeof type!=='string')return null;
|
|
31
|
+
return chartGeometry({type,data:config.data,width:p.width,height:p.height,axes:config.options?.scales?.x?.display!==false||config.options?.scales?.y?.display!==false});
|
|
32
|
+
}
|
|
33
|
+
if(primitive?.specifier!=='recharts'||!['BarChart','LineChart','AreaChart','PieChart'].includes(primitive.name))return null;
|
|
34
|
+
const p=props(node),children=node.parent&&ts.isJsxElement(node.parent)?node.parent.children:[],parts=children.filter(c=>ts.isJsxElement(c)||ts.isJsxSelfClosingElement(c)).map(c=>ts.isJsxElement(c)?c.openingElement:c),series=[],type=primitive.name==='BarChart'?'bar':primitive.name==='AreaChart'?'area':primitive.name==='PieChart'?'pie':'line';let key;
|
|
35
|
+
for(const part of parts){const imp=imported(module,part.tagName.getText(module.source)),v=props(part);if(imp?.specifier!=='recharts')continue;if(imp.name==='XAxis')key=v.dataKey;if(['Bar','Line','Area','Pie'].includes(imp.name))series.push(v);}
|
|
36
|
+
const data=p.data,labels=Array.isArray(data)?data.map((r,i)=>key?r?.[key]:i+1):[];
|
|
37
|
+
return chartGeometry({type,data:{labels,datasets:series.map(s=>({data:Array.isArray(data)?data.map(r=>r?.[s.dataKey]):[],borderColor:s.stroke,backgroundColor:s.fill,fill:type==='area'}))},width:p.width,height:p.height??350});
|
|
38
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { JSDOM, VirtualConsole } from "jsdom";
|
|
2
|
+
import { identity } from "./integrity.mjs";
|
|
3
|
+
|
|
4
|
+
/** Read state dependencies from local AST aliases. This never invokes source functions. */
|
|
5
|
+
export function collectionStateReads(ts, node, ctx) {
|
|
6
|
+
const reads = new Set(),
|
|
7
|
+
seen = new Set();
|
|
8
|
+
let steps = 0;
|
|
9
|
+
function visit(n) {
|
|
10
|
+
if (!n || ++steps > 3000) return;
|
|
11
|
+
if (ts.isIdentifier(n)) {
|
|
12
|
+
if (ctx.__iamVariableKeys[n.text])
|
|
13
|
+
reads.add(ctx.__iamVariableKeys[n.text]);
|
|
14
|
+
else if (ctx.__iamPropBindings?.has(n.text)) {
|
|
15
|
+
const walk = (e) => {
|
|
16
|
+
if (e?.op === "var") reads.add(e.key);
|
|
17
|
+
for (const v of Object.values(e ?? {}))
|
|
18
|
+
if (v && typeof v === "object") {
|
|
19
|
+
if (Array.isArray(v)) v.forEach(walk);
|
|
20
|
+
else walk(v);
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
walk(ctx.__iamPropBindings.get(n.text));
|
|
24
|
+
} else if (ctx.__iamLocals.has(n.text) && !seen.has(n.text)) {
|
|
25
|
+
seen.add(n.text);
|
|
26
|
+
visit(ctx.__iamLocals.get(n.text));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
ts.forEachChild(n, visit);
|
|
30
|
+
}
|
|
31
|
+
visit(node);
|
|
32
|
+
return [...reads];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Store one inert source list, shared by its repeated source instances. No SDK targets or handlers. */
|
|
36
|
+
export function sourceCollectionFragment(html, source, itemCount, record) {
|
|
37
|
+
const dom = new JSDOM("<body>" + html + "</body>", {
|
|
38
|
+
virtualConsole: new VirtualConsole(),
|
|
39
|
+
});
|
|
40
|
+
try {
|
|
41
|
+
const body = dom.window.document.body,
|
|
42
|
+
elementKeys = [],
|
|
43
|
+
sourceElements = [],
|
|
44
|
+
classNames = [];
|
|
45
|
+
for (const node of body.querySelectorAll("*")) {
|
|
46
|
+
const mapped = record.get(node.getAttribute("data-iam-node"));
|
|
47
|
+
if (!mapped) throw Error("Collection source node is unmapped");
|
|
48
|
+
let ref = sourceElements.indexOf(mapped.elementKey);
|
|
49
|
+
if (ref < 0) {
|
|
50
|
+
ref = sourceElements.length;
|
|
51
|
+
sourceElements.push(mapped.elementKey);
|
|
52
|
+
}
|
|
53
|
+
elementKeys.push(ref);
|
|
54
|
+
for (const attr of [...node.attributes])
|
|
55
|
+
if (
|
|
56
|
+
attr.name.startsWith("data-iam-") ||
|
|
57
|
+
[
|
|
58
|
+
"id",
|
|
59
|
+
"data-indiana-id",
|
|
60
|
+
"data-testid",
|
|
61
|
+
"data-test",
|
|
62
|
+
"data-qa",
|
|
63
|
+
].includes(attr.name)
|
|
64
|
+
)
|
|
65
|
+
node.removeAttribute(attr.name);
|
|
66
|
+
const classes = node.getAttribute("class");
|
|
67
|
+
if (classes && classes.length > 64) {
|
|
68
|
+
let index = classNames.indexOf(classes);
|
|
69
|
+
if (index < 0) {
|
|
70
|
+
index = classNames.length;
|
|
71
|
+
classNames.push(classes);
|
|
72
|
+
}
|
|
73
|
+
node.removeAttribute("class");
|
|
74
|
+
node.setAttribute("data-iam-fragment-class", String(index));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const fragment = {
|
|
78
|
+
html: body.innerHTML,
|
|
79
|
+
elementKeys,
|
|
80
|
+
sourceElements,
|
|
81
|
+
classNames,
|
|
82
|
+
source,
|
|
83
|
+
itemCount,
|
|
84
|
+
};
|
|
85
|
+
return { fragmentKey: identity("ui-fragment", fragment), ...fragment };
|
|
86
|
+
} finally {
|
|
87
|
+
dom.window.close();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Restore presentation only; injected rows deliberately have no canonical DOM identity. */
|
|
92
|
+
export function checkedCollectionFragment(doc, fragment) {
|
|
93
|
+
if (
|
|
94
|
+
typeof fragment?.html !== "string" ||
|
|
95
|
+
fragment.html.length > 262144 ||
|
|
96
|
+
!Array.isArray(fragment.elementKeys) ||
|
|
97
|
+
!Array.isArray(fragment.sourceElements) ||
|
|
98
|
+
!Array.isArray(fragment.classNames) ||
|
|
99
|
+
fragment.sourceElements.length > 2000 ||
|
|
100
|
+
fragment.classNames.length > 128 ||
|
|
101
|
+
fragment.classNames.some((s) => typeof s !== "string" || s.length > 4000) ||
|
|
102
|
+
fragment.elementKeys.some(
|
|
103
|
+
(i) =>
|
|
104
|
+
!Number.isInteger(i) || i < 0 || i >= fragment.sourceElements.length,
|
|
105
|
+
) ||
|
|
106
|
+
fragment.elementKeys.length > 2000
|
|
107
|
+
)
|
|
108
|
+
throw Error("Invalid shared source fragment");
|
|
109
|
+
const template = doc.createElement("template");
|
|
110
|
+
template.innerHTML = fragment.html;
|
|
111
|
+
const nodes = [...template.content.querySelectorAll("*")],
|
|
112
|
+
allowed = new Set(
|
|
113
|
+
"div span p button input svg path circle rect line polyline polygon ellipse g title".split(
|
|
114
|
+
" ",
|
|
115
|
+
),
|
|
116
|
+
);
|
|
117
|
+
if (nodes.length !== fragment.elementKeys.length)
|
|
118
|
+
throw Error("Shared source fragment mapping mismatch");
|
|
119
|
+
for (const node of nodes) {
|
|
120
|
+
if (node.hasAttribute("data-iam-fragment-class")) {
|
|
121
|
+
const raw = node.getAttribute("data-iam-fragment-class"),
|
|
122
|
+
index = Number(raw);
|
|
123
|
+
if (
|
|
124
|
+
String(index) !== raw ||
|
|
125
|
+
!Number.isInteger(index) ||
|
|
126
|
+
index < 0 ||
|
|
127
|
+
index >= fragment.classNames.length
|
|
128
|
+
)
|
|
129
|
+
throw Error("Invalid source fragment class");
|
|
130
|
+
node.setAttribute("class", fragment.classNames[index]);
|
|
131
|
+
node.removeAttribute("data-iam-fragment-class");
|
|
132
|
+
}
|
|
133
|
+
if (!allowed.has(node.tagName.toLowerCase()))
|
|
134
|
+
throw Error("Active shared source fragment element");
|
|
135
|
+
for (const attr of node.attributes)
|
|
136
|
+
if (
|
|
137
|
+
!/^(?:class|title|type|value|placeholder|disabled|hidden|checked|tabindex|width|height|viewBox|viewbox|xmlns|d|fill|stroke|stroke-width|stroke-linecap|stroke-linejoin|cx|cy|r|rx|ry|x|y|x1|x2|y1|y2|points|aria-[a-z-]+|data-(?:slot|state|active|disabled|pressed))$/.test(
|
|
138
|
+
attr.name,
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
throw Error("Active shared source fragment attribute: " + attr.name);
|
|
142
|
+
for (const key of ["fill", "stroke"])
|
|
143
|
+
if (
|
|
144
|
+
node.hasAttribute(key) &&
|
|
145
|
+
!/^(?:none|currentColor|transparent|#[a-f\d]{3,8})$/i.test(
|
|
146
|
+
node.getAttribute(key),
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
throw Error("External source fragment paint");
|
|
150
|
+
if (
|
|
151
|
+
node.hasAttribute("xmlns") &&
|
|
152
|
+
node.getAttribute("xmlns") !== "http://www.w3.org/2000/svg"
|
|
153
|
+
)
|
|
154
|
+
throw Error("Invalid source fragment namespace");
|
|
155
|
+
}
|
|
156
|
+
return { content: template.content, nodes };
|
|
157
|
+
}
|
|
158
|
+
export function applySourceCollections(doc, ui, renderKey, values, enabled) {
|
|
159
|
+
const choices = [],
|
|
160
|
+
fragments = new Map((ui?.fragments ?? []).map((f) => [f.fragmentKey, f]));
|
|
161
|
+
for (const row of ui?.collections ?? []) {
|
|
162
|
+
if (row.renderKey !== renderKey) continue;
|
|
163
|
+
const root = [...doc.querySelectorAll("[data-iam-node]")].find(
|
|
164
|
+
(n) => n.getAttribute("data-iam-node") === row.nodeId,
|
|
165
|
+
);
|
|
166
|
+
if (!root) continue;
|
|
167
|
+
root.replaceChildren();
|
|
168
|
+
if (!enabled) continue;
|
|
169
|
+
if (root.parentElement?.closest("[hidden]")) continue;
|
|
170
|
+
const selected = row.cases.find((c) => c.value === values[row.variableKey]),
|
|
171
|
+
satisfied = row.requires.every((r) => values[r.variableKey] === r.value);
|
|
172
|
+
root.hidden = false;
|
|
173
|
+
root.setAttribute("inert", "");
|
|
174
|
+
if (!selected || !satisfied) {
|
|
175
|
+
root.textContent = "未观测 · 此筛选组合未重建";
|
|
176
|
+
root.setAttribute("data-iam-observation", "unobserved-collection");
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const fragment = fragments.get(selected.fragmentKey);
|
|
180
|
+
if (!fragment) throw Error("Missing source collection fragment");
|
|
181
|
+
const parsed = checkedCollectionFragment(doc, fragment);
|
|
182
|
+
root.replaceChildren(parsed.content);
|
|
183
|
+
parsed.nodes.forEach((n, index) => {
|
|
184
|
+
n.setAttribute("data-iam-preview-only", "true");
|
|
185
|
+
n.setAttribute(
|
|
186
|
+
"data-iam-source-element",
|
|
187
|
+
fragment.sourceElements[fragment.elementKeys[index]],
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
choices.push({
|
|
191
|
+
collectionKey: row.collectionKey,
|
|
192
|
+
value: selected.value,
|
|
193
|
+
itemCount: fragment.itemCount,
|
|
194
|
+
source: row.source,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
return choices;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Result, error and empty branches of an unexecuted query are all unobserved. */
|
|
201
|
+
export function markUnobservedData(doc, ui, enabled, selections = new Map()) {
|
|
202
|
+
doc.querySelectorAll("[data-iam-preview-note]").forEach((n) => n.remove());
|
|
203
|
+
const unobserved = [];
|
|
204
|
+
if (!enabled) return unobserved;
|
|
205
|
+
const source = new Map(
|
|
206
|
+
(ui?.conditions ?? []).map((c) => [c.conditionKey, c]),
|
|
207
|
+
);
|
|
208
|
+
for (const root of doc.querySelectorAll(
|
|
209
|
+
'[data-iam-condition][data-iam-alternative="true"]',
|
|
210
|
+
)) {
|
|
211
|
+
const condition = source.get(root.getAttribute("data-iam-condition")),
|
|
212
|
+
id = root.getAttribute("data-iam-node");
|
|
213
|
+
if (!condition?.observation || selections.has(id)) continue;
|
|
214
|
+
const note = doc.createElement("div");
|
|
215
|
+
note.setAttribute("data-iam-preview-note", "unobserved");
|
|
216
|
+
note.className = "iam-observation-note";
|
|
217
|
+
note.setAttribute("role", "status");
|
|
218
|
+
note.textContent =
|
|
219
|
+
condition.observation === "remote" ? "远程数据未观测" : "数据未观测";
|
|
220
|
+
note.title = condition.expression;
|
|
221
|
+
root.before(note);
|
|
222
|
+
if (!note.closest("[hidden]"))
|
|
223
|
+
unobserved.push({
|
|
224
|
+
groupKey: id,
|
|
225
|
+
expression: condition.expression,
|
|
226
|
+
source: condition.source,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
for (const root of doc.querySelectorAll(
|
|
230
|
+
'[data-iam-observation="remote"],[data-iam-observation="unobserved"]',
|
|
231
|
+
)) {
|
|
232
|
+
root.hidden = false;
|
|
233
|
+
root.textContent = "未观测";
|
|
234
|
+
root.classList.add("iam-unobserved-value");
|
|
235
|
+
}
|
|
236
|
+
for (const field of doc.querySelectorAll("[data-iam-input-unobserved]")) {
|
|
237
|
+
field.setAttribute("placeholder", "未观测");
|
|
238
|
+
field.setAttribute("title", "此字段的数据未观测");
|
|
239
|
+
}
|
|
240
|
+
return unobserved;
|
|
241
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { dependency } from "./dependencies.mjs";
|
|
4
|
+
import { sha256 } from "./integrity.mjs";
|
|
5
|
+
|
|
6
|
+
/** Select a browser import graph without evaluating the entry or its configuration. */
|
|
7
|
+
export async function sourceEntry({ root, modules, resolveModule, ts, entry }) {
|
|
8
|
+
let htmlSource;
|
|
9
|
+
if (entry === undefined) {
|
|
10
|
+
const file = path.join(root, "index.html");
|
|
11
|
+
let stat;
|
|
12
|
+
try { stat = await fs.lstat(file); }
|
|
13
|
+
catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
14
|
+
if (stat) {
|
|
15
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 256_000)
|
|
16
|
+
throw Error("Browser entry HTML must be a bounded local file");
|
|
17
|
+
const bytes = await fs.readFile(file), text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
18
|
+
const { JSDOM } = dependency("jsdom").value;
|
|
19
|
+
const dom = new JSDOM(text);
|
|
20
|
+
try {
|
|
21
|
+
const scripts = [...dom.window.document.querySelectorAll('script[type="module"]')];
|
|
22
|
+
if (scripts.length !== 1 || !scripts[0].hasAttribute("src") || scripts[0].textContent.trim() || dom.window.document.querySelector("base[href]"))
|
|
23
|
+
throw Error("Ambiguous browser entry: set entry to one local source module");
|
|
24
|
+
entry = scripts[0].getAttribute("src").replace(/^\/(?!\/)/, "");
|
|
25
|
+
htmlSource = { file: "index.html", sha256: sha256(bytes), bytes: bytes.length };
|
|
26
|
+
} finally { dom.window.close(); }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (entry === undefined) return { discoveryModules: modules };
|
|
30
|
+
if (typeof entry !== "string" || !entry || entry.startsWith("/") || /[\\?#:\s]/.test(entry) || entry.split("/").some(part => !part || part === "." || part === "..") || !modules.has(entry))
|
|
31
|
+
throw Error("entry must name one source module relative to the scanned root");
|
|
32
|
+
const selected = new Map();
|
|
33
|
+
function visit(module) {
|
|
34
|
+
if (selected.has(module.file)) return;
|
|
35
|
+
selected.set(module.file, module);
|
|
36
|
+
const dependencies = new Set([...module.imports.values()].map(ref => ref.specifier));
|
|
37
|
+
function imports(node) {
|
|
38
|
+
if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) dependencies.add(node.moduleSpecifier.text);
|
|
39
|
+
if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword && node.arguments.length === 1 && ts.isStringLiteral(node.arguments[0])) dependencies.add(node.arguments[0].text);
|
|
40
|
+
ts.forEachChild(node, imports);
|
|
41
|
+
}
|
|
42
|
+
imports(module.source);
|
|
43
|
+
for (const specifier of dependencies) {
|
|
44
|
+
const target = resolveModule(module.file, specifier);
|
|
45
|
+
if (target) visit(target);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
visit(modules.get(entry));
|
|
49
|
+
return { discoveryModules: selected, htmlSource, entry };
|
|
50
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Reviewable UI environment projections. No browser globals or merchant callbacks are accessed.
|
|
2
|
+
export function uiEnvironmentPrimitive({ts,node,module,binding,imported,evaluate}){
|
|
3
|
+
const returned=fn=>!fn?.body?null:ts.isArrowFunction(fn)&&!ts.isBlock(fn.body)?fn.body:ts.isBlock(fn?.body)&&fn.body.statements.length===1&&ts.isReturnStatement(fn.body.statements[0])?fn.body.statements[0].expression:null;
|
|
4
|
+
const call=node&&ts.isCallExpression(node)?node:null;if(!call)return null;
|
|
5
|
+
const ref=ts.isIdentifier(call.expression)&&binding(module,call.expression.text),fn=ref?.node;
|
|
6
|
+
const expression=fn&&returned(fn),store=expression&&ts.isCallExpression(expression)?expression:call,owner=expression?ref.module:module;
|
|
7
|
+
const primitive=imported(owner,store.expression.getText(owner.source));
|
|
8
|
+
if(primitive?.specifier==='react'&&primitive.name==='useSyncExternalStore'&&store.arguments.length===3){
|
|
9
|
+
const snapshot=returned(store.arguments[1]),fallback=returned(store.arguments[2]);
|
|
10
|
+
if(!snapshot||!ts.isPropertyAccessExpression(snapshot)||snapshot.name.text!=='matches'||!ts.isCallExpression(snapshot.expression))return null;
|
|
11
|
+
const media=snapshot.expression;if(media.expression.getText(owner.source)!=='window.matchMedia'||media.arguments.length!==1||fallback?.kind!==ts.SyntaxKind.FalseKeyword)return null;
|
|
12
|
+
const query=evaluate(media.arguments[0],owner,{});
|
|
13
|
+
if(typeof query!=='string'||!/^\((?:max|min)-width: [1-9]\d{1,3}px\)$/.test(query))return null;
|
|
14
|
+
return {kind:'viewport',query};
|
|
15
|
+
}
|
|
16
|
+
// Only presentation preference names, with an explicit source SSR guard. Auth/session cookies stay opaque.
|
|
17
|
+
if(ref&&ts.isFunctionDeclaration(fn)&&fn.body&&ts.isBlock(fn.body)&&call.arguments.length===1){
|
|
18
|
+
const key=evaluate(call.arguments[0],module,{}),guard=fn.body.statements[0];
|
|
19
|
+
if(typeof key!=='string'||!/^(?:sidebar_state|layout_(?:variant|collapsible)|theme|color_scheme)$/.test(key))return null;
|
|
20
|
+
if(!guard||!ts.isIfStatement(guard)||guard.expression.getText(ref.module.source).replace(/\s+/g,'')!=="typeofdocument==='undefined'"&&guard.expression.getText(ref.module.source).replace(/\s+/g,'')!=='typeofdocument==="undefined"')return null;
|
|
21
|
+
const branch=ts.isBlock(guard.thenStatement)?guard.thenStatement.statements[0]:guard.thenStatement;
|
|
22
|
+
if(!branch||!ts.isReturnStatement(branch)||branch.expression?.getText(ref.module.source)!=='undefined')return null;
|
|
23
|
+
return {kind:'empty-preference'};
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {sha256} from './integrity.mjs';
|
|
4
|
+
/** Only literal raster references in projected JSX. No crawling, URL fetch or symlink expansion. */
|
|
5
|
+
export async function referencedRasterSources(root,urls){
|
|
6
|
+
const files=[],seen=new Set();let total=0;
|
|
7
|
+
for(const url of [...urls].sort()){
|
|
8
|
+
if(typeof url!=='string'||!/^\/(?!\/)[A-Za-z0-9_./-]+\.(?:png|jpe?g|webp|gif)$/i.test(url))continue;
|
|
9
|
+
const file=url.startsWith('/__iam_source__/')?url.slice('/__iam_source__/'.length):'public'+url;
|
|
10
|
+
if(seen.has(file))continue;seen.add(file);
|
|
11
|
+
if(file.split('/').some(p=>!p||p.startsWith('.'))||/(?:^|\/)(?:api|server|secrets?|credentials)(?:\/|\.)/i.test(file))throw Error('Unsafe referenced image path');
|
|
12
|
+
try{
|
|
13
|
+
let current=root;for(const part of file.split('/')){current=path.join(current,part);if((await fs.lstat(current)).isSymbolicLink())throw Error('Referenced image symlinks are not admitted');}
|
|
14
|
+
const real=await fs.realpath(current),stat=await fs.stat(real);
|
|
15
|
+
if(!real.startsWith(root+path.sep)||!stat.isFile()||stat.size>1048576||(total+=stat.size)>6291456||files.length>=128)throw Error('Referenced image scope or budget exceeded');
|
|
16
|
+
const bytes=await fs.readFile(real);if(bytes.length!==stat.size)throw Error('Referenced image changed while reading');
|
|
17
|
+
files.push({file,sha256:sha256(bytes),bytes:bytes.length});
|
|
18
|
+
}catch(error){if(error.code!=='ENOENT')throw error;}
|
|
19
|
+
}
|
|
20
|
+
return files;
|
|
21
|
+
}
|