@tinkerlandai/indiana-iam 0.5.0 → 0.6.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 CHANGED
@@ -1,8 +1,8 @@
1
1
  # @tinkerlandai/indiana-iam
2
2
 
3
- Build-time generator for the Indiana App Manifest (IAM). On every production build it scans your App's source, generates
4
- and validates the IAM, and writes the release descriptor your pages use to bind visual changes to that exact source.
5
- Indiana's App page map edits pages from these files; the browser SDK applies the published changes.
3
+ Build-time generator for the Indiana App Manifest (IAM). It scans your App's source, generates and validates the IAM,
4
+ and writes an immutable release descriptor. Web integrations bind published visual changes to that exact source.
5
+ React Native / Expo integrations produce source previews; native visual-change execution is a separate capability.
6
6
 
7
7
  Install it as a development dependency (Node.js 22 or later):
8
8
 
@@ -73,6 +73,77 @@ Browser apps use the single local module script in `index.html` to select reacha
73
73
  For multiple entry points, set `indianaIam({ entry: "src/main.tsx" })`, or pass `--entry src/main.tsx` to standalone generation.
74
74
  The path must stay inside the scanned root. Next.js uses its App/Pages root instead.
75
75
 
76
+ ## React Native and Expo Router
77
+
78
+ Use the build-only `@tinkerlandai/indiana-iam/react-native` entry from a Node.js build script. Generate a separate artifact
79
+ for each platform. It reuses the same IAM format, validator, asset packaging, release identity and upload client as Web.
80
+
81
+ ```js
82
+ import { prepareReactNativeIam, finishReactNativeIam } from "@tinkerlandai/indiana-iam/react-native";
83
+
84
+ const options = { root: process.cwd(), platform: "ios", routerRoot: "app", strict: true };
85
+ const prepared = await prepareReactNativeIam(options);
86
+ // Run your existing Expo / Metro bundle step here, using the same unchanged source.
87
+ const result = await finishReactNativeIam({
88
+ ...options,
89
+ releaseId: prepared.releaseId,
90
+ bundleDirectory: "dist/ios", // asset staging directory inside root
91
+ });
92
+ // Include dist/ios + result.descriptorPath with this release's native assets.
93
+ // result.file is the validated IAM outside the checkout.
94
+ ```
95
+
96
+ For Android, use `platform: "android"` and a separate staging directory. With Expo Router, `routerRoot` defaults to
97
+ the sole `app/` or `src/app/` directory. Ordinary route groups, index/dynamic/catch-all paths and nested `_layout`
98
+ modules with `Slot`, `Stack` or `Tabs` retain route and source provenance. Group paths that collapse to the same URL
99
+ are rejected. Shared array groups and native navigation state remain explicit gaps. Expo platform routes and layouts
100
+ need a non-platform base module. For a single exported React Native screen, use `entry: "App.tsx"` instead of `routerRoot`.
101
+ That entry does not extract a React Navigation graph or invoke `AppRegistry`.
102
+
103
+ The adapter selects `.ios` / `.android`, then `.native`, then base modules using Metro's default JS/JSX/TS/TSX
104
+ extensions. Literal `Platform.OS`, `Platform.select`, `StyleSheet.create`, `flatten`, `compose` and static style arrays
105
+ are interpreted from source. Basic View/Text/Image, buttons, text inputs, scrolling and modal declarations project to
106
+ inert semantic HTML. Local raster imports and literal `require` references use the existing bounded asset packer.
107
+ Supported local state setters on `onPress` / `onChangeText` reuse the bounded preview interpreter. Business handlers,
108
+ effects, remote data, custom Metro/Babel transforms, Yoga/device layout, safe-area measurement, navigation stacks,
109
+ gestures and virtual lists are not executed; their missing behavior stays in coverage gaps.
110
+
111
+ Every native preview node stays synthetic and supplies no SDK target binding, even when it has a `testID` or `nativeID`.
112
+ This entry adds no native runtime dependency or automatic source-anchor transform. The descriptor records release identity;
113
+ it does not install a native visual executor or make a Web visual script applicable to native views. Package its file
114
+ with native assets in your existing release process; the helper does not configure Metro or Xcode/Gradle for you.
115
+
116
+ Uploads are disabled by default. Explicitly authorized builds can pass
117
+ `upload: { upload: "always", client: "rn-ios", apiKey, baseUrl }` to `finishReactNativeIam`;
118
+ the default client is `rn-ios` / `rn-android`, and each client's platform is immutable. Use the matching Indiana
119
+ ingestion validator version before uploading new source adapters. Local generation does not deploy it.
120
+
121
+ Standalone generation uses the same adapter:
122
+
123
+ ```sh
124
+ indiana-iam generate --root . --platform ios --router-root app --out <new folder outside checkout>
125
+ indiana-iam generate --root . --platform android --entry App.tsx --out <another new folder>
126
+ ```
127
+
128
+ ## Memos integration example
129
+
130
+ [`examples/memos/indiana.vite.config.mjs`](examples/memos/indiana.vite.config.mjs) wraps the original Vite configuration of
131
+ [Memos v0.25.3](https://github.com/usememos/memos/tree/e17cd163c6c37726f8397c8f426d585b540c9562). It keeps Memos’s Go backend,
132
+ SQLite database and application source, adds build anchors and binds the visual SDK to the generated release descriptor.
133
+
134
+ After installing Memos’s own Web dependencies, set `INDIANA_MEMOS_WEB_ROOT` to its absolute `web` directory,
135
+ `INDIANA_ENVIRONMENT_ID` to the intended Sandbox environment UUID, `INDIANA_CONSOLE_ORIGIN` to `https://dev.tinker.cash`
136
+ and `INDIANA_API_ORIGIN` to `https://api.dev.tinker.cash`. From Memos’s `web` directory, run its installed `vite build`
137
+ with `--config <Indiana checkout>/packages/iam/examples/memos/indiana.vite.config.mjs --mode release --outDir dist --emptyOutDir`.
138
+ The example disables automatic uploads; upload the IAM file reported in the build log through the separately authorized IAM upload flow.
139
+
140
+ Copy `web/dist` into Memos’s `server/router/frontend/dist` before building its native binary. Run that binary on
141
+ `127.0.0.1:15230`, then run [`examples/memos/gateway.mjs`](examples/memos/gateway.mjs) with the same Web root. The gateway
142
+ listens on `127.0.0.1:15231`, serves the generated `.well-known/indiana/releases/` descriptor omitted by Go’s `dist/*` embed,
143
+ and forwards every other request unchanged to native Memos. Ports can be set with `INDIANA_MEMOS_PORT` and
144
+ `INDIANA_MEMOS_BACKEND_PORT`. Allow the exact gateway origin in the same Sandbox environment before publishing page changes.
145
+ Keep accounts, keys, SQLite files, generated manifests and binaries outside the committed example.
146
+
76
147
  ## Options
77
148
 
78
149
  Both integrations accept `{ enabled, name, automaticAnchors, strict, rootFont, omitSvgResources, logger, apiKey, baseUrl, upload, client }`;
@@ -83,8 +154,8 @@ If source changes during compilation, generation fails instead of publishing a d
83
154
 
84
155
  ## Target identity and release changes
85
156
 
86
- Framework builds reuse `id`, `data-testid`, `data-test`, `data-qa`, then `data-indiana-id`. When none is declared,
87
- the source loader adds a deterministic `data-indiana-id` to unambiguous native JSX in the build output and IAM.
157
+ Next.js / Vite builds reuse `id`, `data-testid`, `data-test`, `data-qa`, then `data-indiana-id`. When none is declared,
158
+ the source loader adds a deterministic `data-indiana-id` to unambiguous HTML JSX in the build output and IAM.
88
159
  Source files stay unchanged; formatting, comments and unrelated sibling insertions preserve a leaf's marker.
89
160
  Set `automaticAnchors: false` to use only existing attributes.
90
161
 
@@ -157,7 +228,7 @@ Source-proven `useSyncExternalStore` matchMedia getters retain both typed viewpo
157
228
  viewport. Subscriptions are not run. UI preference cookies with an explicit source SSR guard use an empty-cookie
158
229
  preview; auth/session cookies and arbitrary stores stay unobserved. Pure lazy local-state initializers are interpreted
159
230
  under the same bounded rules; merchant callbacks and browser storage are never executed or read.
160
- IAM has no configured page, node or total artifact byte limit. The exporter retains every supported route.
231
+ Local IAM generation has no configured page, node or total artifact byte limit. The exporter retains every supported route. Online ingestion separately limits each upload to 8 MiB (8,388,608 bytes); a large local artifact is not automatically uploadable. Signed GCS upload URLs expire within ten minutes.
161
232
  Large artifacts store sizeable HTML/preorder-map pairs as `domPayload` (`gzip-json-v1`), while keeping `html: ""`
162
233
  and `nodes: []` in that document's wire record. This lossless storage encoding preserves all source IDs, DOM values,
163
234
  HTML/DOM hashes and targets. Route navigation stores the exact interaction-array index instead of repeating its ID; validation checks node, route, destination and parameters. Source files retain their full hashes; component/element records cover reachable route compositions and shared fragments. Text node IDs that follow the original parent/index identity formula are derived locally instead of repeated; explicit exceptions remain stored, and every resolved source-map hash is checked. The document hash pins its encoded storage body; decoding is local and checks the exact declared size and hashes. Exported page HTML is expanded.
@@ -166,7 +237,7 @@ the selected document on demand, cancels superseded reads and retains one expand
166
237
  come from the complete validated IAM; the complete JSON remains the source of release identity.
167
238
  Serve the output directory over loopback HTTP to open `index.html`; browser `file://` restrictions prevent
168
239
  on-demand reads of its document files. Serving the exported files does not run the App or its backend.
169
- Use the matching 0.4 generator, validator and viewer together. Indiana ingestion/viewer sources must be updated
240
+ Use the matching generator, validator and viewer from the same package version together. Indiana ingestion/viewer sources must be updated
170
241
  before uploading compressed artifacts; publishing this npm package alone does not deploy Indiana. The offline
171
242
  viewer uses the browser's built-in `DecompressionStream` and never fetches a decoder or merchant content.
172
243
  The scanner reads source files only; it never runs your build, imports your code or reads env files. Only the upload
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tinkerlandai/indiana-iam",
3
- "version": "0.5.0",
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.",
3
+ "version": "0.6.0",
4
+ "description": "Build-time Indiana App Manifest (IAM) generator for Next.js, Vite and React Native / Expo source.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
7
7
  "type": "git",
@@ -38,6 +38,10 @@
38
38
  "./vite": {
39
39
  "types": "./src/vite.d.ts",
40
40
  "import": "./src/vite.mjs"
41
+ },
42
+ "./react-native": {
43
+ "types": "./src/react-native.d.ts",
44
+ "import": "./src/react-native.mjs"
41
45
  }
42
46
  },
