@tinkerlandai/indiana-iam 0.1.1 → 0.3.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 +103 -7
- package/assets/viewer.js +55 -2
- package/package.json +3 -1
- package/src/analyze.mjs +168 -90
- package/src/anchors.mjs +127 -0
- package/src/build-options.d.ts +7 -0
- package/src/build.mjs +13 -19
- package/src/cli.mjs +10 -4
- 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 +41 -88
- package/src/generate.mjs +18 -6
- package/src/git-metadata.mjs +61 -0
- package/src/index.d.ts +114 -12
- package/src/index.mjs +3 -1
- package/src/instrument.mjs +2 -2
- package/src/integrity.mjs +4 -2
- package/src/library-entry.mjs +2 -0
- package/src/next-loader.cjs +30 -0
- package/src/next.mjs +26 -3
- package/src/node-hashes.mjs +5 -2
- package/src/rebind.mjs +116 -0
- package/src/release-metadata.mjs +1 -1
- package/src/render-index.mjs +32 -11
- package/src/route-tree.mjs +5 -12
- package/src/routing.mjs +1 -1
- package/src/schema-definition.mjs +8 -1
- package/src/source-entry.mjs +50 -0
- package/src/source-files.mjs +53 -0
- package/src/source-resolution.mjs +268 -0
- package/src/tanstack-routes.mjs +143 -0
- package/src/transform.mjs +32 -0
- package/src/upload.mjs +30 -16
- package/src/validate.mjs +14 -4
- package/src/vite.d.ts +15 -2
- package/src/vite.mjs +33 -7
package/src/analyze.mjs
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { automaticSourceAnchors, anchorAttributes, sourceScriptKind } from "./anchors.mjs";
|
|
4
|
+
import { sourceFiles } from "./source-files.mjs";
|
|
4
5
|
import { dependency } from "./dependencies.mjs";
|
|
5
6
|
import { assembleRouteTree, readVariables } from "./route-tree.mjs";
|
|
7
|
+
import { sourceEntry } from "./source-entry.mjs";
|
|
6
8
|
import { frameworkRoutes, externalImport } from "./framework-routes.mjs";
|
|
9
|
+
import { sourceResolution } from "./source-resolution.mjs";
|
|
7
10
|
import { routeIndexDigest, refreshRenderHashes } from "./render-index.mjs";
|
|
8
11
|
import { storeDocumentNodes } from "./node-hashes.mjs";
|
|
9
12
|
import { staticQuery } from "./static-query.mjs";
|
|
@@ -26,6 +29,28 @@ const { JSDOM } = dependency("jsdom").value;
|
|
|
26
29
|
const UNKNOWN = Symbol("unknown");
|
|
27
30
|
import { sensitiveName, redactLiteral, privateUrl } from "./privacy.mjs";
|
|
28
31
|
const isUnknown = (value) => value === UNKNOWN;
|
|
32
|
+
// React numeric styles use px except for unitless properties and CSS variables.
|
|
33
|
+
const unitlessStyles = new Set("animationIterationCount aspectRatio borderImageOutset borderImageSlice borderImageWidth boxFlex boxFlexGroup boxOrdinalGroup columnCount columns flex flexGrow flexPositive flexShrink flexNegative flexOrder gridArea gridRow gridRowEnd gridRowSpan gridRowStart gridColumn gridColumnEnd gridColumnSpan gridColumnStart fontWeight lineClamp lineHeight opacity order orphans scale tabSize widows zIndex zoom fillOpacity floodOpacity stopOpacity strokeDasharray strokeDashoffset strokeMiterlimit strokeOpacity strokeWidth".split(" "));
|
|
34
|
+
function staticStyle(value, unsupported) {
|
|
35
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
36
|
+
unsupported();
|
|
37
|
+
return "";
|
|
38
|
+
}
|
|
39
|
+
return Object.entries(value).flatMap(([key, raw]) => {
|
|
40
|
+
if (raw == null || typeof raw === "boolean" || raw === "") return [];
|
|
41
|
+
if (!/^(?:[a-zA-Z][a-zA-Z0-9]*|--[a-zA-Z0-9_-]+)$/.test(key) ||
|
|
42
|
+
!["string", "number"].includes(typeof raw) ||
|
|
43
|
+
(typeof raw === "number" && !Number.isFinite(raw)) ||
|
|
44
|
+
/[;{}<>\\]|url\s*\(|expression\s*\(|@import/i.test(String(raw))) {
|
|
45
|
+
unsupported();
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
const name = key.startsWith("--") ? key : key.replace(/[A-Z]/g, letter => "-" + letter.toLowerCase()).replace(/^ms-/, "-ms-");
|
|
49
|
+
const unprefixed = key.replace(/^(?:Webkit|Moz|ms|O)(?=[A-Z])/, "").replace(/^[A-Z]/, letter => letter.toLowerCase());
|
|
50
|
+
const unit = typeof raw === "number" && raw !== 0 && !key.startsWith("--") && !unitlessStyles.has(unprefixed) ? "px" : "";
|
|
51
|
+
return [name + ":" + String(raw).trim() + unit];
|
|
52
|
+
}).join(";");
|
|
53
|
+
}
|
|
29
54
|
const allowedTags = new Set(
|
|
30
55
|
"main section article aside header footer nav div span p h1 h2 h3 h4 h5 h6 a button form label input select option textarea ul ol li dl dt dd table thead tbody tr th td caption fieldset legend details summary pre code blockquote strong em b i small s u mark time br hr img picture source svg path circle rect line polyline polygon ellipse g title".split(
|
|
31
56
|
" ",
|
|
@@ -117,7 +142,8 @@ function functionFrom(node) {
|
|
|
117
142
|
if (
|
|
118
143
|
ts.isFunctionDeclaration(node) ||
|
|
119
144
|
ts.isArrowFunction(node) ||
|
|
120
|
-
ts.isFunctionExpression(node)
|
|
145
|
+
ts.isFunctionExpression(node) ||
|
|
146
|
+
ts.isMethodDeclaration(node)
|
|
121
147
|
)
|
|
122
148
|
return node;
|
|
123
149
|
if (ts.isCallExpression(node))
|
|
@@ -133,52 +159,11 @@ export async function analyze({
|
|
|
133
159
|
maxFiles = 5000,
|
|
134
160
|
maxBytes = 100_000_000,
|
|
135
161
|
navigationPaths = [],
|
|
162
|
+
entry,
|
|
163
|
+
automaticAnchors = false,
|
|
136
164
|
}) {
|
|
137
165
|
root = await fs.realpath(root);
|
|
138
|
-
|
|
139
|
-
try {
|
|
140
|
-
files = execFileSync("git", ["-C", root, "ls-files", "-z"], {
|
|
141
|
-
encoding: "utf8",
|
|
142
|
-
maxBuffer: 8_000_000,
|
|
143
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
144
|
-
})
|
|
145
|
-
.split("\0")
|
|
146
|
-
.filter(Boolean);
|
|
147
|
-
} catch {
|
|
148
|
-
files = [];
|
|
149
|
-
}
|
|
150
|
-
// An authorized copied app can sit inside another repository's ignored
|
|
151
|
-
// verification directory. git succeeds there with an empty index; it must
|
|
152
|
-
// not silently turn a complete application into a zero-route manifest.
|
|
153
|
-
if (!files.length) {
|
|
154
|
-
async function walk(dir) {
|
|
155
|
-
for (const item of await fs.readdir(dir, { withFileTypes: true })) {
|
|
156
|
-
if (
|
|
157
|
-
item.name.startsWith(".") ||
|
|
158
|
-
["node_modules", "dist", "build", "coverage"].includes(item.name)
|
|
159
|
-
)
|
|
160
|
-
continue;
|
|
161
|
-
const p = path.join(dir, item.name);
|
|
162
|
-
if (item.isDirectory()) await walk(p);
|
|
163
|
-
else if (item.isFile())
|
|
164
|
-
files.push(path.relative(root, p).split(path.sep).join("/"));
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
await walk(root);
|
|
168
|
-
}
|
|
169
|
-
files = files
|
|
170
|
-
.filter(
|
|
171
|
-
(file) =>
|
|
172
|
-
/\.(?:[cm]?[jt]sx?|css|mdx|json)$/.test(file) &&
|
|
173
|
-
!/(^|\/)(?:node_modules|\.next|\.git|\.indiana|api|server|secrets?)(\/|$)/.test(
|
|
174
|
-
file,
|
|
175
|
-
) &&
|
|
176
|
-
!/(?:^|\/)(?:\.?env|credentials|secrets)(?:\.|\/)/i.test(file) &&
|
|
177
|
-
!file.startsWith('public/.well-known/indiana/')&&
|
|
178
|
-
!/(?:lock|package-lock)\.json$/.test(file),
|
|
179
|
-
)
|
|
180
|
-
.sort();
|
|
181
|
-
if (files.length > maxFiles) throw new Error("Source file budget exceeded");
|
|
166
|
+
const files = await sourceFiles(root, { maxFiles });
|
|
182
167
|
const modules = new Map(),
|
|
183
168
|
inventory = [],
|
|
184
169
|
gaps = [],
|
|
@@ -232,12 +217,13 @@ export async function analyze({
|
|
|
232
217
|
text,
|
|
233
218
|
ts.ScriptTarget.Latest,
|
|
234
219
|
true,
|
|
235
|
-
|
|
220
|
+
sourceScriptKind(file, ts),
|
|
236
221
|
);
|
|
237
222
|
const module = {
|
|
238
223
|
file,
|
|
239
224
|
text,
|
|
240
225
|
source,
|
|
226
|
+
anchors: automaticAnchors ? automaticSourceAnchors(file, source) : new Map(),
|
|
241
227
|
bindings: new Map(),
|
|
242
228
|
imports: new Map(),
|
|
243
229
|
exports: new Map(),
|
|
@@ -268,6 +254,7 @@ export async function analyze({
|
|
|
268
254
|
}
|
|
269
255
|
if (ts.isFunctionDeclaration(statement) && (statement.name||statement.modifiers?.some(m=>m.kind===ts.SyntaxKind.DefaultKeyword)))
|
|
270
256
|
module.bindings.set(statement.name?.text||'$default', statement);
|
|
257
|
+
if (ts.isEnumDeclaration(statement)) module.bindings.set(statement.name.text, statement);
|
|
271
258
|
if (ts.isVariableStatement(statement))
|
|
272
259
|
for (const d of statement.declarationList.declarations)
|
|
273
260
|
if (ts.isIdentifier(d.name) && d.initializer)
|
|
@@ -366,10 +353,9 @@ export async function analyze({
|
|
|
366
353
|
return binding(target,key,seen);
|
|
367
354
|
}
|
|
368
355
|
const componentDefs = new Map();
|
|
369
|
-
|
|
370
|
-
for (const [symbol, node] of module.bindings) {
|
|
356
|
+
function registerComponent(module, symbol, node) {
|
|
371
357
|
const fn = functionFrom(node);
|
|
372
|
-
if (!fn)
|
|
358
|
+
if (!fn) return null;
|
|
373
359
|
let hasJsx = false;
|
|
374
360
|
function visit(n) {
|
|
375
361
|
if (
|
|
@@ -381,7 +367,7 @@ export async function analyze({
|
|
|
381
367
|
ts.forEachChild(n, visit);
|
|
382
368
|
}
|
|
383
369
|
visit(fn);
|
|
384
|
-
if (!hasJsx)
|
|
370
|
+
if (!hasJsx) return null;
|
|
385
371
|
const key = identity("component", { file: module.file, symbol }),
|
|
386
372
|
ordinals = new Map();
|
|
387
373
|
let ordinal = 0;
|
|
@@ -421,12 +407,21 @@ export async function analyze({
|
|
|
421
407
|
source: loc,
|
|
422
408
|
dependencies: [],
|
|
423
409
|
});
|
|
424
|
-
|
|
425
|
-
|
|
410
|
+
return def;
|
|
411
|
+
}
|
|
412
|
+
for (const module of modules.values())
|
|
413
|
+
for (const [symbol, node] of module.bindings) registerComponent(module, symbol, node);
|
|
414
|
+
const resolution = sourceResolution({ ts, resolveModule, resolveBinding: binding });
|
|
415
|
+
const componentBinding = (module, name) => {
|
|
416
|
+
const ref = resolution.component(module, name);
|
|
417
|
+
if (ref?.wrapped) gap("LAZY_WRAPPER_UNOBSERVED", module.file, name + ": only the statically forwarded successful module is projected; retry/reload behavior is not executed");
|
|
418
|
+
return ref;
|
|
419
|
+
};
|
|
420
|
+
function collectComponentDependencies(def) {
|
|
426
421
|
function collectDependencies(node) {
|
|
427
422
|
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) {
|
|
428
423
|
const opening = ts.isJsxElement(node) ? node.openingElement : node,
|
|
429
|
-
ref =
|
|
424
|
+
ref = componentBinding(def.module, opening.tagName.getText(def.module.source)),
|
|
430
425
|
target = ref && componentDefs.get(ref.module.file + "#" + ref.name);
|
|
431
426
|
if (
|
|
432
427
|
target &&
|
|
@@ -438,6 +433,20 @@ export async function analyze({
|
|
|
438
433
|
}
|
|
439
434
|
collectDependencies(def.fn);
|
|
440
435
|
}
|
|
436
|
+
for (const def of componentDefs.values()) collectComponentDependencies(def);
|
|
437
|
+
const inlineNodes = new WeakMap(), inlineCounts = new Map();
|
|
438
|
+
function inlineComponent(module, node, route) {
|
|
439
|
+
if (inlineNodes.has(node)) return inlineNodes.get(node);
|
|
440
|
+
const group = module.file + "#" + route, occurrence = inlineCounts.get(group) ?? 0;
|
|
441
|
+
inlineCounts.set(group, occurrence + 1);
|
|
442
|
+
const symbol = "$route_" + hashObject("iam/inline-route-component/v1", { route, occurrence }).slice(0, 24);
|
|
443
|
+
module.bindings.set(symbol, node);
|
|
444
|
+
const def = registerComponent(module, symbol, node);
|
|
445
|
+
if (def) collectComponentDependencies(def);
|
|
446
|
+
const target = { file: module.file, symbol };
|
|
447
|
+
inlineNodes.set(node, target);
|
|
448
|
+
return target;
|
|
449
|
+
}
|
|
441
450
|
function sourceLocation(module, node, symbol) {
|
|
442
451
|
const loc = module.source.getLineAndCharacterOfPosition(
|
|
443
452
|
node.getStart(module.source),
|
|
@@ -725,11 +734,16 @@ export async function analyze({
|
|
|
725
734
|
Object.hasOwn(Object(value), "stateInitial") &&
|
|
726
735
|
d.name.elements[0]
|
|
727
736
|
) {
|
|
728
|
-
const
|
|
737
|
+
const stateBinding = d.name.elements[0];
|
|
738
|
+
if (!ts.isBindingElement(stateBinding) || stateBinding.dotDotDotToken || !ts.isIdentifier(stateBinding.name)) {
|
|
739
|
+
gap("STATE_BINDING_UNSUPPORTED", def.module.file, "Omitted, nested or rest state bindings have no named scalar value for UI simulation");
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
const key = stateBinding.name.text;
|
|
729
743
|
ctx[key] = sensitiveName(key) ? UNKNOWN : value.stateInitial;
|
|
730
744
|
ctx.__iamStates.add(key);
|
|
731
745
|
ctx.__iamStateNodes[key] = d;
|
|
732
|
-
if (d.name.elements[1]?.name)
|
|
746
|
+
if (d.name.elements[1]?.name && !d.name.elements[1].dotDotDotToken && ts.isIdentifier(d.name.elements[1].name))
|
|
733
747
|
ctx.__iamSetters[d.name.elements[1].name.text] = key;
|
|
734
748
|
}
|
|
735
749
|
}
|
|
@@ -786,7 +800,10 @@ export async function analyze({
|
|
|
786
800
|
!/^\/(?:[^/?#[\]\\\s]+\/)*[^/?#[\]\\\s]+$/.test(routing.basePath)
|
|
787
801
|
)
|
|
788
802
|
throw new Error("Invalid source basePath");
|
|
789
|
-
const
|
|
803
|
+
const selection = await sourceEntry({ root, modules, resolveModule, ts, entry });
|
|
804
|
+
if (selection.htmlSource) inventory.push(selection.htmlSource);
|
|
805
|
+
inventory.sort((a, b) => a.file < b.file ? -1 : a.file > b.file ? 1 : 0);
|
|
806
|
+
const compositions = frameworkRoutes({ modules, ...selection, resolveModule, ts, sourceLocation, navigationPaths, gap, resolution, inlineComponent });
|
|
790
807
|
const pageFiles = compositions.pageFiles;
|
|
791
808
|
routing.framework = compositions.framework;
|
|
792
809
|
if (compositions.routeSources) routing.routeSources = compositions.routeSources;
|
|
@@ -945,6 +962,20 @@ export async function analyze({
|
|
|
945
962
|
const value = evalExpression(node, owner.module, ctx);
|
|
946
963
|
return ["string", "boolean", "number"].includes(typeof value) ? { op: "const", value } : null;
|
|
947
964
|
}
|
|
965
|
+
function uiExpressionType(expression, payload = {}) {
|
|
966
|
+
if (!expression) return undefined;
|
|
967
|
+
if (expression.op === "const") return typeof expression.value;
|
|
968
|
+
if (expression.op === "var") return ui.variables.find(variable => variable.variableKey === expression.key)?.valueType;
|
|
969
|
+
if (expression.op === "event") return payload[expression.key];
|
|
970
|
+
const type = child => uiExpressionType(child, payload);
|
|
971
|
+
if (expression.op === "not") return type(expression.arg) ? "boolean" : undefined;
|
|
972
|
+
if (expression.op === "classes") return expression.parts.every(part => type(part.when) === "boolean") ? "string" : undefined;
|
|
973
|
+
if (["all", "any"].includes(expression.op)) return expression.args.every(child => type(child) === "boolean") ? "boolean" : undefined;
|
|
974
|
+
const left = type(expression.left), right = type(expression.right);
|
|
975
|
+
if (["eq", "ne"].includes(expression.op)) return left && left === right ? "boolean" : undefined;
|
|
976
|
+
if (["add", "subtract"].includes(expression.op)) return left === "number" && right === "number" ? "number" : undefined;
|
|
977
|
+
return undefined;
|
|
978
|
+
}
|
|
948
979
|
function uiHandler(initializer, owner, ctx) {
|
|
949
980
|
let fn = initializer && ts.isJsxExpression(initializer) ? unwrap(initializer.expression) : null;
|
|
950
981
|
if (fn && ts.isIdentifier(fn) && ctx.__iamPropHandlers.has(fn.text)) return ctx.__iamPropHandlers.get(fn.text);
|
|
@@ -966,8 +997,18 @@ export async function analyze({
|
|
|
966
997
|
const payload = {};
|
|
967
998
|
function eventInputs(e) { if (e.op === "event") payload[e.key] = e.key === "checked" ? "boolean" : "string"; for (const child of [e.arg, e.left, e.right, ...(e.args || []),...(e.parts||[]).map(part=>part.when)]) if (child) eventInputs(child); }
|
|
968
999
|
eventInputs(value);
|
|
1000
|
+
if (uiExpressionType(value, payload) !== ui.variables.find(variable => variable.variableKey === variableKey)?.valueType) return null;
|
|
969
1001
|
return { variableKey, value, payload };
|
|
970
1002
|
}
|
|
1003
|
+
function unobservedValue(node, owner, instance) {
|
|
1004
|
+
const source = sourceLocation(owner.module, node, owner.symbol);
|
|
1005
|
+
const elementKey = identity("element", { component: owner.key, unobservedValue: node.getStart(owner.module.source) - owner.fn.getStart(owner.module.source) });
|
|
1006
|
+
const nodeId = identity("node", { page: pageKey, element: elementKey, instance });
|
|
1007
|
+
elements.set(elementKey, { elementKey, componentKey: owner.key, tag: "span", source, identityStrategy: "provisional_ast_ordinal", editableProperties: [], binding: "preview_only" });
|
|
1008
|
+
record.set(nodeId, { elementKey, source, synthetic: true });
|
|
1009
|
+
// Preserve an unknown child boundary. An empty projection must never prove that replacing text is safe.
|
|
1010
|
+
return '<span data-iam-node="' + nodeId + '" hidden=""></span>';
|
|
1011
|
+
}
|
|
971
1012
|
function renderExpr(node, owner, ctx, instance, depth) {
|
|
972
1013
|
node = unwrap(node);
|
|
973
1014
|
if (!node) return "";
|
|
@@ -1117,7 +1158,7 @@ export async function analyze({
|
|
|
1117
1158
|
.join("");
|
|
1118
1159
|
}
|
|
1119
1160
|
gap("DYNAMIC_COLLECTION", m.file, node.expression.getText(m.source));
|
|
1120
|
-
return
|
|
1161
|
+
return unobservedValue(node, owner, instance);
|
|
1121
1162
|
}
|
|
1122
1163
|
if (!ts.isJsxElement(node) && !ts.isJsxSelfClosingElement(node)) {
|
|
1123
1164
|
if (
|
|
@@ -1141,18 +1182,25 @@ export async function analyze({
|
|
|
1141
1182
|
typeof value !== "boolean"
|
|
1142
1183
|
)
|
|
1143
1184
|
return escape(value);
|
|
1144
|
-
if (isUnknown(value))
|
|
1185
|
+
if (isUnknown(value)) {
|
|
1145
1186
|
gap("DYNAMIC_VALUE", m.file, node.getText(m.source).slice(0, 100));
|
|
1187
|
+
return unobservedValue(node, owner, instance);
|
|
1188
|
+
}
|
|
1146
1189
|
return "";
|
|
1147
1190
|
}
|
|
1148
1191
|
if (++renderedCount > 12000)
|
|
1149
1192
|
throw new Error("Rendered node budget exceeded");
|
|
1150
1193
|
const opening = ts.isJsxElement(node) ? node.openingElement : node,
|
|
1151
1194
|
tag = opening.tagName.getText(m.source),
|
|
1152
|
-
props = {}, uiHandlers = [], uiBindings = [], forwardedHandlers=[];
|
|
1195
|
+
props = m.anchors.get(opening) ? { "data-indiana-id": m.anchors.get(opening) } : {}, uiHandlers = [], uiBindings = [], forwardedHandlers=[];
|
|
1153
1196
|
for (const attr of opening.attributes.properties) {
|
|
1154
1197
|
if (ts.isJsxSpreadAttribute(attr)) {
|
|
1155
1198
|
const v = evalExpression(attr.expression, m, ctx);
|
|
1199
|
+
if (isUnknown(v)) {
|
|
1200
|
+
for (const attribute of anchorAttributes) delete props[attribute];
|
|
1201
|
+
gap("UNKNOWN_ANCHOR_SPREAD", m.file, "Opaque spread may override element identity");
|
|
1202
|
+
continue;
|
|
1203
|
+
}
|
|
1156
1204
|
if (v && typeof v === "object") {
|
|
1157
1205
|
Object.assign(props, v);
|
|
1158
1206
|
const inherited = forwarded.get(v);
|
|
@@ -1252,7 +1300,7 @@ export async function analyze({
|
|
|
1252
1300
|
: props.children?.iamChildren || "";
|
|
1253
1301
|
if (ctx[tag]?.iamChildren !== undefined) return ctx[tag].iamChildren;
|
|
1254
1302
|
const routerPrimitive = externalImport(m, tag);
|
|
1255
|
-
if (["react-router", "react-router-dom"].includes(routerPrimitive?.specifier) && routerPrimitive.name === "Outlet") return ctx.__iamOutlet?.iamChildren ?? "";
|
|
1303
|
+
if (["react-router", "react-router-dom", "@tanstack/react-router"].includes(routerPrimitive?.specifier) && routerPrimitive.name === "Outlet") return ctx.__iamOutlet?.iamChildren ?? "";
|
|
1256
1304
|
if (
|
|
1257
1305
|
/^(?:ThemeProvider|Providers?|Analytics|TailwindIndicator|Toaster)$/.test(
|
|
1258
1306
|
tag,
|
|
@@ -1267,7 +1315,7 @@ export async function analyze({
|
|
|
1267
1315
|
props["data-iam-adapter"] = tag;
|
|
1268
1316
|
}
|
|
1269
1317
|
if (/^[A-Z]/.test(tag) && !intrinsicAlias) {
|
|
1270
|
-
if (tag === "Link" || ["react-router", "react-router-dom"].includes(routerPrimitive?.specifier) && ["Link", "NavLink"].includes(routerPrimitive.name)) {
|
|
1318
|
+
if (tag === "Link" || ["react-router", "react-router-dom", "@tanstack/react-router"].includes(routerPrimitive?.specifier) && ["Link", "NavLink"].includes(routerPrimitive.name)) {
|
|
1271
1319
|
htmlTag = "a";
|
|
1272
1320
|
if (typeof props.to === "string") props.href = props.to;
|
|
1273
1321
|
}
|
|
@@ -1286,7 +1334,7 @@ export async function analyze({
|
|
|
1286
1334
|
.filter((v) => typeof v === "string")
|
|
1287
1335
|
.join(" ");
|
|
1288
1336
|
} else {
|
|
1289
|
-
const ref =
|
|
1337
|
+
const ref = componentBinding(m, tag),
|
|
1290
1338
|
target = ref && componentDefs.get(ref.module.file + "#" + ref.name);
|
|
1291
1339
|
if (target) {
|
|
1292
1340
|
const own = components.get(owner.key);
|
|
@@ -1313,40 +1361,30 @@ export async function analyze({
|
|
|
1313
1361
|
}
|
|
1314
1362
|
}
|
|
1315
1363
|
if (!allowedTags.has(htmlTag)) return children;
|
|
1316
|
-
const anchor =
|
|
1364
|
+
const anchor = anchorAttributes
|
|
1317
1365
|
.map((key) =>
|
|
1318
1366
|
typeof props[key] === "string" ? key + "=" + props[key] : null,
|
|
1319
1367
|
)
|
|
1320
1368
|
.find(Boolean);
|
|
1321
1369
|
const ordinal = owner.ordinals.get(node) ?? 0,
|
|
1322
|
-
explicitAnchor = anchor
|
|
1323
|
-
|
|
1370
|
+
explicitAnchor = Boolean(anchor);
|
|
1371
|
+
let elementKey = identity("element", {
|
|
1324
1372
|
component: owner.key,
|
|
1325
1373
|
tag,
|
|
1326
1374
|
anchor: explicitAnchor ? anchor : "jsx:" + ordinal,
|
|
1327
1375
|
});
|
|
1376
|
+
const source = sourceLocation(m, node, owner.symbol);
|
|
1377
|
+
const prior = elements.get(elementKey);
|
|
1378
|
+
if (prior && (prior.source.file !== source.file || prior.source.line !== source.line || prior.source.column !== source.column)) {
|
|
1379
|
+
// Keep both concrete source sites. The DOM uniqueness check makes duplicate anchors read-only.
|
|
1380
|
+
elementKey = identity("element", { component: owner.key, tag, anchor, ordinal });
|
|
1381
|
+
gap("AMBIGUOUS_ELEMENT_ANCHOR", m.file, tag);
|
|
1382
|
+
}
|
|
1328
1383
|
const nodeId = identity("node", {
|
|
1329
1384
|
page: pageKey,
|
|
1330
1385
|
element: elementKey,
|
|
1331
1386
|
instance,
|
|
1332
|
-
})
|
|
1333
|
-
source = sourceLocation(m, node, owner.symbol);
|
|
1334
|
-
const prior = elements.get(elementKey);
|
|
1335
|
-
if (
|
|
1336
|
-
explicitAnchor &&
|
|
1337
|
-
prior &&
|
|
1338
|
-
(prior.source.file !== source.file ||
|
|
1339
|
-
prior.source.line !== source.line ||
|
|
1340
|
-
prior.source.column !== source.column)
|
|
1341
|
-
)
|
|
1342
|
-
throw new Error(
|
|
1343
|
-
"Ambiguous explicit element anchor " +
|
|
1344
|
-
anchor +
|
|
1345
|
-
" in " +
|
|
1346
|
-
m.file +
|
|
1347
|
-
":" +
|
|
1348
|
-
source.line,
|
|
1349
|
-
);
|
|
1387
|
+
});
|
|
1350
1388
|
elements.set(elementKey, {
|
|
1351
1389
|
elementKey,
|
|
1352
1390
|
componentKey: owner.key,
|
|
@@ -1369,6 +1407,11 @@ export async function analyze({
|
|
|
1369
1407
|
}
|
|
1370
1408
|
for (const binding of uiBindings.filter(b => ["value", "checked", "disabled", "className"].includes(b.property))) {
|
|
1371
1409
|
if(['value','checked'].includes(binding.property)&&!['input','select','textarea'].includes(htmlTag)){gap('UI_BINDING_TARGET_UNSUPPORTED',m.file,tag+': input property cannot be simulated on a non-input adapter');continue;}
|
|
1410
|
+
const expectedType = ["checked", "disabled"].includes(binding.property) ? "boolean" : "string";
|
|
1411
|
+
if (uiExpressionType(binding.value) !== expectedType) {
|
|
1412
|
+
gap("UI_BINDING_TYPE_UNSUPPORTED", m.file, tag + "." + binding.property + ": source value cannot be represented by the typed UI simulation");
|
|
1413
|
+
continue;
|
|
1414
|
+
}
|
|
1372
1415
|
ui.bindings.push({ bindingKey: identity("ui-binding", { node: nodeId, property: binding.property }), routeKey: pageKey, nodeId, elementKey, ...binding });
|
|
1373
1416
|
}
|
|
1374
1417
|
const sensitiveInput = htmlTag === "input" && (["password", "hidden"].includes(String(props.type).toLowerCase()) || sensitiveName(String(props.name || props.id || "")));
|
|
@@ -1380,6 +1423,12 @@ export async function analyze({
|
|
|
1380
1423
|
}
|
|
1381
1424
|
const attrs = { "data-iam-node": nodeId };
|
|
1382
1425
|
for (const [key, value] of Object.entries(props)) {
|
|
1426
|
+
if (key === "style") {
|
|
1427
|
+
const css = staticStyle(value, () => gap("INLINE_STYLE_UNSUPPORTED", m.file,
|
|
1428
|
+
"Inline style at line " + source.line + ": only static CSS values without resource URLs are reconstructed"));
|
|
1429
|
+
if (css) attrs.style = css;
|
|
1430
|
+
continue;
|
|
1431
|
+
}
|
|
1383
1432
|
if (isUnknown(value) || value == null || typeof value === "object")
|
|
1384
1433
|
continue;
|
|
1385
1434
|
if (["href", "src"].includes(key) && privateUrl(value)) {
|
|
@@ -1400,7 +1449,7 @@ export async function analyze({
|
|
|
1400
1449
|
clipRule: "clip-rule",
|
|
1401
1450
|
}[key] || key;
|
|
1402
1451
|
if (
|
|
1403
|
-
!/^(?:class|id|name|type|placeholder|value|title|alt|href|src|role|for|disabled|hidden|checked|width|height|viewBox|d|fill|stroke|stroke-width|stroke-linecap|stroke-linejoin|fill-rule|clip-rule|cx|cy|r|x|y|x1|x2|y1|y2|points|aria-[a-z-]+|data-indiana-id|data-testid|data-iam-(?:adapter|toggle))$/.test(
|
|
1452
|
+
!/^(?:class|id|name|type|placeholder|value|title|alt|href|src|role|for|disabled|hidden|checked|width|height|viewBox|d|fill|stroke|stroke-width|stroke-linecap|stroke-linejoin|fill-rule|clip-rule|cx|cy|r|x|y|x1|x2|y1|y2|points|aria-[a-z-]+|data-indiana-id|data-indiana-map-(?:area|flow|kind|auth|edge)|data-testid|data-test|data-qa|data-iam-(?:adapter|toggle))$/.test(
|
|
1404
1453
|
attr,
|
|
1405
1454
|
)
|
|
1406
1455
|
)
|
|
@@ -1442,16 +1491,24 @@ export async function analyze({
|
|
|
1442
1491
|
);
|
|
1443
1492
|
}
|
|
1444
1493
|
function renderEntry(file, props, instance, symbol) {
|
|
1445
|
-
const entryModule = modules.get(file), entryRef = entryModule &&
|
|
1494
|
+
const entryModule = modules.get(file), entryRef = entryModule && componentBinding(entryModule, symbol ?? entryModule.defaultName);
|
|
1446
1495
|
const entryDef = entryRef && componentDefs.get(entryRef.module.file + "#" + entryRef.name);
|
|
1447
|
-
if (!entryDef) {
|
|
1496
|
+
if (!entryDef) {
|
|
1497
|
+
if (entryRef) {
|
|
1498
|
+
const key = identity("component", { file: entryRef.module.file, symbol: entryRef.name });
|
|
1499
|
+
if (!components.has(key)) components.set(key, { componentKey: key, file: entryRef.module.file, name: entryRef.name, source: sourceLocation(entryRef.module, entryRef.node, entryRef.name), dependencies: [] });
|
|
1500
|
+
}
|
|
1501
|
+
gap("PAGE_NO_JSX", file, "Composition entry has no inspectable JSX; null defaults are inert");
|
|
1502
|
+
return "";
|
|
1503
|
+
}
|
|
1448
1504
|
return renderComponent(entryDef, props, instance, 0);
|
|
1449
1505
|
}
|
|
1450
1506
|
function renderBranch(branch) {
|
|
1451
1507
|
let content = renderEntry(branch.file, {}, branch === composition.composition ? "page" : "slot-page:" + branch.file, branch.symbol);
|
|
1452
1508
|
for (const layer of branch.layers) {
|
|
1453
|
-
const props =
|
|
1454
|
-
|
|
1509
|
+
const props = ["react-router", "tanstack-router"].includes(routing.framework)
|
|
1510
|
+
? { __iamOutlet: { iamChildren: content } }
|
|
1511
|
+
: { children: { iamChildren: content } };
|
|
1455
1512
|
if (/^(?:src\/)?pages\/_app\.[jt]sx?$/.test(layer.file)) { props.Component = { iamChildren: content }; props.pageProps = {}; }
|
|
1456
1513
|
for (const slot of layer.slots) {
|
|
1457
1514
|
props[slot.name] = { iamChildren: renderBranch(slot.branch) };
|
|
@@ -1487,6 +1544,27 @@ export async function analyze({
|
|
|
1487
1544
|
const dom = new JSDOM(
|
|
1488
1545
|
"<!doctype html><html><body>" + html + "</body></html>",
|
|
1489
1546
|
);
|
|
1547
|
+
// JSX children may be forwarded more than once, and HTML repair may clone
|
|
1548
|
+
// an invalid nested anchor. Each concrete occurrence needs a unique node;
|
|
1549
|
+
// ambiguous copies remain preview-only and have no simulated state bridge.
|
|
1550
|
+
const occurrences = new Map();
|
|
1551
|
+
for (const element of dom.window.document.querySelectorAll("[data-iam-node]")) {
|
|
1552
|
+
const id = element.getAttribute("data-iam-node");
|
|
1553
|
+
const matches = occurrences.get(id) ?? [];
|
|
1554
|
+
matches.push(element);
|
|
1555
|
+
occurrences.set(id, matches);
|
|
1556
|
+
}
|
|
1557
|
+
for (const [original, matches] of occurrences) {
|
|
1558
|
+
if (matches.length < 2) continue;
|
|
1559
|
+
const rec = record.get(original);
|
|
1560
|
+
if (!rec) throw Error("Unknown repeated source node");
|
|
1561
|
+
gap("REPEATED_SOURCE_INSTANCE", rec.source.file, "Repeated forwarded children or HTML repair: copies are preview-only without a state bridge");
|
|
1562
|
+
for (const [index, element] of matches.entries()) {
|
|
1563
|
+
const id = identity("node", { page: pageKey, original, occurrence: index });
|
|
1564
|
+
element.setAttribute("data-iam-node", id);
|
|
1565
|
+
record.set(id, { ...rec, synthetic: true });
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1490
1568
|
const nodes = [];
|
|
1491
1569
|
function collect(node, parentId, index) {
|
|
1492
1570
|
if (node.nodeType !== 1 && node.nodeType !== 3) return null;
|
|
@@ -1712,7 +1790,7 @@ export async function analyze({
|
|
|
1712
1790
|
schemaVersion: 2,
|
|
1713
1791
|
producer: {
|
|
1714
1792
|
name: "indiana-app-manifest-local",
|
|
1715
|
-
version: "0.
|
|
1793
|
+
version: "0.19.0",
|
|
1716
1794
|
method: "typescript-ast-and-bounded-static-projection",
|
|
1717
1795
|
},
|
|
1718
1796
|
app: { name, platforms: ["web"] },
|
package/src/anchors.mjs
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { dependency } from "./dependencies.mjs";
|
|
2
|
+
import { sha256 } from "./integrity.mjs";
|
|
3
|
+
import { redactLiteral } from "./privacy.mjs";
|
|
4
|
+
|
|
5
|
+
export const anchorAttributes = [
|
|
6
|
+
"id",
|
|
7
|
+
"data-testid",
|
|
8
|
+
"data-test",
|
|
9
|
+
"data-qa",
|
|
10
|
+
"data-indiana-id",
|
|
11
|
+
];
|
|
12
|
+
export function sourceScriptKind(file, ts) {
|
|
13
|
+
return /\.[cm]?ts$/.test(file)
|
|
14
|
+
? ts.ScriptKind.TS
|
|
15
|
+
: /\.tsx$/.test(file) ? ts.ScriptKind.TSX : ts.ScriptKind.JSX;
|
|
16
|
+
}
|
|
17
|
+
export function instrumentableSource(file, text) {
|
|
18
|
+
return (
|
|
19
|
+
typeof text === "string" &&
|
|
20
|
+
Buffer.byteLength(text) <= 1_000_000 &&
|
|
21
|
+
/\.[cm]?[jt]sx?$/.test(file) &&
|
|
22
|
+
!/(?:^|\/)(?:node_modules|\.next|\.git|\.indiana|api|server|secrets?)(?:\/|$)/.test(
|
|
23
|
+
file,
|
|
24
|
+
)
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
export const anchorSelectorPattern =
|
|
28
|
+
/^[a-z][a-z0-9-]*\[(?:id|data-testid|data-test|data-qa|data-indiana-id)="(?:[^"\\\x00-\x1f\x7f]|\\["\\])*"\]$/;
|
|
29
|
+
|
|
30
|
+
export function anchorSelector(node) {
|
|
31
|
+
if (node.kind !== "element") return null;
|
|
32
|
+
for (const attribute of anchorAttributes) {
|
|
33
|
+
const value = node.attributes[attribute];
|
|
34
|
+
if (
|
|
35
|
+
typeof value !== "string" ||
|
|
36
|
+
!value ||
|
|
37
|
+
value.length > 256 ||
|
|
38
|
+
/[\x00-\x1f\x7f]/.test(value) ||
|
|
39
|
+
redactLiteral(value) !== value
|
|
40
|
+
)
|
|
41
|
+
continue;
|
|
42
|
+
const selector = `${node.tag}[${attribute}="${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"]`;
|
|
43
|
+
if (selector.length <= 512) return selector;
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A semantic source fingerprint; never line numbers, absolute paths or sibling ordinals. */
|
|
49
|
+
export function automaticSourceAnchors(file, source) {
|
|
50
|
+
if (!instrumentableSource(file, source.text)) return new Map();
|
|
51
|
+
const ts = dependency("typescript").value;
|
|
52
|
+
const candidates = [],
|
|
53
|
+
groups = new Map();
|
|
54
|
+
function owner(node) {
|
|
55
|
+
for (let parent = node.parent; parent; parent = parent.parent) {
|
|
56
|
+
if (!ts.isFunctionLike(parent)) continue;
|
|
57
|
+
if (parent.name && ts.isIdentifier(parent.name)) return parent.name.text;
|
|
58
|
+
if (
|
|
59
|
+
ts.isVariableDeclaration(parent.parent) &&
|
|
60
|
+
ts.isIdentifier(parent.parent.name)
|
|
61
|
+
)
|
|
62
|
+
return parent.parent.name.text;
|
|
63
|
+
if (
|
|
64
|
+
ts.isExportAssignment(parent.parent) ||
|
|
65
|
+
parent.modifiers?.some(
|
|
66
|
+
(modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword,
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
return "default";
|
|
70
|
+
}
|
|
71
|
+
return "module";
|
|
72
|
+
}
|
|
73
|
+
const shapes = new WeakMap();
|
|
74
|
+
function shape(node) {
|
|
75
|
+
if (shapes.has(node)) return shapes.get(node);
|
|
76
|
+
let value;
|
|
77
|
+
if (ts.isJsxText(node))
|
|
78
|
+
value = [node.kind, node.text.replace(/\s+/g, " ").trim()];
|
|
79
|
+
else if (
|
|
80
|
+
ts.isIdentifier(node) ||
|
|
81
|
+
ts.isStringLiteral(node) ||
|
|
82
|
+
ts.isNumericLiteral(node)
|
|
83
|
+
)
|
|
84
|
+
value = [node.kind, node.text];
|
|
85
|
+
else {
|
|
86
|
+
const children = [];
|
|
87
|
+
ts.forEachChild(node, (child) => {
|
|
88
|
+
if (!ts.isJsxText(child) || child.text.trim())
|
|
89
|
+
children.push(shape(child));
|
|
90
|
+
});
|
|
91
|
+
value = [node.kind, ...children];
|
|
92
|
+
}
|
|
93
|
+
const digest = sha256(JSON.stringify(value));
|
|
94
|
+
shapes.set(node, digest);
|
|
95
|
+
return digest;
|
|
96
|
+
}
|
|
97
|
+
function visit(node) {
|
|
98
|
+
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) {
|
|
99
|
+
const opening = ts.isJsxElement(node) ? node.openingElement : node;
|
|
100
|
+
const tag = opening.tagName.getText(source);
|
|
101
|
+
if (
|
|
102
|
+
/^[a-z][a-z0-9-]*$/.test(tag) &&
|
|
103
|
+
!["html", "head", "script", "style", "link", "meta"].includes(tag) &&
|
|
104
|
+
!opening.attributes.properties.some(
|
|
105
|
+
(attribute) =>
|
|
106
|
+
ts.isJsxAttribute(attribute) &&
|
|
107
|
+
anchorAttributes.includes(attribute.name.getText(source)),
|
|
108
|
+
)
|
|
109
|
+
) {
|
|
110
|
+
const signature =
|
|
111
|
+
file.replaceAll("\\", "/") + "\0" + owner(node) + "\0" + shape(node);
|
|
112
|
+
const anchor =
|
|
113
|
+
"iam_" + sha256("iam/build-anchor/v1\0" + signature).slice(0, 32);
|
|
114
|
+
candidates.push({ opening, anchor });
|
|
115
|
+
groups.set(anchor, (groups.get(anchor) ?? 0) + 1);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
ts.forEachChild(node, visit);
|
|
119
|
+
}
|
|
120
|
+
visit(source);
|
|
121
|
+
// Identical source sites cannot be disambiguated by their position. Leave them read-only.
|
|
122
|
+
return new Map(
|
|
123
|
+
candidates
|
|
124
|
+
.filter((candidate) => groups.get(candidate.anchor) === 1)
|
|
125
|
+
.map(({ opening, anchor }) => [opening, anchor]),
|
|
126
|
+
);
|
|
127
|
+
}
|
package/src/build-options.d.ts
CHANGED
|
@@ -4,6 +4,10 @@ import type { BuildLogger } from "./index.js";
|
|
|
4
4
|
export type IndianaIamBuildOptions = {
|
|
5
5
|
/** Set false to build without generating an IAM. */
|
|
6
6
|
enabled?: boolean;
|
|
7
|
+
/** Reuse existing UI IDs and add build-only anchors for unambiguous native JSX (default: true). */
|
|
8
|
+
automaticAnchors?: boolean;
|
|
9
|
+
/** Browser entry relative to root for apps with multiple source roots. */
|
|
10
|
+
entry?: string;
|
|
7
11
|
/** App name recorded in the IAM; defaults to the root folder name. */
|
|
8
12
|
name?: string;
|
|
9
13
|
/** Fail the build when the IAM cannot be generated (default: warn and continue). */
|
|
@@ -15,6 +19,9 @@ export type IndianaIamBuildOptions = {
|
|
|
15
19
|
logger?: BuildLogger;
|
|
16
20
|
/** Key holding only app-manifests:write; defaults to `INDIANA_IAM_KEY`. Without one the IAM is not uploaded. */
|
|
17
21
|
apiKey?: string;
|
|
22
|
+
/** Client this build belongs to, such as `web` or `web-dashboard`; each client has its own current version.
|
|
23
|
+
* Defaults to `INDIANA_IAM_CLIENT` or `web`. */
|
|
24
|
+
client?: string;
|
|
18
25
|
/** Indiana origin; defaults to `INDIANA_IAM_BASE_URL` or https://tinker.cash. */
|
|
19
26
|
baseUrl?: string;
|
|
20
27
|
/** "production" (default) skips Vercel preview deployments; "always" uploads every build; false never uploads. */
|