@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 +78 -7
- package/package.json +6 -2
- package/src/analyze.mjs +108 -29
- package/src/build.mjs +6 -4
- package/src/cli.mjs +8 -1
- package/src/framework-routes.mjs +5 -6
- package/src/generate.mjs +10 -0
- package/src/index.d.ts +16 -3
- package/src/library-entry.mjs +2 -1
- package/src/react-native-projection.mjs +300 -0
- package/src/react-native-routes.mjs +182 -0
- package/src/react-native.d.ts +47 -0
- package/src/react-native.mjs +45 -0
- package/src/render-index.mjs +1 -1
- package/src/routing.mjs +1 -1
- package/src/schema-definition.mjs +1 -1
- package/src/source-collections.mjs +3 -0
- package/src/source-next-redirects.mjs +98 -0
- package/src/source-preview-css.mjs +117 -0
- package/src/source-redirects.mjs +2 -0
- package/src/ui-preview.mjs +14 -0
- package/src/ui-structure.mjs +2 -1
- package/src/validate.mjs +7 -1
package/src/generate.mjs
CHANGED
|
@@ -24,6 +24,8 @@ export async function analyzeSource({
|
|
|
24
24
|
navigationPaths = [],
|
|
25
25
|
entry,
|
|
26
26
|
automaticAnchors = false,
|
|
27
|
+
platform = "web",
|
|
28
|
+
routerRoot,
|
|
27
29
|
}) {
|
|
28
30
|
root = await fs.realpath(root);
|
|
29
31
|
return analyze({
|
|
@@ -34,6 +36,8 @@ export async function analyzeSource({
|
|
|
34
36
|
navigationPaths,
|
|
35
37
|
entry,
|
|
36
38
|
automaticAnchors,
|
|
39
|
+
platform,
|
|
40
|
+
routerRoot,
|
|
37
41
|
});
|
|
38
42
|
}
|
|
39
43
|
|
|
@@ -50,6 +54,8 @@ export async function generateAppManifest({
|
|
|
50
54
|
navigationPaths,
|
|
51
55
|
entry,
|
|
52
56
|
automaticAnchors = false,
|
|
57
|
+
platform,
|
|
58
|
+
routerRoot,
|
|
53
59
|
releaseId,
|
|
54
60
|
buildCss = [],
|
|
55
61
|
publicAssets = [],
|
|
@@ -70,7 +76,11 @@ export async function generateAppManifest({
|
|
|
70
76
|
navigationPaths,
|
|
71
77
|
entry,
|
|
72
78
|
automaticAnchors,
|
|
79
|
+
platform,
|
|
80
|
+
routerRoot,
|
|
73
81
|
});
|
|
82
|
+
if (analysis && platform !== undefined && (analysis.app.platforms.length !== 1 || analysis.app.platforms[0] !== platform))
|
|
83
|
+
throw Error("Analyzed IAM belongs to another platform");
|
|
74
84
|
for (const document of manifest.documents)
|
|
75
85
|
if (document.domPayload) {
|
|
76
86
|
Object.assign(document, documentDom(document));
|
package/src/index.d.ts
CHANGED
|
@@ -20,8 +20,12 @@ export type ValidationCounts = Record<string, unknown>;
|
|
|
20
20
|
|
|
21
21
|
export type GenerateOptions = {
|
|
22
22
|
root: string;
|
|
23
|
-
/**
|
|
23
|
+
/** Web browser entry or single-root RN screen, relative to root. */
|
|
24
24
|
entry?: string;
|
|
25
|
+
/** Defaults to web. RN/Expo artifacts are generated separately for ios and android. */
|
|
26
|
+
platform?: "web" | "ios" | "android";
|
|
27
|
+
/** Expo route root for a native platform; mutually exclusive with entry. */
|
|
28
|
+
routerRoot?: string;
|
|
25
29
|
automaticAnchors?: boolean;
|
|
26
30
|
/** A previous {@link analyzeSource} result; scanned again when omitted. */
|
|
27
31
|
analysis?: AppManifest;
|
|
@@ -50,8 +54,12 @@ export type GeneratedAppManifest = {
|
|
|
50
54
|
|
|
51
55
|
export declare function analyzeSource(options: {
|
|
52
56
|
root: string;
|
|
53
|
-
/**
|
|
57
|
+
/** Web browser entry or single-root RN screen, relative to root. */
|
|
54
58
|
entry?: string;
|
|
59
|
+
/** Defaults to web. RN/Expo artifacts are generated separately for ios and android. */
|
|
60
|
+
platform?: "web" | "ios" | "android";
|
|
61
|
+
/** Expo route root for a native platform; mutually exclusive with entry. */
|
|
62
|
+
routerRoot?: string;
|
|
55
63
|
automaticAnchors?: boolean;
|
|
56
64
|
name?: string;
|
|
57
65
|
repository?: string;
|
|
@@ -87,8 +95,12 @@ export type BuildLogger = Pick<Console, "log" | "warn">;
|
|
|
87
95
|
export type BuildReleaseOptions = {
|
|
88
96
|
strict?: boolean;
|
|
89
97
|
logger?: BuildLogger;
|
|
90
|
-
/**
|
|
98
|
+
/** Web browser entry or single-root RN screen, relative to root. */
|
|
91
99
|
entry?: string;
|
|
100
|
+
/** Defaults to web. RN/Expo artifacts are generated separately for ios and android. */
|
|
101
|
+
platform?: "web" | "ios" | "android";
|
|
102
|
+
/** Expo route root for a native platform; mutually exclusive with entry. */
|
|
103
|
+
routerRoot?: string;
|
|
92
104
|
automaticAnchors?: boolean;
|
|
93
105
|
};
|
|
94
106
|
export declare function prepareBuildRelease(
|
|
@@ -106,6 +118,7 @@ export declare function finishBuildRelease(
|
|
|
106
118
|
omitSvgResources?: boolean;
|
|
107
119
|
upload?: {
|
|
108
120
|
apiKey?: string;
|
|
121
|
+
client?: string;
|
|
109
122
|
baseUrl?: string;
|
|
110
123
|
upload?: "production" | "always" | false;
|
|
111
124
|
fetch?: typeof globalThis.fetch;
|
package/src/library-entry.mjs
CHANGED
|
@@ -19,7 +19,7 @@ export async function validateIamBytes(bytes) {
|
|
|
19
19
|
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
20
20
|
const manifest = parseManifest(text);
|
|
21
21
|
const validation = await validate(manifest);
|
|
22
|
-
if (manifest.coverage.runtimeObserved || manifest.app.platforms.length !== 1 || manifest.app.platforms[0]
|
|
22
|
+
if (manifest.coverage.runtimeObserved || manifest.app.platforms.length !== 1 || !(manifest.app.platforms[0] === "web" || ["ios", "android"].includes(manifest.app.platforms[0]) && ["expo-router", "react-native"].includes(manifest.routing.framework)) || manifest.documents.some(d => d.kind !== "source_projection" || !['source_inferred','uncovered'].includes(d.evidence))) throw new Error("Only supported source projection profiles are admitted");
|
|
23
23
|
return { manifest, validation, sha256: sha256(bytes), byteLength: bytes.byteLength };
|
|
24
24
|
}
|
|
25
25
|
/** Only a freshly authorized immutable ready fact from the trusted finalizer can use this path.
|
|
@@ -30,6 +30,7 @@ export async function readFinalizedIamBytes(bytes, fact) {
|
|
|
30
30
|
if (stamp?.profile !== "bounded-web-preview" || stamp.runtimeVerified !== false || stamp.valid !== true || ![stamp.pages, stamp.components, stamp.elements, stamp.documents, stamp.concreteNodes].every(n => Number.isSafeInteger(n) && n >= 0) || typeof stamp.routeTree?.digest !== "string") return validateIamBytes(bytes);
|
|
31
31
|
const manifest = parseManifest(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
32
32
|
assertManifestShape(manifest);
|
|
33
|
+
if (["expo-router", "react-native"].includes(manifest.routing?.framework)) return validateIamBytes(bytes);
|
|
33
34
|
if (manifest.format !== "indiana-app-manifest" || manifest.schemaVersion !== 2 || manifest.routeTree.schemaVersion !== 3 || manifest.coverage.runtimeObserved || manifest.app.platforms.length !== 1 || manifest.app.platforms[0] !== "web" || manifest.documents.some(d => d.kind !== "source_projection" || !["source_inferred", "uncovered"].includes(d.evidence)) || manifest.pages.length !== stamp.pages || manifest.documents.length !== stamp.documents || manifest.integrity.routeTreeDigest !== stamp.routeTree.digest) throw Error("Finalized IAM admission mismatch");
|
|
34
35
|
const trusted = await loadTrustedRuntimes();
|
|
35
36
|
for (const script of manifest.scripts ?? []) if (sha256(script.js) !== script.sha256 || script.scriptKey !== "script_" + script.sha256 || !trusted.includes(script.js)) throw Error("Finalized IAM runtime mismatch");
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { sensitiveName } from "./privacy.mjs";
|
|
3
|
+
|
|
4
|
+
function imported(module, expression, ts) {
|
|
5
|
+
if (ts.isIdentifier(expression)) return module.imports.get(expression.text);
|
|
6
|
+
if (ts.isPropertyAccessExpression(expression)) {
|
|
7
|
+
const owner = imported(module, expression.expression, ts);
|
|
8
|
+
return owner
|
|
9
|
+
? {
|
|
10
|
+
specifier: owner.specifier,
|
|
11
|
+
name:
|
|
12
|
+
owner.name === "*" || owner.name === "default"
|
|
13
|
+
? expression.name.text
|
|
14
|
+
: owner.name + "." + expression.name.text,
|
|
15
|
+
}
|
|
16
|
+
: null;
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Recognized RN constants only. Application functions, hooks and native modules are never invoked. */
|
|
22
|
+
export function nativeExpression(
|
|
23
|
+
node,
|
|
24
|
+
module,
|
|
25
|
+
{ ts, platform, evaluate, unknown, shadowed, gap },
|
|
26
|
+
) {
|
|
27
|
+
let root = node;
|
|
28
|
+
while (ts.isPropertyAccessExpression(root) || ts.isCallExpression(root))
|
|
29
|
+
root = root.expression;
|
|
30
|
+
if (ts.isIdentifier(root) && shadowed(root.text)) return null;
|
|
31
|
+
const ref = imported(module, node, ts);
|
|
32
|
+
if (ref?.specifier === "react-native" && ref.name === "Platform.OS")
|
|
33
|
+
return { value: platform };
|
|
34
|
+
if (
|
|
35
|
+
ref?.specifier === "react-native" &&
|
|
36
|
+
["StyleSheet.absoluteFill", "StyleSheet.absoluteFillObject"].includes(
|
|
37
|
+
ref.name,
|
|
38
|
+
)
|
|
39
|
+
)
|
|
40
|
+
return {
|
|
41
|
+
value: { position: "absolute", top: 0, right: 0, bottom: 0, left: 0 },
|
|
42
|
+
};
|
|
43
|
+
if (!ts.isCallExpression(node)) return null;
|
|
44
|
+
const call = imported(module, node.expression, ts);
|
|
45
|
+
if (call?.specifier === "react-native") {
|
|
46
|
+
if (call.name === "Platform.select" && node.arguments.length === 1) {
|
|
47
|
+
const choices = evaluate(node.arguments[0]);
|
|
48
|
+
if (!choices || typeof choices !== "object" || Array.isArray(choices))
|
|
49
|
+
return { value: unknown };
|
|
50
|
+
const key = [platform, "native", "default"].find((key) =>
|
|
51
|
+
Object.hasOwn(choices, key),
|
|
52
|
+
);
|
|
53
|
+
return { value: key ? choices[key] : undefined };
|
|
54
|
+
}
|
|
55
|
+
if (call.name === "StyleSheet.create" && node.arguments.length === 1)
|
|
56
|
+
return { value: evaluate(node.arguments[0]) };
|
|
57
|
+
if (call.name === "StyleSheet.flatten" && node.arguments.length === 1)
|
|
58
|
+
return { value: flattenNativeStyle(evaluate(node.arguments[0]), gap) };
|
|
59
|
+
if (call.name === "StyleSheet.compose" && node.arguments.length === 2)
|
|
60
|
+
return { value: flattenNativeStyle(node.arguments.map(evaluate), gap) };
|
|
61
|
+
}
|
|
62
|
+
if (
|
|
63
|
+
ts.isIdentifier(node.expression) &&
|
|
64
|
+
node.expression.text === "require" &&
|
|
65
|
+
!module.bindings.has("require") &&
|
|
66
|
+
!module.imports.has("require") &&
|
|
67
|
+
node.arguments.length === 1 &&
|
|
68
|
+
ts.isStringLiteral(node.arguments[0])
|
|
69
|
+
) {
|
|
70
|
+
const specifier = node.arguments[0].text;
|
|
71
|
+
if (
|
|
72
|
+
!specifier.startsWith(".") ||
|
|
73
|
+
!/\.(?:png|jpe?g|webp|gif)$/i.test(specifier) ||
|
|
74
|
+
/[\\?#]/.test(specifier)
|
|
75
|
+
)
|
|
76
|
+
return null;
|
|
77
|
+
const file = path.posix.normalize(
|
|
78
|
+
path.posix.join(path.posix.dirname(module.file), specifier),
|
|
79
|
+
);
|
|
80
|
+
if (
|
|
81
|
+
!file.startsWith("../") &&
|
|
82
|
+
!file.split("/").some((part) => part.startsWith(".")) &&
|
|
83
|
+
!sensitiveName(file)
|
|
84
|
+
)
|
|
85
|
+
return { value: { uri: "/__iam_source__/" + file } };
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function flattenNativeStyle(value, gap, depth = 0) {
|
|
91
|
+
if (value === null || value === undefined || value === false) return {};
|
|
92
|
+
if (depth > 20) {
|
|
93
|
+
gap("Nested RN styles exceed the static projection depth");
|
|
94
|
+
return {};
|
|
95
|
+
}
|
|
96
|
+
if (Array.isArray(value))
|
|
97
|
+
return Object.assign(
|
|
98
|
+
{},
|
|
99
|
+
...value.map((item) => flattenNativeStyle(item, gap, depth + 1)),
|
|
100
|
+
);
|
|
101
|
+
if (typeof value === "object") return value;
|
|
102
|
+
gap("Dynamic style IDs and values are not resolved");
|
|
103
|
+
return {};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const styleKeys = new Set(
|
|
107
|
+
"alignContent alignItems alignSelf aspectRatio backgroundColor borderBottomColor borderBottomLeftRadius borderBottomRightRadius borderBottomWidth borderColor borderLeftColor borderLeftWidth borderRadius borderRightColor borderRightWidth borderStyle borderTopColor borderTopLeftRadius borderTopRightRadius borderTopWidth borderWidth bottom color columnGap direction display flexBasis flexDirection flexGrow flexShrink flexWrap fontFamily fontSize fontStyle fontWeight gap height justifyContent left letterSpacing lineHeight margin marginBottom marginLeft marginRight marginTop maxHeight maxWidth minHeight minWidth opacity overflow padding paddingBottom paddingLeft paddingRight paddingTop position right rowGap textAlign textDecorationColor textDecorationLine top verticalAlign width zIndex".split(
|
|
108
|
+
" ",
|
|
109
|
+
),
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
function projectStyle(value, gap) {
|
|
113
|
+
const result = {};
|
|
114
|
+
const aliases = {
|
|
115
|
+
marginHorizontal: ["marginLeft", "marginRight"],
|
|
116
|
+
marginVertical: ["marginTop", "marginBottom"],
|
|
117
|
+
paddingHorizontal: ["paddingLeft", "paddingRight"],
|
|
118
|
+
paddingVertical: ["paddingTop", "paddingBottom"],
|
|
119
|
+
marginStart: ["marginInlineStart"],
|
|
120
|
+
marginEnd: ["marginInlineEnd"],
|
|
121
|
+
paddingStart: ["paddingInlineStart"],
|
|
122
|
+
paddingEnd: ["paddingInlineEnd"],
|
|
123
|
+
};
|
|
124
|
+
for (const [key, raw] of Object.entries(flattenNativeStyle(value, gap))) {
|
|
125
|
+
if (
|
|
126
|
+
!["string", "number"].includes(typeof raw) ||
|
|
127
|
+
(typeof raw === "number" && !Number.isFinite(raw))
|
|
128
|
+
) {
|
|
129
|
+
gap("Unresolved RN style: " + key);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (key === "flex" && typeof raw === "number" && raw >= 0) {
|
|
133
|
+
Object.assign(result, {
|
|
134
|
+
flexGrow: raw,
|
|
135
|
+
flexShrink: raw ? 1 : 0,
|
|
136
|
+
...(raw ? { flexBasis: 0 } : {}),
|
|
137
|
+
});
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (aliases[key]) {
|
|
141
|
+
for (const alias of aliases[key]) result[alias] = raw;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (!styleKeys.has(key)) {
|
|
145
|
+
gap("Native style requires runtime layout: " + key);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
result[key] =
|
|
149
|
+
key === "lineHeight" && typeof raw === "number" ? raw + "px" : raw;
|
|
150
|
+
}
|
|
151
|
+
return result;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Semantic HTML is an inert source preview, never a native view locator or executor. */
|
|
155
|
+
export function nativePrimitive(module, tag, props, { platform, gap }) {
|
|
156
|
+
const [owner, ...members] = tag.split("."),
|
|
157
|
+
ref = module.imports.get(owner);
|
|
158
|
+
if (!ref) return null;
|
|
159
|
+
const namespace = ref.name === "*" || ref.name === "default";
|
|
160
|
+
const name = namespace ? members[0] : ref.name,
|
|
161
|
+
nested = members.length > (namespace ? 1 : 0);
|
|
162
|
+
if (ref.specifier === "expo-router") {
|
|
163
|
+
if (["Slot", "Stack", "Tabs", "Drawer"].includes(name))
|
|
164
|
+
return { outlet: !nested, ignore: nested };
|
|
165
|
+
if (name === "Redirect") {
|
|
166
|
+
gap(
|
|
167
|
+
"Native redirects are source declarations; they do not establish navigation state",
|
|
168
|
+
);
|
|
169
|
+
return { ignore: true };
|
|
170
|
+
}
|
|
171
|
+
if (name === "Link")
|
|
172
|
+
return {
|
|
173
|
+
tag: "a",
|
|
174
|
+
props: {
|
|
175
|
+
href: typeof props.href === "string" ? props.href : undefined,
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
if (nested) return null;
|
|
181
|
+
const safeArea =
|
|
182
|
+
ref.specifier === "react-native-safe-area-context" &&
|
|
183
|
+
["SafeAreaView", "SafeAreaProvider"].includes(name);
|
|
184
|
+
const image = ref.specifier === "expo-image" && name === "Image";
|
|
185
|
+
if (ref.specifier !== "react-native" && !safeArea && !image) return null;
|
|
186
|
+
const tags = {
|
|
187
|
+
View: "div",
|
|
188
|
+
Text: "span",
|
|
189
|
+
Image: "img",
|
|
190
|
+
Pressable: "button",
|
|
191
|
+
TouchableOpacity: "button",
|
|
192
|
+
TouchableHighlight: "button",
|
|
193
|
+
TouchableWithoutFeedback: "button",
|
|
194
|
+
Button: "button",
|
|
195
|
+
TextInput: props.multiline === true ? "textarea" : "input",
|
|
196
|
+
ScrollView: "div",
|
|
197
|
+
SafeAreaView: "div",
|
|
198
|
+
SafeAreaProvider: "div",
|
|
199
|
+
KeyboardAvoidingView: "div",
|
|
200
|
+
Modal: "div",
|
|
201
|
+
ActivityIndicator: "span",
|
|
202
|
+
};
|
|
203
|
+
if (!tags[name]) {
|
|
204
|
+
gap("Unsupported native component: " + String(name));
|
|
205
|
+
return { tag: "div", props: { "data-iam-native-unobserved": name } };
|
|
206
|
+
}
|
|
207
|
+
const style = {
|
|
208
|
+
...(name !== "Text" &&
|
|
209
|
+
!["Image", "TextInput", "Button", "ActivityIndicator"].includes(name)
|
|
210
|
+
? {
|
|
211
|
+
display: "flex",
|
|
212
|
+
flexDirection: "column",
|
|
213
|
+
alignItems: "stretch",
|
|
214
|
+
flexShrink: 0,
|
|
215
|
+
}
|
|
216
|
+
: {}),
|
|
217
|
+
boxSizing: "border-box",
|
|
218
|
+
...projectStyle(props.style, gap),
|
|
219
|
+
};
|
|
220
|
+
const attributes = {
|
|
221
|
+
style,
|
|
222
|
+
"data-iam-native-component": name,
|
|
223
|
+
"data-iam-native-platform": platform,
|
|
224
|
+
...(typeof props.testID === "string"
|
|
225
|
+
? { "data-testid": props.testID }
|
|
226
|
+
: {}),
|
|
227
|
+
...(typeof props.nativeID === "string" ? { id: props.nativeID } : {}),
|
|
228
|
+
...(typeof props.accessibilityLabel === "string"
|
|
229
|
+
? { "aria-label": props.accessibilityLabel }
|
|
230
|
+
: {}),
|
|
231
|
+
};
|
|
232
|
+
if (tags[name] === "button")
|
|
233
|
+
Object.assign(attributes, {
|
|
234
|
+
type: "button",
|
|
235
|
+
disabled: props.disabled === true,
|
|
236
|
+
style: {
|
|
237
|
+
font: "inherit",
|
|
238
|
+
padding: 0,
|
|
239
|
+
borderWidth: 0,
|
|
240
|
+
backgroundColor: "transparent",
|
|
241
|
+
...style,
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
if (name === "Text") attributes.style = { whiteSpace: "pre-wrap", ...style };
|
|
245
|
+
const sensitive =
|
|
246
|
+
name === "TextInput" &&
|
|
247
|
+
props.secureTextEntry !== undefined &&
|
|
248
|
+
props.secureTextEntry !== false;
|
|
249
|
+
if (name === "TextInput")
|
|
250
|
+
Object.assign(attributes, {
|
|
251
|
+
type: sensitive ? "password" : "text",
|
|
252
|
+
readOnly: props.editable === false,
|
|
253
|
+
placeholder: props.placeholder,
|
|
254
|
+
value: props.value,
|
|
255
|
+
defaultValue: props.defaultValue,
|
|
256
|
+
});
|
|
257
|
+
if (name === "Image") {
|
|
258
|
+
attributes.src =
|
|
259
|
+
typeof props.source?.uri === "string"
|
|
260
|
+
? props.source.uri
|
|
261
|
+
: typeof props.source === "string"
|
|
262
|
+
? props.source
|
|
263
|
+
: undefined;
|
|
264
|
+
if (
|
|
265
|
+
props.resizeMode &&
|
|
266
|
+
["contain", "cover", "stretch", "center"].includes(props.resizeMode)
|
|
267
|
+
)
|
|
268
|
+
style.objectFit = {
|
|
269
|
+
contain: "contain",
|
|
270
|
+
cover: "cover",
|
|
271
|
+
stretch: "fill",
|
|
272
|
+
center: "none",
|
|
273
|
+
}[props.resizeMode];
|
|
274
|
+
attributes.alt =
|
|
275
|
+
typeof props.accessibilityLabel === "string"
|
|
276
|
+
? props.accessibilityLabel
|
|
277
|
+
: "";
|
|
278
|
+
}
|
|
279
|
+
if (name === "ScrollView") {
|
|
280
|
+
style.overflow = "auto";
|
|
281
|
+
if (props.horizontal === true) style.flexDirection = "row";
|
|
282
|
+
if (props.contentContainerStyle)
|
|
283
|
+
gap("ScrollView contentContainerStyle needs a native layout measurement");
|
|
284
|
+
}
|
|
285
|
+
if (name === "Modal") {
|
|
286
|
+
attributes.hidden = props.visible === false;
|
|
287
|
+
attributes.role = "dialog";
|
|
288
|
+
gap("Modal focus, stacking and native transitions are unobserved");
|
|
289
|
+
}
|
|
290
|
+
if (safeArea || name === "SafeAreaView" || name === "KeyboardAvoidingView")
|
|
291
|
+
gap("Safe-area and keyboard insets are unobserved");
|
|
292
|
+
return {
|
|
293
|
+
tag: tags[name],
|
|
294
|
+
props: attributes,
|
|
295
|
+
sensitive,
|
|
296
|
+
...(name === "Button" && typeof props.title === "string"
|
|
297
|
+
? { text: props.title }
|
|
298
|
+
: {}),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { compositionIdentity, flattenComposition } from "./render-index.mjs";
|
|
3
|
+
|
|
4
|
+
const extensions = ["js", "jsx", "ts", "tsx"];
|
|
5
|
+
|
|
6
|
+
/** Metro's default source extensions; explicit extensions remain explicit. No config is executed. */
|
|
7
|
+
export function nativeModuleCandidates(stem, platform) {
|
|
8
|
+
if (/\.[cm]?[jt]sx?$/.test(stem)) return [stem];
|
|
9
|
+
const files = (base) =>
|
|
10
|
+
extensions.flatMap((ext) => [
|
|
11
|
+
`${base}.${platform}.${ext}`,
|
|
12
|
+
`${base}.native.${ext}`,
|
|
13
|
+
`${base}.${ext}`,
|
|
14
|
+
]);
|
|
15
|
+
return [...files(stem), ...files(stem + "/index")];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** File-based Expo pages and an explicitly selected single-root RN screen. */
|
|
19
|
+
export function reactNativeRoutes({
|
|
20
|
+
modules,
|
|
21
|
+
platform,
|
|
22
|
+
entry,
|
|
23
|
+
routerRoot,
|
|
24
|
+
binding,
|
|
25
|
+
sourceLocation,
|
|
26
|
+
gap,
|
|
27
|
+
}) {
|
|
28
|
+
const files = [...modules.keys()];
|
|
29
|
+
const selected = (stem) =>
|
|
30
|
+
nativeModuleCandidates(stem, platform).find((file) => modules.has(file));
|
|
31
|
+
const routeSources = [],
|
|
32
|
+
entries = [],
|
|
33
|
+
unsupported = [];
|
|
34
|
+
const add = (file, route, layers = []) => {
|
|
35
|
+
const module = modules.get(file),
|
|
36
|
+
ref = binding(module, module.defaultName);
|
|
37
|
+
const source = sourceLocation(
|
|
38
|
+
module,
|
|
39
|
+
module.source,
|
|
40
|
+
module.defaultName ?? "$uncovered-route",
|
|
41
|
+
);
|
|
42
|
+
const composition = { lane: ".", file, layers };
|
|
43
|
+
entries.push({
|
|
44
|
+
route,
|
|
45
|
+
sourceFile: file,
|
|
46
|
+
routeSource: source,
|
|
47
|
+
composition,
|
|
48
|
+
compositionKey: compositionIdentity(flattenComposition(composition)),
|
|
49
|
+
entryMode: "hard",
|
|
50
|
+
gaps: [],
|
|
51
|
+
});
|
|
52
|
+
routeSources.push({
|
|
53
|
+
path: route,
|
|
54
|
+
source,
|
|
55
|
+
componentFile: ref?.module.file ?? file,
|
|
56
|
+
componentSymbol: ref?.name ?? "$uncovered-route",
|
|
57
|
+
});
|
|
58
|
+
};
|
|
59
|
+
if (entry !== undefined) {
|
|
60
|
+
if (routerRoot !== undefined)
|
|
61
|
+
throw Error("Use an RN entry or an Expo routerRoot, not both");
|
|
62
|
+
if (
|
|
63
|
+
typeof entry !== "string" ||
|
|
64
|
+
!entry ||
|
|
65
|
+
entry.startsWith("/") ||
|
|
66
|
+
/[\\?#:]/.test(entry) ||
|
|
67
|
+
entry.split("/").some((part) => !part || part === "." || part === "..")
|
|
68
|
+
)
|
|
69
|
+
throw Error("RN entry must be a source path inside the scanned root");
|
|
70
|
+
const file = selected(entry.replace(/\.[cm]?[jt]sx?$/, ""));
|
|
71
|
+
if (!file) throw Error("RN entry is not a local source module");
|
|
72
|
+
add(file, "/");
|
|
73
|
+
gap(
|
|
74
|
+
"RN_SINGLE_SCREEN_ENTRY",
|
|
75
|
+
file,
|
|
76
|
+
"Only the selected screen is projected; React Navigation graphs and native back stacks are not inferred from component names",
|
|
77
|
+
);
|
|
78
|
+
return {
|
|
79
|
+
entries,
|
|
80
|
+
routeSources,
|
|
81
|
+
pageFiles: [file],
|
|
82
|
+
unsupported,
|
|
83
|
+
framework: "react-native",
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
if (
|
|
87
|
+
routerRoot !== undefined &&
|
|
88
|
+
(typeof routerRoot !== "string" ||
|
|
89
|
+
!routerRoot ||
|
|
90
|
+
routerRoot.startsWith("/") ||
|
|
91
|
+
/[\\?#:]/.test(routerRoot) ||
|
|
92
|
+
routerRoot
|
|
93
|
+
.split("/")
|
|
94
|
+
.some(
|
|
95
|
+
(part) =>
|
|
96
|
+
!part || part === "." || part === ".." || part.startsWith("."),
|
|
97
|
+
))
|
|
98
|
+
)
|
|
99
|
+
throw Error("Expo routerRoot must be a directory inside the scanned root");
|
|
100
|
+
const roots = routerRoot
|
|
101
|
+
? [routerRoot]
|
|
102
|
+
: ["app", "src/app"].filter((root) =>
|
|
103
|
+
files.some((file) => file.startsWith(root + "/")),
|
|
104
|
+
);
|
|
105
|
+
if (roots.length !== 1)
|
|
106
|
+
throw Error(
|
|
107
|
+
"Select one Expo routerRoot, or set entry for a single-root RN screen",
|
|
108
|
+
);
|
|
109
|
+
const root = roots[0],
|
|
110
|
+
prefix = root + "/";
|
|
111
|
+
const bases = files.filter(
|
|
112
|
+
(file) =>
|
|
113
|
+
file.startsWith(prefix) &&
|
|
114
|
+
/\.[jt]sx?$/.test(file) &&
|
|
115
|
+
!/\.(?:ios|android|native|web)\.[jt]sx?$/.test(file),
|
|
116
|
+
);
|
|
117
|
+
const seen = new Set();
|
|
118
|
+
for (const base of bases) {
|
|
119
|
+
const stem = base.replace(/\.[jt]sx?$/, "");
|
|
120
|
+
if (seen.has(stem)) continue;
|
|
121
|
+
seen.add(stem);
|
|
122
|
+
const file = selected(stem),
|
|
123
|
+
relative = stem.slice(prefix.length),
|
|
124
|
+
parts = relative.split("/");
|
|
125
|
+
if (parts.some((part) => part.startsWith("+") || part === "_layout"))
|
|
126
|
+
continue;
|
|
127
|
+
if (
|
|
128
|
+
parts.some(
|
|
129
|
+
(part) =>
|
|
130
|
+
/^\([^()]+,[^()]+\)$/.test(part) ||
|
|
131
|
+
(/[()]/.test(part) && !/^\([^()]+\)$/.test(part)),
|
|
132
|
+
)
|
|
133
|
+
) {
|
|
134
|
+
unsupported.push({
|
|
135
|
+
file,
|
|
136
|
+
reason:
|
|
137
|
+
"Shared route groups and ambiguous native route compositions need an explicit adapter",
|
|
138
|
+
});
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const segments = parts.filter((part) => !/^\([^()]+\)$/.test(part));
|
|
142
|
+
if (segments.at(-1) === "index") segments.pop();
|
|
143
|
+
const route = "/" + segments.join("/");
|
|
144
|
+
if (entries.some((item) => item.route === route))
|
|
145
|
+
throw Error("Ambiguous Expo route after removing groups: " + route);
|
|
146
|
+
const layers = [];
|
|
147
|
+
let directory = path.posix.dirname(stem);
|
|
148
|
+
while (directory === root || directory.startsWith(prefix)) {
|
|
149
|
+
// Expo requires a non-platform route/layout to establish universal route identity.
|
|
150
|
+
if (
|
|
151
|
+
extensions.some((ext) => modules.has(`${directory}/_layout.${ext}`))
|
|
152
|
+
) {
|
|
153
|
+
const layout = selected(directory + "/_layout");
|
|
154
|
+
layers.push({ file: layout, slots: [] });
|
|
155
|
+
}
|
|
156
|
+
if (directory === root) break;
|
|
157
|
+
directory = path.posix.dirname(directory);
|
|
158
|
+
}
|
|
159
|
+
add(file, route, layers);
|
|
160
|
+
}
|
|
161
|
+
for (const file of files.filter(
|
|
162
|
+
(file) =>
|
|
163
|
+
file.startsWith(prefix) &&
|
|
164
|
+
/\.(?:ios|android|native|web)\.[jt]sx?$/.test(file),
|
|
165
|
+
)) {
|
|
166
|
+
const stem = file.replace(/\.(?:ios|android|native|web)\.[jt]sx?$/, "");
|
|
167
|
+
if (!extensions.some((ext) => modules.has(stem + "." + ext)))
|
|
168
|
+
gap(
|
|
169
|
+
"EXPO_PLATFORM_ROUTE_WITHOUT_BASE",
|
|
170
|
+
file,
|
|
171
|
+
"Platform-only files do not establish universal Expo routes; add a non-platform route module",
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
if (!entries.length) throw Error("No supported Expo routes under " + root);
|
|
175
|
+
return {
|
|
176
|
+
entries,
|
|
177
|
+
routeSources,
|
|
178
|
+
pageFiles: entries.map((item) => item.sourceFile),
|
|
179
|
+
unsupported,
|
|
180
|
+
framework: "expo-router",
|
|
181
|
+
};
|
|
182
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { BuildLogger } from "./index.js";
|
|
2
|
+
|
|
3
|
+
export type ReactNativeIamOptions = {
|
|
4
|
+
root: string;
|
|
5
|
+
/** Generate one artifact per native platform, including its platform-specific source. */
|
|
6
|
+
platform: "ios" | "android";
|
|
7
|
+
/** Single exported RN screen, relative to root. Omit for Expo Router file routes. */
|
|
8
|
+
entry?: string;
|
|
9
|
+
/** Expo route directory; defaults to the sole app/ or src/app/ directory. */
|
|
10
|
+
routerRoot?: string;
|
|
11
|
+
name?: string;
|
|
12
|
+
strict?: boolean;
|
|
13
|
+
logger?: BuildLogger;
|
|
14
|
+
};
|
|
15
|
+
export type PreparedReactNativeIam = {
|
|
16
|
+
releaseId: string;
|
|
17
|
+
descriptorPath: string;
|
|
18
|
+
platform: "ios" | "android";
|
|
19
|
+
};
|
|
20
|
+
export declare function prepareReactNativeIam(
|
|
21
|
+
options: ReactNativeIamOptions,
|
|
22
|
+
): Promise<PreparedReactNativeIam | null>;
|
|
23
|
+
export declare function finishReactNativeIam(
|
|
24
|
+
options: ReactNativeIamOptions & {
|
|
25
|
+
releaseId: string;
|
|
26
|
+
/** Relative asset staging directory inside root. Package the descriptor with native release assets. */
|
|
27
|
+
bundleDirectory: string;
|
|
28
|
+
upload?: {
|
|
29
|
+
/** Disabled by default; explicitly enable only for an authorized upload. */
|
|
30
|
+
upload?: "production" | "always" | false;
|
|
31
|
+
apiKey?: string;
|
|
32
|
+
baseUrl?: string;
|
|
33
|
+
/** A fixed platform client; defaults to rn-ios / rn-android. */
|
|
34
|
+
client?: string;
|
|
35
|
+
fetch?: typeof globalThis.fetch;
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
): Promise<
|
|
39
|
+
| (PreparedReactNativeIam & {
|
|
40
|
+
file: string;
|
|
41
|
+
digest: string;
|
|
42
|
+
bytes: number;
|
|
43
|
+
pages: number;
|
|
44
|
+
uploaded: Record<string, unknown> | null;
|
|
45
|
+
})
|
|
46
|
+
| null
|
|
47
|
+
>;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { prepareBuildRelease, finishBuildRelease } from "./build.mjs";
|
|
2
|
+
import { releasePath } from "./release-metadata.mjs";
|
|
3
|
+
|
|
4
|
+
function nativeOptions(options) {
|
|
5
|
+
if (!["ios", "android"].includes(options?.platform))
|
|
6
|
+
throw new Error("React Native IAM requires platform ios or android");
|
|
7
|
+
return { ...options, automaticAnchors: false };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Source scan before bundling. Does not execute Expo, Metro, Babel or App modules. */
|
|
11
|
+
export async function prepareReactNativeIam(options) {
|
|
12
|
+
const result = await prepareBuildRelease(nativeOptions(options));
|
|
13
|
+
return (
|
|
14
|
+
result && {
|
|
15
|
+
...result,
|
|
16
|
+
descriptorPath: releasePath(result.releaseId),
|
|
17
|
+
platform: options.platform,
|
|
18
|
+
}
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** After JS bundling, before packaging native assets: verify source identity and write the descriptor. */
|
|
23
|
+
export async function finishReactNativeIam(options) {
|
|
24
|
+
const { bundleDirectory, ...source } = nativeOptions(options);
|
|
25
|
+
if (typeof bundleDirectory !== "string" || !bundleDirectory)
|
|
26
|
+
throw new Error(
|
|
27
|
+
"React Native IAM requires an in-root bundleDirectory for release assets",
|
|
28
|
+
);
|
|
29
|
+
const result = await finishBuildRelease({
|
|
30
|
+
...source,
|
|
31
|
+
publicDirectory: bundleDirectory,
|
|
32
|
+
upload: {
|
|
33
|
+
client: `rn-${options.platform}`,
|
|
34
|
+
upload: false,
|
|
35
|
+
...options.upload,
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
return (
|
|
39
|
+
result && {
|
|
40
|
+
...result,
|
|
41
|
+
descriptorPath: releasePath(result.releaseId),
|
|
42
|
+
platform: options.platform,
|
|
43
|
+
}
|
|
44
|
+
);
|
|
45
|
+
}
|