43
47
  "scripts": {
package/src/analyze.mjs CHANGED
@@ -16,6 +16,8 @@ import { dependency } from "./dependencies.mjs";
16
16
  import { assembleRouteTree, readVariables } from "./route-tree.mjs";
17
17
  import { sourceEntry } from "./source-entry.mjs";
18
18
  import { frameworkRoutes, externalImport } from "./framework-routes.mjs";
19
+ import { reactNativeRoutes, nativeModuleCandidates } from "./react-native-routes.mjs";
20
+ import { nativeExpression, nativePrimitive } from "./react-native-projection.mjs";
19
21
  import { sourceResolution } from "./source-resolution.mjs";
20
22
  import { routeIndexDigest, refreshRenderHashes, flattenComposition } from "./render-index.mjs";
21
23
  import { storeDocumentNodes } from "./node-hashes.mjs";
@@ -200,7 +202,12 @@ export async function analyze({
200
202
  navigationPaths = [],
201
203
  entry,
202
204
  automaticAnchors = false,
205
+ platform = "web",
206
+ routerRoot,
203
207
  }) {
208
+ if (!["web", "ios", "android"].includes(platform)) throw Error("Unsupported IAM platform");
209
+ const nativePlatform = platform !== "web";
210
+ if (!nativePlatform && routerRoot !== undefined) throw Error("routerRoot is only supported for native Expo projects");
204
211
  root = await fs.realpath(root);
205
212
  const files = await sourceFiles(root, { maxFiles });
206
213
  const modules = new Map(),
@@ -266,7 +273,7 @@ export async function analyze({
266
273
  file,
267
274
  text,
268
275
  source,
269
- anchors: automaticAnchors && !mdx ? automaticSourceAnchors(file, source) : new Map(),
276
+ anchors: automaticAnchors && !nativePlatform && !mdx ? automaticSourceAnchors(file, source) : new Map(),
270
277
  mdx,
271
278
  originalSource: mdx ? ts.createSourceFile(file, originalText, ts.ScriptTarget.Latest, false) : source,
272
279
  bindings: new Map(),
@@ -338,6 +345,15 @@ export async function analyze({
338
345
  module.exports.set(e.name.text,local);if(e.name.text==='default')module.defaultName=local;
339
346
  }
340
347
  }
348
+ if (/^(?:src\/)?pages\//.test(file)) {
349
+ for (const statement of source.statements) {
350
+ const assignment = ts.isExpressionStatement(statement) && unwrap(statement.expression);
351
+ if (assignment && ts.isBinaryExpression(assignment) && assignment.operatorToken.kind === ts.SyntaxKind.EqualsToken && ts.isPropertyAccessExpression(assignment.left) && assignment.left.name.text === 'getLayout' && ts.isIdentifier(assignment.left.expression) && assignment.left.expression.text === module.defaultName) {
352
+ const fn = functionFrom(assignment.right);
353
+ if (fn && fn.parameters.length === 1 && ts.isIdentifier(fn.parameters[0].name) && !fn.modifiers?.some(m => m.kind === ts.SyntaxKind.AsyncKeyword)) module.bindings.set('$pageLayout', fn);
354
+ }
355
+ }
356
+ }
341
357
  modules.set(file, module);
342
358
  }
343
359
  const alias = await sourceAliases(root,ts);
@@ -360,7 +376,7 @@ export async function analyze({
360
376
  }
361
377
  if (!relative || relative.startsWith("../")) return null;
362
378
  return (
363
- [
379
+ (nativePlatform ? nativeModuleCandidates(relative, platform) : [
364
380
  relative,
365
381
  ...[
366
382
  ".tsx",
@@ -374,7 +390,7 @@ export async function analyze({
374
390
  "/index.ts",
375
391
  "/index.jsx",
376
392
  ].map((ext) => relative + ext),
377
- ]
393
+ ])
378
394
  .map((p) => modules.get(p))
379
395
  .find(Boolean) || null
380
396
  );
@@ -391,7 +407,7 @@ export async function analyze({
391
407
  }
392
408
  function sourceContextCall(node,module,ctx){
393
409
  const imported=externalImport(module,node.expression.getText(module.source));
394
- if(imported?.specifier==='react-i18next'&&imported.name==='useTranslation')return {t:translationHook(node,module,ctx)};
410
+ if(['react-i18next','next-i18next'].includes(imported?.specifier)&&imported.name==='useTranslation')return {t:translationHook(node,module,ctx)};
395
411
  if(imported?.specifier==='react'&&imported.name==='useContext'&&ts.isIdentifier(node.arguments[0]))return ctx.__iamContexts?.get(contextKey(module,node.arguments[0].text))??UNKNOWN;
396
412
  const ref=ts.isIdentifier(node.expression)&&binding(module,node.expression.text),fn=ref&&functionFrom(ref.node);
397
413
  if(!fn?.body||!ts.isBlock(fn.body))return UNKNOWN;
@@ -411,6 +427,15 @@ export async function analyze({
411
427
  }
412
428
  let i18nextPreview;
413
429
  function translationHook(node,module,ctx){
430
+ if(externalImport(module,node.expression.getText(module.source))?.specifier==='next-i18next') {
431
+ const config=[...modules.values()].find(m=>/^next-i18next\.config\.[cm]?[jt]s$/.test(m.file));
432
+ const statement=config?.source.statements.find(s=>ts.isExpressionStatement(s)&&ts.isBinaryExpression(s.expression)&&s.expression.left.getText(config.source)==='module.exports');
433
+ const options=statement&&evalExpression(statement.expression.right,config,{}),language=options?.i18n?.defaultLocale;
434
+ const namespace=node.arguments[0]?evalExpression(node.arguments[0],module,ctx):options?.defaultNS??'common';
435
+ const catalog=typeof language==='string'&&typeof namespace==='string'?jsonResources.get('public/locales/'+language+'/'+namespace+'.json'):null;
436
+ if(catalog&&typeof catalog==='object') {translationResources.add(catalog);gap('UI_LOCALE_FALLBACK_PREVIEW',config.file,'Read-only preview uses source defaultLocale '+language+'; request locale and remote translations are unobserved.');return {[TRANSLATION]:catalog};}
437
+ return UNKNOWN;
438
+ }
414
439
  i18nextPreview??=sourceI18next({ts,modules,binding,imported:externalImport,jsonResource})??false;
415
440
  if(!i18nextPreview)return UNKNOWN;
416
441
  const namespace=node.arguments[0]?evalExpression(node.arguments[0],module,ctx):i18nextPreview.namespace;
@@ -426,6 +451,10 @@ export async function analyze({
426
451
  if(seen.has(module.file+'#'+tag)||seen.size>8)return null;seen.add(module.file+'#'+tag);
427
452
  const [name,part,extra]=tag.split('.'),ref=module.imports.get(name);
428
453
  const imported=externalImport(module,tag);
454
+ if (ref?.specifier === 'vaul' && ref.name === 'Drawer' && !extra) {
455
+ const mapped = {Root:'Root',Portal:'Portal',Overlay:'Backdrop',Content:'Popup',Title:'Title',Description:'Description',Trigger:'Trigger',Close:'Close'}[part];
456
+ if (mapped) return {family:'Dialog',part:mapped};
457
+ }
429
458
  if (ref?.specifier === '@mantine/core' && ref.name === 'Tabs' && !extra) return {family:'Tabs',part:part ?? 'Root',mantine:true};
430
459
  if(imported?.specifier==='@headlessui/react'){
431
460
  const part={Dialog:'Root',DialogPanel:'Popup',DialogBackdrop:'Backdrop',DialogTitle:'Title',Description:'Description'}[imported.name];
@@ -599,6 +628,13 @@ export async function analyze({
599
628
  node = unwrap(node);
600
629
  if (!node || depth > 30) return UNKNOWN;
601
630
  const ev = (n) => evalExpression(n, module, context, stack, depth + 1);
631
+ if (nativePlatform) {
632
+ const result = nativeExpression(node, module, { ts, platform, evaluate: ev, unknown: UNKNOWN,
633
+ shadowed: name => Object.hasOwn(context, name),
634
+ gap: detail => gap("RN_CAPABILITY_UNOBSERVED", module.file, detail),
635
+ });
636
+ if (result) return result.value;
637
+ }
602
638
  if (ts.isAwaitExpression(node)) return query(node, module, context, true);
603
639
  const component=!(ts.isIdentifier(node)&&Object.hasOwn(context,node.text))&&sourceComponent(node,module);if(component)return component;
604
640
  if (ts.isStringLiteralLike(node) || ts.isNumericLiteral(node))
@@ -791,6 +827,7 @@ export async function analyze({
791
827
  args = node.arguments.map(ev),
792
828
  method = node.expression.name.text;
793
829
  if (isUnknown(target) || args.some(isUnknown)) return query(node,module,context);
830
+ if (typeof target === 'string' && method === 'toString' && !args.length) return target;
794
831
  if (
795
832
  typeof target === "string" &&
796
833
  [
@@ -855,7 +892,7 @@ export async function analyze({
855
892
  const ctx = { ...props, __iamSetters: {}, __iamStates: new Set(), __iamStateNodes: {}, __iamLocals: new Map(), __iamQueryLocals: new Map(), __iamPropBindings: new Map(), __iamPropHandlers: new Map(), __iamDisclosures: [] };
856
893
  const param = def.fn?.parameters?.[0];
857
894
  if (param) {
858
- if (ts.isIdentifier(param.name)) ctx[param.name.text] = props;
895
+ if (ts.isIdentifier(param.name)) ctx[param.name.text] = def.symbol === '$pageLayout' ? props.children : props;
859
896
  else if (ts.isObjectBindingPattern(param.name))
860
897
  for (const el of param.name.elements) {
861
898
  if (el.dotDotDotToken && ts.isIdentifier(el.name)) {
@@ -962,7 +999,7 @@ export async function analyze({
962
999
  module.file,
963
1000
  "Middleware/proxy may change public routes; not executed by source analysis",
964
1001
  );
965
- if (!/^next\.config\.[cm]?[jt]s$/.test(module.file)) continue;
1002
+ if (nativePlatform || !/^next\.config\.[cm]?[jt]s$/.test(module.file)) continue;
966
1003
  const found = new Set();
967
1004
  function readConfig(node) {
968
1005
  const key = node.name?.text;
@@ -1000,11 +1037,18 @@ export async function analyze({
1000
1037
  !/^\/(?:[^/?#[\]\\\s]+\/)*[^/?#[\]\\\s]+$/.test(routing.basePath)
1001
1038
  )
1002
1039
  throw new Error("Invalid source basePath");
1003
- const selection = await sourceEntry({ root, modules, resolveModule, ts, entry });
1040
+ const selection = nativePlatform ? { discoveryModules: modules } : await sourceEntry({ root, modules, resolveModule, ts, entry });
1041
+ if (nativePlatform) {
1042
+ gap("RN_SOURCE_PREVIEW", "", "Inert HTML projection of React Native source. Native Yoga layout, device density, accessibility, gestures and navigation stacks are not observed; preview DOM locators never authorize native visual changes.");
1043
+ gap("RN_METRO_CONFIG_UNOBSERVED", "", "Uses default Metro source extensions; custom resolvers, Babel plugins and Metro configuration are not executed.");
1044
+ }
1004
1045
  const flowbiteTheme=rootFlowbiteTheme({ts,modules:selection.discoveryModules,imported:externalImport,evaluate:evalExpression});
1005
1046
  if (selection.htmlSource) inventory.push(selection.htmlSource);
1006
1047
  inventory.sort((a, b) => a.file < b.file ? -1 : a.file > b.file ? 1 : 0);
1007
- const compositions = frameworkRoutes({ modules, ...selection, resolveModule, ts, sourceLocation, navigationPaths, gap, resolution, inlineComponent });
1048
+ if (nativePlatform && navigationPaths.length) throw Error("Retained Web navigation paths do not describe a native back stack");
1049
+ const compositions = nativePlatform
1050
+ ? reactNativeRoutes({ modules, platform, entry, routerRoot, binding, sourceLocation, gap })
1051
+ : frameworkRoutes({ modules, ...selection, resolveModule, ts, sourceLocation, navigationPaths, gap, resolution, inlineComponent });
1008
1052
  const pageFiles = compositions.pageFiles;
1009
1053
  routing.framework = compositions.framework;
1010
1054
  if (compositions.routeSources) routing.routeSources = compositions.routeSources;
@@ -1193,7 +1237,7 @@ export async function analyze({
1193
1237
  if(fn&&ts.isPropertyAccessExpression(fn)&&ctx.__iamPropHandlers.has(fn.getText(owner.module.source)))return ctx.__iamPropHandlers.get(fn.getText(owner.module.source));
1194
1238
  const eventValue = (variableKey) => ({ variableKey, value: {op:'event',key:'value'}, payload:{value:ui.variables.find(v=>v.variableKey===variableKey)?.valueType} });
1195
1239
  if(fn&&ts.isIdentifier(fn)&&ctx.__iamPropHandlers.has(fn.text))return ctx.__iamPropHandlers.get(fn.text);
1196
- if(fn&&ts.isIdentifier(fn)&&ctx.__iamSetters[fn.text]&&['onValueChange','onOpenChange'].includes(property)) {
1240
+ if(fn&&ts.isIdentifier(fn)&&ctx.__iamSetters[fn.text]&&['onValueChange','onOpenChange',...(nativePlatform?['onChangeText']:[])].includes(property)) {
1197
1241
  const key=ctx.__iamVariableKeys[ctx.__iamSetters[fn.text]];return key?eventValue(key):null;
1198
1242
  }
1199
1243
  if(fn&&ts.isIdentifier(fn)&&ctx.__iamLocals.has(fn.text))fn=unwrap(ctx.__iamLocals.get(fn.text));
@@ -1223,7 +1267,7 @@ export async function analyze({
1223
1267
  if(!arg)return null;
1224
1268
  const forwardedWrites=(inherited.writes||[{variableKey:inherited.variableKey,value:inherited.value}]).map(write=>({...write,value:replaceEvent(write.value,arg)}));
1225
1269
  for(const write of forwardedWrites)inputs(write.value);
1226
- if(['onValueChange','onOpenChange'].includes(property))for(const [key,type]of Object.entries(inherited.payload))if(Object.hasOwn(payload,key))payload[key]=type;
1270
+ if(['onValueChange','onOpenChange',...(nativePlatform?['onChangeText']:[])].includes(property))for(const [key,type]of Object.entries(inherited.payload))if(Object.hasOwn(payload,key))payload[key]=type;
1227
1271
  if(forwardedWrites.some(write=>uiExpressionType(write.value,payload)!==ui.variables.find(v=>v.variableKey===write.variableKey)?.valueType))return null;
1228
1272
  writes.push(...forwardedWrites);continue;
1229
1273
  }
@@ -1234,7 +1278,7 @@ export async function analyze({
1234
1278
  const value=uiExpression(functional?functional.body:input,owner,ctx,fn.parameters[0]?.name?.text||null,aliases);
1235
1279
  if(!value)return null;
1236
1280
  inputs(value);const expected=ui.variables.find(v=>v.variableKey===variableKey)?.valueType;
1237
- if(value.op==='event'&&value.key==='value'&&['onValueChange','onOpenChange'].includes(property))payload.value=expected;
1281
+ if(value.op==='event'&&value.key==='value'&&['onValueChange','onOpenChange',...(nativePlatform?['onChangeText']:[])].includes(property))payload.value=expected;
1238
1282
  if(uiExpressionType(value,payload)!==expected)return null;
1239
1283
  writes.push({variableKey,value});
1240
1284
  }
@@ -1271,7 +1315,7 @@ export async function analyze({
1271
1315
  "data",
1272
1316
  "length",
1273
1317
  "size",
1274
- ].includes(n.name.text)
1318
+ ].includes(n.name.text) || ts.isPropertyAccessExpression(n) && isUnknown(evalExpression(n.expression,owner.module,ctx))
1275
1319
  )
1276
1320
  shape = true;
1277
1321
  ts.forEachChild(n, inspect);
@@ -1548,11 +1592,11 @@ export async function analyze({
1548
1592
  function renderExpr(node, owner, ctx, instance, depth) {
1549
1593
  node = unwrap(node);
1550
1594
  if (!node) return "";
1551
- if (depth > 45) { gap("RENDER_DEPTH_LIMIT", owner.module.file, owner.symbol + ": JSX traversal budget exceeded"); return ""; }
1595
+ if (depth > 128) { gap("RENDER_DEPTH_LIMIT", owner.module.file, owner.symbol + ": JSX nesting budget exceeded"); return ""; }
1552
1596
  const m = owner.module;
1553
1597
  if(ts.isCallExpression(node)&&ts.isIdentifier(node.expression)&&ctx.__iamLocals.has(node.expression.text)){
1554
1598
  const fn=unwrap(ctx.__iamLocals.get(node.expression.text));
1555
- if(fn&&ts.isArrowFunction(fn)&&!fn.modifiers?.some(v=>v.kind===ts.SyntaxKind.AsyncKeyword)&&!ts.isBlock(fn.body)&&hasSourceJsx(fn.body)&&fn.parameters.length<=8&&fn.parameters.every(p=>ts.isIdentifier(p.name)&&!p.dotDotDotToken)&&!node.arguments.some(ts.isSpreadElement)){
1599
+ if(fn&&(ts.isArrowFunction(fn)||ts.isFunctionDeclaration(fn)||ts.isFunctionExpression(fn))&&!fn.modifiers?.some(v=>v.kind===ts.SyntaxKind.AsyncKeyword)&&(!ts.isBlock(fn.body)||fn.body.statements.every(s=>ts.isVariableStatement(s)||ts.isIfStatement(s)||ts.isReturnStatement(s)))&&hasSourceJsx(uiReturnExpression(fn))&&fn.parameters.length<=8&&fn.parameters.every(p=>ts.isIdentifier(p.name)&&!p.dotDotDotToken)&&!node.arguments.some(ts.isSpreadElement)){
1556
1600
  return renderInvocation({callback:{fn,owner,ctx,instance:instance+'/source-helper'},args:node.arguments.map(arg=>evalExpression(arg,m,ctx)),caller:ctx},instance,depth+1);
1557
1601
  }
1558
1602
  }
@@ -1712,6 +1756,18 @@ export async function analyze({
1712
1756
  .join("");
1713
1757
  }
1714
1758
  gap("DYNAMIC_COLLECTION", m.file, node.expression.getText(m.source));
1759
+ // Inspect one source template, never a fabricated data item. It stays
1760
+ // hidden in the page preview; a nested UI scene can inspect its structure.
1761
+ if (fn && (ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)) && !fn.modifiers?.some(m => m.kind === ts.SyntaxKind.AsyncKeyword) && hasSourceJsx(uiReturnExpression(fn)) && !ctx.__iamUnknownTemplate) {
1762
+ const next = {...ctx,__iamUnknownTemplate:true};
1763
+ for (const parameter of fn.parameters) {
1764
+ if (ts.isIdentifier(parameter.name)) next[parameter.name.text] = UNKNOWN;
1765
+ else if (ts.isObjectBindingPattern(parameter.name)) for (const field of parameter.name.elements) if (ts.isIdentifier(field.name)) next[field.name.text] = UNKNOWN;
1766
+ }
1767
+ const source = sourceLocation(m,node,owner.symbol),elementKey=identity('element',{component:owner.key,template:node.getStart(m.source)}),id=identity('node',{page:pageKey,element:elementKey,instance});
1768
+ elements.set(elementKey,{elementKey,componentKey:owner.key,tag:'div',source,identityStrategy:'provisional_ast_ordinal',editableProperties:[],binding:'preview_only'});record.set(id,{elementKey,source,synthetic:true});
1769
+ return '<div data-iam-node="'+id+'" data-iam-source-template="unobserved" hidden="">'+renderExpr(uiReturnExpression(fn),owner,next,instance+'/source-template',depth+1)+'</div>';
1770
+ }
1715
1771
  return unobservedValue(node, owner, instance,queryBacked(node.expression.expression,owner,ctx));
1716
1772
  }
1717
1773
  if (!ts.isJsxElement(node) && !ts.isJsxSelfClosingElement(node)) {
@@ -1776,7 +1832,7 @@ export async function analyze({
1776
1832
  }
1777
1833
  for (let i = uiBindings.length - 1; i >= 0; i--) if (uiBindings[i].property === key) uiBindings.splice(i, 1);
1778
1834
  for (let i = uiHandlers.length - 1; i >= 0; i--) if (uiHandlers[i].key === key) uiHandlers.splice(i, 1);
1779
- if (["onClick", "onChange", "onInput", "onValueChange", "onOpenChange", "onClose"].includes(key)) {
1835
+ if (["onClick", "onChange", "onInput", "onValueChange", "onOpenChange", "onClose", ...(nativePlatform ? ["onPress", "onChangeText"] : [])].includes(key)) {
1780
1836
  props[key] = undefined;
1781
1837
  const supported = ctx.__iamCollectionTemplate?null:uiHandler(attr.initializer, owner, ctx, key);
1782
1838
  if (supported) { props[key] = undefined; uiHandlers.push({ ...supported, key, source: sourceLocation(m, attr, owner.symbol) }); }
@@ -1906,8 +1962,21 @@ export async function analyze({
1906
1962
  }
1907
1963
  if(tabPart==='Root') {
1908
1964
  const valueBinding=uiBindings.find(b=>b.property==='value')?.value;
1909
- const controller=uiHandlers.find(h=>h.key==='onValueChange'||basePart?.mantine&&h.key==='onChange');
1910
- tabGroup={tabKey:identity('ui-tab',{page:pageKey,scope:ctx.__iamScope,instance}),routeKey:pageKey,renderKey:'pending',nodeId:'pending',variableKey:valueBinding?.op==='var'?valueBinding.key:null,initialValue:typeof props.value==='string'?props.value:typeof props.defaultValue==='string'?props.defaultValue:null,items:[],source:sourceLocation(m,node,owner.symbol),scopeSource:ui.variables.find(v=>v.variableKey===valueBinding?.key)?.source??sourceLocation(m,node,owner.symbol),controller};
1965
+ let controller=uiHandlers.find(h=>h.key==='onValueChange'||basePart?.mantine&&h.key==='onChange');
1966
+ const attribute = name => {const a=opening.attributes.properties.find(a=>ts.isJsxAttribute(a)&&a.name.text===name);return a?.initializer&&ts.isJsxExpression(a.initializer)?unwrap(a.initializer.expression):null;};
1967
+ const input=attribute('value'),handler=attribute('onValueChange');
1968
+ if (!controller && handler && (ts.isArrowFunction(handler)||ts.isFunctionExpression(handler)) && !handler.modifiers?.length && handler.parameters.length===1 && ts.isIdentifier(handler.parameters[0].name)) {
1969
+ let call=ts.isBlock(handler.body)&&handler.body.statements.length===1?handler.body.statements[0]:handler.body;
1970
+ if(ts.isExpressionStatement(call))call=call.expression;call=unwrap(call);
1971
+ const label=call&&ts.isCallExpression(call)&&ts.isIdentifier(call.expression)&&call.arguments.length===1?ctx.__iamSetters[call.expression.text]:null,key=label&&ctx.__iamVariableKeys[label];
1972
+ if(key&&input&&collectionStateReads(ts,input,ctx).includes(key)) controller={variableKey:key,forValue:value=>{
1973
+ const written=evalExpression(call.arguments[0],m,{...ctx,[handler.parameters[0].name.text]:value});
1974
+ if(typeof written!=='string'||evalExpression(input,m,{...ctx,[label]:written})!==value)return null;
1975
+ return {variableKey:key,value:{op:'const',value:written}};
1976
+ }};
1977
+ }
1978
+ const variableKey=valueBinding?.op==='var'?valueBinding.key:controller?.forValue?controller.variableKey:null;
1979
+ tabGroup={tabKey:identity('ui-tab',{page:pageKey,scope:ctx.__iamScope,instance}),routeKey:pageKey,renderKey:'pending',nodeId:'pending',variableKey,initialValue:typeof props.value==='string'?props.value:typeof props.defaultValue==='string'?props.defaultValue:null,items:[],source:sourceLocation(m,node,owner.symbol),scopeSource:ui.variables.find(v=>v.variableKey===variableKey)?.source??sourceLocation(m,node,owner.symbol),controller};
1911
1980
  childCtx={...childCtx,__iamTabs:tabGroup};
1912
1981
  }
1913
1982
  const mantineRef=m.imports.get(tag.split('.')[0]),mantineName=mantineRef?.specifier==='@mantine/core'?(mantineRef.name==='*'?tag.split('.').slice(1).join('.'):mantineRef.name+tag.split('.').slice(1).map(s=>'.'+s).join('')):null;
@@ -1921,7 +1990,8 @@ export async function analyze({
1921
1990
  const localRef=componentBinding(m,tag),localTarget=ctx[tag]?.[COMPONENT]??(localRef&&componentDefs.get(localRef.module.file+'#'+localRef.name));
1922
1991
  const dynamicNode=ctx.__iamLocals.get(tag);
1923
1992
  const registry=!localTarget&&dynamicNode?componentRegistry({ts,node:dynamicNode,module:m,locals:ctx.__iamLocals,binding,functionFrom,value:(n,module)=>evalExpression(n,module,ctx),component:(n,module)=>sourceComponent(n,module)?.[COMPONENT],variable:name=>ctx.__iamVariableKeys[name]??(ctx.__iamPropBindings.get(name)?.op==='var'?ctx.__iamPropBindings.get(name).key:null),location:sourceLocation,unknown:UNKNOWN,imported:externalImport}):null;
1924
- const renderChildren=(inherited=childCtx)=>ts.isJsxElement(node)?node.children.map((child,i)=>renderExpr(child,owner,{...childCtx,__iamContexts:inherited.__iamContexts??childCtx.__iamContexts,__iamTabs:inherited.__iamTabs??childCtx.__iamTabs,__iamOverlays:inherited.__iamOverlays??childCtx.__iamOverlays},instance+'/child-'+i,depth+1)).join(''):props.children?.iamRenderChildren?props.children.iamRenderChildren(inherited):props.children?.iamChildren||'';
1993
+ let childrenRenders=0;
1994
+ const renderChildren=(inherited=childCtx)=>{if(childrenRenders)gap('REPEATED_SOURCE_INSTANCE',m.file,'Forwarded children have distinct preview instances; repeated runtime selectors remain ambiguous');const childInstance=instance+(childrenRenders++?'/forward-'+(childrenRenders-1):'');return ts.isJsxElement(node)?node.children.map((child,i)=>renderExpr(child,owner,{...childCtx,__iamContexts:inherited.__iamContexts??childCtx.__iamContexts,__iamTabs:inherited.__iamTabs??childCtx.__iamTabs,__iamOverlays:inherited.__iamOverlays??childCtx.__iamOverlays},childInstance+'/child-'+i,depth+1)).join(''):props.children?.iamRenderChildren?props.children.iamRenderChildren(inherited):props.children?.iamChildren||'';};
1925
1995
  let children = localTarget||registry ? '' : renderChildren();
1926
1996
  const mantine = mantinePrimitive(m.imports.get(tag.split('.')[0]), props, tag.split('.').slice(1).join('.'));
1927
1997
  if (mantine) {
@@ -1976,6 +2046,14 @@ export async function analyze({
1976
2046
 
1977
2047
  if (ctx[tag]?.iamChildren !== undefined) return ctx[tag].iamChildren;
1978
2048
  if (ctx[tag]?.iamRenderChildren) return ctx[tag].iamRenderChildren(ctx);
2049
+ const native = nativePlatform ? nativePrimitive(m, tag, props, { platform, gap: detail => gap("RN_CAPABILITY_UNOBSERVED", m.file, detail) }) : null;
2050
+ if (native?.ignore) return "";
2051
+ if (native?.outlet) {
2052
+ gap("RN_NAVIGATOR_SOURCE_ONLY", m.file, "Projects the current route through the source layout; native headers, tabs, focus and stack history are unobserved");
2053
+ return ctx.__iamOutlet?.iamRenderChildren?.(ctx) ?? "";
2054
+ }
2055
+ if (native?.props) Object.assign(props, native.props);
2056
+ if (native?.text !== undefined) children = escape(native.text);
1979
2057
  const routerPrimitive = externalImport(m, tag);
1980
2058
  if (['react-router','react-router-dom'].includes(routerPrimitive?.specifier)&&routerPrimitive.name==='Navigate')return '';
1981
2059
  if (["react-router", "react-router-dom", "@tanstack/react-router"].includes(routerPrimitive?.specifier) && routerPrimitive.name === "Outlet") return ctx.__iamOutlet?.iamRenderChildren ? ctx.__iamOutlet.iamRenderChildren(ctx) : ctx.__iamOutlet?.iamChildren ?? "";
@@ -1994,7 +2072,8 @@ export async function analyze({
1994
2072
  props["data-iam-adapter"] = tag;
1995
2073
  }
1996
2074
  if (/^[A-Z]/.test(tag) && !intrinsicAlias) {
1997
- if (mantine) htmlTag = mantine.tag;
2075
+ if (native?.tag) htmlTag = native.tag;
2076
+ else if (mantine) htmlTag = mantine.tag;
1998
2077
  else if(tabPart){
1999
2078
  htmlTag=tabPart==='Tab'?'button':'div';
2000
2079
  if(tabPart==='List')props.role='tablist';
@@ -2128,7 +2207,7 @@ export async function analyze({
2128
2207
  record.set(nodeId, {
2129
2208
  elementKey,
2130
2209
  source,
2131
- synthetic: Boolean(m.mdx) || htmlTag !== tag && tag !== "Link" && tag !== "Image"&&!intrinsicAlias&&!knownExternalPrimitive,
2210
+ synthetic: nativePlatform || Boolean(m.mdx) || htmlTag !== tag && tag !== "Link" && tag !== "Image"&&!intrinsicAlias&&!knownExternalPrimitive,
2132
2211
  });
2133
2212
  if(tabPart==='Root'){
2134
2213
  // Tab initialization below shares the same concrete source container.
@@ -2147,11 +2226,11 @@ export async function analyze({
2147
2226
  if(!tabGroup.items.some(item=>item.value===props.value))tabGroup.items.push({value:props.value,nodeId});
2148
2227
  props['data-iam-tab-value']=props.value;props['data-iam-tab-key']=tabGroup.tabKey;
2149
2228
  const replace=value=>value.op==='event'&&value.key==='value'?{op:'const',value:props.value}:Object.fromEntries(Object.entries(value).map(([k,v])=>[k,Array.isArray(v)?v.map(item=>item&&typeof item==='object'?replace(item):item):v&&typeof v==='object'?replace(v):v]));
2150
- if(tabGroup.controller){const writes=(tabGroup.controller.writes||[{variableKey:tabGroup.controller.variableKey,value:tabGroup.controller.value}]).map(w=>({...w,value:replace(w.value)}));uiHandlers.push({...writes[0],writes,key:'onClick',payload:{},source});}
2229
+ if(tabGroup.controller){const specialized=tabGroup.controller.forValue?.(props.value),writes=(tabGroup.controller.forValue?(specialized?[specialized]:[]):tabGroup.controller.writes||[{variableKey:tabGroup.controller.variableKey,value:tabGroup.controller.value}]).map(w=>({...w,value:replace(w.value)}));if(writes.length)uiHandlers.push({...writes[0],writes,key:'onClick',payload:{},source});}
2151
2230
  }
2152
2231
  if(tabPart==='Panel'&&tabGroup?.variableKey&&typeof props.value==='string')uiBindings.push({property:'visible',value:{op:'eq',left:{op:'var',key:tabGroup.variableKey},right:{op:'const',value:props.value}},source});
2153
2232
  for (const handler of uiHandlers.filter(h=>!['onValueChange','onOpenChange','onClose'].includes(h.key)&&!(tabPart==='Root'&&h.key==='onChange'))) {
2154
- const event = handler.key === "onClick" ? "click" : htmlTag === "select" || handler.payload.checked ? "change" : "input";
2233
+ const event = ["onClick", "onPress"].includes(handler.key) ? "click" : htmlTag === "select" || handler.payload.checked ? "change" : "input";
2155
2234
  const writes=handler.writes||[{variableKey:handler.variableKey,value:handler.value}];
2156
2235
  ui.transitions.push({ transitionKey: identity("ui-transition", { page: pageKey, node: nodeId, event }), routeKey: pageKey, fromStateKey: "pending", toStateKey: "pending", trigger: { nodeId, event, payload: handler.payload }, guard: { op: "const", value: true }, stateReads: [...new Set(writes.flatMap(w=>readVariables(w.value)))].sort(), stateWrites: writes, simulation: "implemented", evidence: "source_inferred", source: handler.source });
2157
2236
  }
@@ -2164,7 +2243,7 @@ export async function analyze({
2164
2243
  }
2165
2244
  ui.bindings.push({ bindingKey: identity("ui-binding", { node: nodeId, property: binding.property }), routeKey: pageKey, nodeId, elementKey, ...binding });
2166
2245
  }
2167
- const sensitiveInput = ['input', 'textarea', 'select'].includes(htmlTag) && (htmlTag === 'input' && ["password", "hidden"].includes(String(props.type).toLowerCase()) || sensitiveName(String(props.name || props.id || "")));
2246
+ const sensitiveInput = native?.sensitive || ['input', 'textarea', 'select'].includes(htmlTag) && (htmlTag === 'input' && ["password", "hidden"].includes(String(props.type).toLowerCase()) || sensitiveName(String(props.name || props.id || "")));
2168
2247
  if (sensitiveInput) {
2169
2248
  delete props.value; delete props.defaultValue;
2170
2249
  if (htmlTag === 'textarea') children = '';
@@ -2212,7 +2291,7 @@ export async function analyze({
2212
2291
  clipRule: "clip-rule",
2213
2292
  }[key] || key;
2214
2293
  if (
2215
- !/^(?:class|id|name|type|placeholder|value|title|alt|href|src|role|for|disabled|hidden|checked|required|readonly|multiple|selected|open|start|min|max|step|minlength|maxlength|pattern|autocomplete|inputmode|rows|cols|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-(?:slot|state|side|variant|collapsible|orientation|active)|data-testid|data-test|data-qa|data-iam-(?:adapter|toggle|tab-value|tab-key|condition|alternative|condition-default|unobserved))$/.test(
2294
+ !/^(?:class|id|name|type|placeholder|value|title|alt|href|src|role|for|disabled|hidden|checked|required|readonly|multiple|selected|open|start|min|max|step|minlength|maxlength|pattern|autocomplete|inputmode|rows|cols|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-(?:slot|state|side|variant|collapsible|orientation|active)|data-testid|data-test|data-qa|data-iam-(?:native-component|native-platform|native-unobserved|adapter|toggle|tab-value|tab-key|condition|alternative|condition-default|unobserved))$/.test(
2216
2295
  attr,
2217
2296
  )
2218
2297
  )
@@ -2284,7 +2363,7 @@ export async function analyze({
2284
2363
  return '<div data-iam-node="'+contentId+'" data-iam-page-content="true">'+content+'</div>';
2285
2364
  }
2286
2365
  const layer=branch.layers[index],child={iamRenderChildren:next=>renderLayer(index-1,next)};
2287
- const props = ["react-router", "tanstack-router"].includes(routing.framework)
2366
+ const props = ["react-router", "tanstack-router", "expo-router"].includes(routing.framework)
2288
2367
  ? { ...contextProps(ctx), __iamOutlet: child }
2289
2368
  : { ...contextProps(ctx), children: child };
2290
2369
  if (/^(?:src\/)?pages\/_app\.[jt]sx?$/.test(layer.file)) { props.Component = child; props.pageProps = {}; }
@@ -2437,7 +2516,7 @@ export async function analyze({
2437
2516
  stateKey,
2438
2517
  kind: "source_projection",
2439
2518
  evidence: unprojected?'uncovered':"source_inferred",
2440
- viewport: { width: 1280, height: 800 },
2519
+ viewport: nativePlatform ? { width: 390, height: 844 } : { width: 1280, height: 800 },
2441
2520
  html: concreteRoot.outerHTML,
2442
2521
  css: "",
2443
2522
  js: "",
@@ -2590,9 +2669,9 @@ export async function analyze({
2590
2669
  producer: {
2591
2670
  name: "indiana-app-manifest-local",
2592
2671
  version: "0.27.0",
2593
- method: "typescript-ast-and-bounded-static-projection;lucide-static-"+LUCIDE_SOURCE_VERSION,
2672
+ method: "typescript-ast-and-bounded-static-projection;lucide-static-"+LUCIDE_SOURCE_VERSION+(nativePlatform ? ";react-native-source-preview-v1" : ""),
2594
2673
  },
2595
- app: { name, platforms: ["web"] },
2674
+ app: { name, platforms: [platform] },
2596
2675
  routing,
2597
2676
  release: { repository, commit, sourceGraphDigest },
2598
2677
  pages,
@@ -2607,7 +2686,7 @@ export async function analyze({
2607
2686
  runtimeObserved: false,
2608
2687
  generatedPageCount: pages.length,
2609
2688
  sourcePageFileCount: pageFiles.length,
2610
- unsupportedFrameworks: ["vue", "svelte", "android", "ios"],
2689
+ unsupportedFrameworks: nativePlatform ? ["vue", "svelte", "swiftui", "compose", "flutter", "react-navigation"] : ["vue", "svelte", "android", "ios"],
2611
2690
  gaps,
2612
2691
  },
2613
2692
  integrity: {
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, entry, automaticAnchors = true, strict = false, logger = console }) {
30
+ export async function prepareBuildRelease({ root, name, entry, platform = "web", routerRoot, automaticAnchors = platform === "web", 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, entry, automaticAnchors });
33
+ const analysis = await analyzeSource({ root, name, entry, platform, routerRoot, automaticAnchors });
34
34
  const releaseId = deriveReleaseId(analysis);
35
35
  process.env[STATE] = JSON.stringify({ root, releaseId, automaticAnchors });
36
36
  return { releaseId };
@@ -60,7 +60,9 @@ export async function finishBuildRelease({
60
60
  releaseId,
61
61
  name,
62
62
  entry,
63
- automaticAnchors = true,
63
+ platform = "web",
64
+ routerRoot,
65
+ automaticAnchors = platform === "web",
64
66
  cssDirectory,
65
67
  mounts,
66
68
  publicDirectory,
@@ -72,7 +74,7 @@ export async function finishBuildRelease({
72
74
  }) {
73
75
  root = await fs.realpath(root);
74
76
  return guarded({ strict, logger }, async () => {
75
- const analysis = await analyzeSource({ root, name, entry, automaticAnchors });
77
+ const analysis = await analyzeSource({ root, name, entry, platform, routerRoot, automaticAnchors });
76
78
  if (deriveReleaseId(analysis) !== releaseId) throw new Error("Source changed during the build; rebuild before publishing IAM");
77
79
  const css = cssDirectory ? await compiledCss(root, cssDirectory) : [];
78
80
  const plain = () => generateAppManifest({ root, analysis, releaseId, referencedImages:true, mounts });
package/src/cli.mjs CHANGED
@@ -10,7 +10,7 @@ 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> [--entry <source module> --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> [--platform web|ios|android --router-root <expo routes> --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
15
  " | INDIANA_IAM_KEY=<key> indiana-iam upload --file <manifest> [--client <key> --label <label> --base-url <url>]" +
16
16
  " | indiana-iam prepare --root <app>" +
@@ -23,6 +23,8 @@ const { values, positionals } = parseArgs({
23
23
  out: { type: "string" },
24
24
  name: { type: "string" },
25
25
  entry: { type: "string" },
26
+ platform: { type: "string" },
27
+ "router-root": { type: "string" },
26
28
  repository: { type: "string" },
27
29
  commit: { type: "string" },
28
30
  file: { type: "string" },
@@ -49,6 +51,8 @@ if (command === "generate") {
49
51
  if (!values.root || !values.out) throw new Error("generate requires --root and --out");
50
52
  if (values["write-public-release"] && !values["release-id"])
51
53
  throw new Error("--write-public-release requires the prebuild --release-id");
54
+ if (values["write-public-release"] && values.platform && values.platform !== "web")
55
+ throw new Error("Use the React Native build entry to stage a native release descriptor");
52
56
  if (values["root-font"] && !values["build-css"]?.length)
53
57
  throw new Error("--root-font requires --build-css");
54
58
  const root = await fs.realpath(values.root);
@@ -56,12 +60,15 @@ if (command === "generate") {
56
60
  root,
57
61
  name: values.name,
58
62
  entry: values.entry,
63
+ platform: values.platform,
64
+ routerRoot: values["router-root"],
59
65
  repository: values.repository || "",
60
66
  commit: values.commit || "",
61
67
  navigationPaths: (values["navigation-path"] || []).map((value) => JSON.parse(value)),
62
68
  releaseId: values["release-id"],
63
69
  buildCss: values["build-css"] || [],
64
70
  publicAssets: values["public-asset"] || [],
71
+ referencedImages: ["ios", "android"].includes(values.platform),
65
72
  optimizePublicImages: values["optimize-public-images"],
66
73
  rootFont: values["root-font"] || "",
67
74
  omitSvgResources: values["omit-svg-resources"],
@@ -70,12 +70,11 @@ export function frameworkRoutes({
70
70
  const layers = [];
71
71
  if (customApp) {
72
72
  // A dynamic per-page layout is not the shared App wrapper contract.
73
- if (/\.getLayout\b/.test(modules.get(customApp).text))
74
- gap(
75
- "PAGES_LAYOUT_UNSUPPORTED",
76
- customApp,
77
- "Per-page getLayout requires an explicit adapter or a runtime observation; the page alone is projected",
78
- );
73
+ if (/\.getLayout\b/.test(modules.get(customApp).text)) {
74
+ if (modules.get(file).bindings.has('$pageLayout')) layers.push({file,symbol:'$pageLayout',slots:[]});
75
+ else if (/\.getLayout\s*=/.test(modules.get(file).text)) gap('PAGES_LAYOUT_UNSUPPORTED',file,'Dynamic per-page layout remains unobserved');
76
+ gap('PAGES_APP_WRAPPER_UNOBSERVED',customApp,'Per-page source layout is projected separately; shared provider effects are not executed');
77
+ }
79
78
  else layers.push({ file: customApp, slots: [] });
80
79
  }
81
80
  const composition = { lane, file, layers };