english-lang 0.1.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 +119 -0
- package/dist/cli/engc.js +198 -0
- package/dist/index.js +20 -0
- package/dist/src/ast/ASTNodes.js +6 -0
- package/dist/src/codegen/ReactNativeBackend.js +657 -0
- package/dist/src/lexer/Lexer.js +167 -0
- package/dist/src/parser/Parser.js +1165 -0
- package/package.json +23 -0
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// ============================================================
|
|
3
|
+
// ReactNativeBackend.ts — Emits React Native TypeScript from AST
|
|
4
|
+
//
|
|
5
|
+
// Generates clean, standard TypeScript that a senior developer
|
|
6
|
+
// would write by hand. Same .eng input → same output every time.
|
|
7
|
+
// ============================================================
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.ReactNativeBackend = void 0;
|
|
10
|
+
// ─────────────────────────────────────────────────────────────
|
|
11
|
+
class ReactNativeBackend {
|
|
12
|
+
constructor() {
|
|
13
|
+
this.out = [];
|
|
14
|
+
this.depth = 0;
|
|
15
|
+
}
|
|
16
|
+
// ── Public entry point ────────────────────────────────────
|
|
17
|
+
emit(program, sourceFile) {
|
|
18
|
+
this.out = [];
|
|
19
|
+
this.depth = 0;
|
|
20
|
+
for (const screen of program.screens) {
|
|
21
|
+
this.emitScreen(screen, sourceFile, program.types);
|
|
22
|
+
this.line('');
|
|
23
|
+
}
|
|
24
|
+
for (const comp of program.components) {
|
|
25
|
+
this.emitComponent(comp, sourceFile);
|
|
26
|
+
this.line('');
|
|
27
|
+
}
|
|
28
|
+
return this.out.join('\n');
|
|
29
|
+
}
|
|
30
|
+
// ── Type declarations (noun) ──────────────────────────────
|
|
31
|
+
emitTypeDecl(type) {
|
|
32
|
+
this.line(`export interface ${type.name} {`);
|
|
33
|
+
this.depth++;
|
|
34
|
+
for (const field of type.fields) {
|
|
35
|
+
const tsType = this.tsTypeFromAnnotation(field.type);
|
|
36
|
+
this.line(`${field.name}: ${tsType};`);
|
|
37
|
+
}
|
|
38
|
+
this.depth--;
|
|
39
|
+
this.line('}');
|
|
40
|
+
this.line('');
|
|
41
|
+
}
|
|
42
|
+
// ── Screen ────────────────────────────────────────────────
|
|
43
|
+
emitScreen(screen, sourceFile, types = []) {
|
|
44
|
+
const stateVarTypes = this.inferStateTypes(screen.state);
|
|
45
|
+
const buttonHandlers = this.buildButtonHandlerMap(screen.events);
|
|
46
|
+
const needsEffect = screen.events.some(e => e.trigger.kind === 'ScreenOpens' || e.trigger.kind === 'ScreenCloses');
|
|
47
|
+
const needsState = screen.state.length > 0;
|
|
48
|
+
// Header
|
|
49
|
+
this.line(`// Generated from ${sourceFile}`);
|
|
50
|
+
this.line(`// Do not edit — modify the .eng source instead.`);
|
|
51
|
+
this.line('');
|
|
52
|
+
// Imports
|
|
53
|
+
const hooks = [];
|
|
54
|
+
if (needsState)
|
|
55
|
+
hooks.push('useState');
|
|
56
|
+
if (needsEffect)
|
|
57
|
+
hooks.push('useEffect');
|
|
58
|
+
this.line(`import React${hooks.length ? `, { ${hooks.join(', ')} }` : ''} from 'react';`);
|
|
59
|
+
const rnImports = this.collectRNImports(screen.layout, screen.events);
|
|
60
|
+
if (rnImports.length) {
|
|
61
|
+
this.line(`import { ${rnImports.join(', ')} } from 'react-native';`);
|
|
62
|
+
}
|
|
63
|
+
this.line('');
|
|
64
|
+
// Type interfaces (noun declarations)
|
|
65
|
+
for (const type of types) {
|
|
66
|
+
this.emitTypeDecl(type);
|
|
67
|
+
}
|
|
68
|
+
// Component function
|
|
69
|
+
this.line(`export function ${screen.name}({ navigation }: any) {`);
|
|
70
|
+
this.depth++;
|
|
71
|
+
// useState hooks
|
|
72
|
+
for (const field of screen.state) {
|
|
73
|
+
const tsType = this.tsType(field, stateVarTypes);
|
|
74
|
+
const initVal = this.emitExpr(field.defaultValue);
|
|
75
|
+
const setter = setterName(field.name);
|
|
76
|
+
this.line(`const [${field.name}, ${setter}] = useState<${tsType}>(${initVal});`);
|
|
77
|
+
}
|
|
78
|
+
if (screen.state.length)
|
|
79
|
+
this.line('');
|
|
80
|
+
// useEffect for screen opens
|
|
81
|
+
const screenOpensHandler = screen.events.find(e => e.trigger.kind === 'ScreenOpens');
|
|
82
|
+
if (screenOpensHandler) {
|
|
83
|
+
this.line('useEffect(() => {');
|
|
84
|
+
this.depth++;
|
|
85
|
+
for (const stmt of screenOpensHandler.body) {
|
|
86
|
+
this.emitStatement(stmt, stateVarTypes);
|
|
87
|
+
}
|
|
88
|
+
this.depth--;
|
|
89
|
+
this.line('}, []);');
|
|
90
|
+
this.line('');
|
|
91
|
+
}
|
|
92
|
+
// Button handler functions
|
|
93
|
+
for (const event of screen.events) {
|
|
94
|
+
if (event.trigger.kind !== 'ButtonPressed')
|
|
95
|
+
continue;
|
|
96
|
+
const fnName = buttonHandlers.get(event.trigger.label);
|
|
97
|
+
this.line(`const ${fnName} = () => {`);
|
|
98
|
+
this.depth++;
|
|
99
|
+
for (const stmt of event.body) {
|
|
100
|
+
this.emitStatement(stmt, stateVarTypes);
|
|
101
|
+
}
|
|
102
|
+
this.depth--;
|
|
103
|
+
this.line('};');
|
|
104
|
+
this.line('');
|
|
105
|
+
}
|
|
106
|
+
// Action functions
|
|
107
|
+
for (const action of screen.actions) {
|
|
108
|
+
this.emitAction(action, stateVarTypes);
|
|
109
|
+
}
|
|
110
|
+
// JSX return
|
|
111
|
+
this.line('return (');
|
|
112
|
+
this.depth++;
|
|
113
|
+
this.line('<View style={styles.container}>');
|
|
114
|
+
this.depth++;
|
|
115
|
+
if (screen.layout.length === 0) {
|
|
116
|
+
this.line('{/* empty screen */}');
|
|
117
|
+
}
|
|
118
|
+
else if (screen.layout.length === 1) {
|
|
119
|
+
this.emitLayoutNode(screen.layout[0], buttonHandlers, stateVarTypes);
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
this.line('<View>');
|
|
123
|
+
this.depth++;
|
|
124
|
+
for (const node of screen.layout) {
|
|
125
|
+
this.emitLayoutNode(node, buttonHandlers, stateVarTypes);
|
|
126
|
+
}
|
|
127
|
+
this.depth--;
|
|
128
|
+
this.line('</View>');
|
|
129
|
+
}
|
|
130
|
+
this.depth--;
|
|
131
|
+
this.line('</View>');
|
|
132
|
+
this.depth--;
|
|
133
|
+
this.line(');');
|
|
134
|
+
this.depth--;
|
|
135
|
+
this.line('}');
|
|
136
|
+
this.line('');
|
|
137
|
+
// StyleSheet
|
|
138
|
+
this.emitStyleSheet();
|
|
139
|
+
}
|
|
140
|
+
// ── Component ─────────────────────────────────────────────
|
|
141
|
+
emitComponent(comp, sourceFile) {
|
|
142
|
+
const paramList = comp.params.map(p => `${p.name}: ${this.tsTypeFromAnnotation(p.type)}`).join(', ');
|
|
143
|
+
const propsType = comp.params.length ? `{ ${paramList} }` : 'Record<string, never>';
|
|
144
|
+
const buttonHandlers = this.buildButtonHandlerMap(comp.events);
|
|
145
|
+
const stateVarTypes = new Map();
|
|
146
|
+
this.line(`export function ${comp.name}({ ${comp.params.map(p => p.name).join(', ')} }: ${propsType}) {`);
|
|
147
|
+
this.depth++;
|
|
148
|
+
this.line('return (');
|
|
149
|
+
this.depth++;
|
|
150
|
+
if (comp.layout.length === 1) {
|
|
151
|
+
this.emitLayoutNode(comp.layout[0], buttonHandlers, stateVarTypes);
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
this.line('<View>');
|
|
155
|
+
this.depth++;
|
|
156
|
+
for (const node of comp.layout) {
|
|
157
|
+
this.emitLayoutNode(node, buttonHandlers, stateVarTypes);
|
|
158
|
+
}
|
|
159
|
+
this.depth--;
|
|
160
|
+
this.line('</View>');
|
|
161
|
+
}
|
|
162
|
+
this.depth--;
|
|
163
|
+
this.line(');');
|
|
164
|
+
this.depth--;
|
|
165
|
+
this.line('}');
|
|
166
|
+
}
|
|
167
|
+
// ── Action functions ──────────────────────────────────────
|
|
168
|
+
emitAction(action, stateVarTypes) {
|
|
169
|
+
const fnName = camelCase(action.verbPhrase);
|
|
170
|
+
const params = action.params.map(p => `${p.name}: ${this.tsTypeFromAnnotation(p.type)}`).join(', ');
|
|
171
|
+
const retType = action.returnType ? `: ${this.tsTypeFromAnnotation(action.returnType)}` : '';
|
|
172
|
+
this.line(`const ${fnName} = async (${params})${retType} => {`);
|
|
173
|
+
this.depth++;
|
|
174
|
+
for (const stmt of action.body) {
|
|
175
|
+
this.emitStatement(stmt, stateVarTypes);
|
|
176
|
+
}
|
|
177
|
+
this.depth--;
|
|
178
|
+
this.line('};');
|
|
179
|
+
this.line('');
|
|
180
|
+
}
|
|
181
|
+
// ── Statements ────────────────────────────────────────────
|
|
182
|
+
emitStatement(stmt, stateVars) {
|
|
183
|
+
switch (stmt.kind) {
|
|
184
|
+
case 'SetStatement': {
|
|
185
|
+
const setter = stateVars.has(stmt.name) ? `${setterName(stmt.name)}` : stmt.name;
|
|
186
|
+
const val = this.emitExpr(stmt.value);
|
|
187
|
+
if (stateVars.has(stmt.name)) {
|
|
188
|
+
this.line(`${setter}(${val});`);
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
this.line(`${stmt.name} = ${val};`);
|
|
192
|
+
}
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
case 'FetchStatement': {
|
|
196
|
+
this.line(`try {`);
|
|
197
|
+
this.depth++;
|
|
198
|
+
const url = this.emitExpr(stmt.url);
|
|
199
|
+
if (stmt.method === 'GET') {
|
|
200
|
+
this.line(`const _res = await fetch(${url});`);
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
this.line(`const _res = await fetch(${url}, { method: '${stmt.method}' });`);
|
|
204
|
+
}
|
|
205
|
+
this.line(`const ${stmt.resultName} = await _res.json();`);
|
|
206
|
+
if (stmt.onSuccess) {
|
|
207
|
+
this.line(`const ${stmt.onSuccess.param} = ${stmt.resultName};`);
|
|
208
|
+
for (const s of stmt.onSuccess.body) {
|
|
209
|
+
this.emitStatement(s, stateVars);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
this.depth--;
|
|
213
|
+
if (stmt.onFailure) {
|
|
214
|
+
this.line(`} catch (${stmt.onFailure.param}) {`);
|
|
215
|
+
this.depth++;
|
|
216
|
+
for (const s of stmt.onFailure.body) {
|
|
217
|
+
this.emitStatement(s, stateVars);
|
|
218
|
+
}
|
|
219
|
+
this.depth--;
|
|
220
|
+
}
|
|
221
|
+
this.line(`}`);
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
case 'GoToStatement': {
|
|
225
|
+
const passing = stmt.passing ? `, { item: ${this.emitExpr(stmt.passing)} }` : '';
|
|
226
|
+
this.line(`navigation.navigate('${stmt.screen}'${passing});`);
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
case 'PresentStatement':
|
|
230
|
+
this.line(`navigation.navigate('${stmt.screen}');`);
|
|
231
|
+
break;
|
|
232
|
+
case 'GoBackStatement':
|
|
233
|
+
this.line(`navigation.goBack();`);
|
|
234
|
+
break;
|
|
235
|
+
case 'DismissStatement':
|
|
236
|
+
this.line(`navigation.goBack();`);
|
|
237
|
+
break;
|
|
238
|
+
case 'ReturnStatement':
|
|
239
|
+
this.line(`return ${this.emitExpr(stmt.value)};`);
|
|
240
|
+
break;
|
|
241
|
+
case 'CallStatement': {
|
|
242
|
+
const fnName = camelCase(stmt.verbPhrase);
|
|
243
|
+
const args = stmt.args.map(a => this.emitExpr(a)).join(', ');
|
|
244
|
+
this.line(`${fnName}(${args});`);
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
case 'IfStatement': {
|
|
248
|
+
const cond = this.emitConditionJS(stmt.condition);
|
|
249
|
+
this.line(`if (${cond}) {`);
|
|
250
|
+
this.depth++;
|
|
251
|
+
for (const s of stmt.thenBody)
|
|
252
|
+
this.emitStatement(s, stateVars);
|
|
253
|
+
this.depth--;
|
|
254
|
+
for (const branch of stmt.otherwiseIfBranches) {
|
|
255
|
+
this.line(`} else if (${this.emitConditionJS(branch.condition)}) {`);
|
|
256
|
+
this.depth++;
|
|
257
|
+
for (const s of branch.body)
|
|
258
|
+
this.emitStatement(s, stateVars);
|
|
259
|
+
this.depth--;
|
|
260
|
+
}
|
|
261
|
+
if (stmt.elseBody) {
|
|
262
|
+
this.line(`} else {`);
|
|
263
|
+
this.depth++;
|
|
264
|
+
for (const s of stmt.elseBody)
|
|
265
|
+
this.emitStatement(s, stateVars);
|
|
266
|
+
this.depth--;
|
|
267
|
+
}
|
|
268
|
+
this.line(`}`);
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
// ── Layout nodes → JSX ───────────────────────────────────
|
|
274
|
+
emitLayoutNode(node, handlers, stateVars) {
|
|
275
|
+
switch (node.kind) {
|
|
276
|
+
case 'VerticalStack': {
|
|
277
|
+
const styleAttr = this.stackStyleAttr('column', node.spacing, node.padding);
|
|
278
|
+
this.line(`<View${styleAttr}>`);
|
|
279
|
+
this.depth++;
|
|
280
|
+
for (const child of node.children)
|
|
281
|
+
this.emitLayoutNode(child, handlers, stateVars);
|
|
282
|
+
this.depth--;
|
|
283
|
+
this.line(`</View>`);
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
case 'HorizontalStack': {
|
|
287
|
+
const styleAttr = this.stackStyleAttr('row', node.spacing, null);
|
|
288
|
+
this.line(`<View${styleAttr}>`);
|
|
289
|
+
this.depth++;
|
|
290
|
+
for (const child of node.children)
|
|
291
|
+
this.emitLayoutNode(child, handlers, stateVars);
|
|
292
|
+
this.depth--;
|
|
293
|
+
this.line(`</View>`);
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
case 'ScrollView': {
|
|
297
|
+
const horizontal = node.direction === 'horizontal' ? ' horizontal' : '';
|
|
298
|
+
this.line(`<ScrollView${horizontal}>`);
|
|
299
|
+
this.depth++;
|
|
300
|
+
for (const child of node.children)
|
|
301
|
+
this.emitLayoutNode(child, handlers, stateVars);
|
|
302
|
+
this.depth--;
|
|
303
|
+
this.line(`</ScrollView>`);
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
case 'Grid': {
|
|
307
|
+
// Use FlatList with numColumns for grids
|
|
308
|
+
this.line(`{/* grid ${node.columns} columns */}`);
|
|
309
|
+
this.line(`<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: ${node.spacing ?? 0} }}>`);
|
|
310
|
+
this.depth++;
|
|
311
|
+
for (const child of node.children)
|
|
312
|
+
this.emitLayoutNode(child, handlers, stateVars);
|
|
313
|
+
this.depth--;
|
|
314
|
+
this.line(`</View>`);
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
case 'Card': {
|
|
318
|
+
const radius = node.cornerRadius ?? 8;
|
|
319
|
+
this.line(`<View style={[styles.card, { borderRadius: ${radius} }]}>`);
|
|
320
|
+
this.depth++;
|
|
321
|
+
for (const child of node.children)
|
|
322
|
+
this.emitLayoutNode(child, handlers, stateVars);
|
|
323
|
+
this.depth--;
|
|
324
|
+
this.line(`</View>`);
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
case 'Text': {
|
|
328
|
+
const styleRef = this.textStyleRef(node.style, node.color);
|
|
329
|
+
const content = this.exprToJSX(node.expr, stateVars);
|
|
330
|
+
this.line(`<Text${styleRef}>${content}</Text>`);
|
|
331
|
+
break;
|
|
332
|
+
}
|
|
333
|
+
case 'Button': {
|
|
334
|
+
const handler = handlers.get(node.label);
|
|
335
|
+
const onPress = handler ? ` onPress={${handler}}` : ' onPress={() => {}}';
|
|
336
|
+
this.line(`<TouchableOpacity style={styles.button}${onPress}>`);
|
|
337
|
+
this.depth++;
|
|
338
|
+
this.line(`<Text style={styles.buttonText}>${node.label}</Text>`);
|
|
339
|
+
this.depth--;
|
|
340
|
+
this.line(`</TouchableOpacity>`);
|
|
341
|
+
break;
|
|
342
|
+
}
|
|
343
|
+
case 'TextField': {
|
|
344
|
+
const setter = setterName(node.boundTo);
|
|
345
|
+
const placeholder = node.placeholder ? ` placeholder="${node.placeholder}"` : '';
|
|
346
|
+
this.line(`<TextInput`);
|
|
347
|
+
this.depth++;
|
|
348
|
+
this.line(`style={styles.input}`);
|
|
349
|
+
this.line(`value={${node.boundTo}}`);
|
|
350
|
+
this.line(`onChangeText={${setter}}`);
|
|
351
|
+
if (node.placeholder)
|
|
352
|
+
this.line(`placeholder="${node.placeholder}"`);
|
|
353
|
+
this.depth--;
|
|
354
|
+
this.line(`/>`);
|
|
355
|
+
break;
|
|
356
|
+
}
|
|
357
|
+
case 'Image': {
|
|
358
|
+
const src = this.emitExpr(node.source);
|
|
359
|
+
const w = node.width ? node.width : 'undefined';
|
|
360
|
+
const h = node.height ? node.height : 'undefined';
|
|
361
|
+
this.line(`<Image source={{ uri: ${src} }} style={{ width: ${w}, height: ${h} }} />`);
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
364
|
+
case 'Icon':
|
|
365
|
+
this.line(`{/* Icon: ${node.name} */}`);
|
|
366
|
+
break;
|
|
367
|
+
case 'Spacer':
|
|
368
|
+
this.line(`<View style={{ flex: 1 }} />`);
|
|
369
|
+
break;
|
|
370
|
+
case 'Divider':
|
|
371
|
+
this.line(`<View style={styles.divider} />`);
|
|
372
|
+
break;
|
|
373
|
+
case 'LoadingSpinner': {
|
|
374
|
+
const centered = node.centered ? ` style={{ alignSelf: 'center' }}` : '';
|
|
375
|
+
this.line(`<ActivityIndicator${centered} />`);
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
case 'ForEach': {
|
|
379
|
+
const col = this.emitExpr(node.collection);
|
|
380
|
+
this.line(`{${col}.map((${node.item}, _idx) => (`);
|
|
381
|
+
this.depth++;
|
|
382
|
+
this.line(`<React.Fragment key={_idx}>`);
|
|
383
|
+
this.depth++;
|
|
384
|
+
for (const child of node.children)
|
|
385
|
+
this.emitLayoutNode(child, handlers, stateVars);
|
|
386
|
+
this.depth--;
|
|
387
|
+
this.line(`</React.Fragment>`);
|
|
388
|
+
this.depth--;
|
|
389
|
+
this.line(`))}`);
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
case 'LayoutIf': {
|
|
393
|
+
const cond = this.emitConditionJSX(node.condition);
|
|
394
|
+
if (node.otherwiseIfBranches.length === 0 && !node.elseBranch) {
|
|
395
|
+
// Simple && short-circuit
|
|
396
|
+
this.line(`{${cond} && (`);
|
|
397
|
+
this.depth++;
|
|
398
|
+
if (node.thenBranch.length === 1) {
|
|
399
|
+
this.emitLayoutNode(node.thenBranch[0], handlers, stateVars);
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
this.line(`<>`);
|
|
403
|
+
this.depth++;
|
|
404
|
+
for (const child of node.thenBranch)
|
|
405
|
+
this.emitLayoutNode(child, handlers, stateVars);
|
|
406
|
+
this.depth--;
|
|
407
|
+
this.line(`</>`);
|
|
408
|
+
}
|
|
409
|
+
this.depth--;
|
|
410
|
+
this.line(`)}`);
|
|
411
|
+
}
|
|
412
|
+
else {
|
|
413
|
+
// Ternary / nested ternary
|
|
414
|
+
this.line(`{${cond} ? (`);
|
|
415
|
+
this.depth++;
|
|
416
|
+
this.line(`<>`);
|
|
417
|
+
this.depth++;
|
|
418
|
+
for (const child of node.thenBranch)
|
|
419
|
+
this.emitLayoutNode(child, handlers, stateVars);
|
|
420
|
+
this.depth--;
|
|
421
|
+
this.line(`</>`);
|
|
422
|
+
this.depth--;
|
|
423
|
+
this.line(`) : (`);
|
|
424
|
+
this.depth++;
|
|
425
|
+
if (node.elseBranch) {
|
|
426
|
+
this.line(`<>`);
|
|
427
|
+
this.depth++;
|
|
428
|
+
for (const child of node.elseBranch)
|
|
429
|
+
this.emitLayoutNode(child, handlers, stateVars);
|
|
430
|
+
this.depth--;
|
|
431
|
+
this.line(`</>`);
|
|
432
|
+
}
|
|
433
|
+
else {
|
|
434
|
+
this.line(`null`);
|
|
435
|
+
}
|
|
436
|
+
this.depth--;
|
|
437
|
+
this.line(`)}`);
|
|
438
|
+
}
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
case 'ComponentCall': {
|
|
442
|
+
const props = node.args.map(a => `${a.name}={${this.emitExpr(a.value)}}`).join(' ');
|
|
443
|
+
this.line(`<${node.name}${props ? ' ' + props : ''} />`);
|
|
444
|
+
break;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
// ── Expressions → JS strings ──────────────────────────────
|
|
449
|
+
emitExpr(expr) {
|
|
450
|
+
switch (expr.kind) {
|
|
451
|
+
case 'StringLiteral': {
|
|
452
|
+
// Convert {variable} and {object's property} to template literal interpolation
|
|
453
|
+
const hasInterp = /{[\w]/.test(expr.value);
|
|
454
|
+
if (hasInterp) {
|
|
455
|
+
const interpolated = expr.value
|
|
456
|
+
.replace(/{(\w+)'s\s+(\w+)}/g, '${$1.$2}') // {product's name} → ${product.name}
|
|
457
|
+
.replace(/{([\w]+)}/g, '${$1}'); // {count} → ${count}
|
|
458
|
+
return '`' + interpolated + '`';
|
|
459
|
+
}
|
|
460
|
+
return `"${expr.value}"`;
|
|
461
|
+
}
|
|
462
|
+
case 'NumberLiteral': return String(expr.value);
|
|
463
|
+
case 'BooleanLiteral': return String(expr.value);
|
|
464
|
+
case 'NullLiteral': return 'null';
|
|
465
|
+
case 'EmptyListLiteral': return '[]';
|
|
466
|
+
case 'VariableRef': return expr.name;
|
|
467
|
+
case 'PropertyAccess': return `${expr.object}.${expr.property}`;
|
|
468
|
+
case 'BinaryExpr': return `(${this.emitExpr(expr.left)} ${expr.op} ${this.emitExpr(expr.right)})`;
|
|
469
|
+
case 'UnaryExpr': return `${expr.op}(${this.emitExpr(expr.operand)})`;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
// Expression inside JSX curly braces — may need String() for numbers
|
|
473
|
+
exprToJSX(expr, stateVars) {
|
|
474
|
+
if (expr.kind === 'StringLiteral') {
|
|
475
|
+
// Interpolated string: render as JSX expression with template literal
|
|
476
|
+
if (/{[\w]/.test(expr.value)) {
|
|
477
|
+
const interpolated = expr.value
|
|
478
|
+
.replace(/{(\w+)'s\s+(\w+)}/g, '${$1.$2}')
|
|
479
|
+
.replace(/{([\w]+)}/g, '${$1}');
|
|
480
|
+
return '{`' + interpolated + '`}';
|
|
481
|
+
}
|
|
482
|
+
return expr.value;
|
|
483
|
+
}
|
|
484
|
+
const js = this.emitExpr(expr);
|
|
485
|
+
if (expr.kind === 'VariableRef' && stateVars.get(expr.name) === 'number') {
|
|
486
|
+
return `{String(${js})}`;
|
|
487
|
+
}
|
|
488
|
+
return `{${js}}`;
|
|
489
|
+
}
|
|
490
|
+
// ── Conditions → JS boolean expressions ──────────────────
|
|
491
|
+
emitConditionJS(cond) {
|
|
492
|
+
switch (cond.kind) {
|
|
493
|
+
case 'Truthy': return this.emitExpr(cond.expr);
|
|
494
|
+
case 'Falsy': return `!${this.emitExpr(cond.expr)}`;
|
|
495
|
+
case 'Equals': return `${this.emitExpr(cond.left)} === ${this.emitExpr(cond.right)}`;
|
|
496
|
+
case 'NotEquals': return `${this.emitExpr(cond.left)} !== ${this.emitExpr(cond.right)}`;
|
|
497
|
+
case 'GreaterThan': return `${this.emitExpr(cond.left)} > ${this.emitExpr(cond.right)}`;
|
|
498
|
+
case 'LessThan': return `${this.emitExpr(cond.left)} < ${this.emitExpr(cond.right)}`;
|
|
499
|
+
case 'AtLeast': return `${this.emitExpr(cond.left)} >= ${this.emitExpr(cond.right)}`;
|
|
500
|
+
case 'AtMost': return `${this.emitExpr(cond.left)} <= ${this.emitExpr(cond.right)}`;
|
|
501
|
+
case 'HasItems': return `${this.emitExpr(cond.expr)}.length > 0`;
|
|
502
|
+
case 'IsEmpty': return `${this.emitExpr(cond.expr)}.length === 0`;
|
|
503
|
+
case 'Exists': return `${this.emitExpr(cond.expr)} != null`;
|
|
504
|
+
case 'Contains': return `${this.emitExpr(cond.haystack)}.includes(${this.emitExpr(cond.needle)})`;
|
|
505
|
+
case 'And': return `(${this.emitConditionJS(cond.left)}) && (${this.emitConditionJS(cond.right)})`;
|
|
506
|
+
case 'Or': return `(${this.emitConditionJS(cond.left)}) || (${this.emitConditionJS(cond.right)})`;
|
|
507
|
+
case 'Not': return `!(${this.emitConditionJS(cond.inner)})`;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
// Condition suitable for JSX interpolation {cond && ... }
|
|
511
|
+
emitConditionJSX(cond) {
|
|
512
|
+
return this.emitConditionJS(cond);
|
|
513
|
+
}
|
|
514
|
+
// ── StyleSheet ────────────────────────────────────────────
|
|
515
|
+
emitStyleSheet() {
|
|
516
|
+
this.line(`const styles = StyleSheet.create({`);
|
|
517
|
+
this.depth++;
|
|
518
|
+
this.line(`container: { flex: 1, backgroundColor: '#fff' },`);
|
|
519
|
+
this.line(`inner: { flex: 1, padding: 16 },`);
|
|
520
|
+
this.line(`heading: { fontSize: 24, fontWeight: 'bold', color: '#111' },`);
|
|
521
|
+
this.line(`subheading: { fontSize: 18, fontWeight: '600', color: '#222' },`);
|
|
522
|
+
this.line(`body: { fontSize: 16, color: '#333' },`);
|
|
523
|
+
this.line(`caption: { fontSize: 12, color: '#888' },`);
|
|
524
|
+
this.line(`button: { backgroundColor: '#007AFF', borderRadius: 8, padding: 14, alignItems: 'center' },`);
|
|
525
|
+
this.line(`buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },`);
|
|
526
|
+
this.line(`input: { borderWidth: 1, borderColor: '#ddd', borderRadius: 8, padding: 12, fontSize: 16 },`);
|
|
527
|
+
this.line(`card: { backgroundColor: '#fff', borderRadius: 8, padding: 16, shadowColor: '#000', shadowOpacity: 0.1, shadowRadius: 4, elevation: 3 },`);
|
|
528
|
+
this.line(`divider: { height: 1, backgroundColor: '#eee', marginVertical: 8 },`);
|
|
529
|
+
this.depth--;
|
|
530
|
+
this.line(`});`);
|
|
531
|
+
}
|
|
532
|
+
// ── Helpers ────────────────────────────────────────────────
|
|
533
|
+
inferStateTypes(fields) {
|
|
534
|
+
const map = new Map();
|
|
535
|
+
for (const f of fields) {
|
|
536
|
+
if (f.typeHint) {
|
|
537
|
+
map.set(f.name, this.tsTypeFromAnnotation(f.typeHint));
|
|
538
|
+
}
|
|
539
|
+
else {
|
|
540
|
+
switch (f.defaultValue.kind) {
|
|
541
|
+
case 'StringLiteral':
|
|
542
|
+
map.set(f.name, 'string');
|
|
543
|
+
break;
|
|
544
|
+
case 'NumberLiteral':
|
|
545
|
+
map.set(f.name, 'number');
|
|
546
|
+
break;
|
|
547
|
+
case 'BooleanLiteral':
|
|
548
|
+
map.set(f.name, 'boolean');
|
|
549
|
+
break;
|
|
550
|
+
default:
|
|
551
|
+
map.set(f.name, 'any');
|
|
552
|
+
break;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
return map;
|
|
557
|
+
}
|
|
558
|
+
tsType(field, stateVarTypes) {
|
|
559
|
+
return stateVarTypes.get(field.name) ?? 'any';
|
|
560
|
+
}
|
|
561
|
+
tsTypeFromAnnotation(ann) {
|
|
562
|
+
switch (ann.kind) {
|
|
563
|
+
case 'SimpleType': return this.mapSimpleType(ann.name);
|
|
564
|
+
case 'ListType': return `${this.mapSimpleType(ann.itemType)}[]`;
|
|
565
|
+
case 'OptionalType': return `${this.tsTypeFromAnnotation(ann.inner)} | null`;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
mapSimpleType(name) {
|
|
569
|
+
const map = {
|
|
570
|
+
text: 'string', number: 'number', numeric: 'number', decimal: 'number',
|
|
571
|
+
boolean: 'boolean', date: 'Date', any: 'any'
|
|
572
|
+
};
|
|
573
|
+
return map[name] ?? name;
|
|
574
|
+
}
|
|
575
|
+
buildButtonHandlerMap(events) {
|
|
576
|
+
const map = new Map();
|
|
577
|
+
for (const e of events) {
|
|
578
|
+
if (e.trigger.kind === 'ButtonPressed') {
|
|
579
|
+
map.set(e.trigger.label, buttonHandlerName(e.trigger.label));
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
return map;
|
|
583
|
+
}
|
|
584
|
+
collectRNImports(layout, events) {
|
|
585
|
+
const needed = new Set(['View', 'StyleSheet']);
|
|
586
|
+
const scan = (nodes) => {
|
|
587
|
+
for (const node of nodes) {
|
|
588
|
+
switch (node.kind) {
|
|
589
|
+
case 'Text':
|
|
590
|
+
needed.add('Text');
|
|
591
|
+
break;
|
|
592
|
+
case 'Button':
|
|
593
|
+
needed.add('TouchableOpacity');
|
|
594
|
+
needed.add('Text');
|
|
595
|
+
break;
|
|
596
|
+
case 'TextField':
|
|
597
|
+
needed.add('TextInput');
|
|
598
|
+
break;
|
|
599
|
+
case 'Image':
|
|
600
|
+
needed.add('Image');
|
|
601
|
+
break;
|
|
602
|
+
case 'LoadingSpinner':
|
|
603
|
+
needed.add('ActivityIndicator');
|
|
604
|
+
break;
|
|
605
|
+
case 'ScrollView':
|
|
606
|
+
needed.add('ScrollView');
|
|
607
|
+
break;
|
|
608
|
+
}
|
|
609
|
+
if ('children' in node)
|
|
610
|
+
scan(node.children);
|
|
611
|
+
if ('thenBranch' in node) {
|
|
612
|
+
scan(node.thenBranch);
|
|
613
|
+
if (node.elseBranch)
|
|
614
|
+
scan(node.elseBranch);
|
|
615
|
+
}
|
|
616
|
+
if ('body' in node && Array.isArray(node.body))
|
|
617
|
+
scan(node.body);
|
|
618
|
+
}
|
|
619
|
+
};
|
|
620
|
+
scan(layout);
|
|
621
|
+
return Array.from(needed).sort();
|
|
622
|
+
}
|
|
623
|
+
textStyleRef(style, color) {
|
|
624
|
+
const styleStr = `styles.${style}`;
|
|
625
|
+
if (!color)
|
|
626
|
+
return ` style={${styleStr}}`;
|
|
627
|
+
return ` style={[${styleStr}, { color: '${color}' }]}`;
|
|
628
|
+
}
|
|
629
|
+
stackStyleAttr(direction, spacing, padding) {
|
|
630
|
+
const parts = [`flexDirection: '${direction}'`];
|
|
631
|
+
if (spacing)
|
|
632
|
+
parts.push(`gap: ${spacing}`);
|
|
633
|
+
if (padding)
|
|
634
|
+
parts.push(`padding: ${padding}`);
|
|
635
|
+
return ` style={{ ${parts.join(', ')} }}`;
|
|
636
|
+
}
|
|
637
|
+
// ── Output helpers ────────────────────────────────────────
|
|
638
|
+
line(text) {
|
|
639
|
+
const pad = ' '.repeat(this.depth);
|
|
640
|
+
this.out.push(pad + text);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
exports.ReactNativeBackend = ReactNativeBackend;
|
|
644
|
+
// ─────────────────────────────────────────────────────────────
|
|
645
|
+
// Utility functions
|
|
646
|
+
// ─────────────────────────────────────────────────────────────
|
|
647
|
+
function setterName(name) {
|
|
648
|
+
return 'set' + name[0].toUpperCase() + name.slice(1);
|
|
649
|
+
}
|
|
650
|
+
function camelCase(words) {
|
|
651
|
+
return words
|
|
652
|
+
.map((w, i) => i === 0 ? w : w[0].toUpperCase() + w.slice(1))
|
|
653
|
+
.join('');
|
|
654
|
+
}
|
|
655
|
+
function buttonHandlerName(label) {
|
|
656
|
+
return 'handle' + label.split(/\s+/).map(w => w[0].toUpperCase() + w.slice(1)).join('');
|
|
657
|
+
}
|