@tinkerlandai/indiana-iam 0.1.1 → 0.2.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 +30 -3
- package/package.json +2 -1
- package/src/analyze.mjs +65 -71
- package/src/anchors.mjs +122 -0
- package/src/build-options.d.ts +2 -0
- package/src/build.mjs +6 -18
- package/src/generate.mjs +4 -3
- package/src/git-metadata.mjs +61 -0
- package/src/index.d.ts +103 -11
- package/src/index.mjs +2 -0
- package/src/instrument.mjs +2 -2
- package/src/library-entry.mjs +1 -0
- package/src/next-loader.cjs +30 -0
- package/src/next.mjs +24 -2
- package/src/rebind.mjs +116 -0
- package/src/release-metadata.mjs +1 -1
- package/src/render-index.mjs +26 -7
- package/src/route-tree.mjs +5 -12
- package/src/source-files.mjs +53 -0
- package/src/transform.mjs +32 -0
- package/src/upload.mjs +2 -10
- package/src/vite.d.ts +12 -2
- package/src/vite.mjs +15 -4
package/README.md
CHANGED
|
@@ -40,6 +40,13 @@ Point the visual SDK at the descriptor:
|
|
|
40
40
|
|
|
41
41
|
`next dev` and other phases leave your config unchanged. Add `public/.well-known/indiana/` to `.gitignore`.
|
|
42
42
|
|
|
43
|
+
No developer harness or business SDK npm dependency is needed. The public script above runs the visual runtime;
|
|
44
|
+
Indiana owns authorization, immutable uploads and experiment publication.
|
|
45
|
+
IAM does not discover or launch merchant harnesses, test scripts, agents, browsers or development servers. It does
|
|
46
|
+
not import merchant modules to execute them. Source enumeration and optional Git commit labels use filesystem reads;
|
|
47
|
+
the package starts no external commands and works without a Git executable. Developers' explicitly configured
|
|
48
|
+
framework callbacks remain part of their normal build.
|
|
49
|
+
|
|
43
50
|
## Vite and React Router
|
|
44
51
|
|
|
45
52
|
```js
|
|
@@ -54,9 +61,28 @@ Server builds are skipped.
|
|
|
54
61
|
|
|
55
62
|
## Options
|
|
56
63
|
|
|
57
|
-
Both integrations accept `{ enabled, name, strict, rootFont, omitSvgResources, logger, apiKey, baseUrl, upload }`;
|
|
64
|
+
Both integrations accept `{ enabled, name, automaticAnchors, strict, rootFont, omitSvgResources, logger, apiKey, baseUrl, upload }`;
|
|
58
65
|
`withIndianaIam` also takes `root`. By default a source the scanner cannot read prints a warning and the build continues without an IAM;
|
|
59
66
|
`strict: true` fails the build instead. The same source always produces the same release ID and IAM bytes.
|
|
67
|
+
If source changes during compilation, generation fails instead of publishing a descriptor for different code.
|
|
68
|
+
|
|
69
|
+
## Target identity and release changes
|
|
70
|
+
|
|
71
|
+
Framework builds reuse `id`, `data-testid`, `data-test`, `data-qa`, then `data-indiana-id`. When none is declared,
|
|
72
|
+
the source loader adds a deterministic `data-indiana-id` to unambiguous native JSX in the build output and IAM.
|
|
73
|
+
Source files stay unchanged; formatting, comments and unrelated sibling insertions preserve a leaf's marker.
|
|
74
|
+
Set `automaticAnchors: false` to use only existing attributes.
|
|
75
|
+
|
|
76
|
+
Repeated component instances, identical source sites, unknown props and duplicate DOM selectors cannot prove a
|
|
77
|
+
unique target and remain read-only. React `key` contributes source evidence but is not a DOM selector or a global ID;
|
|
78
|
+
dynamic keys are never evaluated a second time or exposed as customer identifiers. Supported routing and state
|
|
79
|
+
coverage stays bounded and explicit.
|
|
80
|
+
|
|
81
|
+
The visual runtime requests delivery for the page's exact IAM digest. Indiana rebinds saved changes only when the
|
|
82
|
+
route, composition, target selector, element content and render uniqueness still match. Text changes also require
|
|
83
|
+
unchanged subtree content. A failure excludes the whole experiment for that release; it does not renormalize
|
|
84
|
+
variation weights or rewrite authored pins. Old deployments keep their own bindings during a rolling release.
|
|
85
|
+
The Console reports incompatible references for review. Local extraction does not certify arbitrary runtime data.
|
|
60
86
|
|
|
61
87
|
## Other builds
|
|
62
88
|
|
|
@@ -67,8 +93,9 @@ indiana-iam prepare --root .
|
|
|
67
93
|
indiana-iam anchor --root . --out <plan.json> [--apply]
|
|
68
94
|
```
|
|
69
95
|
|
|
70
|
-
|
|
71
|
-
|
|
96
|
+
Standalone generation uses existing attributes. Custom builds can pair `transformSourceForIam(code, relativeFile)`
|
|
97
|
+
with `generateAppManifest({ root, automaticAnchors: true })`; use both on the same original source. `anchor` is an
|
|
98
|
+
optional command for explicit source annotations. Next and Vite do not need that command or a harness.
|
|
72
99
|
|
|
73
100
|
## Scope
|
|
74
101
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tinkerlandai/indiana-iam",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Build-time Indiana App Manifest (IAM) generator for Next.js and Vite apps: scans source after the production build and writes the IAM and its release descriptor.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"ajv": "6.15.0",
|
|
47
47
|
"css-tree": "3.2.1",
|
|
48
48
|
"jsdom": "26.1.0",
|
|
49
|
+
"magic-string": "0.30.21",
|
|
49
50
|
"tailwind-merge": "3.7.0",
|
|
50
51
|
"tailwindcss": "4.3.3",
|
|
51
52
|
"typescript": "5.9.3"
|
package/src/analyze.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { automaticSourceAnchors, anchorAttributes } 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";
|
|
6
7
|
import { frameworkRoutes, externalImport } from "./framework-routes.mjs";
|
|
@@ -26,6 +27,28 @@ const { JSDOM } = dependency("jsdom").value;
|
|
|
26
27
|
const UNKNOWN = Symbol("unknown");
|
|
27
28
|
import { sensitiveName, redactLiteral, privateUrl } from "./privacy.mjs";
|
|
28
29
|
const isUnknown = (value) => value === UNKNOWN;
|
|
30
|
+
// React numeric styles use px except for unitless properties and CSS variables.
|
|
31
|
+
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(" "));
|
|
32
|
+
function staticStyle(value, unsupported) {
|
|
33
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
34
|
+
unsupported();
|
|
35
|
+
return "";
|
|
36
|
+
}
|
|
37
|
+
return Object.entries(value).flatMap(([key, raw]) => {
|
|
38
|
+
if (raw == null || typeof raw === "boolean" || raw === "") return [];
|
|
39
|
+
if (!/^(?:[a-zA-Z][a-zA-Z0-9]*|--[a-zA-Z0-9_-]+)$/.test(key) ||
|
|
40
|
+
!["string", "number"].includes(typeof raw) ||
|
|
41
|
+
(typeof raw === "number" && !Number.isFinite(raw)) ||
|
|
42
|
+
/[;{}<>\\]|url\s*\(|expression\s*\(|@import/i.test(String(raw))) {
|
|
43
|
+
unsupported();
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
const name = key.startsWith("--") ? key : key.replace(/[A-Z]/g, letter => "-" + letter.toLowerCase()).replace(/^ms-/, "-ms-");
|
|
47
|
+
const unprefixed = key.replace(/^(?:Webkit|Moz|ms|O)(?=[A-Z])/, "").replace(/^[A-Z]/, letter => letter.toLowerCase());
|
|
48
|
+
const unit = typeof raw === "number" && raw !== 0 && !key.startsWith("--") && !unitlessStyles.has(unprefixed) ? "px" : "";
|
|
49
|
+
return [name + ":" + String(raw).trim() + unit];
|
|
50
|
+
}).join(";");
|
|
51
|
+
}
|
|
29
52
|
const allowedTags = new Set(
|
|
30
53
|
"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
54
|
" ",
|
|
@@ -133,52 +156,10 @@ export async function analyze({
|
|
|
133
156
|
maxFiles = 5000,
|
|
134
157
|
maxBytes = 100_000_000,
|
|
135
158
|
navigationPaths = [],
|
|
159
|
+
automaticAnchors = false,
|
|
136
160
|
}) {
|
|
137
161
|
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");
|
|
162
|
+
const files = await sourceFiles(root, { maxFiles });
|
|
182
163
|
const modules = new Map(),
|
|
183
164
|
inventory = [],
|
|
184
165
|
gaps = [],
|
|
@@ -238,6 +219,7 @@ export async function analyze({
|
|
|
238
219
|
file,
|
|
239
220
|
text,
|
|
240
221
|
source,
|
|
222
|
+
anchors: automaticAnchors ? automaticSourceAnchors(file, source) : new Map(),
|
|
241
223
|
bindings: new Map(),
|
|
242
224
|
imports: new Map(),
|
|
243
225
|
exports: new Map(),
|
|
@@ -968,6 +950,15 @@ export async function analyze({
|
|
|
968
950
|
eventInputs(value);
|
|
969
951
|
return { variableKey, value, payload };
|
|
970
952
|
}
|
|
953
|
+
function unobservedValue(node, owner, instance) {
|
|
954
|
+
const source = sourceLocation(owner.module, node, owner.symbol);
|
|
955
|
+
const elementKey = identity("element", { component: owner.key, unobservedValue: node.getStart(owner.module.source) - owner.fn.getStart(owner.module.source) });
|
|
956
|
+
const nodeId = identity("node", { page: pageKey, element: elementKey, instance });
|
|
957
|
+
elements.set(elementKey, { elementKey, componentKey: owner.key, tag: "span", source, identityStrategy: "provisional_ast_ordinal", editableProperties: [], binding: "preview_only" });
|
|
958
|
+
record.set(nodeId, { elementKey, source, synthetic: true });
|
|
959
|
+
// Preserve an unknown child boundary. An empty projection must never prove that replacing text is safe.
|
|
960
|
+
return '<span data-iam-node="' + nodeId + '" hidden=""></span>';
|
|
961
|
+
}
|
|
971
962
|
function renderExpr(node, owner, ctx, instance, depth) {
|
|
972
963
|
node = unwrap(node);
|
|
973
964
|
if (!node) return "";
|
|
@@ -1117,7 +1108,7 @@ export async function analyze({
|
|
|
1117
1108
|
.join("");
|
|
1118
1109
|
}
|
|
1119
1110
|
gap("DYNAMIC_COLLECTION", m.file, node.expression.getText(m.source));
|
|
1120
|
-
return
|
|
1111
|
+
return unobservedValue(node, owner, instance);
|
|
1121
1112
|
}
|
|
1122
1113
|
if (!ts.isJsxElement(node) && !ts.isJsxSelfClosingElement(node)) {
|
|
1123
1114
|
if (
|
|
@@ -1141,18 +1132,25 @@ export async function analyze({
|
|
|
1141
1132
|
typeof value !== "boolean"
|
|
1142
1133
|
)
|
|
1143
1134
|
return escape(value);
|
|
1144
|
-
if (isUnknown(value))
|
|
1135
|
+
if (isUnknown(value)) {
|
|
1145
1136
|
gap("DYNAMIC_VALUE", m.file, node.getText(m.source).slice(0, 100));
|
|
1137
|
+
return unobservedValue(node, owner, instance);
|
|
1138
|
+
}
|
|
1146
1139
|
return "";
|
|
1147
1140
|
}
|
|
1148
1141
|
if (++renderedCount > 12000)
|
|
1149
1142
|
throw new Error("Rendered node budget exceeded");
|
|
1150
1143
|
const opening = ts.isJsxElement(node) ? node.openingElement : node,
|
|
1151
1144
|
tag = opening.tagName.getText(m.source),
|
|
1152
|
-
props = {}, uiHandlers = [], uiBindings = [], forwardedHandlers=[];
|
|
1145
|
+
props = m.anchors.get(opening) ? { "data-indiana-id": m.anchors.get(opening) } : {}, uiHandlers = [], uiBindings = [], forwardedHandlers=[];
|
|
1153
1146
|
for (const attr of opening.attributes.properties) {
|
|
1154
1147
|
if (ts.isJsxSpreadAttribute(attr)) {
|
|
1155
1148
|
const v = evalExpression(attr.expression, m, ctx);
|
|
1149
|
+
if (isUnknown(v)) {
|
|
1150
|
+
for (const attribute of anchorAttributes) delete props[attribute];
|
|
1151
|
+
gap("UNKNOWN_ANCHOR_SPREAD", m.file, "Opaque spread may override element identity");
|
|
1152
|
+
continue;
|
|
1153
|
+
}
|
|
1156
1154
|
if (v && typeof v === "object") {
|
|
1157
1155
|
Object.assign(props, v);
|
|
1158
1156
|
const inherited = forwarded.get(v);
|
|
@@ -1313,40 +1311,30 @@ export async function analyze({
|
|
|
1313
1311
|
}
|
|
1314
1312
|
}
|
|
1315
1313
|
if (!allowedTags.has(htmlTag)) return children;
|
|
1316
|
-
const anchor =
|
|
1314
|
+
const anchor = anchorAttributes
|
|
1317
1315
|
.map((key) =>
|
|
1318
1316
|
typeof props[key] === "string" ? key + "=" + props[key] : null,
|
|
1319
1317
|
)
|
|
1320
1318
|
.find(Boolean);
|
|
1321
1319
|
const ordinal = owner.ordinals.get(node) ?? 0,
|
|
1322
|
-
explicitAnchor = anchor
|
|
1323
|
-
|
|
1320
|
+
explicitAnchor = Boolean(anchor);
|
|
1321
|
+
let elementKey = identity("element", {
|
|
1324
1322
|
component: owner.key,
|
|
1325
1323
|
tag,
|
|
1326
1324
|
anchor: explicitAnchor ? anchor : "jsx:" + ordinal,
|
|
1327
1325
|
});
|
|
1326
|
+
const source = sourceLocation(m, node, owner.symbol);
|
|
1327
|
+
const prior = elements.get(elementKey);
|
|
1328
|
+
if (prior && (prior.source.file !== source.file || prior.source.line !== source.line || prior.source.column !== source.column)) {
|
|
1329
|
+
// Keep both concrete source sites. The DOM uniqueness check makes duplicate anchors read-only.
|
|
1330
|
+
elementKey = identity("element", { component: owner.key, tag, anchor, ordinal });
|
|
1331
|
+
gap("AMBIGUOUS_ELEMENT_ANCHOR", m.file, tag);
|
|
1332
|
+
}
|
|
1328
1333
|
const nodeId = identity("node", {
|
|
1329
1334
|
page: pageKey,
|
|
1330
1335
|
element: elementKey,
|
|
1331
1336
|
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
|
-
);
|
|
1337
|
+
});
|
|
1350
1338
|
elements.set(elementKey, {
|
|
1351
1339
|
elementKey,
|
|
1352
1340
|
componentKey: owner.key,
|
|
@@ -1380,6 +1368,12 @@ export async function analyze({
|
|
|
1380
1368
|
}
|
|
1381
1369
|
const attrs = { "data-iam-node": nodeId };
|
|
1382
1370
|
for (const [key, value] of Object.entries(props)) {
|
|
1371
|
+
if (key === "style") {
|
|
1372
|
+
const css = staticStyle(value, () => gap("INLINE_STYLE_UNSUPPORTED", m.file,
|
|
1373
|
+
"Inline style at line " + source.line + ": only static CSS values without resource URLs are reconstructed"));
|
|
1374
|
+
if (css) attrs.style = css;
|
|
1375
|
+
continue;
|
|
1376
|
+
}
|
|
1383
1377
|
if (isUnknown(value) || value == null || typeof value === "object")
|
|
1384
1378
|
continue;
|
|
1385
1379
|
if (["href", "src"].includes(key) && privateUrl(value)) {
|
|
@@ -1400,7 +1394,7 @@ export async function analyze({
|
|
|
1400
1394
|
clipRule: "clip-rule",
|
|
1401
1395
|
}[key] || key;
|
|
1402
1396
|
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(
|
|
1397
|
+
!/^(?: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-test|data-qa|data-iam-(?:adapter|toggle))$/.test(
|
|
1404
1398
|
attr,
|
|
1405
1399
|
)
|
|
1406
1400
|
)
|
|
@@ -1712,7 +1706,7 @@ export async function analyze({
|
|
|
1712
1706
|
schemaVersion: 2,
|
|
1713
1707
|
producer: {
|
|
1714
1708
|
name: "indiana-app-manifest-local",
|
|
1715
|
-
version: "0.
|
|
1709
|
+
version: "0.17.0",
|
|
1716
1710
|
method: "typescript-ast-and-bounded-static-projection",
|
|
1717
1711
|
},
|
|
1718
1712
|
app: { name, platforms: ["web"] },
|
package/src/anchors.mjs
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
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 instrumentableSource(file, text) {
|
|
13
|
+
return (
|
|
14
|
+
typeof text === "string" &&
|
|
15
|
+
Buffer.byteLength(text) <= 1_000_000 &&
|
|
16
|
+
/\.[cm]?[jt]sx?$/.test(file) &&
|
|
17
|
+
!/(?:^|\/)(?:node_modules|\.next|\.git|\.indiana|api|server|secrets?)(?:\/|$)/.test(
|
|
18
|
+
file,
|
|
19
|
+
)
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
export const anchorSelectorPattern =
|
|
23
|
+
/^[a-z][a-z0-9-]*\[(?:id|data-testid|data-test|data-qa|data-indiana-id)="(?:[^"\\\x00-\x1f\x7f]|\\["\\])*"\]$/;
|
|
24
|
+
|
|
25
|
+
export function anchorSelector(node) {
|
|
26
|
+
if (node.kind !== "element") return null;
|
|
27
|
+
for (const attribute of anchorAttributes) {
|
|
28
|
+
const value = node.attributes[attribute];
|
|
29
|
+
if (
|
|
30
|
+
typeof value !== "string" ||
|
|
31
|
+
!value ||
|
|
32
|
+
value.length > 256 ||
|
|
33
|
+
/[\x00-\x1f\x7f]/.test(value) ||
|
|
34
|
+
redactLiteral(value) !== value
|
|
35
|
+
)
|
|
36
|
+
continue;
|
|
37
|
+
const selector = `${node.tag}[${attribute}="${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"]`;
|
|
38
|
+
if (selector.length <= 512) return selector;
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A semantic source fingerprint; never line numbers, absolute paths or sibling ordinals. */
|
|
44
|
+
export function automaticSourceAnchors(file, source) {
|
|
45
|
+
if (!instrumentableSource(file, source.text)) return new Map();
|
|
46
|
+
const ts = dependency("typescript").value;
|
|
47
|
+
const candidates = [],
|
|
48
|
+
groups = new Map();
|
|
49
|
+
function owner(node) {
|
|
50
|
+
for (let parent = node.parent; parent; parent = parent.parent) {
|
|
51
|
+
if (!ts.isFunctionLike(parent)) continue;
|
|
52
|
+
if (parent.name && ts.isIdentifier(parent.name)) return parent.name.text;
|
|
53
|
+
if (
|
|
54
|
+
ts.isVariableDeclaration(parent.parent) &&
|
|
55
|
+
ts.isIdentifier(parent.parent.name)
|
|
56
|
+
)
|
|
57
|
+
return parent.parent.name.text;
|
|
58
|
+
if (
|
|
59
|
+
ts.isExportAssignment(parent.parent) ||
|
|
60
|
+
parent.modifiers?.some(
|
|
61
|
+
(modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword,
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
return "default";
|
|
65
|
+
}
|
|
66
|
+
return "module";
|
|
67
|
+
}
|
|
68
|
+
const shapes = new WeakMap();
|
|
69
|
+
function shape(node) {
|
|
70
|
+
if (shapes.has(node)) return shapes.get(node);
|
|
71
|
+
let value;
|
|
72
|
+
if (ts.isJsxText(node))
|
|
73
|
+
value = [node.kind, node.text.replace(/\s+/g, " ").trim()];
|
|
74
|
+
else if (
|
|
75
|
+
ts.isIdentifier(node) ||
|
|
76
|
+
ts.isStringLiteral(node) ||
|
|
77
|
+
ts.isNumericLiteral(node)
|
|
78
|
+
)
|
|
79
|
+
value = [node.kind, node.text];
|
|
80
|
+
else {
|
|
81
|
+
const children = [];
|
|
82
|
+
ts.forEachChild(node, (child) => {
|
|
83
|
+
if (!ts.isJsxText(child) || child.text.trim())
|
|
84
|
+
children.push(shape(child));
|
|
85
|
+
});
|
|
86
|
+
value = [node.kind, ...children];
|
|
87
|
+
}
|
|
88
|
+
const digest = sha256(JSON.stringify(value));
|
|
89
|
+
shapes.set(node, digest);
|
|
90
|
+
return digest;
|
|
91
|
+
}
|
|
92
|
+
function visit(node) {
|
|
93
|
+
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) {
|
|
94
|
+
const opening = ts.isJsxElement(node) ? node.openingElement : node;
|
|
95
|
+
const tag = opening.tagName.getText(source);
|
|
96
|
+
if (
|
|
97
|
+
/^[a-z][a-z0-9-]*$/.test(tag) &&
|
|
98
|
+
!["html", "head", "script", "style", "link", "meta"].includes(tag) &&
|
|
99
|
+
!opening.attributes.properties.some(
|
|
100
|
+
(attribute) =>
|
|
101
|
+
ts.isJsxAttribute(attribute) &&
|
|
102
|
+
anchorAttributes.includes(attribute.name.getText(source)),
|
|
103
|
+
)
|
|
104
|
+
) {
|
|
105
|
+
const signature =
|
|
106
|
+
file.replaceAll("\\", "/") + "\0" + owner(node) + "\0" + shape(node);
|
|
107
|
+
const anchor =
|
|
108
|
+
"iam_" + sha256("iam/build-anchor/v1\0" + signature).slice(0, 32);
|
|
109
|
+
candidates.push({ opening, anchor });
|
|
110
|
+
groups.set(anchor, (groups.get(anchor) ?? 0) + 1);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
ts.forEachChild(node, visit);
|
|
114
|
+
}
|
|
115
|
+
visit(source);
|
|
116
|
+
// Identical source sites cannot be disambiguated by their position. Leave them read-only.
|
|
117
|
+
return new Map(
|
|
118
|
+
candidates
|
|
119
|
+
.filter((candidate) => groups.get(candidate.anchor) === 1)
|
|
120
|
+
.map(({ opening, anchor }) => [opening, anchor]),
|
|
121
|
+
);
|
|
122
|
+
}
|
package/src/build-options.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ 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;
|
|
7
9
|
/** App name recorded in the IAM; defaults to the root folder name. */
|
|
8
10
|
name?: string;
|
|
9
11
|
/** Fail the build when the IAM cannot be generated (default: warn and continue). */
|
package/src/build.mjs
CHANGED
|
@@ -8,7 +8,6 @@ import { buildLabel, uploadAppManifest, uploadSettings } from "./upload.mjs";
|
|
|
8
8
|
const MAX_BUILD_CSS = 16;
|
|
9
9
|
// Shared with build workers that re-evaluate the build configuration.
|
|
10
10
|
const STATE = "INDIANA_IAM_BUILD_RELEASE";
|
|
11
|
-
const analyses = new Map();
|
|
12
11
|
|
|
13
12
|
const prefix = "[indiana-iam] ";
|
|
14
13
|
const report = (logger, level, message) => logger[level](prefix + message);
|
|
@@ -28,28 +27,16 @@ async function guarded({ strict, logger }, action, what) {
|
|
|
28
27
|
* Before compilation: scans source once per build and returns its release ID.
|
|
29
28
|
* The same source and producer always get the same ID, so the page can embed its descriptor URL before the IAM exists.
|
|
30
29
|
*/
|
|
31
|
-
export async function prepareBuildRelease({ root, name, strict = false, logger = console }) {
|
|
30
|
+
export async function prepareBuildRelease({ root, name, automaticAnchors = true, strict = false, logger = console }) {
|
|
32
31
|
return guarded({ strict, logger }, async () => {
|
|
33
32
|
root = await fs.realpath(root);
|
|
34
|
-
const
|
|
35
|
-
if (shared?.root === root) return { releaseId: shared.releaseId };
|
|
36
|
-
const analysis = await analyzeSource({ root, name });
|
|
33
|
+
const analysis = await analyzeSource({ root, name, automaticAnchors });
|
|
37
34
|
const releaseId = deriveReleaseId(analysis);
|
|
38
|
-
|
|
39
|
-
process.env[STATE] = JSON.stringify({ root, releaseId });
|
|
35
|
+
process.env[STATE] = JSON.stringify({ root, releaseId, automaticAnchors });
|
|
40
36
|
return { releaseId };
|
|
41
37
|
}, "IAM generation");
|
|
42
38
|
}
|
|
43
39
|
|
|
44
|
-
function parseState(value) {
|
|
45
|
-
try {
|
|
46
|
-
const state = value ? JSON.parse(value) : null;
|
|
47
|
-
return typeof state?.root === "string" && typeof state?.releaseId === "string" ? state : null;
|
|
48
|
-
} catch {
|
|
49
|
-
return null;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
40
|
async function compiledCss(root, directory) {
|
|
54
41
|
let entries;
|
|
55
42
|
try {
|
|
@@ -72,6 +59,7 @@ export async function finishBuildRelease({
|
|
|
72
59
|
root,
|
|
73
60
|
releaseId,
|
|
74
61
|
name,
|
|
62
|
+
automaticAnchors = true,
|
|
75
63
|
cssDirectory,
|
|
76
64
|
mounts,
|
|
77
65
|
publicDirectory,
|
|
@@ -83,7 +71,8 @@ export async function finishBuildRelease({
|
|
|
83
71
|
}) {
|
|
84
72
|
root = await fs.realpath(root);
|
|
85
73
|
return guarded({ strict, logger }, async () => {
|
|
86
|
-
const analysis =
|
|
74
|
+
const analysis = await analyzeSource({ root, name, automaticAnchors });
|
|
75
|
+
if (deriveReleaseId(analysis) !== releaseId) throw new Error("Source changed during the build; rebuild before publishing IAM");
|
|
87
76
|
const css = cssDirectory ? await compiledCss(root, cssDirectory) : [];
|
|
88
77
|
const plain = () => generateAppManifest({ root, analysis, releaseId });
|
|
89
78
|
let result;
|
|
@@ -130,6 +119,5 @@ export async function finishBuildRelease({
|
|
|
130
119
|
|
|
131
120
|
/** Clears the per-build state; build tools call this only from tests. */
|
|
132
121
|
export function resetBuildRelease() {
|
|
133
|
-
analyses.clear();
|
|
134
122
|
delete process.env[STATE];
|
|
135
123
|
}
|
package/src/generate.mjs
CHANGED
|
@@ -11,9 +11,9 @@ import { refreshRenderHashes } from "./render-index.mjs";
|
|
|
11
11
|
export const IAM_MAX_BYTES = 8_388_608;
|
|
12
12
|
|
|
13
13
|
/** Scans the source checkout. The result is the input of {@link generateAppManifest}. */
|
|
14
|
-
export async function analyzeSource({ root, name, repository = "", commit = "", navigationPaths = [] }) {
|
|
14
|
+
export async function analyzeSource({ root, name, repository = "", commit = "", navigationPaths = [], automaticAnchors = false }) {
|
|
15
15
|
root = await fs.realpath(root);
|
|
16
|
-
return analyze({ root, name: name || path.basename(root), repository, commit, navigationPaths });
|
|
16
|
+
return analyze({ root, name: name || path.basename(root), repository, commit, navigationPaths, automaticAnchors });
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
/**
|
|
@@ -27,6 +27,7 @@ export async function generateAppManifest({
|
|
|
27
27
|
repository,
|
|
28
28
|
commit,
|
|
29
29
|
navigationPaths,
|
|
30
|
+
automaticAnchors = false,
|
|
30
31
|
releaseId,
|
|
31
32
|
buildCss = [],
|
|
32
33
|
publicAssets = [],
|
|
@@ -38,7 +39,7 @@ export async function generateAppManifest({
|
|
|
38
39
|
root = await fs.realpath(root);
|
|
39
40
|
const manifest = analysis
|
|
40
41
|
? structuredClone(analysis)
|
|
41
|
-
: await analyzeSource({ root, name, repository, commit, navigationPaths });
|
|
42
|
+
: await analyzeSource({ root, name, repository, commit, navigationPaths, automaticAnchors });
|
|
42
43
|
if (releaseId) {
|
|
43
44
|
releasePath(releaseId);
|
|
44
45
|
manifest.release.runtimeBinding = { schemaVersion: 1, releaseId };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const commit = /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/i;
|
|
5
|
+
function read(file, max = 4096) {
|
|
6
|
+
try {
|
|
7
|
+
if (statSync(file).size <= max) return readFileSync(file, "utf8").trim();
|
|
8
|
+
} catch {
|
|
9
|
+
/* Metadata is optional. */
|
|
10
|
+
}
|
|
11
|
+
return "";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** An optional display label only. No executable, hook, config or credential helper is loaded. */
|
|
15
|
+
export function repositoryCommit(root) {
|
|
16
|
+
let directory = path.resolve(root);
|
|
17
|
+
while (true) {
|
|
18
|
+
const entry = path.join(directory, ".git");
|
|
19
|
+
let git;
|
|
20
|
+
try {
|
|
21
|
+
const stat = statSync(entry);
|
|
22
|
+
if (stat.isDirectory()) git = entry;
|
|
23
|
+
else if (stat.isFile()) {
|
|
24
|
+
const match = /^gitdir: (.+)$/.exec(read(entry));
|
|
25
|
+
if (match) git = path.resolve(directory, match[1]);
|
|
26
|
+
}
|
|
27
|
+
} catch {
|
|
28
|
+
/* Look for a containing repository. */
|
|
29
|
+
}
|
|
30
|
+
if (git) {
|
|
31
|
+
const head = read(path.join(git, "HEAD"));
|
|
32
|
+
if (commit.test(head)) return head;
|
|
33
|
+
const match = /^ref: (refs\/[A-Za-z0-9._/-]+)$/.exec(head);
|
|
34
|
+
if (
|
|
35
|
+
!match ||
|
|
36
|
+
match[1]
|
|
37
|
+
.split("/")
|
|
38
|
+
.some((part) => !part || part === "." || part === "..")
|
|
39
|
+
)
|
|
40
|
+
return "";
|
|
41
|
+
const common = read(path.join(git, "commondir"));
|
|
42
|
+
for (const metadata of [
|
|
43
|
+
...new Set([git, ...(common ? [path.resolve(git, common)] : [])]),
|
|
44
|
+
]) {
|
|
45
|
+
const loose = read(path.join(metadata, match[1]));
|
|
46
|
+
if (commit.test(loose)) return loose;
|
|
47
|
+
for (const line of read(
|
|
48
|
+
path.join(metadata, "packed-refs"),
|
|
49
|
+
1_000_000,
|
|
50
|
+
).split(/\r?\n/)) {
|
|
51
|
+
const [hash, ref] = line.split(" ");
|
|
52
|
+
if (ref === match[1] && commit.test(hash)) return hash;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return "";
|
|
56
|
+
}
|
|
57
|
+
const parent = path.dirname(directory);
|
|
58
|
+
if (parent === directory) return "";
|
|
59
|
+
directory = parent;
|
|
60
|
+
}
|
|
61
|
+
}
|