@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.
Files changed (55) hide show
  1. package/README.md +127 -5
  2. package/assets/document-controller.js +18 -1
  3. package/assets/document-runtime.js +189 -1
  4. package/assets/viewer.js +74 -7
  5. package/package.json +6 -13
  6. package/src/analyze.mjs +897 -123
  7. package/src/anchors.mjs +5 -0
  8. package/src/build-options.d.ts +6 -1
  9. package/src/build-runtime.mjs +4 -1
  10. package/src/build-styles.mjs +49 -6
  11. package/src/build.mjs +13 -7
  12. package/src/cli.mjs +10 -4
  13. package/src/component-registry.mjs +229 -0
  14. package/src/dependencies.mjs +2 -2
  15. package/src/dom-payload-browser.mjs +28 -0
  16. package/src/dom-payload-json.mjs +12 -0
  17. package/src/dom-payload.mjs +50 -0
  18. package/src/framework-routes.mjs +44 -89
  19. package/src/generate.mjs +20 -7
  20. package/src/graph-sources.mjs +54 -0
  21. package/src/index.d.ts +13 -1
  22. package/src/index.mjs +1 -1
  23. package/src/integrity.mjs +4 -2
  24. package/src/library-entry.mjs +5 -0
  25. package/src/lucide-source.mjs +18 -0
  26. package/src/next-loader.cjs +7 -1
  27. package/src/next.cjs +5 -0
  28. package/src/next.mjs +4 -3
  29. package/src/node-hashes.mjs +13 -9
  30. package/src/render-index.mjs +6 -4
  31. package/src/route-lazy.mjs +80 -0
  32. package/src/route-tree.mjs +15 -3
  33. package/src/routing.mjs +1 -1
  34. package/src/schema-definition.mjs +8 -1
  35. package/src/source-attributes.mjs +24 -0
  36. package/src/source-charts.mjs +38 -0
  37. package/src/source-collections.mjs +241 -0
  38. package/src/source-entry.mjs +50 -0
  39. package/src/source-environment.mjs +26 -0
  40. package/src/source-media.mjs +21 -0
  41. package/src/source-query-evidence.mjs +64 -0
  42. package/src/source-redirects.mjs +388 -0
  43. package/src/source-resolution.mjs +268 -0
  44. package/src/static-primitives.mjs +48 -0
  45. package/src/static-query.mjs +39 -11
  46. package/src/tab-scenes.mjs +214 -0
  47. package/src/tanstack-routes.mjs +143 -0
  48. package/src/transform.mjs +2 -2
  49. package/src/ui-preview.mjs +225 -0
  50. package/src/ui-state-machine.mjs +76 -0
  51. package/src/ui-structure.mjs +645 -0
  52. package/src/upload.mjs +28 -6
  53. package/src/validate.mjs +19 -4
  54. package/src/vite.d.ts +4 -1
  55. package/src/vite.mjs +25 -10
package/src/anchors.mjs CHANGED
@@ -9,6 +9,11 @@ export const anchorAttributes = [
9
9
  "data-qa",
10
10
  "data-indiana-id",
11
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
+ }
12
17
  export function instrumentableSource(file, text) {
13
18
  return (
14
19
  typeof text === "string" &&
@@ -6,17 +6,22 @@ export type IndianaIamBuildOptions = {
6
6
  enabled?: boolean;
7
7
  /** Reuse existing UI IDs and add build-only anchors for unambiguous native JSX (default: true). */
8
8
  automaticAnchors?: boolean;
9
+ /** Browser entry relative to root for apps with multiple source roots. */
10
+ entry?: string;
9
11
  /** App name recorded in the IAM; defaults to the root folder name. */
10
12
  name?: string;
11
13
  /** Fail the build when the IAM cannot be generated (default: warn and continue). */
12
14
  strict?: boolean;
13
15
  /** Font family declared by the compiled CSS that renders the root element. */
14
16
  rootFont?: string;
15
- /** Drop inline SVG resources from compiled CSS instead of skipping the CSS. */
17
+ /** Drop inline SVG resources with a coverage gap instead of skipping the CSS (default: true). */
16
18
  omitSvgResources?: boolean;
17
19
  logger?: BuildLogger;
18
20
  /** Key holding only app-manifests:write; defaults to `INDIANA_IAM_KEY`. Without one the IAM is not uploaded. */
19
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;
20
25
  /** Indiana origin; defaults to `INDIANA_IAM_BASE_URL` or https://tinker.cash. */
21
26
  baseUrl?: string;
22
27
  /** "production" (default) skips Vercel preview deployments; "always" uploads every build; false never uploads. */
@@ -1,10 +1,13 @@
1
1
  import fs from "node:fs/promises";
2
2
  import { expandDomNodes } from './node-hashes.mjs';
3
+ import {applySourceAttributes} from './source-attributes.mjs';
4
+ import {applySourceCollections,markUnobservedData,checkedCollectionFragment} from './source-collections.mjs';
3
5
  // Only these two reviewed local assets form the executable preview runtime.
4
6
  const machine = await fs.readFile(new URL("../assets/state-machine.js", import.meta.url), "utf8");
7
+ await fs.writeFile(new URL('./ui-state-machine.mjs', import.meta.url), '/* Generated from assets/state-machine.js by build-runtime.mjs. */\n'+machine);
5
8
  const controller = await fs.readFile(new URL("../assets/document-controller.js", import.meta.url), "utf8");
6
9
  if (!machine.includes("export function createStateMachine(")) throw new Error("Unexpected state runtime export");
7
- await fs.writeFile(new URL("../assets/document-runtime.js", import.meta.url), "/* Generated by scripts/build-runtime.mjs. */\n(() => {\n" + machine.replace("export function createStateMachine(", "function createStateMachine(") + "\n" + controller + "\n})();\n");
10
+ await fs.writeFile(new URL("../assets/document-runtime.js", import.meta.url), "/* Generated by scripts/build-runtime.mjs. */\n(() => {\n" + machine.replace("export function createStateMachine(", "function createStateMachine(") + "\n" + [checkedCollectionFragment,applySourceAttributes,applySourceCollections,markUnobservedData].map(fn=>fn.toString()).join('\n')+'\n'+ controller + "\n})();\n");
8
11
  const viewerUrl=new URL('../assets/viewer.js',import.meta.url),viewer=await fs.readFile(viewerUrl,'utf8');
9
12
  if(!viewer.includes('/* BEGIN HTML DOM DERIVATION */'))throw Error('Missing DOM derivation asset boundary');
10
13
  await fs.writeFile(viewerUrl,viewer.replace(/\/\* BEGIN HTML DOM DERIVATION \*\/[\s\S]*?\/\* END HTML DOM DERIVATION \*\//,'/* BEGIN HTML DOM DERIVATION */\n'+expandDomNodes.toString()+'\n /* END HTML DOM DERIVATION */'));
@@ -30,8 +30,8 @@ function checkedMounts(mounts) {
30
30
  }
31
31
 
32
32
  /** Explicit public build inputs only. Never runs builds, reads env, downloads URLs or imports App code. */
33
- export async function attachBuildStyles(manifest, { root, files = [], publicFiles = [], optimizePublicImages = false, rootFont = "", omitSvgResources = false, mounts = nextStaticMounts() }) {
34
- if (!Array.isArray(files) || files.length > 16 || !Array.isArray(publicFiles) || publicFiles.length > 128 || (!files.length && !publicFiles.length)) throw new Error("Choose up to 16 CSS files and 128 public assets");
33
+ export async function attachBuildStyles(manifest, { root, files = [], publicFiles = [], referencedImages = false, optimizePublicImages = false, rootFont = "", omitSvgResources = false, mounts = nextStaticMounts() }) {
34
+ if (!Array.isArray(files) || files.length > 16 || !Array.isArray(publicFiles) || publicFiles.length > 128 || (!files.length && !publicFiles.length && !referencedImages)) throw new Error("Choose up to 16 CSS files and 128 public assets");
35
35
  if (rootFont && !/^[A-Za-z][A-Za-z0-9 _-]{0,63}$/.test(rootFont)) throw new Error("Invalid root font family");
36
36
  root = await fs.realpath(root);
37
37
  const cssTree = dependency("css-tree").value;
@@ -45,9 +45,9 @@ export async function attachBuildStyles(manifest, { root, files = [], publicFile
45
45
  const fileOf = pathname => { const mount = [...served].sort((a, b) => b.urlPath.length - a.urlPath.length).find(m => pathname.startsWith(m.urlPath)); if (!mount) throw new Error("CSS asset is outside the public build directories"); return mount.directory + "/" + pathname.slice(mount.urlPath.length); };
46
46
  const inputs = new Map(), assets = new Map(manifest.assets.map(a => [a.assetKey, a]));
47
47
  let total = 0, publicSourceBytes = 0;
48
- async function read(file, publicImage = false) {
48
+ async function read(file, publicImage = false, sourceImage = false) {
49
49
  const absolute = path.resolve(root, file), real = await fs.realpath(absolute);
50
- if ((await fs.lstat(absolute)).isSymbolicLink() || !allowed.some(base => real.startsWith(base + path.sep))) throw new Error("Asset must be inside a real public build directory");
50
+ if ((await fs.lstat(absolute)).isSymbolicLink() || !(sourceImage ? real.startsWith(root+path.sep) : allowed.some(base => real.startsWith(base + path.sep)))) throw new Error("Asset must be inside a real public build directory");
51
51
  const relative = path.relative(root, real).split(path.sep).join("/");
52
52
  if (inputs.has(relative)) return inputs.get(relative);
53
53
  const stat = await fs.stat(real);
@@ -113,14 +113,50 @@ export async function attachBuildStyles(manifest, { root, files = [], publicFile
113
113
  assets.set(key, {assetKey:key, sourceUrl, mime, bytes:bytes.length, sha256:digest, dataUrl:'data:'+mime+';base64,'+bytes.toString('base64')});
114
114
  }
115
115
  }
116
+ if(referencedImages){
117
+ const urls=[...new Set(manifest.documents.flatMap(doc=>documentNodes(doc).map(n=>n.attributes?.src).filter(v=>typeof v==='string'&&/^\/(?!\/)[A-Za-z0-9_./-]+\.(?:png|jpe?g|webp|gif)$/i.test(v))))].sort();
118
+ if(urls.length>128)throw Error('Referenced image budget exceeded');
119
+ for(const url of urls){
120
+ if(assets.size>=128)throw Error('Referenced image budget exceeded');
121
+ if([...assets.values()].some(a=>a.sourceUrl===url))continue;
122
+ const source=url.startsWith('/__iam_source__/'),file=source?url.slice('/__iam_source__/'.length):fileOf(url);
123
+ if(file.split('/').some(p=>!p||p==='..'||source&&p.startsWith('.'))||/(?:^|\/)(?:api|server|secrets?|credentials)(?:\/|\.)/i.test(file))continue;
124
+ try{
125
+ const input=await read(file,false,source);let bytes=input.bytes,mime=types.get(path.extname(file).toLowerCase());
126
+ const valid=mime==='image/png'?bytes.subarray(0,8).equals(Buffer.from([137,80,78,71,13,10,26,10])):mime==='image/jpeg'?bytes[0]===255&&bytes[1]===216&&bytes[2]===255:mime==='image/webp'?bytes.subarray(0,4).toString()==='RIFF'&&bytes.subarray(8,12).toString()==='WEBP':/^GIF8[79]a$/.test(bytes.subarray(0,6).toString());
127
+ if(!valid)throw Error('Referenced raster signature mismatch');
128
+ if(bytes.length>65536){
129
+ const sharp=dependency('sharp').value;if(sharp.versions.sharp!=='0.35.5')throw Error('Pinned image encoder required');
130
+ const image=sharp(bytes,{limitInputPixels:16777216,failOn:'warning'}),metadata=await image.metadata();if((metadata.pages||1)!==1)throw Error('Animated source images require an explicit adapter');
131
+ bytes=await image.rotate().resize({width:800,height:800,fit:'inside',withoutEnlargement:true}).webp({quality:90,effort:4}).toBuffer();mime='image/webp';
132
+ manifest.coverage.gaps.push({code:'REFERENCED_IMAGE_PREVIEW_OPTIMIZED',file,detail:'Build preview profile for rasters over 64 KiB: auto-orient, fit inside 800x800, WebP quality 90; source bytes remain hashed. Lossy preview, not original pixels.'});
133
+ }
134
+ const digest=sha256(bytes),assetKey=identity('asset',{sourceUrl:url,mime,sha256:digest});assets.set(assetKey,{assetKey,sourceUrl:url,mime,bytes:bytes.length,sha256:digest,dataUrl:'data:'+mime+';base64,'+bytes.toString('base64')});
135
+ }catch(error){
136
+ // A missing reference is a fidelity gap. Unsafe files/budgets fail the packaging step.
137
+ if(error.code!=='ENOENT')throw error;
138
+ manifest.coverage.gaps.push({code:'REFERENCED_IMAGE_MISSING',file,detail:'Referenced raster is absent from the local public build; no network fetch performed.'});
139
+ }
140
+ }
141
+ }
116
142
  if (optimizePublicImages && publicFiles.length) manifest.coverage.gaps.push({code:'PUBLIC_IMAGES_OPTIMIZED', file:publicFiles[0], detail:'Explicit sharp 0.35.5 profile: auto-orient, fit inside 800x800 without enlargement, WebP quality 90 effort 4; metadata stripped. Source bytes hashed independently; not pixel-identical originals.'});
117
143
  const sheets = [], families = new Set();
118
144
  for (const file of [...new Set(files)].sort()) {
119
145
  if (!file.endsWith(".css")) throw new Error("Compiled CSS file required");
120
146
  const input = await read(file), ast = cssTree.parse(new TextDecoder("utf-8", { fatal: true }).decode(input.bytes), { parseCustomProperty: true });
121
147
  const urls = [];
148
+ // A build may retain a remote font import. Keep its local stylesheet, but
149
+ // never fetch or copy the external dependency into an offline projection.
150
+ cssTree.walk(ast, { visit: "Atrule", enter(node, item, list) {
151
+ if (node.name.toLowerCase() !== "import") return;
152
+ const target = node.prelude?.children?.first;
153
+ if (!["String", "Url"].includes(target?.type) || !/^(?:https?:)?\/\//i.test(target.value))
154
+ throw new Error("Precompile CSS imports before packaging");
155
+ list.remove(item);
156
+ if (!manifest.coverage.gaps.some(g => g.code === "BUILD_EXTERNAL_STYLESHEET_OMITTED" && g.file === input.relative))
157
+ manifest.coverage.gaps.push({ code: "BUILD_EXTERNAL_STYLESHEET_OMITTED", file: input.relative, detail: "External CSS import omitted without network access; local compiled styles retained. Remote fonts or rules remain unavailable." });
158
+ }});
122
159
  cssTree.walk(ast, node => {
123
- if (node.type === "Atrule" && node.name.toLowerCase() === "import") throw new Error("Precompile CSS imports before packaging");
124
160
  if (node.type === "Atrule" && node.name.toLowerCase() === "font-face") node.block?.children.forEach(declaration => { if (declaration.type === "Declaration" && declaration.property === "font-family") families.add(cssTree.generate(declaration.value).replace(/^["']|["']$/g, "")); });
125
161
  if (node.type === "Url") urls.push(node);
126
162
  });
@@ -128,7 +164,7 @@ export async function attachBuildStyles(manifest, { root, files = [], publicFile
128
164
  sheets.push(cssTree.generate(ast));
129
165
  }
130
166
  if (rootFont && !families.has(rootFont)) throw new Error("Root font must exist in compiled font faces");
131
- const css = sheets.join("\n") + "\n.iam-page{min-height:100vh}.iam-icon{display:inline-flex;align-items:center;justify-content:center;width:1.1em;height:1.1em}.iam-placeholder{padding:1rem;border:1px dashed #d4d4d4}" + (rootFont ? "\nbody{font-family:" + JSON.stringify(rootFont) + "," + JSON.stringify(rootFont + " Fallback") + ",sans-serif}" : "");
167
+ const css = sheets.join("\n") + "\n[hidden]{display:none!important}.iam-state{display:contents}.iam-page{min-height:100vh}.iam-icon{display:inline-flex;align-items:center;justify-content:center;width:1.1em;height:1.1em}.iam-placeholder{padding:1rem;border:1px dashed #d4d4d4}" + (rootFont ? "\nbody{font-family:" + JSON.stringify(rootFont) + "," + JSON.stringify(rootFont + " Fallback") + ",sans-serif}" : "");
132
168
  if (/<\/(?:style|script)/i.test(css)) throw new Error("HTML closing tag in compiled CSS");
133
169
  if (files.length) for (const doc of manifest.documents) { doc.css = css; sealDocument(doc); }
134
170
  // One content-addressed image in the envelope; DOM instances carry references.
@@ -142,6 +178,13 @@ export async function attachBuildStyles(manifest, { root, files = [], publicFile
142
178
  doc.html = doc.html.replaceAll('src="' + asset.sourceUrl + '"', 'src="' + reference + '"');
143
179
  }
144
180
  storeDocumentNodes(doc, nodes, new Map(manifest.elements.map(element => [element.elementKey, element])));
181
+ // Resource replacement changes element and ancestor hashes. Keep target
182
+ // fingerprints tied to the final document, never the pre-packaging HTML.
183
+ const finalNodes=documentNodes(doc);
184
+ for(const target of manifest.routeTree?.targets??[]){
185
+ if(target.routeKey!==doc.pageKey)continue;const node=finalNodes.find(n=>n.nodeId===target.nodeId);if(!node)continue;
186
+ target.contentHash=node.contentHash;if(target.subtreeHash!==undefined)target.subtreeHash=node.subtreeHash;
187
+ }
145
188
  }
146
189
  manifest.coverage.gaps = manifest.coverage.gaps.filter(g => g.code !== 'PUBLIC_ASSET_NOT_PACKAGED' || ![...assets.values()].some(a => g.detail.startsWith(a.sourceUrl + ':')));
147
190
  refreshRenderHashes(manifest);
package/src/build.mjs CHANGED
@@ -27,10 +27,10 @@ async function guarded({ strict, logger }, action, what) {
27
27
  * Before compilation: scans source once per build and returns its release ID.
28
28
  * The same source and producer always get the same ID, so the page can embed its descriptor URL before the IAM exists.
29
29
  */
30
- export async function prepareBuildRelease({ root, name, automaticAnchors = true, strict = false, logger = console }) {
30
+ export async function prepareBuildRelease({ root, name, entry, automaticAnchors = true, strict = false, logger = console }) {
31
31
  return guarded({ strict, logger }, async () => {
32
32
  root = await fs.realpath(root);
33
- const analysis = await analyzeSource({ root, name, automaticAnchors });
33
+ const analysis = await analyzeSource({ root, name, entry, automaticAnchors });
34
34
  const releaseId = deriveReleaseId(analysis);
35
35
  process.env[STATE] = JSON.stringify({ root, releaseId, automaticAnchors });
36
36
  return { releaseId };
@@ -59,22 +59,23 @@ export async function finishBuildRelease({
59
59
  root,
60
60
  releaseId,
61
61
  name,
62
+ entry,
62
63
  automaticAnchors = true,
63
64
  cssDirectory,
64
65
  mounts,
65
66
  publicDirectory,
66
67
  rootFont = "",
67
- omitSvgResources = false,
68
+ omitSvgResources = true,
68
69
  upload = {},
69
70
  strict = false,
70
71
  logger = console,
71
72
  }) {
72
73
  root = await fs.realpath(root);
73
74
  return guarded({ strict, logger }, async () => {
74
- const analysis = await analyzeSource({ root, name, automaticAnchors });
75
+ const analysis = await analyzeSource({ root, name, entry, automaticAnchors });
75
76
  if (deriveReleaseId(analysis) !== releaseId) throw new Error("Source changed during the build; rebuild before publishing IAM");
76
77
  const css = cssDirectory ? await compiledCss(root, cssDirectory) : [];
77
- const plain = () => generateAppManifest({ root, analysis, releaseId });
78
+ const plain = () => generateAppManifest({ root, analysis, releaseId, referencedImages:true, mounts });
78
79
  let result;
79
80
  if (!css.length) result = await plain();
80
81
  else if (css.length > MAX_BUILD_CSS) {
@@ -82,7 +83,7 @@ export async function finishBuildRelease({
82
83
  result = await plain();
83
84
  } else
84
85
  try {
85
- result = await generateAppManifest({ root, analysis, releaseId, buildCss: css, mounts, rootFont, omitSvgResources });
86
+ result = await generateAppManifest({ root, analysis, releaseId, buildCss: css, referencedImages:true, mounts, rootFont, omitSvgResources });
86
87
  } catch (error) {
87
88
  report(logger, "warn", "compiled CSS skipped: " + (error instanceof Error ? error.message : String(error)));
88
89
  result = await plain();
@@ -104,11 +105,16 @@ export async function finishBuildRelease({
104
105
  serialized: result.serialized,
105
106
  digest: result.digest,
106
107
  label,
108
+ client: settings.client,
107
109
  apiKey: settings.apiKey,
108
110
  baseUrl: settings.baseUrl,
109
111
  fetch: upload.fetch,
110
112
  });
111
- report(logger, "log", `IAM ${result.digest.slice(0, 12)} ${outcome.uploaded ? "uploaded" : "already uploaded"} as the current version (${label})`);
113
+ report(
114
+ logger,
115
+ "log",
116
+ `IAM ${result.digest.slice(0, 12)} ${outcome.uploaded ? "uploaded" : "already uploaded"} as the current version of client ${settings.client} (${label})`,
117
+ );
112
118
  return outcome.version;
113
119
  }, "IAM upload");
114
120
  else if (settings.missingKey && upload.upload !== false)
package/src/cli.mjs CHANGED
@@ -10,9 +10,9 @@ import { planAnchors, applyAnchors } from "./instrument.mjs";
10
10
  import { buildLabel, uploadAppManifest, uploadSettings } from "./upload.mjs";
11
11
 
12
12
  const usage =
13
- "Usage: indiana-iam generate --root <app> --out <new directory> [--name <name> --repository <url> --commit <sha> --release-id <uuid> --write-public-release --build-css <file>...]" +
13
+ "Usage: indiana-iam generate --root <app> --out <new directory> [--entry <source module> --name <name> --repository <url> --commit <sha> --release-id <uuid> --write-public-release --build-css <file>...]" +
14
14
  " | indiana-iam validate --file <manifest>" +
15
- " | INDIANA_IAM_KEY=<key> indiana-iam upload --file <manifest> [--label <label> --base-url <url>]" +
15
+ " | INDIANA_IAM_KEY=<key> indiana-iam upload --file <manifest> [--client <key> --label <label> --base-url <url>]" +
16
16
  " | indiana-iam prepare --root <app>" +
17
17
  " | indiana-iam anchor --root <checkout> --out <new plan file> [--apply --primitive-map <file> --public-labels --forward-anchors]";
18
18
 
@@ -22,6 +22,7 @@ const { values, positionals } = parseArgs({
22
22
  root: { type: "string" },
23
23
  out: { type: "string" },
24
24
  name: { type: "string" },
25
+ entry: { type: "string" },
25
26
  repository: { type: "string" },
26
27
  commit: { type: "string" },
27
28
  file: { type: "string" },
@@ -39,6 +40,7 @@ const { values, positionals } = parseArgs({
39
40
  "public-labels": { type: "boolean", default: false },
40
41
  "forward-anchors": { type: "boolean", default: false },
41
42
  label: { type: "string" },
43
+ client: { type: "string" },
42
44
  "base-url": { type: "string" },
43
45
  },
44
46
  });
@@ -53,6 +55,7 @@ if (command === "generate") {
53
55
  const result = await generateAppManifest({
54
56
  root,
55
57
  name: values.name,
58
+ entry: values.entry,
56
59
  repository: values.repository || "",
57
60
  commit: values.commit || "",
58
61
  navigationPaths: (values["navigation-path"] || []).map((value) => JSON.parse(value)),
@@ -90,7 +93,7 @@ if (command === "generate") {
90
93
  } else if (command === "upload") {
91
94
  if (!values.file) throw new Error("upload requires --file");
92
95
  // The key is read from INDIANA_IAM_KEY only, so it stays out of shell history.
93
- const settings = uploadSettings({ baseUrl: values["base-url"], upload: "always" });
96
+ const settings = uploadSettings({ baseUrl: values["base-url"], upload: "always", client: values.client });
94
97
  if (settings.missingKey) throw new Error("Set INDIANA_IAM_KEY to a key with only the app-manifests:write permission");
95
98
  const bytes = await fs.readFile(values.file),
96
99
  serialized = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
@@ -99,10 +102,13 @@ if (command === "generate") {
99
102
  serialized,
100
103
  digest: sha256(bytes),
101
104
  label: values.label || buildLabel(path.dirname(path.resolve(values.file))),
105
+ client: settings.client,
102
106
  apiKey: settings.apiKey,
103
107
  baseUrl: settings.baseUrl,
104
108
  });
105
- console.log(JSON.stringify({ uploaded, version: { id: version.id, state: version.state, isCurrent: version.isCurrent } }, null, 2));
109
+ console.log(
110
+ JSON.stringify({ uploaded, version: { id: version.id, client: version.clientKey, state: version.state, isCurrent: version.isCurrent } }, null, 2),
111
+ );
106
112
  } else if (command === "prepare") {
107
113
  if (!values.root) throw new Error("prepare requires --root");
108
114
  console.log(JSON.stringify(await prepareRelease(values.root)));
@@ -0,0 +1,229 @@
1
+ /** Follow finite source selectors to their literal component registry, retaining alternatives as assumptions. */
2
+ export function componentRegistry({
3
+ ts,
4
+ node,
5
+ module,
6
+ locals,
7
+ binding,
8
+ functionFrom,
9
+ value,
10
+ component,
11
+ variable,
12
+ location,
13
+ unknown,
14
+ imported,
15
+ }) {
16
+ let steps = 0;
17
+ const choices = [],
18
+ variables = new Set(),
19
+ seen = new Set();
20
+ const unwrap = (n) => {
21
+ while (
22
+ n &&
23
+ (ts.isParenthesizedExpression(n) ||
24
+ ts.isAsExpression(n) ||
25
+ ts.isNonNullExpression(n) ||
26
+ ts.isSatisfiesExpression(n))
27
+ )
28
+ n = n.expression;
29
+ return n;
30
+ };
31
+ const localEnvironment = (fn, m, parent) => {
32
+ const env = new Map(parent);
33
+ if (ts.isBlock(fn.body))
34
+ for (const s of fn.body.statements)
35
+ if (ts.isVariableStatement(s))
36
+ for (const d of s.declarationList.declarations)
37
+ if (ts.isIdentifier(d.name))
38
+ env.set(d.name.text, { node: d.initializer, module: m, env });
39
+ return env;
40
+ };
41
+ const returns = (fn) =>
42
+ ts.isBlock(fn.body)
43
+ ? fn.body.statements
44
+ .filter((s) => ts.isReturnStatement(s))
45
+ .map((s) => s.expression)
46
+ : [fn.body];
47
+ function walk(raw, m, env, path = [], record = null, depth = 0) {
48
+ const n = unwrap(raw);
49
+ if (!n || depth > 24 || ++steps > 3000 || choices.length > 32) return;
50
+ if (!path.length && !(ts.isIdentifier(n) && env.has(n.text))) {
51
+ const c = component(n, m);
52
+ if (c) {
53
+ const optionValue =
54
+ typeof record?.key === "string" ? record.key : c.symbol;
55
+ choices.push({
56
+ component: c,
57
+ value: optionValue,
58
+ label:
59
+ typeof record?.label === "string"
60
+ ? record.label
61
+ : typeof record?.labelKey === "string"
62
+ ? record.labelKey
63
+ : c.symbol,
64
+ source: location(m, n, c.symbol),
65
+ scope: typeof record?.scope === "string" ? record.scope : null,
66
+ });
67
+ return;
68
+ }
69
+ }
70
+ const identity = m.file + ":" + n.pos + ":" + n.end + ":" + path.join("/");
71
+ if (seen.has(identity)) return;
72
+ seen.add(identity);
73
+ if (ts.isIdentifier(n)) {
74
+ const local = env.get(n.text);
75
+ if (local) {
76
+ walk(local.node, local.module, local.env, path, record, depth + 1);
77
+ return;
78
+ }
79
+ const ref = binding(m, n.text);
80
+ if (ref) walk(ref.node, ref.module, new Map(), path, record, depth + 1);
81
+ return;
82
+ }
83
+ if (ts.isPropertyAccessExpression(n)) {
84
+ walk(n.expression, m, env, [n.name.text, ...path], record, depth + 1);
85
+ return;
86
+ }
87
+ if (ts.isElementAccessExpression(n)) {
88
+ const key = value(n.argumentExpression, m);
89
+ walk(
90
+ n.expression,
91
+ m,
92
+ env,
93
+ [
94
+ typeof key === "number" || typeof key === "string"
95
+ ? String(key)
96
+ : "[]",
97
+ ...path,
98
+ ],
99
+ record,
100
+ depth + 1,
101
+ );
102
+ return;
103
+ }
104
+ if (
105
+ ts.isBinaryExpression(n) &&
106
+ [ts.SyntaxKind.QuestionQuestionToken, ts.SyntaxKind.BarBarToken].includes(
107
+ n.operatorToken.kind,
108
+ )
109
+ ) {
110
+ walk(n.left, m, env, path, record, depth + 1);
111
+ walk(n.right, m, env, path, record, depth + 1);
112
+ return;
113
+ }
114
+ if (ts.isConditionalExpression(n)) {
115
+ walk(n.whenTrue, m, env, path, record, depth + 1);
116
+ walk(n.whenFalse, m, env, path, record, depth + 1);
117
+ return;
118
+ }
119
+ if (ts.isArrayLiteralExpression(n)) {
120
+ const [key, ...rest] = path;
121
+ if (key === "[]")
122
+ for (const item of n.elements)
123
+ walk(item, m, env, rest, record, depth + 1);
124
+ else if (/^\d+$/.test(key ?? "") && n.elements[Number(key)])
125
+ walk(n.elements[Number(key)], m, env, rest, record, depth + 1);
126
+ return;
127
+ }
128
+ if (ts.isObjectLiteralExpression(n) && path.length) {
129
+ const [key, ...rest] = path,
130
+ fields = n.properties.filter(
131
+ (p) =>
132
+ ts.isPropertyAssignment(p) || ts.isShorthandPropertyAssignment(p),
133
+ );
134
+ const field = fields.find((p) => p.name.text === key);
135
+ if (!field) return;
136
+ const meta = Object.fromEntries(
137
+ fields.flatMap((p) => {
138
+ if (!["key", "label", "labelKey", "scope"].includes(p.name.text))
139
+ return [];
140
+ const v = value(
141
+ ts.isPropertyAssignment(p) ? p.initializer : p.name,
142
+ m,
143
+ );
144
+ return typeof v === "string" ? [[p.name.text, v]] : [];
145
+ }),
146
+ );
147
+ walk(
148
+ ts.isPropertyAssignment(field) ? field.initializer : field.name,
149
+ m,
150
+ env,
151
+ rest,
152
+ Object.keys(meta).length ? meta : record,
153
+ depth + 1,
154
+ );
155
+ return;
156
+ }
157
+ if (ts.isCallExpression(n)) {
158
+ if (
159
+ ts.isPropertyAccessExpression(n.expression) &&
160
+ ["find", "filter"].includes(n.expression.name.text)
161
+ ) {
162
+ const fn = unwrap(n.arguments[0]);
163
+ if (
164
+ n.expression.name.text === "find" &&
165
+ fn &&
166
+ ts.isArrowFunction(fn) &&
167
+ ts.isBinaryExpression(unwrap(fn.body))
168
+ ) {
169
+ const comparison = unwrap(fn.body);
170
+ if (
171
+ comparison.operatorToken.kind ===
172
+ ts.SyntaxKind.EqualsEqualsEqualsToken
173
+ )
174
+ for (const side of [comparison.left, comparison.right])
175
+ if (ts.isIdentifier(side)) {
176
+ const key = variable(side.text);
177
+ if (key) variables.add(key);
178
+ }
179
+ }
180
+ walk(
181
+ n.expression.expression,
182
+ m,
183
+ env,
184
+ n.expression.name.text === "find" ? ["[]", ...path] : path,
185
+ record,
186
+ depth + 1,
187
+ );
188
+ return;
189
+ }
190
+ const callback = unwrap(n.arguments[0]);
191
+ if (
192
+ callback &&
193
+ (ts.isArrowFunction(callback) || ts.isFunctionExpression(callback)) &&
194
+ imported(m, n.expression.getText(m.source))?.specifier === "react" &&
195
+ imported(m, n.expression.getText(m.source))?.name === "useMemo"
196
+ ) {
197
+ const local = localEnvironment(callback, m, env);
198
+ for (const returned of returns(callback))
199
+ walk(returned, m, local, path, record, depth + 1);
200
+ return;
201
+ }
202
+ if (ts.isIdentifier(n.expression)) {
203
+ const ref = binding(m, n.expression.text),
204
+ fn = ref && functionFrom(ref.node);
205
+ if (
206
+ fn &&
207
+ !fn.modifiers?.some((a) => a.kind === ts.SyntaxKind.AsyncKeyword)
208
+ ) {
209
+ const local = localEnvironment(fn, ref.module, new Map());
210
+ for (const returned of returns(fn))
211
+ walk(returned, ref.module, local, path, record, depth + 1);
212
+ }
213
+ }
214
+ }
215
+ }
216
+ const env = new Map(
217
+ [...locals].map(([key, init]) => [key, { node: init, module, env: null }]),
218
+ );
219
+ for (const entry of env.values()) entry.env = env;
220
+ walk(node, module, env);
221
+ const options = [
222
+ ...new Map(
223
+ choices.map((c) => [c.value + "\0" + c.component.key, c]),
224
+ ).values(),
225
+ ];
226
+ return options.length > 1 && options.length <= 32
227
+ ? { options, variableKey: variables.size === 1 ? [...variables][0] : null }
228
+ : null;
229
+ }
@@ -2,7 +2,7 @@ import { createRequire } from "node:module";
2
2
 
3
3
  const require = createRequire(import.meta.url);
4
4
 
5
- /** Loads one of this package's pinned dependencies (sharp is an optional peer). */
5
+ /** Loads one of this package's pinned dependencies including the image encoder. */
6
6
  export function dependency(name) {
7
7
  try {
8
8
  return { value: require(name), resolve: (path) => require.resolve(path) };
@@ -13,7 +13,7 @@ export function dependency(name) {
13
13
  )
14
14
  throw error;
15
15
  throw new Error(
16
- `Missing ${name}. Reinstall @tinkerlandai/indiana-iam with its dependencies${name === "sharp" ? ", and install sharp 0.35.5 to optimize public images" : ""}.`,
16
+ `Missing ${name}. Reinstall @tinkerlandai/indiana-iam with its dependencies.`,
17
17
  );
18
18
  }
19
19
  }
@@ -0,0 +1,28 @@
1
+ import { checkedDomPayload, parsedDomPayload } from "./dom-payload-json.mjs";
2
+
3
+ /** The viewer decodes locally; it never fetches content or executes merchant code. */
4
+ export async function documentDomBrowser(document) {
5
+ const payload = checkedDomPayload(document);
6
+ if (!payload) return { html: document.html, nodes: document.nodes };
7
+ if (typeof DecompressionStream !== "function") throw Error("Compressed IAM requires DecompressionStream support");
8
+ const binary = atob(payload.data);
9
+ if (btoa(binary) !== payload.data) throw Error("Noncanonical compressed DOM base64");
10
+ const compressed = Uint8Array.from(binary, character => character.charCodeAt(0));
11
+ const stream = new ReadableStream({ start(controller) { controller.enqueue(compressed); controller.close(); } }).pipeThrough(new DecompressionStream("gzip"));
12
+ const reader = stream.getReader(), chunks = [];
13
+ let total = 0;
14
+ try {
15
+ for (;;) {
16
+ const next = await reader.read();
17
+ if (next.done) break;
18
+ total += next.value.byteLength;
19
+ if (total > payload.bytes) throw Error("Expanded DOM byte budget exceeded");
20
+ chunks.push(next.value);
21
+ }
22
+ } finally { await reader.cancel().catch(() => {}); reader.releaseLock(); }
23
+ if (total !== payload.bytes) throw Error("Expanded DOM byte length mismatch");
24
+ const decoded = new Uint8Array(total);
25
+ let offset = 0;
26
+ for (const chunk of chunks) { decoded.set(chunk, offset); offset += chunk.byteLength; }
27
+ return parsedDomPayload(new TextDecoder("utf-8", { fatal: true }).decode(decoded));
28
+ }
@@ -0,0 +1,12 @@
1
+ /** Shared with the offline viewer. Encodings describe storage, never executable code. */
2
+ export function checkedDomPayload(document) {
3
+ const payload = document.domPayload;
4
+ if (!payload) return null;
5
+ if (document.html !== "" || !Array.isArray(document.nodes) || document.nodes.length || Object.keys(payload).sort().join(",") !== "bytes,data,encoding" || payload.encoding !== "gzip-json-v1" || !Number.isSafeInteger(payload.bytes) || payload.bytes < 1 || payload.bytes > 8_388_608 || typeof payload.data !== "string" || !payload.data.length || payload.data.length > 11_184_812 || payload.data.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(payload.data)) throw Error("Invalid compressed DOM payload");
6
+ return payload;
7
+ }
8
+ export function parsedDomPayload(text) {
9
+ const value = JSON.parse(text);
10
+ if (!value || Object.keys(value).sort().join(",") !== "html,nodes" || typeof value.html !== "string" || value.html.length > 2_000_000 || !value.html.isWellFormed() || !Array.isArray(value.nodes) || value.nodes.length > 12000 || JSON.stringify(value) !== text) throw Error("Invalid expanded DOM payload");
11
+ return value;
12
+ }
@@ -0,0 +1,50 @@
1
+ import { gzip, Inflate } from "pako";
2
+ import { checkedDomPayload, parsedDomPayload } from "./dom-payload-json.mjs";
3
+
4
+ export const MAX_EXPANDED_DOM_BYTES = 33_554_432;
5
+
6
+ /** Lossless, local decoding with a declared and enforced output bound. */
7
+ export function documentDom(document) {
8
+ const payload = checkedDomPayload(document);
9
+ if (!payload) return { html: document.html, nodes: document.nodes };
10
+ const compressed = Buffer.from(payload.data, "base64");
11
+ if (compressed.toString("base64") !== payload.data) throw Error("Noncanonical compressed DOM base64");
12
+ const inflater = new Inflate({ windowBits: 31, chunkSize: 32_768 });
13
+ const chunks = [];
14
+ let total = 0;
15
+ inflater.onData = chunk => {
16
+ total += chunk.byteLength;
17
+ if (total > payload.bytes) throw Error("Expanded DOM byte budget exceeded");
18
+ chunks.push(chunk);
19
+ };
20
+ if (!inflater.push(compressed, true) || inflater.err || !inflater.ended) throw Error("Invalid compressed DOM stream: " + inflater.msg);
21
+ if (inflater.strm.avail_in !== 0) throw Error("Trailing compressed DOM data");
22
+ if (total !== payload.bytes) throw Error("Expanded DOM byte length mismatch");
23
+ const decoded = new Uint8Array(total);
24
+ let offset = 0;
25
+ for (const chunk of chunks) { decoded.set(chunk, offset); offset += chunk.byteLength; }
26
+ return parsedDomPayload(new TextDecoder("utf-8", { fatal: true }).decode(decoded));
27
+ }
28
+
29
+ export function assertDomBudget(documents) {
30
+ let total = 0;
31
+ for (const document of documents) {
32
+ const payload = checkedDomPayload(document);
33
+ total += payload ? payload.bytes : Buffer.byteLength(JSON.stringify({ html: document.html, nodes: document.nodes }));
34
+ if (total > MAX_EXPANDED_DOM_BYTES) throw Error("Expanded DOM aggregate byte budget exceeded");
35
+ }
36
+ }
37
+
38
+ /** Only sizeable, beneficial payloads are compressed; the original IDs and values survive byte-for-byte. */
39
+ export function packDocumentDom(document) {
40
+ if (document.domPayload) return false;
41
+ const raw = Buffer.from(JSON.stringify({ html: document.html, nodes: document.nodes }));
42
+ if (raw.length < 16_384 || raw.length > 8_388_608) return false;
43
+ const zipped = gzip(raw, { level: 9, header: { time: 0, os: 255 } });
44
+ const payload = { encoding: "gzip-json-v1", bytes: raw.length, data: Buffer.from(zipped).toString("base64") };
45
+ if (Buffer.byteLength(JSON.stringify(payload)) + 1024 >= raw.length) return false;
46
+ document.html = "";
47
+ document.nodes = [];
48
+ document.domPayload = payload;
49
+ return true;
50
+ }