@loj-lang/rdsl-compiler 0.5.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 +23 -0
- package/dist/cache-signature.d.ts +2 -0
- package/dist/cache-signature.d.ts.map +1 -0
- package/dist/cache-signature.js +17 -0
- package/dist/cache-signature.js.map +1 -0
- package/dist/codegen.d.ts +37 -0
- package/dist/codegen.d.ts.map +1 -0
- package/dist/codegen.js +6394 -0
- package/dist/codegen.js.map +1 -0
- package/dist/dependency-graph.d.ts +23 -0
- package/dist/dependency-graph.d.ts.map +1 -0
- package/dist/dependency-graph.js +516 -0
- package/dist/dependency-graph.js.map +1 -0
- package/dist/expr.d.ts +24 -0
- package/dist/expr.d.ts.map +1 -0
- package/dist/expr.js +359 -0
- package/dist/expr.js.map +1 -0
- package/dist/flow-proof.d.ts +68 -0
- package/dist/flow-proof.d.ts.map +1 -0
- package/dist/flow-proof.js +487 -0
- package/dist/flow-proof.js.map +1 -0
- package/dist/host-files.d.ts +27 -0
- package/dist/host-files.d.ts.map +1 -0
- package/dist/host-files.js +441 -0
- package/dist/host-files.js.map +1 -0
- package/dist/index.d.ts +120 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +948 -0
- package/dist/index.js.map +1 -0
- package/dist/ir.d.ts +451 -0
- package/dist/ir.d.ts.map +1 -0
- package/dist/ir.js +13 -0
- package/dist/ir.js.map +1 -0
- package/dist/manifest.d.ts +104 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +635 -0
- package/dist/manifest.js.map +1 -0
- package/dist/node-inspect.d.ts +23 -0
- package/dist/node-inspect.d.ts.map +1 -0
- package/dist/node-inspect.js +475 -0
- package/dist/node-inspect.js.map +1 -0
- package/dist/normalize.d.ts +101 -0
- package/dist/normalize.d.ts.map +1 -0
- package/dist/normalize.js +1771 -0
- package/dist/normalize.js.map +1 -0
- package/dist/page-table-block.d.ts +69 -0
- package/dist/page-table-block.d.ts.map +1 -0
- package/dist/page-table-block.js +241 -0
- package/dist/page-table-block.js.map +1 -0
- package/dist/parser.d.ts +262 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +1335 -0
- package/dist/parser.js.map +1 -0
- package/dist/project-paths.d.ts +6 -0
- package/dist/project-paths.d.ts.map +1 -0
- package/dist/project-paths.js +84 -0
- package/dist/project-paths.js.map +1 -0
- package/dist/relation-projection.d.ts +60 -0
- package/dist/relation-projection.d.ts.map +1 -0
- package/dist/relation-projection.js +121 -0
- package/dist/relation-projection.js.map +1 -0
- package/dist/rules-proof.d.ts +95 -0
- package/dist/rules-proof.d.ts.map +1 -0
- package/dist/rules-proof.js +537 -0
- package/dist/rules-proof.js.map +1 -0
- package/dist/source-files.d.ts +9 -0
- package/dist/source-files.d.ts.map +1 -0
- package/dist/source-files.js +27 -0
- package/dist/source-files.js.map +1 -0
- package/dist/style-proof.d.ts +70 -0
- package/dist/style-proof.d.ts.map +1 -0
- package/dist/style-proof.js +640 -0
- package/dist/style-proof.js.map +1 -0
- package/dist/validator.d.ts +51 -0
- package/dist/validator.d.ts.map +1 -0
- package/dist/validator.js +2487 -0
- package/dist/validator.js.map +1 -0
- package/package.json +40 -0
|
@@ -0,0 +1,1771 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ReactDSL Normalizer
|
|
3
|
+
*
|
|
4
|
+
* Transforms a RawAST into the versioned IR (Intermediate Representation).
|
|
5
|
+
* - Assigns stable node IDs
|
|
6
|
+
* - Expands shorthands
|
|
7
|
+
* - Parses expressions into ExprNode ASTs
|
|
8
|
+
* - Parses effects into EffectNode ASTs
|
|
9
|
+
* - Resolves decorator arguments
|
|
10
|
+
*/
|
|
11
|
+
import { parseColumnEntry } from './parser.js';
|
|
12
|
+
import { parseExpr } from './expr.js';
|
|
13
|
+
import { compileFlowSource, isFlowSourceFile } from './flow-proof.js';
|
|
14
|
+
import { compileRulesSource, isRulesSourceFile } from './rules-proof.js';
|
|
15
|
+
import { compileStyleSource, isStyleSourceFile } from './style-proof.js';
|
|
16
|
+
import { dirnameProjectPath, resolveProjectPath, toProjectRelativePath, } from './project-paths.js';
|
|
17
|
+
const NORMALIZE_CACHE_VERSION = '0.1.7';
|
|
18
|
+
const FLOW_LINK_REGEX = /^@flow\(["'](.+?)["']\)$/;
|
|
19
|
+
const RULES_LINK_REGEX = /^@rules\(["'](.+?)["']\)$/;
|
|
20
|
+
const STYLE_LINK_REGEX = /^@style\(["'](.+?)["']\)$/;
|
|
21
|
+
const ASSET_LINK_REGEX = /^@asset\(["'](.+?)["']\)$/;
|
|
22
|
+
// ─── Main Entry Point ────────────────────────────────────────────
|
|
23
|
+
export function normalize(ast, options = {}) {
|
|
24
|
+
const previousCache = options.cache?.version === NORMALIZE_CACHE_VERSION
|
|
25
|
+
? options.cache
|
|
26
|
+
: undefined;
|
|
27
|
+
const appSegment = resolveNormalizedSegment(createNormalizeSignature({
|
|
28
|
+
app: ast.app
|
|
29
|
+
? {
|
|
30
|
+
name: ast.app.name,
|
|
31
|
+
theme: ast.app.theme,
|
|
32
|
+
auth: ast.app.auth,
|
|
33
|
+
style: ast.app.style,
|
|
34
|
+
seo: ast.app.seo,
|
|
35
|
+
}
|
|
36
|
+
: undefined,
|
|
37
|
+
compiler: ast.compiler,
|
|
38
|
+
}), previousCache?.app, () => normalizeAppShell(ast, options.projectRoot, options.readFile));
|
|
39
|
+
const navigationGroups = (ast.app?.navigation ?? []).map((group, index) => resolveNormalizedNavGroup(group, `app.nav.${index}`, previousCache?.navigationGroups[`app.nav.${index}`], options.projectRoot, appSegment.value.compiler.language, options.readFile));
|
|
40
|
+
const models = ast.models.map((model) => resolveNormalizedModel(model, previousCache?.models[model.name], options.projectRoot));
|
|
41
|
+
const resources = ast.resources.map((resource) => resolveNormalizedResource(resource, previousCache?.resources[resource.name], options.projectRoot, appSegment.value.style, appSegment.value.compiler.language, options.readFile));
|
|
42
|
+
const readModels = ast.readModels.map((readModel) => resolveNormalizedReadModel(readModel, previousCache?.readModels[readModel.name], options.projectRoot, options.readFile));
|
|
43
|
+
const pages = ast.pages.map((page) => resolveNormalizedPage(page, previousCache?.pages[page.name], options.projectRoot, appSegment.value.style, options.readFile));
|
|
44
|
+
const ir = {
|
|
45
|
+
id: 'app.main',
|
|
46
|
+
kind: 'app',
|
|
47
|
+
schemaVersion: '0.1.0',
|
|
48
|
+
name: appSegment.value.name,
|
|
49
|
+
compiler: appSegment.value.compiler,
|
|
50
|
+
theme: appSegment.value.theme,
|
|
51
|
+
auth: appSegment.value.auth,
|
|
52
|
+
style: appSegment.value.style,
|
|
53
|
+
seo: appSegment.value.seo,
|
|
54
|
+
navigation: navigationGroups.map((entry) => entry.value),
|
|
55
|
+
models: models.map((entry) => entry.value),
|
|
56
|
+
resources: resources.map((entry) => entry.value),
|
|
57
|
+
readModels: readModels.map((entry) => entry.value),
|
|
58
|
+
pages: pages.map((entry) => entry.value),
|
|
59
|
+
sourceSpan: appSegment.value.sourceSpan,
|
|
60
|
+
};
|
|
61
|
+
ir.escapeStats = computeEscapeStats(ir);
|
|
62
|
+
return {
|
|
63
|
+
ir,
|
|
64
|
+
errors: [
|
|
65
|
+
...appSegment.errors,
|
|
66
|
+
...navigationGroups.flatMap((entry) => entry.errors),
|
|
67
|
+
...models.flatMap((entry) => entry.errors),
|
|
68
|
+
...resources.flatMap((entry) => entry.errors),
|
|
69
|
+
...readModels.flatMap((entry) => entry.errors),
|
|
70
|
+
...pages.flatMap((entry) => entry.errors),
|
|
71
|
+
],
|
|
72
|
+
cacheSnapshot: {
|
|
73
|
+
version: NORMALIZE_CACHE_VERSION,
|
|
74
|
+
app: appSegment.cacheEntry,
|
|
75
|
+
navigationGroups: Object.fromEntries(navigationGroups.map((entry) => [entry.value.id, entry.cacheSnapshot])),
|
|
76
|
+
models: Object.fromEntries(ast.models.map((model, index) => [model.name, models[index].cacheSnapshot])),
|
|
77
|
+
resources: Object.fromEntries(ast.resources.map((resource, index) => [resource.name, resources[index].cacheSnapshot])),
|
|
78
|
+
readModels: Object.fromEntries(ast.readModels.map((readModel, index) => [readModel.name, readModels[index].cacheSnapshot])),
|
|
79
|
+
pages: Object.fromEntries(ast.pages.map((page, index) => [page.name, pages[index].cacheSnapshot])),
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function resolveNormalizedSegment(signature, previous, emit) {
|
|
84
|
+
if (previous && previous.signature === signature) {
|
|
85
|
+
return {
|
|
86
|
+
value: previous.value,
|
|
87
|
+
errors: previous.errors,
|
|
88
|
+
cacheEntry: previous,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
const emitted = emit();
|
|
92
|
+
const cacheEntry = {
|
|
93
|
+
signature,
|
|
94
|
+
value: emitted.value,
|
|
95
|
+
errors: emitted.errors,
|
|
96
|
+
};
|
|
97
|
+
return {
|
|
98
|
+
value: emitted.value,
|
|
99
|
+
errors: emitted.errors,
|
|
100
|
+
cacheEntry,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function createNormalizeSignature(value) {
|
|
104
|
+
return JSON.stringify(value, (_key, current) => {
|
|
105
|
+
if (!current || typeof current !== 'object' || Array.isArray(current)) {
|
|
106
|
+
return current;
|
|
107
|
+
}
|
|
108
|
+
const normalized = {};
|
|
109
|
+
for (const [key, entry] of Object.entries(current)) {
|
|
110
|
+
if (key === 'sourceSpan') {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
normalized[key] = entry;
|
|
114
|
+
}
|
|
115
|
+
return normalized;
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
function resolveNormalizedResource(raw, previous, projectRoot, appStyle, compilerLanguage = 'typescript', readFile) {
|
|
119
|
+
const resourceSignature = createNormalizeSignature(raw);
|
|
120
|
+
if (previous?.resource?.signature === resourceSignature) {
|
|
121
|
+
return {
|
|
122
|
+
value: previous.resource.value,
|
|
123
|
+
errors: previous.resource.errors,
|
|
124
|
+
cacheSnapshot: previous,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
const id = `resource.${raw.name}`;
|
|
128
|
+
const list = raw.list
|
|
129
|
+
? resolveNormalizedListView(raw.list, `${id}.view.list`, previous?.list, projectRoot, appStyle, compilerLanguage, readFile)
|
|
130
|
+
: undefined;
|
|
131
|
+
const edit = raw.edit
|
|
132
|
+
? resolveNormalizedFormView(raw.edit, `${id}.view.edit`, previous?.edit, projectRoot, appStyle, compilerLanguage, readFile, normalizeEditView)
|
|
133
|
+
: undefined;
|
|
134
|
+
const create = raw.create
|
|
135
|
+
? resolveNormalizedFormView(raw.create, `${id}.view.create`, previous?.create, projectRoot, appStyle, compilerLanguage, readFile, normalizeCreateView)
|
|
136
|
+
: undefined;
|
|
137
|
+
const read = raw.read
|
|
138
|
+
? resolveNormalizedReadView(raw.read, `${id}.view.read`, previous?.read, projectRoot, appStyle, compilerLanguage, readFile)
|
|
139
|
+
: undefined;
|
|
140
|
+
const errors = [
|
|
141
|
+
...(list?.errors ?? []),
|
|
142
|
+
...(edit?.errors ?? []),
|
|
143
|
+
...(create?.errors ?? []),
|
|
144
|
+
...(read?.errors ?? []),
|
|
145
|
+
];
|
|
146
|
+
const value = {
|
|
147
|
+
id,
|
|
148
|
+
kind: 'resource',
|
|
149
|
+
name: raw.name,
|
|
150
|
+
model: raw.model,
|
|
151
|
+
api: raw.api,
|
|
152
|
+
workflow: raw.workflow
|
|
153
|
+
? normalizeFlowLink(raw.workflow, raw.workflowSourceSpan?.file ?? raw.sourceSpan?.file, projectRoot, readFile, errors, id, 'resource workflow')
|
|
154
|
+
: undefined,
|
|
155
|
+
workflowStyle: raw.workflowStyle
|
|
156
|
+
? normalizeStyleReference(raw.workflowStyle, appStyle, `${id}.workflow.style`, errors, 'resource workflow style')
|
|
157
|
+
: undefined,
|
|
158
|
+
views: {
|
|
159
|
+
list: list?.value,
|
|
160
|
+
edit: edit?.value,
|
|
161
|
+
create: create?.value,
|
|
162
|
+
read: read?.value,
|
|
163
|
+
},
|
|
164
|
+
sourceSpan: raw.sourceSpan,
|
|
165
|
+
};
|
|
166
|
+
return {
|
|
167
|
+
value,
|
|
168
|
+
errors,
|
|
169
|
+
cacheSnapshot: {
|
|
170
|
+
resource: {
|
|
171
|
+
signature: resourceSignature,
|
|
172
|
+
value,
|
|
173
|
+
errors,
|
|
174
|
+
},
|
|
175
|
+
list: list?.cacheSnapshot,
|
|
176
|
+
edit: edit?.cacheSnapshot,
|
|
177
|
+
create: create?.cacheSnapshot,
|
|
178
|
+
read: read?.cacheSnapshot,
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
function resolveNormalizedNavGroup(raw, id, previous, projectRoot, compilerLanguage = 'typescript', readFile) {
|
|
183
|
+
const groupSignature = createNormalizeSignature(raw);
|
|
184
|
+
if (previous?.group?.signature === groupSignature) {
|
|
185
|
+
return {
|
|
186
|
+
value: previous.group.value,
|
|
187
|
+
errors: previous.group.errors,
|
|
188
|
+
cacheSnapshot: previous,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
const items = raw.items.map((item, index) => {
|
|
192
|
+
const itemId = `${id}.${index}`;
|
|
193
|
+
return resolveNormalizedSegment(createNormalizeSignature(item), previous?.items[itemId], () => ({
|
|
194
|
+
value: normalizeNavItem(item, itemId),
|
|
195
|
+
errors: [],
|
|
196
|
+
}));
|
|
197
|
+
});
|
|
198
|
+
const errors = [];
|
|
199
|
+
const value = {
|
|
200
|
+
id,
|
|
201
|
+
kind: 'navGroup',
|
|
202
|
+
group: normalizeMessageLike(raw.group, `${id}.group`, errors, 'navigation group label'),
|
|
203
|
+
visibleIf: raw.visibleIf
|
|
204
|
+
? tryParseRuleValue(raw.visibleIf, id, errors, raw.sourceSpan?.file, projectRoot, compilerLanguage, readFile, 'navigation rule')
|
|
205
|
+
: undefined,
|
|
206
|
+
items: items.map((entry) => entry.value),
|
|
207
|
+
sourceSpan: raw.sourceSpan,
|
|
208
|
+
};
|
|
209
|
+
const combinedErrors = [
|
|
210
|
+
...errors,
|
|
211
|
+
...items.flatMap((entry) => entry.errors),
|
|
212
|
+
];
|
|
213
|
+
return {
|
|
214
|
+
value,
|
|
215
|
+
errors: combinedErrors,
|
|
216
|
+
cacheSnapshot: {
|
|
217
|
+
group: {
|
|
218
|
+
signature: groupSignature,
|
|
219
|
+
value,
|
|
220
|
+
errors: combinedErrors,
|
|
221
|
+
},
|
|
222
|
+
items: Object.fromEntries(items.map((entry, index) => [`${id}.${index}`, entry.cacheEntry])),
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
function resolveNormalizedModel(raw, previous, _projectRoot) {
|
|
227
|
+
const modelSignature = createNormalizeSignature(raw);
|
|
228
|
+
if (previous?.model?.signature === modelSignature) {
|
|
229
|
+
return {
|
|
230
|
+
value: previous.model.value,
|
|
231
|
+
errors: previous.model.errors,
|
|
232
|
+
cacheSnapshot: previous,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
const id = `model.${raw.name}`;
|
|
236
|
+
const fields = raw.fields.map((field, index) => {
|
|
237
|
+
const fieldKey = `${id}.field.${index}`;
|
|
238
|
+
return resolveNormalizedSegment(createNormalizeSignature(field), previous?.fields[fieldKey], () => normalizeModelField(field, `${id}.field.${field.name}`));
|
|
239
|
+
});
|
|
240
|
+
const value = {
|
|
241
|
+
id,
|
|
242
|
+
kind: 'model',
|
|
243
|
+
name: raw.name,
|
|
244
|
+
fields: fields.map((entry) => entry.value),
|
|
245
|
+
sourceSpan: raw.sourceSpan,
|
|
246
|
+
};
|
|
247
|
+
return {
|
|
248
|
+
value,
|
|
249
|
+
errors: [],
|
|
250
|
+
cacheSnapshot: {
|
|
251
|
+
model: {
|
|
252
|
+
signature: modelSignature,
|
|
253
|
+
value,
|
|
254
|
+
errors: [],
|
|
255
|
+
},
|
|
256
|
+
fields: Object.fromEntries(fields.map((entry, index) => [`${id}.field.${index}`, entry.cacheEntry])),
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
function resolveNormalizedReadModel(raw, previous, projectRoot, readFile) {
|
|
261
|
+
const readModelSignature = createNormalizeSignature(raw);
|
|
262
|
+
if (previous?.readModel?.signature === readModelSignature) {
|
|
263
|
+
return {
|
|
264
|
+
value: previous.readModel.value,
|
|
265
|
+
errors: previous.readModel.errors,
|
|
266
|
+
cacheSnapshot: previous,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
const id = `readModel.${raw.name}`;
|
|
270
|
+
const inputs = raw.inputs.map((field, index) => {
|
|
271
|
+
const fieldKey = `${id}.inputs.${index}`;
|
|
272
|
+
return resolveNormalizedSegment(createNormalizeSignature(field), previous?.inputs[fieldKey], () => normalizeReadModelField(field, `${id}.inputs.${field.name}`, 'inputs'));
|
|
273
|
+
});
|
|
274
|
+
const result = raw.result.map((field, index) => {
|
|
275
|
+
const fieldKey = `${id}.result.${index}`;
|
|
276
|
+
return resolveNormalizedSegment(createNormalizeSignature(field), previous?.result[fieldKey], () => normalizeReadModelField(field, `${id}.result.${field.name}`, 'result'));
|
|
277
|
+
});
|
|
278
|
+
const list = raw.list
|
|
279
|
+
? resolveNormalizedReadModelListView(raw.list, `${id}.list`, previous?.list)
|
|
280
|
+
: undefined;
|
|
281
|
+
const combinedErrors = [
|
|
282
|
+
...inputs.flatMap((entry) => entry.errors),
|
|
283
|
+
...result.flatMap((entry) => entry.errors),
|
|
284
|
+
...(list?.errors ?? []),
|
|
285
|
+
];
|
|
286
|
+
const value = {
|
|
287
|
+
id,
|
|
288
|
+
kind: 'readModel',
|
|
289
|
+
name: raw.name,
|
|
290
|
+
api: raw.api || '',
|
|
291
|
+
rules: raw.rules
|
|
292
|
+
? normalizeRulesLink(raw.rules, raw.sourceSpan?.file, projectRoot, readFile, combinedErrors, id, 'readModel rules')
|
|
293
|
+
: undefined,
|
|
294
|
+
inputs: inputs.map((entry) => entry.value),
|
|
295
|
+
result: result.map((entry) => entry.value),
|
|
296
|
+
list: list?.value,
|
|
297
|
+
sourceSpan: raw.sourceSpan,
|
|
298
|
+
};
|
|
299
|
+
return {
|
|
300
|
+
value,
|
|
301
|
+
errors: combinedErrors,
|
|
302
|
+
cacheSnapshot: {
|
|
303
|
+
readModel: {
|
|
304
|
+
signature: readModelSignature,
|
|
305
|
+
value,
|
|
306
|
+
errors: combinedErrors,
|
|
307
|
+
},
|
|
308
|
+
inputs: Object.fromEntries(inputs.map((entry, index) => [`${id}.inputs.${index}`, entry.cacheEntry])),
|
|
309
|
+
result: Object.fromEntries(result.map((entry, index) => [`${id}.result.${index}`, entry.cacheEntry])),
|
|
310
|
+
list: list?.cacheSnapshot,
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
function resolveNormalizedReadModelListView(raw, id, previous) {
|
|
315
|
+
const viewSignature = createNormalizeSignature(raw);
|
|
316
|
+
if (previous?.view?.signature === viewSignature) {
|
|
317
|
+
return {
|
|
318
|
+
value: previous.view.value,
|
|
319
|
+
errors: previous.view.errors,
|
|
320
|
+
cacheSnapshot: previous,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
const columns = (raw.columns || []).map((column, index) => {
|
|
324
|
+
const columnKey = `${id}.column.${index}`;
|
|
325
|
+
return resolveNormalizedSegment(createNormalizeSignature(column), previous?.columns[columnKey], () => {
|
|
326
|
+
const errors = [];
|
|
327
|
+
return {
|
|
328
|
+
value: normalizeColumn(column, `${id}.column.${column.field}`, errors),
|
|
329
|
+
errors,
|
|
330
|
+
};
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
const combinedErrors = columns.flatMap((entry) => entry.errors);
|
|
334
|
+
const value = {
|
|
335
|
+
id,
|
|
336
|
+
kind: 'readModel.list',
|
|
337
|
+
columns: columns.map((entry) => entry.value),
|
|
338
|
+
groupBy: raw.groupBy ?? [],
|
|
339
|
+
pivotBy: raw.pivotBy,
|
|
340
|
+
pagination: raw.pagination ? {
|
|
341
|
+
size: raw.pagination.size || 20,
|
|
342
|
+
style: raw.pagination.style || 'numbered',
|
|
343
|
+
} : undefined,
|
|
344
|
+
sourceSpan: raw.sourceSpan,
|
|
345
|
+
};
|
|
346
|
+
return {
|
|
347
|
+
value,
|
|
348
|
+
errors: combinedErrors,
|
|
349
|
+
cacheSnapshot: {
|
|
350
|
+
view: {
|
|
351
|
+
signature: viewSignature,
|
|
352
|
+
value,
|
|
353
|
+
errors: combinedErrors,
|
|
354
|
+
},
|
|
355
|
+
columns: Object.fromEntries(columns.map((entry, index) => [`${id}.column.${index}`, entry.cacheEntry])),
|
|
356
|
+
},
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
function resolveNormalizedListView(raw, id, previous, projectRoot, appStyle, compilerLanguage = 'typescript', readFile) {
|
|
360
|
+
const viewSignature = createNormalizeSignature(raw);
|
|
361
|
+
if (previous?.view?.signature === viewSignature) {
|
|
362
|
+
return {
|
|
363
|
+
value: previous.view.value,
|
|
364
|
+
errors: previous.view.errors,
|
|
365
|
+
cacheSnapshot: previous,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
const filters = (raw.filters || []).map((filter, index) => {
|
|
369
|
+
const filterKey = `${id}.filter.${index}`;
|
|
370
|
+
return resolveNormalizedSegment(createNormalizeSignature(filter), previous?.filters[filterKey], () => ({
|
|
371
|
+
value: {
|
|
372
|
+
id: `${id}.filter.${filter}`,
|
|
373
|
+
kind: 'filter',
|
|
374
|
+
field: filter,
|
|
375
|
+
sourceSpan: undefined,
|
|
376
|
+
},
|
|
377
|
+
errors: [],
|
|
378
|
+
}));
|
|
379
|
+
});
|
|
380
|
+
const columns = (raw.columns || []).map((column, index) => {
|
|
381
|
+
const columnKey = `${id}.column.${index}`;
|
|
382
|
+
return resolveNormalizedSegment(createNormalizeSignature(column), previous?.columns[columnKey], () => {
|
|
383
|
+
const errors = [];
|
|
384
|
+
return {
|
|
385
|
+
value: normalizeColumn(column, `${id}.column.${column.field}`, errors, projectRoot, compilerLanguage, readFile),
|
|
386
|
+
errors,
|
|
387
|
+
};
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
const actions = (raw.actions || []).map((action, index) => {
|
|
391
|
+
const actionKey = `${id}.action.${index}`;
|
|
392
|
+
return resolveNormalizedSegment(createNormalizeSignature(action), previous?.actions[actionKey], () => ({
|
|
393
|
+
value: normalizeAction(action, `${id}.action.${action.name}`),
|
|
394
|
+
errors: [],
|
|
395
|
+
}));
|
|
396
|
+
});
|
|
397
|
+
const errors = [];
|
|
398
|
+
const value = {
|
|
399
|
+
id,
|
|
400
|
+
kind: 'view.list',
|
|
401
|
+
title: normalizeMessageLike(raw.title, `${id}.title`, errors, 'list title'),
|
|
402
|
+
style: raw.style ? normalizeStyleReference(raw.style, appStyle, `${id}.style`, errors, 'list style') : undefined,
|
|
403
|
+
filters: filters.map((entry) => entry.value),
|
|
404
|
+
columns: columns.map((entry) => entry.value),
|
|
405
|
+
actions: actions.map((entry) => entry.value),
|
|
406
|
+
pagination: raw.pagination ? {
|
|
407
|
+
size: raw.pagination.size || 20,
|
|
408
|
+
style: raw.pagination.style || 'numbered',
|
|
409
|
+
} : undefined,
|
|
410
|
+
rules: raw.rules
|
|
411
|
+
? normalizeRules(raw.rules, id, errors, raw.sourceSpan?.file, projectRoot, compilerLanguage, readFile)
|
|
412
|
+
: undefined,
|
|
413
|
+
sourceSpan: raw.sourceSpan,
|
|
414
|
+
};
|
|
415
|
+
const combinedErrors = [
|
|
416
|
+
...errors,
|
|
417
|
+
...filters.flatMap((entry) => entry.errors),
|
|
418
|
+
...columns.flatMap((entry) => entry.errors),
|
|
419
|
+
...actions.flatMap((entry) => entry.errors),
|
|
420
|
+
];
|
|
421
|
+
return {
|
|
422
|
+
value,
|
|
423
|
+
errors: combinedErrors,
|
|
424
|
+
cacheSnapshot: {
|
|
425
|
+
view: {
|
|
426
|
+
signature: viewSignature,
|
|
427
|
+
value,
|
|
428
|
+
errors: combinedErrors,
|
|
429
|
+
},
|
|
430
|
+
filters: Object.fromEntries(filters.map((entry, index) => [`${id}.filter.${index}`, entry.cacheEntry])),
|
|
431
|
+
columns: Object.fromEntries(columns.map((entry, index) => [`${id}.column.${index}`, entry.cacheEntry])),
|
|
432
|
+
actions: Object.fromEntries(actions.map((entry, index) => [`${id}.action.${index}`, entry.cacheEntry])),
|
|
433
|
+
},
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
function resolveNormalizedFormView(raw, id, previous, projectRoot, appStyle, compilerLanguage = 'typescript', readFile, emitView) {
|
|
437
|
+
const viewSignature = createNormalizeSignature(raw);
|
|
438
|
+
if (previous?.view?.signature === viewSignature) {
|
|
439
|
+
return {
|
|
440
|
+
value: previous.view.value,
|
|
441
|
+
errors: previous.view.errors,
|
|
442
|
+
cacheSnapshot: previous,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
const rawFields = (raw.fields || []);
|
|
446
|
+
const fields = rawFields.map((field, index) => {
|
|
447
|
+
const fieldKey = `${id}.field.${index}`;
|
|
448
|
+
return resolveNormalizedSegment(createNormalizeSignature(field), previous?.fields[fieldKey], () => {
|
|
449
|
+
const errors = [];
|
|
450
|
+
return {
|
|
451
|
+
value: normalizeFormFieldLike(field, id, errors, projectRoot, compilerLanguage, readFile),
|
|
452
|
+
errors,
|
|
453
|
+
};
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
const rawIncludes = 'includes' in raw && Array.isArray(raw.includes) ? raw.includes : [];
|
|
457
|
+
const includes = rawIncludes.map((include, index) => {
|
|
458
|
+
const includeKey = `${id}.include.${index}`;
|
|
459
|
+
return resolveNormalizedSegment(createNormalizeSignature(include), previous?.includes[includeKey]?.include, () => {
|
|
460
|
+
const includeErrors = [];
|
|
461
|
+
const rawIncludeFields = include.fields || [];
|
|
462
|
+
const includeFields = rawIncludeFields.map((field, fieldIndex) => resolveNormalizedSegment(createNormalizeSignature(field), previous?.includes[includeKey]?.fields[`${includeKey}.field.${fieldIndex}`], () => {
|
|
463
|
+
const fieldErrors = [];
|
|
464
|
+
return {
|
|
465
|
+
value: normalizeFormFieldLike(field, includeKey, fieldErrors, projectRoot, compilerLanguage, readFile),
|
|
466
|
+
errors: fieldErrors,
|
|
467
|
+
};
|
|
468
|
+
}));
|
|
469
|
+
const value = {
|
|
470
|
+
id: `${id}.include.${include.field}`,
|
|
471
|
+
kind: 'createInclude',
|
|
472
|
+
field: include.field,
|
|
473
|
+
minItems: typeof include.minItems === 'number' && Number.isFinite(include.minItems)
|
|
474
|
+
? include.minItems
|
|
475
|
+
: 0,
|
|
476
|
+
fields: includeFields.map((entry) => entry.value),
|
|
477
|
+
rulesLink: typeof include.rules === 'string'
|
|
478
|
+
? normalizeRulesLink(include.rules, include.sourceSpan?.file, projectRoot, readFile, includeErrors, `${id}.include.${include.field}`, `${id}.includes.${include.field} rules`)
|
|
479
|
+
: undefined,
|
|
480
|
+
sourceSpan: include.sourceSpan,
|
|
481
|
+
};
|
|
482
|
+
return {
|
|
483
|
+
value,
|
|
484
|
+
errors: [
|
|
485
|
+
...includeErrors,
|
|
486
|
+
...includeFields.flatMap((entry) => entry.errors),
|
|
487
|
+
],
|
|
488
|
+
};
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
const errors = [];
|
|
492
|
+
const value = emitView(raw, id, fields.map((entry) => entry.value), includes.map((entry) => entry.value), errors, appStyle, projectRoot, compilerLanguage, readFile);
|
|
493
|
+
const combinedErrors = [
|
|
494
|
+
...errors,
|
|
495
|
+
...fields.flatMap((entry) => entry.errors),
|
|
496
|
+
...includes.flatMap((entry) => entry.errors),
|
|
497
|
+
];
|
|
498
|
+
return {
|
|
499
|
+
value,
|
|
500
|
+
errors: combinedErrors,
|
|
501
|
+
cacheSnapshot: {
|
|
502
|
+
view: {
|
|
503
|
+
signature: viewSignature,
|
|
504
|
+
value,
|
|
505
|
+
errors: combinedErrors,
|
|
506
|
+
},
|
|
507
|
+
fields: Object.fromEntries(fields.map((entry, index) => [`${id}.field.${index}`, entry.cacheEntry])),
|
|
508
|
+
includes: Object.fromEntries(includes.map((entry, index) => {
|
|
509
|
+
const includeKey = `${id}.include.${index}`;
|
|
510
|
+
const include = rawIncludes[index];
|
|
511
|
+
return [
|
|
512
|
+
includeKey,
|
|
513
|
+
{
|
|
514
|
+
include: entry.cacheEntry,
|
|
515
|
+
fields: Object.fromEntries((include?.fields || []).map((field, fieldIndex) => [
|
|
516
|
+
`${includeKey}.field.${fieldIndex}`,
|
|
517
|
+
resolveNormalizedSegment(createNormalizeSignature(field), previous?.includes[includeKey]?.fields[`${includeKey}.field.${fieldIndex}`], () => {
|
|
518
|
+
const fieldErrors = [];
|
|
519
|
+
return {
|
|
520
|
+
value: normalizeFormFieldLike(field, includeKey, fieldErrors, projectRoot, compilerLanguage, readFile),
|
|
521
|
+
errors: fieldErrors,
|
|
522
|
+
};
|
|
523
|
+
}).cacheEntry,
|
|
524
|
+
])),
|
|
525
|
+
},
|
|
526
|
+
];
|
|
527
|
+
})),
|
|
528
|
+
},
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
function resolveNormalizedReadView(raw, id, previous, projectRoot, appStyle, compilerLanguage = 'typescript', readFile) {
|
|
532
|
+
const viewSignature = createNormalizeSignature(raw);
|
|
533
|
+
if (previous?.view?.signature === viewSignature) {
|
|
534
|
+
return {
|
|
535
|
+
value: previous.view.value,
|
|
536
|
+
errors: previous.view.errors,
|
|
537
|
+
cacheSnapshot: previous,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
const fields = (raw.fields || []).map((field, index) => {
|
|
541
|
+
const fieldKey = `${id}.field.${index}`;
|
|
542
|
+
return resolveNormalizedSegment(createNormalizeSignature(field), previous?.fields[fieldKey], () => {
|
|
543
|
+
const errors = [];
|
|
544
|
+
return {
|
|
545
|
+
value: normalizeColumn(field, `${id}.field.${field.field}`, errors, projectRoot, compilerLanguage, readFile),
|
|
546
|
+
errors,
|
|
547
|
+
};
|
|
548
|
+
});
|
|
549
|
+
});
|
|
550
|
+
const related = (raw.related || []).map((field, index) => {
|
|
551
|
+
const relatedKey = `${id}.related.${index}`;
|
|
552
|
+
return resolveNormalizedSegment(createNormalizeSignature(field), previous?.related[relatedKey], () => ({
|
|
553
|
+
value: {
|
|
554
|
+
id: `${id}.related.${field}`,
|
|
555
|
+
kind: 'relatedPanel',
|
|
556
|
+
field,
|
|
557
|
+
sourceSpan: undefined,
|
|
558
|
+
},
|
|
559
|
+
errors: [],
|
|
560
|
+
}));
|
|
561
|
+
});
|
|
562
|
+
const errors = [
|
|
563
|
+
...fields.flatMap((entry) => entry.errors),
|
|
564
|
+
...related.flatMap((entry) => entry.errors),
|
|
565
|
+
];
|
|
566
|
+
const value = normalizeReadView(raw, id, fields.map((entry) => entry.value), related.map((entry) => entry.value), errors, appStyle);
|
|
567
|
+
return {
|
|
568
|
+
value,
|
|
569
|
+
errors,
|
|
570
|
+
cacheSnapshot: {
|
|
571
|
+
view: {
|
|
572
|
+
signature: viewSignature,
|
|
573
|
+
value,
|
|
574
|
+
errors,
|
|
575
|
+
},
|
|
576
|
+
fields: Object.fromEntries(fields.map((entry, index) => [`${id}.field.${index}`, entry.cacheEntry])),
|
|
577
|
+
related: Object.fromEntries(related.map((entry, index) => [`${id}.related.${index}`, entry.cacheEntry])),
|
|
578
|
+
},
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
function resolveNormalizedPage(raw, previous, projectRoot, appStyle, readFile) {
|
|
582
|
+
const pageSignature = createNormalizeSignature(raw);
|
|
583
|
+
if (previous?.page?.signature === pageSignature) {
|
|
584
|
+
return {
|
|
585
|
+
value: previous.page.value,
|
|
586
|
+
errors: previous.page.errors,
|
|
587
|
+
cacheSnapshot: previous,
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
const id = `page.${raw.name}`;
|
|
591
|
+
const blocks = raw.blocks.map((block, index) => resolveNormalizedSegment(createNormalizeSignature(block), previous?.blocks[`${id}.block.${index}`], () => {
|
|
592
|
+
const errors = [];
|
|
593
|
+
return {
|
|
594
|
+
value: normalizeDashboardBlock(block, `${id}.block.${index}`, errors, projectRoot, appStyle),
|
|
595
|
+
errors,
|
|
596
|
+
};
|
|
597
|
+
}));
|
|
598
|
+
const errors = blocks.flatMap((entry) => entry.errors);
|
|
599
|
+
const style = raw.style ? normalizeStyleReference(raw.style, appStyle, `${id}.style`, errors, 'page style') : undefined;
|
|
600
|
+
const seo = raw.seo
|
|
601
|
+
? {
|
|
602
|
+
description: raw.seo.description
|
|
603
|
+
? normalizeMessageLike(raw.seo.description, `${id}.seo.description`, errors, 'page seo description')
|
|
604
|
+
: undefined,
|
|
605
|
+
canonicalPath: normalizeCanonicalPath(raw.seo.canonicalPath, `${id}.seo.canonicalPath`, errors),
|
|
606
|
+
image: raw.seo.image
|
|
607
|
+
? normalizeAssetLink(raw.seo.image, raw.sourceSpan?.file, projectRoot, readFile, errors, `${id}.seo.image`, 'page seo image')
|
|
608
|
+
: undefined,
|
|
609
|
+
noIndex: raw.seo.noIndex === true,
|
|
610
|
+
}
|
|
611
|
+
: undefined;
|
|
612
|
+
const value = {
|
|
613
|
+
id,
|
|
614
|
+
kind: 'page',
|
|
615
|
+
name: raw.name,
|
|
616
|
+
title: normalizeMessageLike(raw.title, `${id}.title`, errors, 'page title'),
|
|
617
|
+
style,
|
|
618
|
+
seo,
|
|
619
|
+
pageType: raw.type === 'dashboard' ? 'dashboard' : 'custom',
|
|
620
|
+
path: raw.path,
|
|
621
|
+
layout: raw.layout,
|
|
622
|
+
actions: (raw.actions || []).map((action, index) => normalizePageAction(action, `${id}.action.${index}`)),
|
|
623
|
+
blocks: blocks.map((entry) => entry.value),
|
|
624
|
+
sourceSpan: raw.sourceSpan,
|
|
625
|
+
};
|
|
626
|
+
return {
|
|
627
|
+
value,
|
|
628
|
+
errors,
|
|
629
|
+
cacheSnapshot: {
|
|
630
|
+
page: {
|
|
631
|
+
signature: pageSignature,
|
|
632
|
+
value,
|
|
633
|
+
errors,
|
|
634
|
+
},
|
|
635
|
+
blocks: Object.fromEntries(blocks.map((entry, index) => [`${id}.block.${index}`, entry.cacheEntry])),
|
|
636
|
+
},
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
// ─── Helpers ─────────────────────────────────────────────────────
|
|
640
|
+
function normalizeAppShell(ast, projectRoot, readFile) {
|
|
641
|
+
const errors = [];
|
|
642
|
+
const style = ast.app?.style
|
|
643
|
+
? normalizeStyleLink(ast.app.style, ast.app.sourceSpan?.file, projectRoot, readFile, errors, 'app.main', 'app style')
|
|
644
|
+
: undefined;
|
|
645
|
+
const seo = ast.app?.seo
|
|
646
|
+
? {
|
|
647
|
+
siteName: ast.app.seo.siteName
|
|
648
|
+
? normalizeMessageLike(ast.app.seo.siteName, 'app.main.seo.siteName', errors, 'app seo siteName')
|
|
649
|
+
: undefined,
|
|
650
|
+
defaultTitle: ast.app.seo.defaultTitle
|
|
651
|
+
? normalizeMessageLike(ast.app.seo.defaultTitle, 'app.main.seo.defaultTitle', errors, 'app seo defaultTitle')
|
|
652
|
+
: undefined,
|
|
653
|
+
titleTemplate: ast.app.seo.titleTemplate
|
|
654
|
+
? normalizeMessageLike(ast.app.seo.titleTemplate, 'app.main.seo.titleTemplate', errors, 'app seo titleTemplate')
|
|
655
|
+
: undefined,
|
|
656
|
+
defaultDescription: ast.app.seo.defaultDescription
|
|
657
|
+
? normalizeMessageLike(ast.app.seo.defaultDescription, 'app.main.seo.defaultDescription', errors, 'app seo defaultDescription')
|
|
658
|
+
: undefined,
|
|
659
|
+
defaultImage: ast.app.seo.defaultImage
|
|
660
|
+
? normalizeAssetLink(ast.app.seo.defaultImage, ast.app.sourceSpan?.file, projectRoot, readFile, errors, 'app.main.seo.defaultImage', 'app seo defaultImage')
|
|
661
|
+
: undefined,
|
|
662
|
+
favicon: ast.app.seo.favicon
|
|
663
|
+
? normalizeAssetLink(ast.app.seo.favicon, ast.app.sourceSpan?.file, projectRoot, readFile, errors, 'app.main.seo.favicon', 'app seo favicon')
|
|
664
|
+
: undefined,
|
|
665
|
+
}
|
|
666
|
+
: undefined;
|
|
667
|
+
return {
|
|
668
|
+
value: {
|
|
669
|
+
name: ast.app?.name || 'Untitled',
|
|
670
|
+
compiler: normalizeCompiler(ast.compiler, errors),
|
|
671
|
+
theme: normalizeTheme(ast.app?.theme, errors),
|
|
672
|
+
auth: normalizeAuth(ast.app?.auth, errors),
|
|
673
|
+
style,
|
|
674
|
+
seo,
|
|
675
|
+
sourceSpan: ast.app?.sourceSpan,
|
|
676
|
+
},
|
|
677
|
+
errors,
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
function normalizeTheme(theme, errors) {
|
|
681
|
+
if (theme === undefined)
|
|
682
|
+
return 'light';
|
|
683
|
+
if (theme === 'dark')
|
|
684
|
+
return 'dark';
|
|
685
|
+
if (theme !== 'light') {
|
|
686
|
+
errors.push({
|
|
687
|
+
message: `Invalid app theme "${theme}". Expected "light" or "dark".`,
|
|
688
|
+
nodeId: 'app.main',
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
return 'light';
|
|
692
|
+
}
|
|
693
|
+
function normalizeAuth(auth, errors) {
|
|
694
|
+
if (auth === undefined)
|
|
695
|
+
return 'none';
|
|
696
|
+
if (auth === 'jwt')
|
|
697
|
+
return 'jwt';
|
|
698
|
+
if (auth === 'session')
|
|
699
|
+
return 'session';
|
|
700
|
+
if (auth !== 'none') {
|
|
701
|
+
errors.push({
|
|
702
|
+
message: `Invalid auth mode "${auth}". Expected "jwt", "session", or "none".`,
|
|
703
|
+
nodeId: 'app.main',
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
return 'none';
|
|
707
|
+
}
|
|
708
|
+
function normalizeCompiler(compiler, errors) {
|
|
709
|
+
const target = compiler?.target;
|
|
710
|
+
if (target === undefined || target === 'react') {
|
|
711
|
+
return {
|
|
712
|
+
target: 'react',
|
|
713
|
+
language: 'typescript',
|
|
714
|
+
sourceSpan: compiler?.sourceSpan,
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
errors.push({
|
|
718
|
+
message: `Invalid compiler target "${target}". Expected "react".`,
|
|
719
|
+
nodeId: 'app.main',
|
|
720
|
+
});
|
|
721
|
+
return {
|
|
722
|
+
target: 'react',
|
|
723
|
+
language: 'typescript',
|
|
724
|
+
sourceSpan: compiler?.sourceSpan,
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
// ─── Navigation ──────────────────────────────────────────────────
|
|
728
|
+
function normalizeNavItem(raw, id) {
|
|
729
|
+
return {
|
|
730
|
+
id,
|
|
731
|
+
kind: 'navItem',
|
|
732
|
+
label: typeof raw.label === 'string' ? raw.label : normalizeMessageDescriptor(raw.label, `${id}.label`, [], 'navigation label'),
|
|
733
|
+
icon: raw.icon,
|
|
734
|
+
target: raw.target,
|
|
735
|
+
sourceSpan: raw.sourceSpan,
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
function normalizeReadModelField(raw, id, section) {
|
|
739
|
+
const errors = [];
|
|
740
|
+
return {
|
|
741
|
+
value: {
|
|
742
|
+
id,
|
|
743
|
+
kind: 'readModel.field',
|
|
744
|
+
name: raw.name,
|
|
745
|
+
section,
|
|
746
|
+
fieldType: normalizeFieldType(raw.typeExpr, id, errors),
|
|
747
|
+
decorators: raw.decorators.map((decorator) => normalizeDecorator(decorator)),
|
|
748
|
+
sourceSpan: raw.sourceSpan,
|
|
749
|
+
},
|
|
750
|
+
errors,
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
function normalizeModelField(raw, id) {
|
|
754
|
+
const errors = [];
|
|
755
|
+
return {
|
|
756
|
+
value: {
|
|
757
|
+
id,
|
|
758
|
+
kind: 'field',
|
|
759
|
+
name: raw.name,
|
|
760
|
+
fieldType: normalizeFieldType(raw.typeExpr, id, errors),
|
|
761
|
+
decorators: raw.decorators.map(d => normalizeDecorator(d)),
|
|
762
|
+
sourceSpan: raw.sourceSpan,
|
|
763
|
+
},
|
|
764
|
+
errors,
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
function normalizeFieldType(typeExpr, nodeId, errors) {
|
|
768
|
+
const relationMatch = typeExpr.match(/^belongsTo\(\s*([^)]+?)\s*\)$/);
|
|
769
|
+
if (relationMatch) {
|
|
770
|
+
return {
|
|
771
|
+
type: 'relation',
|
|
772
|
+
kind: 'belongsTo',
|
|
773
|
+
target: relationMatch[1].trim(),
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
if (typeExpr.startsWith('belongsTo(')) {
|
|
777
|
+
errors.push({
|
|
778
|
+
message: 'belongsTo() must use the form belongsTo(Target)',
|
|
779
|
+
nodeId,
|
|
780
|
+
});
|
|
781
|
+
return { type: 'scalar', name: 'string' };
|
|
782
|
+
}
|
|
783
|
+
const hasManyMatch = typeExpr.match(/^hasMany\(\s*([^,]+?)\s*,\s*by:\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)$/);
|
|
784
|
+
if (hasManyMatch) {
|
|
785
|
+
return {
|
|
786
|
+
type: 'relation',
|
|
787
|
+
kind: 'hasMany',
|
|
788
|
+
target: hasManyMatch[1].trim(),
|
|
789
|
+
by: hasManyMatch[2].trim(),
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
if (typeExpr.startsWith('hasMany(')) {
|
|
793
|
+
errors.push({
|
|
794
|
+
message: 'hasMany() must use the form hasMany(Target, by: relationField)',
|
|
795
|
+
nodeId,
|
|
796
|
+
});
|
|
797
|
+
return { type: 'scalar', name: 'string' };
|
|
798
|
+
}
|
|
799
|
+
const enumMatch = typeExpr.match(/^enum\(([^)]+)\)$/);
|
|
800
|
+
if (enumMatch) {
|
|
801
|
+
const values = enumMatch[1].split(',').map(v => v.trim());
|
|
802
|
+
return { type: 'enum', values };
|
|
803
|
+
}
|
|
804
|
+
const scalarTypes = ['string', 'number', 'boolean', 'datetime'];
|
|
805
|
+
for (const t of scalarTypes) {
|
|
806
|
+
if (typeExpr === t) {
|
|
807
|
+
return { type: 'scalar', name: t };
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
// Default to string for unknown types (with a warning in a real implementation)
|
|
811
|
+
return { type: 'scalar', name: 'string' };
|
|
812
|
+
}
|
|
813
|
+
function normalizeDecorator(raw) {
|
|
814
|
+
const dec = { name: raw.name };
|
|
815
|
+
if (raw.args) {
|
|
816
|
+
dec.args = parseDecoratorArgs(raw.args);
|
|
817
|
+
}
|
|
818
|
+
return dec;
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* Parse decorator arguments like:
|
|
822
|
+
* admin:red, editor:blue → { admin: "red", editor: "blue" }
|
|
823
|
+
* 2 → { value: 2 }
|
|
824
|
+
* "Delete this user?" → { value: "Delete this user?" }
|
|
825
|
+
* mode == "edit" → { expr: 'mode == "edit"' }
|
|
826
|
+
* "./components/Cell.tsx" → { path: "./components/Cell.tsx" }
|
|
827
|
+
*/
|
|
828
|
+
function parseDecoratorArgs(args) {
|
|
829
|
+
const trimmed = args.trim();
|
|
830
|
+
// Check if it's a file path (escape hatch)
|
|
831
|
+
if (trimmed.startsWith('"./') || trimmed.startsWith("'./")) {
|
|
832
|
+
return { path: trimmed.replace(/^["']|["']$/g, '') };
|
|
833
|
+
}
|
|
834
|
+
// Check if it's a quoted string
|
|
835
|
+
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
|
836
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
837
|
+
return { value: trimmed.slice(1, -1) };
|
|
838
|
+
}
|
|
839
|
+
// Check if it's a number
|
|
840
|
+
if (/^\d+$/.test(trimmed)) {
|
|
841
|
+
return { value: Number(trimmed) };
|
|
842
|
+
}
|
|
843
|
+
// Check if it's a key:value map (admin:red, editor:blue)
|
|
844
|
+
if (trimmed.includes(':') && !trimmed.includes('==')) {
|
|
845
|
+
const entries = {};
|
|
846
|
+
const pairs = trimmed.split(',').map(s => s.trim());
|
|
847
|
+
for (const pair of pairs) {
|
|
848
|
+
const [k, v] = pair.split(':').map(s => s.trim());
|
|
849
|
+
if (k && v)
|
|
850
|
+
entries[k] = v;
|
|
851
|
+
}
|
|
852
|
+
return entries;
|
|
853
|
+
}
|
|
854
|
+
// Check if it's an expression
|
|
855
|
+
if (trimmed.includes('==') || trimmed.includes('!=') || trimmed.includes('.')) {
|
|
856
|
+
return { expr: trimmed };
|
|
857
|
+
}
|
|
858
|
+
return { value: trimmed };
|
|
859
|
+
}
|
|
860
|
+
// ─── Resource ────────────────────────────────────────────────────
|
|
861
|
+
function normalizeColumn(raw, id, errors, projectRoot, compilerLanguage = 'typescript', readFile) {
|
|
862
|
+
const column = {
|
|
863
|
+
id,
|
|
864
|
+
kind: 'column',
|
|
865
|
+
field: raw.field,
|
|
866
|
+
decorators: raw.decorators.map(d => ({
|
|
867
|
+
name: d.name,
|
|
868
|
+
args: d.args ? parseDecoratorArgs(d.args) : undefined,
|
|
869
|
+
})),
|
|
870
|
+
sourceSpan: raw.sourceSpan,
|
|
871
|
+
};
|
|
872
|
+
// Logic escape tier 1: @expr() for computed label
|
|
873
|
+
const exprDec = raw.decorators.find(d => d.name === 'expr');
|
|
874
|
+
if (exprDec?.args) {
|
|
875
|
+
column.dynamicLabel = {
|
|
876
|
+
source: 'escape-expr',
|
|
877
|
+
escape: { tier: 'expr', raw: exprDec.args },
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
// Logic escape tier 2: @fn() for display transform
|
|
881
|
+
const fnDec = raw.decorators.find(d => d.name === 'fn');
|
|
882
|
+
if (fnDec?.args) {
|
|
883
|
+
const parsed = parseDecoratorArgs(fnDec.args);
|
|
884
|
+
if (typeof parsed['path'] === 'string') {
|
|
885
|
+
column.displayFn = normalizeFnEscape(parsed['path'], raw.sourceSpan?.file, projectRoot, compilerLanguage, readFile, errors, id, 'column @fn');
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
// UI escape: @custom() for full React cell renderer
|
|
889
|
+
const customDec = raw.decorators.find(d => d.name === 'custom');
|
|
890
|
+
if (customDec?.args) {
|
|
891
|
+
const parsed = parseDecoratorArgs(customDec.args);
|
|
892
|
+
if (typeof parsed['path'] === 'string') {
|
|
893
|
+
column.customRenderer = normalizeEscapePath(parsed['path'], raw.sourceSpan?.file, projectRoot, errors, id, 'column @custom');
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
return column;
|
|
897
|
+
}
|
|
898
|
+
function normalizeAction(raw, id) {
|
|
899
|
+
const action = {
|
|
900
|
+
id,
|
|
901
|
+
kind: 'action',
|
|
902
|
+
name: raw.name,
|
|
903
|
+
sourceSpan: raw.sourceSpan,
|
|
904
|
+
};
|
|
905
|
+
const confirmDec = raw.decorators.find(d => d.name === 'confirm');
|
|
906
|
+
if (confirmDec?.args) {
|
|
907
|
+
const parsed = parseDecoratorArgs(confirmDec.args);
|
|
908
|
+
action.confirm = typeof parsed['value'] === 'string' ? parsed['value'] : String(confirmDec.args);
|
|
909
|
+
}
|
|
910
|
+
return action;
|
|
911
|
+
}
|
|
912
|
+
function normalizeEditView(raw, id, fields, includes, errors, appStyle, projectRoot, compilerLanguage = 'typescript', readFile) {
|
|
913
|
+
const edit = raw;
|
|
914
|
+
const rules = typeof edit.rules === 'string'
|
|
915
|
+
? undefined
|
|
916
|
+
: edit.rules
|
|
917
|
+
? normalizeRules(edit.rules, id, errors, edit.sourceSpan?.file, projectRoot, compilerLanguage, readFile)
|
|
918
|
+
: undefined;
|
|
919
|
+
const rulesLink = typeof edit.rules === 'string'
|
|
920
|
+
? normalizeRulesLink(edit.rules, edit.sourceSpan?.file, projectRoot, readFile, errors, id, 'edit rules')
|
|
921
|
+
: undefined;
|
|
922
|
+
return {
|
|
923
|
+
id,
|
|
924
|
+
kind: 'view.edit',
|
|
925
|
+
style: raw.style ? normalizeStyleReference(raw.style, appStyle, `${id}.style`, errors, 'edit style') : undefined,
|
|
926
|
+
fields,
|
|
927
|
+
includes,
|
|
928
|
+
rules,
|
|
929
|
+
rulesLink,
|
|
930
|
+
onSuccess: (edit.onSuccess || []).map(e => normalizeEffect(e, id, errors)),
|
|
931
|
+
sourceSpan: edit.sourceSpan,
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
function normalizeCreateView(raw, id, fields, includes, errors, appStyle, projectRoot, compilerLanguage = 'typescript', readFile) {
|
|
935
|
+
const create = raw;
|
|
936
|
+
const rules = typeof create.rules === 'string'
|
|
937
|
+
? undefined
|
|
938
|
+
: create.rules
|
|
939
|
+
? normalizeRules(create.rules, id, errors, create.sourceSpan?.file, projectRoot, compilerLanguage, readFile)
|
|
940
|
+
: undefined;
|
|
941
|
+
const rulesLink = typeof create.rules === 'string'
|
|
942
|
+
? normalizeRulesLink(create.rules, create.sourceSpan?.file, projectRoot, readFile, errors, id, 'create rules')
|
|
943
|
+
: undefined;
|
|
944
|
+
return {
|
|
945
|
+
id,
|
|
946
|
+
kind: 'view.create',
|
|
947
|
+
style: raw.style ? normalizeStyleReference(raw.style, appStyle, `${id}.style`, errors, 'create style') : undefined,
|
|
948
|
+
fields,
|
|
949
|
+
includes,
|
|
950
|
+
rules,
|
|
951
|
+
rulesLink,
|
|
952
|
+
onSuccess: (create.onSuccess || []).map(e => normalizeEffect(e, id, errors)),
|
|
953
|
+
sourceSpan: create.sourceSpan,
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
function normalizeReadView(raw, id, fields, related, errors, appStyle) {
|
|
957
|
+
return {
|
|
958
|
+
id,
|
|
959
|
+
kind: 'view.read',
|
|
960
|
+
title: normalizeMessageLike(raw.title, `${id}.title`, errors, 'read title'),
|
|
961
|
+
style: raw.style ? normalizeStyleReference(raw.style, appStyle, `${id}.style`, errors, 'read style') : undefined,
|
|
962
|
+
fields,
|
|
963
|
+
related,
|
|
964
|
+
sourceSpan: raw.sourceSpan,
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
function normalizeFormFieldLike(raw, viewId, errors, projectRoot, compilerLanguage = 'typescript', readFile) {
|
|
968
|
+
if (typeof raw === 'string') {
|
|
969
|
+
const parsed = parseColumnEntry(raw);
|
|
970
|
+
return {
|
|
971
|
+
id: `${viewId}.field.${parsed.field}`,
|
|
972
|
+
kind: 'formField',
|
|
973
|
+
field: parsed.field,
|
|
974
|
+
decorators: parsed.decorators.map((decorator) => normalizeDecorator(decorator)),
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
return normalizeFormField(raw, `${viewId}.field.${raw.field}`, errors, projectRoot, compilerLanguage, readFile);
|
|
978
|
+
}
|
|
979
|
+
function normalizeFormField(raw, id, errors, projectRoot, compilerLanguage = 'typescript', readFile) {
|
|
980
|
+
const field = {
|
|
981
|
+
id,
|
|
982
|
+
kind: 'formField',
|
|
983
|
+
field: raw.field,
|
|
984
|
+
decorators: raw.decorators.map(d => normalizeDecorator(d)),
|
|
985
|
+
sourceSpan: raw.sourceSpan,
|
|
986
|
+
};
|
|
987
|
+
// Logic escape tier 1: @expr() for visibility
|
|
988
|
+
const exprDec = raw.decorators.find(d => d.name === 'expr');
|
|
989
|
+
if (exprDec?.args) {
|
|
990
|
+
field.visibleWhen = {
|
|
991
|
+
source: 'escape-expr',
|
|
992
|
+
escape: { tier: 'expr', raw: exprDec.args },
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
// Logic escape tier 2: @fn() for validation
|
|
996
|
+
const fnDec = raw.decorators.find(d => d.name === 'fn');
|
|
997
|
+
if (fnDec?.args) {
|
|
998
|
+
const parsed = parseDecoratorArgs(fnDec.args);
|
|
999
|
+
if (typeof parsed['path'] === 'string') {
|
|
1000
|
+
field.validateFn = normalizeFnEscape(parsed['path'], raw.sourceSpan?.file, projectRoot, compilerLanguage, readFile, errors, id, 'field validate @fn');
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
// UI escape: @custom() for full React field component
|
|
1004
|
+
const customDec = raw.decorators.find(d => d.name === 'custom');
|
|
1005
|
+
if (customDec?.args) {
|
|
1006
|
+
const parsed = parseDecoratorArgs(customDec.args);
|
|
1007
|
+
if (typeof parsed['path'] === 'string') {
|
|
1008
|
+
field.customField = normalizeEscapePath(parsed['path'], raw.sourceSpan?.file, projectRoot, errors, id, 'field @custom');
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
if (raw.rules?.visibleIf) {
|
|
1012
|
+
field.visibleWhen = tryParseRuleValue(raw.rules.visibleIf, `${id}.rules.visibleIf`, errors, raw.sourceSpan?.file, projectRoot, compilerLanguage, readFile, 'field visibleIf rule');
|
|
1013
|
+
}
|
|
1014
|
+
if (raw.rules?.enabledIf) {
|
|
1015
|
+
field.enabledWhen = tryParseRuleValue(raw.rules.enabledIf, `${id}.rules.enabledIf`, errors, raw.sourceSpan?.file, projectRoot, compilerLanguage, readFile, 'field enabledIf rule');
|
|
1016
|
+
}
|
|
1017
|
+
return field;
|
|
1018
|
+
}
|
|
1019
|
+
// ─── Rule Value Parser ───────────────────────────────────────────
|
|
1020
|
+
// Detects the tier of a rule string:
|
|
1021
|
+
// @expr(...) → escape-expr (Tier 1)
|
|
1022
|
+
// @fn(...) → escape-fn (Tier 2)
|
|
1023
|
+
// plain expression → builtin (Tier 0)
|
|
1024
|
+
function tryParseRuleValue(input, nodeId, errors, sourceFile, projectRoot, compilerLanguage = 'typescript', readFile, label = 'rule') {
|
|
1025
|
+
const trimmed = input.trim();
|
|
1026
|
+
// Tier 1: @expr("TS expression")
|
|
1027
|
+
const exprMatch = trimmed.match(/^@expr\((.+)\)$/);
|
|
1028
|
+
if (exprMatch) {
|
|
1029
|
+
return {
|
|
1030
|
+
source: 'escape-expr',
|
|
1031
|
+
escape: { tier: 'expr', raw: exprMatch[1] },
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
// Tier 2: @fn("./path/to/file.ts")
|
|
1035
|
+
const fnMatch = trimmed.match(/^@fn\(["'](.+?)["']\)$/);
|
|
1036
|
+
if (fnMatch) {
|
|
1037
|
+
return {
|
|
1038
|
+
source: 'escape-fn',
|
|
1039
|
+
escape: normalizeFnEscape(fnMatch[1], sourceFile, projectRoot, compilerLanguage, readFile, errors, nodeId, label),
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
1042
|
+
// Tier 0: built-in DSL expression
|
|
1043
|
+
const expr = tryParseExpr(trimmed, nodeId, errors);
|
|
1044
|
+
if (expr) {
|
|
1045
|
+
return { source: 'builtin', expr };
|
|
1046
|
+
}
|
|
1047
|
+
return undefined;
|
|
1048
|
+
}
|
|
1049
|
+
function normalizeRules(raw, nodeId, errors, sourceFile, projectRoot, compilerLanguage = 'typescript', readFile) {
|
|
1050
|
+
const rules = {};
|
|
1051
|
+
if (raw.visibleIf)
|
|
1052
|
+
rules.visibleIf = tryParseRuleValue(raw.visibleIf, nodeId, errors, sourceFile, projectRoot, compilerLanguage, readFile, 'visibleIf rule');
|
|
1053
|
+
if (raw.enabledIf)
|
|
1054
|
+
rules.enabledIf = tryParseRuleValue(raw.enabledIf, nodeId, errors, sourceFile, projectRoot, compilerLanguage, readFile, 'enabledIf rule');
|
|
1055
|
+
if (raw.allowIf)
|
|
1056
|
+
rules.allowIf = tryParseRuleValue(raw.allowIf, nodeId, errors, sourceFile, projectRoot, compilerLanguage, readFile, 'allowIf rule');
|
|
1057
|
+
if (raw.enforce)
|
|
1058
|
+
rules.enforce = tryParseRuleValue(raw.enforce, nodeId, errors, sourceFile, projectRoot, compilerLanguage, readFile, 'enforce rule');
|
|
1059
|
+
return rules;
|
|
1060
|
+
}
|
|
1061
|
+
function normalizeEffect(raw, nodeId, errors) {
|
|
1062
|
+
switch (raw.type) {
|
|
1063
|
+
case 'refresh':
|
|
1064
|
+
return { type: 'refresh', target: typeof raw.value === 'string' ? raw.value : '' };
|
|
1065
|
+
case 'invalidate':
|
|
1066
|
+
return { type: 'invalidate', target: typeof raw.value === 'string' ? raw.value : '' };
|
|
1067
|
+
case 'toast':
|
|
1068
|
+
return { type: 'toast', message: normalizeToastMessage(raw.value, nodeId, errors) };
|
|
1069
|
+
case 'redirect':
|
|
1070
|
+
return { type: 'redirect', target: typeof raw.value === 'string' ? raw.value : '' };
|
|
1071
|
+
case 'openDialog':
|
|
1072
|
+
return { type: 'openDialog', dialog: typeof raw.value === 'string' ? raw.value : '' };
|
|
1073
|
+
case 'emitEvent':
|
|
1074
|
+
return { type: 'emitEvent', event: typeof raw.value === 'string' ? raw.value : '' };
|
|
1075
|
+
default:
|
|
1076
|
+
return { type: 'toast', message: `Unknown effect: ${raw.type}` };
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
function normalizeToastMessage(raw, nodeId, errors) {
|
|
1080
|
+
if (typeof raw === 'string') {
|
|
1081
|
+
return raw;
|
|
1082
|
+
}
|
|
1083
|
+
return normalizeToastDescriptor(raw, nodeId, errors);
|
|
1084
|
+
}
|
|
1085
|
+
function normalizeMessageLike(raw, nodeId, errors, label) {
|
|
1086
|
+
if (typeof raw === 'string' || raw === undefined) {
|
|
1087
|
+
return raw ?? '';
|
|
1088
|
+
}
|
|
1089
|
+
return normalizeMessageDescriptor(raw, nodeId, errors, label);
|
|
1090
|
+
}
|
|
1091
|
+
function normalizeMessageDescriptor(raw, nodeId, errors, label) {
|
|
1092
|
+
const key = typeof raw.key === 'string' ? raw.key.trim() : '';
|
|
1093
|
+
const defaultMessage = typeof raw.defaultMessage === 'string' ? raw.defaultMessage : undefined;
|
|
1094
|
+
if (!key && (!defaultMessage || defaultMessage.trim().length === 0)) {
|
|
1095
|
+
errors.push({
|
|
1096
|
+
message: `${label} descriptor must define a non-empty "key" and/or "defaultMessage"`,
|
|
1097
|
+
nodeId,
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
const values = raw.values
|
|
1101
|
+
? Object.fromEntries(Object.entries(raw.values).flatMap(([name, value]) => {
|
|
1102
|
+
if (typeof value === 'object' && value !== null && 'ref' in value) {
|
|
1103
|
+
errors.push({
|
|
1104
|
+
message: `${label} descriptor values may only use scalar literals in the current slice`,
|
|
1105
|
+
nodeId,
|
|
1106
|
+
});
|
|
1107
|
+
return [];
|
|
1108
|
+
}
|
|
1109
|
+
return [[name, value]];
|
|
1110
|
+
}))
|
|
1111
|
+
: undefined;
|
|
1112
|
+
const descriptor = {};
|
|
1113
|
+
if (key) {
|
|
1114
|
+
descriptor.key = key;
|
|
1115
|
+
}
|
|
1116
|
+
if (defaultMessage && defaultMessage.length > 0) {
|
|
1117
|
+
descriptor.defaultMessage = defaultMessage;
|
|
1118
|
+
}
|
|
1119
|
+
if (values && Object.keys(values).length > 0) {
|
|
1120
|
+
descriptor.values = values;
|
|
1121
|
+
}
|
|
1122
|
+
return descriptor;
|
|
1123
|
+
}
|
|
1124
|
+
function normalizeToastDescriptor(raw, nodeId, errors) {
|
|
1125
|
+
const key = typeof raw.key === 'string' ? raw.key.trim() : '';
|
|
1126
|
+
if (!key) {
|
|
1127
|
+
errors.push({
|
|
1128
|
+
message: 'toast descriptor must define a non-empty "key"',
|
|
1129
|
+
nodeId,
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
1132
|
+
const values = raw.values
|
|
1133
|
+
? Object.fromEntries(Object.entries(raw.values).map(([name, value]) => [name, normalizeToastValue(name, value, nodeId, errors)]))
|
|
1134
|
+
: undefined;
|
|
1135
|
+
const descriptor = {
|
|
1136
|
+
key,
|
|
1137
|
+
};
|
|
1138
|
+
if (typeof raw.defaultMessage === 'string' && raw.defaultMessage.length > 0) {
|
|
1139
|
+
descriptor.defaultMessage = raw.defaultMessage;
|
|
1140
|
+
}
|
|
1141
|
+
if (values && Object.keys(values).length > 0) {
|
|
1142
|
+
descriptor.values = values;
|
|
1143
|
+
}
|
|
1144
|
+
return descriptor;
|
|
1145
|
+
}
|
|
1146
|
+
function normalizeToastValue(name, raw, nodeId, errors) {
|
|
1147
|
+
if (typeof raw === 'string'
|
|
1148
|
+
|| typeof raw === 'number'
|
|
1149
|
+
|| typeof raw === 'boolean'
|
|
1150
|
+
|| raw === null) {
|
|
1151
|
+
return raw;
|
|
1152
|
+
}
|
|
1153
|
+
const ref = typeof raw.ref === 'string' ? raw.ref.trim() : '';
|
|
1154
|
+
if (!ref) {
|
|
1155
|
+
errors.push({
|
|
1156
|
+
message: `toast.values.${name} must be a scalar or { ref: <path> }`,
|
|
1157
|
+
nodeId,
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
return { ref };
|
|
1161
|
+
}
|
|
1162
|
+
// ─── Expression Parse Helper ─────────────────────────────────────
|
|
1163
|
+
function tryParseExpr(input, nodeId, errors) {
|
|
1164
|
+
try {
|
|
1165
|
+
return parseExpr(input);
|
|
1166
|
+
}
|
|
1167
|
+
catch (err) {
|
|
1168
|
+
errors.push({
|
|
1169
|
+
message: `Invalid expression "${input}": ${err instanceof Error ? err.message : String(err)}`,
|
|
1170
|
+
nodeId,
|
|
1171
|
+
});
|
|
1172
|
+
return undefined;
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
function normalizeEscapePath(rawPath, sourceFile, projectRoot, errors, nodeId, label) {
|
|
1176
|
+
if (!projectRoot) {
|
|
1177
|
+
return rawPath;
|
|
1178
|
+
}
|
|
1179
|
+
if (!sourceFile) {
|
|
1180
|
+
errors.push({
|
|
1181
|
+
message: `${label} path "${rawPath}" cannot be resolved because the source file is unknown`,
|
|
1182
|
+
nodeId,
|
|
1183
|
+
});
|
|
1184
|
+
return rawPath;
|
|
1185
|
+
}
|
|
1186
|
+
const resolved = resolveProjectPath(dirnameProjectPath(sourceFile), rawPath);
|
|
1187
|
+
const projectRelative = toProjectRelativePath(projectRoot, resolved);
|
|
1188
|
+
if (!projectRelative) {
|
|
1189
|
+
errors.push({
|
|
1190
|
+
message: `${label} path "${rawPath}" resolves outside the project root`,
|
|
1191
|
+
nodeId,
|
|
1192
|
+
});
|
|
1193
|
+
return rawPath;
|
|
1194
|
+
}
|
|
1195
|
+
return projectRelative;
|
|
1196
|
+
}
|
|
1197
|
+
function normalizeFlowLink(input, sourceFile, projectRoot, readFile, errors, nodeId, label) {
|
|
1198
|
+
const trimmed = input.trim();
|
|
1199
|
+
const match = trimmed.match(FLOW_LINK_REGEX);
|
|
1200
|
+
if (!match) {
|
|
1201
|
+
errors.push({
|
|
1202
|
+
message: `${label} must use @flow("./path") syntax`,
|
|
1203
|
+
nodeId,
|
|
1204
|
+
});
|
|
1205
|
+
return undefined;
|
|
1206
|
+
}
|
|
1207
|
+
const rawPath = match[1];
|
|
1208
|
+
const explicitFlowPath = isFlowSourceFile(rawPath);
|
|
1209
|
+
if (!explicitFlowPath && hasExplicitExtension(rawPath)) {
|
|
1210
|
+
errors.push({
|
|
1211
|
+
message: `${label} "@flow(\\"${rawPath}\\")" must use an extensionless path or explicit .flow.loj suffix`,
|
|
1212
|
+
nodeId,
|
|
1213
|
+
});
|
|
1214
|
+
return undefined;
|
|
1215
|
+
}
|
|
1216
|
+
const requestedPath = explicitFlowPath ? rawPath : `${rawPath}.flow.loj`;
|
|
1217
|
+
const resolvedPath = normalizeEscapePath(rawPath === requestedPath ? rawPath : requestedPath, sourceFile, projectRoot, errors, nodeId, label);
|
|
1218
|
+
if (!readFile) {
|
|
1219
|
+
errors.push({
|
|
1220
|
+
message: `${label} "@flow(\\"${rawPath}\\")" cannot be resolved because file loading is unavailable`,
|
|
1221
|
+
nodeId,
|
|
1222
|
+
});
|
|
1223
|
+
return undefined;
|
|
1224
|
+
}
|
|
1225
|
+
if (!hostFileExists(resolvedPath, readFile)) {
|
|
1226
|
+
errors.push({
|
|
1227
|
+
message: `${label} "@flow(\\"${rawPath}\\")" did not resolve; expected ${resolvedPath}`,
|
|
1228
|
+
nodeId,
|
|
1229
|
+
});
|
|
1230
|
+
return undefined;
|
|
1231
|
+
}
|
|
1232
|
+
const compileResult = compileFlowSource(readFile(resolvedPath) ?? '', resolvedPath);
|
|
1233
|
+
for (const error of compileResult.errors) {
|
|
1234
|
+
const location = error.line !== undefined && error.col !== undefined
|
|
1235
|
+
? `:${error.line}:${error.col}`
|
|
1236
|
+
: '';
|
|
1237
|
+
errors.push({
|
|
1238
|
+
message: `${label} "@flow(\\"${rawPath}\\")" could not be compiled at ${resolvedPath}${location}: ${error.message}`,
|
|
1239
|
+
nodeId,
|
|
1240
|
+
});
|
|
1241
|
+
}
|
|
1242
|
+
if (!compileResult.success || !compileResult.program || !compileResult.manifest) {
|
|
1243
|
+
return undefined;
|
|
1244
|
+
}
|
|
1245
|
+
return {
|
|
1246
|
+
id: `${nodeId}.workflow`,
|
|
1247
|
+
kind: 'flow.link',
|
|
1248
|
+
logicalPath: explicitFlowPath ? undefined : rawPath,
|
|
1249
|
+
resolvedPath,
|
|
1250
|
+
lockIn: explicitFlowPath ? 'explicit' : 'neutral',
|
|
1251
|
+
program: compileResult.program,
|
|
1252
|
+
manifest: compileResult.manifest,
|
|
1253
|
+
sourceSpan: undefined,
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1256
|
+
function normalizeRulesLink(input, sourceFile, projectRoot, readFile, errors, nodeId, label) {
|
|
1257
|
+
const trimmed = input.trim();
|
|
1258
|
+
const match = trimmed.match(RULES_LINK_REGEX);
|
|
1259
|
+
if (!match) {
|
|
1260
|
+
errors.push({
|
|
1261
|
+
message: `${label} must use @rules("./path") syntax`,
|
|
1262
|
+
nodeId,
|
|
1263
|
+
});
|
|
1264
|
+
return undefined;
|
|
1265
|
+
}
|
|
1266
|
+
const rawPath = match[1];
|
|
1267
|
+
const explicitRulesPath = isRulesSourceFile(rawPath);
|
|
1268
|
+
if (!explicitRulesPath && hasExplicitExtension(rawPath)) {
|
|
1269
|
+
errors.push({
|
|
1270
|
+
message: `${label} "@rules(\\"${rawPath}\\")" must use an extensionless path or explicit .rules.loj suffix`,
|
|
1271
|
+
nodeId,
|
|
1272
|
+
});
|
|
1273
|
+
return undefined;
|
|
1274
|
+
}
|
|
1275
|
+
const requestedPath = explicitRulesPath ? rawPath : `${rawPath}.rules.loj`;
|
|
1276
|
+
const resolvedPath = normalizeEscapePath(rawPath === requestedPath ? rawPath : requestedPath, sourceFile, projectRoot, errors, nodeId, label);
|
|
1277
|
+
if (!readFile) {
|
|
1278
|
+
errors.push({
|
|
1279
|
+
message: `${label} "@rules(\\"${rawPath}\\")" cannot be resolved because file loading is unavailable`,
|
|
1280
|
+
nodeId,
|
|
1281
|
+
});
|
|
1282
|
+
return undefined;
|
|
1283
|
+
}
|
|
1284
|
+
if (!hostFileExists(resolvedPath, readFile)) {
|
|
1285
|
+
errors.push({
|
|
1286
|
+
message: `${label} "@rules(\\"${rawPath}\\")" did not resolve; expected ${resolvedPath}`,
|
|
1287
|
+
nodeId,
|
|
1288
|
+
});
|
|
1289
|
+
return undefined;
|
|
1290
|
+
}
|
|
1291
|
+
const compileResult = compileRulesSource(readFile(resolvedPath) ?? '', resolvedPath);
|
|
1292
|
+
for (const error of compileResult.errors) {
|
|
1293
|
+
const location = error.line !== undefined && error.col !== undefined
|
|
1294
|
+
? `:${error.line}:${error.col}`
|
|
1295
|
+
: '';
|
|
1296
|
+
errors.push({
|
|
1297
|
+
message: `${label} "@rules(\\"${rawPath}\\")" could not be compiled at ${resolvedPath}${location}: ${error.message}`,
|
|
1298
|
+
nodeId,
|
|
1299
|
+
});
|
|
1300
|
+
}
|
|
1301
|
+
if (!compileResult.success || !compileResult.program || !compileResult.manifest) {
|
|
1302
|
+
return undefined;
|
|
1303
|
+
}
|
|
1304
|
+
return {
|
|
1305
|
+
id: `${nodeId}.rules`,
|
|
1306
|
+
kind: 'rules.link',
|
|
1307
|
+
logicalPath: explicitRulesPath ? undefined : rawPath,
|
|
1308
|
+
resolvedPath,
|
|
1309
|
+
lockIn: explicitRulesPath ? 'explicit' : 'neutral',
|
|
1310
|
+
program: compileResult.program,
|
|
1311
|
+
manifest: compileResult.manifest,
|
|
1312
|
+
sourceSpan: undefined,
|
|
1313
|
+
};
|
|
1314
|
+
}
|
|
1315
|
+
function normalizeStyleLink(input, sourceFile, projectRoot, readFile, errors, nodeId, label) {
|
|
1316
|
+
const trimmed = input.trim();
|
|
1317
|
+
const match = trimmed.match(STYLE_LINK_REGEX);
|
|
1318
|
+
if (!match) {
|
|
1319
|
+
errors.push({
|
|
1320
|
+
message: `${label} must use @style("./path") syntax`,
|
|
1321
|
+
nodeId,
|
|
1322
|
+
});
|
|
1323
|
+
return undefined;
|
|
1324
|
+
}
|
|
1325
|
+
const rawPath = match[1];
|
|
1326
|
+
const explicitStylePath = isStyleSourceFile(rawPath);
|
|
1327
|
+
if (!explicitStylePath && hasExplicitExtension(rawPath)) {
|
|
1328
|
+
errors.push({
|
|
1329
|
+
message: `${label} "@style(\\"${rawPath}\\")" must use an extensionless path or explicit .style.loj suffix`,
|
|
1330
|
+
nodeId,
|
|
1331
|
+
});
|
|
1332
|
+
return undefined;
|
|
1333
|
+
}
|
|
1334
|
+
const requestedPath = explicitStylePath ? rawPath : `${rawPath}.style.loj`;
|
|
1335
|
+
const resolvedPath = normalizeEscapePath(rawPath === requestedPath ? rawPath : requestedPath, sourceFile, projectRoot, errors, nodeId, label);
|
|
1336
|
+
if (!readFile) {
|
|
1337
|
+
errors.push({
|
|
1338
|
+
message: `${label} "@style(\\"${rawPath}\\")" cannot be resolved because file loading is unavailable`,
|
|
1339
|
+
nodeId,
|
|
1340
|
+
});
|
|
1341
|
+
return undefined;
|
|
1342
|
+
}
|
|
1343
|
+
if (!hostFileExists(resolvedPath, readFile)) {
|
|
1344
|
+
errors.push({
|
|
1345
|
+
message: `${label} "@style(\\"${rawPath}\\")" did not resolve; expected ${resolvedPath}`,
|
|
1346
|
+
nodeId,
|
|
1347
|
+
});
|
|
1348
|
+
return undefined;
|
|
1349
|
+
}
|
|
1350
|
+
const compileResult = compileStyleSource(readFile(resolvedPath) ?? '', resolvedPath);
|
|
1351
|
+
for (const error of compileResult.errors) {
|
|
1352
|
+
const location = error.line !== undefined && error.col !== undefined
|
|
1353
|
+
? `:${error.line}:${error.col}`
|
|
1354
|
+
: '';
|
|
1355
|
+
errors.push({
|
|
1356
|
+
message: `${label} "@style(\\"${rawPath}\\")" could not be compiled at ${resolvedPath}${location}: ${error.message}`,
|
|
1357
|
+
nodeId,
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1360
|
+
if (!compileResult.success || !compileResult.program || !compileResult.manifest) {
|
|
1361
|
+
return undefined;
|
|
1362
|
+
}
|
|
1363
|
+
return {
|
|
1364
|
+
id: `${nodeId}.style`,
|
|
1365
|
+
kind: 'style.link',
|
|
1366
|
+
logicalPath: explicitStylePath ? undefined : rawPath,
|
|
1367
|
+
resolvedPath,
|
|
1368
|
+
lockIn: explicitStylePath ? 'explicit' : 'neutral',
|
|
1369
|
+
program: compileResult.program,
|
|
1370
|
+
manifest: compileResult.manifest,
|
|
1371
|
+
sourceSpan: undefined,
|
|
1372
|
+
};
|
|
1373
|
+
}
|
|
1374
|
+
function normalizeAssetLink(input, sourceFile, projectRoot, readFile, errors, nodeId, label) {
|
|
1375
|
+
const trimmed = input.trim();
|
|
1376
|
+
const match = trimmed.match(ASSET_LINK_REGEX);
|
|
1377
|
+
if (!match) {
|
|
1378
|
+
errors.push({
|
|
1379
|
+
message: `${label} must use @asset("./path") syntax`,
|
|
1380
|
+
nodeId,
|
|
1381
|
+
});
|
|
1382
|
+
return undefined;
|
|
1383
|
+
}
|
|
1384
|
+
const rawPath = match[1];
|
|
1385
|
+
const resolvedPath = normalizeEscapePath(rawPath, sourceFile, projectRoot, errors, nodeId, label);
|
|
1386
|
+
if (!readFile) {
|
|
1387
|
+
errors.push({
|
|
1388
|
+
message: `${label} "@asset(\\"${rawPath}\\")" cannot be resolved because file loading is unavailable`,
|
|
1389
|
+
nodeId,
|
|
1390
|
+
});
|
|
1391
|
+
return undefined;
|
|
1392
|
+
}
|
|
1393
|
+
if (!hostFileExists(resolvedPath, readFile)) {
|
|
1394
|
+
errors.push({
|
|
1395
|
+
message: `${label} "@asset(\\"${rawPath}\\")" did not resolve; expected ${resolvedPath}`,
|
|
1396
|
+
nodeId,
|
|
1397
|
+
});
|
|
1398
|
+
return undefined;
|
|
1399
|
+
}
|
|
1400
|
+
return {
|
|
1401
|
+
id: `${nodeId}.asset`,
|
|
1402
|
+
kind: 'asset.link',
|
|
1403
|
+
logicalPath: rawPath,
|
|
1404
|
+
resolvedPath,
|
|
1405
|
+
lockIn: 'neutral',
|
|
1406
|
+
sourceSpan: undefined,
|
|
1407
|
+
};
|
|
1408
|
+
}
|
|
1409
|
+
function normalizeStyleReference(input, appStyle, nodeId, errors, label) {
|
|
1410
|
+
const trimmed = input.trim();
|
|
1411
|
+
if (!trimmed) {
|
|
1412
|
+
errors.push({
|
|
1413
|
+
message: `${label} must not be empty`,
|
|
1414
|
+
nodeId,
|
|
1415
|
+
});
|
|
1416
|
+
return undefined;
|
|
1417
|
+
}
|
|
1418
|
+
if (!appStyle) {
|
|
1419
|
+
errors.push({
|
|
1420
|
+
message: `${label} requires app.style to link a .style.loj source`,
|
|
1421
|
+
nodeId,
|
|
1422
|
+
});
|
|
1423
|
+
return undefined;
|
|
1424
|
+
}
|
|
1425
|
+
if (!appStyle.program.styles.some((style) => style.name === trimmed)) {
|
|
1426
|
+
errors.push({
|
|
1427
|
+
message: `${label} "${trimmed}" was not found in ${appStyle.resolvedPath}`,
|
|
1428
|
+
nodeId,
|
|
1429
|
+
});
|
|
1430
|
+
return undefined;
|
|
1431
|
+
}
|
|
1432
|
+
return trimmed;
|
|
1433
|
+
}
|
|
1434
|
+
function normalizeCanonicalPath(input, nodeId, errors) {
|
|
1435
|
+
if (input === undefined) {
|
|
1436
|
+
return undefined;
|
|
1437
|
+
}
|
|
1438
|
+
const trimmed = input.trim();
|
|
1439
|
+
if (!trimmed) {
|
|
1440
|
+
errors.push({
|
|
1441
|
+
message: 'page seo canonicalPath must not be empty',
|
|
1442
|
+
nodeId,
|
|
1443
|
+
});
|
|
1444
|
+
return undefined;
|
|
1445
|
+
}
|
|
1446
|
+
if (!trimmed.startsWith('/')) {
|
|
1447
|
+
errors.push({
|
|
1448
|
+
message: 'page seo canonicalPath must start with "/"',
|
|
1449
|
+
nodeId,
|
|
1450
|
+
});
|
|
1451
|
+
return undefined;
|
|
1452
|
+
}
|
|
1453
|
+
return trimmed;
|
|
1454
|
+
}
|
|
1455
|
+
function normalizeFnEscape(rawPath, sourceFile, projectRoot, compilerLanguage, readFile, errors, nodeId, label) {
|
|
1456
|
+
if (hasExplicitExtension(rawPath)) {
|
|
1457
|
+
return {
|
|
1458
|
+
tier: 'fn',
|
|
1459
|
+
path: normalizeEscapePath(rawPath, sourceFile, projectRoot, errors, nodeId, label),
|
|
1460
|
+
lockIn: 'explicit',
|
|
1461
|
+
};
|
|
1462
|
+
}
|
|
1463
|
+
const logicalPath = rawPath;
|
|
1464
|
+
const basePath = normalizeEscapePath(rawPath, sourceFile, projectRoot, errors, nodeId, label);
|
|
1465
|
+
if (!projectRoot || !sourceFile) {
|
|
1466
|
+
return {
|
|
1467
|
+
tier: 'fn',
|
|
1468
|
+
logicalPath,
|
|
1469
|
+
path: `${basePath}${preferredFnExtensions(compilerLanguage)[0] ?? ''}`,
|
|
1470
|
+
lockIn: 'neutral',
|
|
1471
|
+
};
|
|
1472
|
+
}
|
|
1473
|
+
const candidates = preferredFnExtensions(compilerLanguage).map((extension) => `${basePath}${extension}`);
|
|
1474
|
+
const matches = readFile
|
|
1475
|
+
? candidates.filter((candidate) => hostFileExists(candidate, readFile))
|
|
1476
|
+
: candidates;
|
|
1477
|
+
if (readFile && matches.length === 0) {
|
|
1478
|
+
errors.push({
|
|
1479
|
+
message: `@fn("${rawPath}") did not resolve for react/${compilerLanguage}; expected ${candidates.join(' or ')}`,
|
|
1480
|
+
nodeId,
|
|
1481
|
+
});
|
|
1482
|
+
}
|
|
1483
|
+
if (readFile && matches.length > 1) {
|
|
1484
|
+
errors.push({
|
|
1485
|
+
message: `@fn("${rawPath}") is ambiguous; found ${matches.join(' and ')}`,
|
|
1486
|
+
nodeId,
|
|
1487
|
+
});
|
|
1488
|
+
}
|
|
1489
|
+
return {
|
|
1490
|
+
tier: 'fn',
|
|
1491
|
+
logicalPath,
|
|
1492
|
+
path: matches[0] ?? candidates[0] ?? basePath,
|
|
1493
|
+
lockIn: 'neutral',
|
|
1494
|
+
};
|
|
1495
|
+
}
|
|
1496
|
+
function preferredFnExtensions(language) {
|
|
1497
|
+
return language === 'typescript' ? ['.ts', '.js'] : ['.js'];
|
|
1498
|
+
}
|
|
1499
|
+
function hasExplicitExtension(rawPath) {
|
|
1500
|
+
const fileName = rawPath.split('/').pop() ?? rawPath;
|
|
1501
|
+
return /\.[A-Za-z0-9]+$/.test(fileName);
|
|
1502
|
+
}
|
|
1503
|
+
function hostFileExists(fileName, readFile) {
|
|
1504
|
+
try {
|
|
1505
|
+
return readFile(fileName) !== undefined;
|
|
1506
|
+
}
|
|
1507
|
+
catch {
|
|
1508
|
+
return false;
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
// ─── Page ────────────────────────────────────────────────────────
|
|
1512
|
+
function normalizePage(raw, projectRoot, appStyle, readFile) {
|
|
1513
|
+
const id = `page.${raw.name}`;
|
|
1514
|
+
const errors = [];
|
|
1515
|
+
return {
|
|
1516
|
+
value: {
|
|
1517
|
+
id,
|
|
1518
|
+
kind: 'page',
|
|
1519
|
+
name: raw.name,
|
|
1520
|
+
title: normalizeMessageLike(raw.title, `${id}.title`, errors, 'page title'),
|
|
1521
|
+
style: raw.style ? normalizeStyleReference(raw.style, appStyle, `${id}.style`, errors, 'page style') : undefined,
|
|
1522
|
+
seo: raw.seo
|
|
1523
|
+
? {
|
|
1524
|
+
description: raw.seo.description
|
|
1525
|
+
? normalizeMessageLike(raw.seo.description, `${id}.seo.description`, errors, 'page seo description')
|
|
1526
|
+
: undefined,
|
|
1527
|
+
canonicalPath: normalizeCanonicalPath(raw.seo.canonicalPath, `${id}.seo.canonicalPath`, errors),
|
|
1528
|
+
image: raw.seo.image
|
|
1529
|
+
? normalizeAssetLink(raw.seo.image, raw.sourceSpan?.file, projectRoot, readFile, errors, `${id}.seo.image`, 'page seo image')
|
|
1530
|
+
: undefined,
|
|
1531
|
+
noIndex: raw.seo.noIndex === true,
|
|
1532
|
+
}
|
|
1533
|
+
: undefined,
|
|
1534
|
+
pageType: raw.type === 'dashboard' ? 'dashboard' : 'custom',
|
|
1535
|
+
path: raw.path,
|
|
1536
|
+
layout: raw.layout,
|
|
1537
|
+
actions: (raw.actions || []).map((action, index) => normalizePageAction(action, `${id}.action.${index}`)),
|
|
1538
|
+
blocks: raw.blocks.map((b, i) => normalizeDashboardBlock(b, `${id}.block.${i}`, errors, projectRoot, appStyle)),
|
|
1539
|
+
sourceSpan: raw.sourceSpan,
|
|
1540
|
+
},
|
|
1541
|
+
errors,
|
|
1542
|
+
};
|
|
1543
|
+
}
|
|
1544
|
+
function normalizeDashboardBlock(raw, id, errors, projectRoot, appStyle) {
|
|
1545
|
+
const block = {
|
|
1546
|
+
id,
|
|
1547
|
+
kind: 'dashboardBlock',
|
|
1548
|
+
blockType: raw.type || 'custom',
|
|
1549
|
+
title: normalizeMessageLike(raw.title, `${id}.title`, errors, 'page block title'),
|
|
1550
|
+
style: raw.style ? normalizeStyleReference(raw.style, appStyle, `${id}.style`, errors, 'page block style') : undefined,
|
|
1551
|
+
data: raw.data,
|
|
1552
|
+
queryState: raw.queryState,
|
|
1553
|
+
selectionState: raw.selectionState,
|
|
1554
|
+
dateNavigation: raw.dateNavigation
|
|
1555
|
+
? {
|
|
1556
|
+
field: raw.dateNavigation.field,
|
|
1557
|
+
prevLabel: raw.dateNavigation.prevLabel
|
|
1558
|
+
? normalizeMessageLike(raw.dateNavigation.prevLabel, `${id}.dateNavigation.prevLabel`, errors, 'dateNavigation prevLabel')
|
|
1559
|
+
: undefined,
|
|
1560
|
+
nextLabel: raw.dateNavigation.nextLabel
|
|
1561
|
+
? normalizeMessageLike(raw.dateNavigation.nextLabel, `${id}.dateNavigation.nextLabel`, errors, 'dateNavigation nextLabel')
|
|
1562
|
+
: undefined,
|
|
1563
|
+
sourceSpan: raw.dateNavigation.sourceSpan,
|
|
1564
|
+
}
|
|
1565
|
+
: undefined,
|
|
1566
|
+
rowActions: (raw.rowActions || []).map((action, index) => normalizeDashboardRowAction(action, `${id}.rowAction.${index}`)),
|
|
1567
|
+
sourceSpan: raw.sourceSpan,
|
|
1568
|
+
};
|
|
1569
|
+
// Escape hatch tier 3: custom block
|
|
1570
|
+
if (raw.custom) {
|
|
1571
|
+
block.customBlock = normalizeEscapePath(raw.custom, raw.sourceSpan?.file, projectRoot, errors, id, 'dashboard block @custom');
|
|
1572
|
+
block.blockType = 'custom';
|
|
1573
|
+
}
|
|
1574
|
+
return block;
|
|
1575
|
+
}
|
|
1576
|
+
function normalizePageAction(raw, id) {
|
|
1577
|
+
const resourceLabel = raw.create.resource === ''
|
|
1578
|
+
? 'Resource'
|
|
1579
|
+
: raw.create.resource.charAt(0).toUpperCase() + raw.create.resource.slice(1);
|
|
1580
|
+
return {
|
|
1581
|
+
id,
|
|
1582
|
+
kind: 'pageAction',
|
|
1583
|
+
action: 'create',
|
|
1584
|
+
resource: raw.create.resource,
|
|
1585
|
+
label: normalizeMessageLike(raw.create.label ?? `Create ${resourceLabel}`, `${id}.label`, [], 'page action label'),
|
|
1586
|
+
seed: Object.fromEntries(Object.entries(raw.create.seed || {}).flatMap(([fieldName, value]) => {
|
|
1587
|
+
const normalized = normalizePageActionSeedValue(value);
|
|
1588
|
+
return normalized ? [[fieldName, normalized]] : [];
|
|
1589
|
+
})),
|
|
1590
|
+
sourceSpan: raw.sourceSpan,
|
|
1591
|
+
};
|
|
1592
|
+
}
|
|
1593
|
+
function normalizePageActionSeedValue(raw) {
|
|
1594
|
+
if (typeof raw === 'string' || typeof raw === 'number' || typeof raw === 'boolean') {
|
|
1595
|
+
return {
|
|
1596
|
+
kind: 'literal',
|
|
1597
|
+
value: raw,
|
|
1598
|
+
};
|
|
1599
|
+
}
|
|
1600
|
+
if (raw.input) {
|
|
1601
|
+
const [queryState, ...fieldParts] = raw.input.split('.');
|
|
1602
|
+
return {
|
|
1603
|
+
kind: 'inputField',
|
|
1604
|
+
queryState,
|
|
1605
|
+
field: fieldParts.join('.'),
|
|
1606
|
+
sourceSpan: raw.sourceSpan,
|
|
1607
|
+
};
|
|
1608
|
+
}
|
|
1609
|
+
if (raw.selection) {
|
|
1610
|
+
const [selectionState, ...fieldParts] = raw.selection.split('.');
|
|
1611
|
+
return {
|
|
1612
|
+
kind: 'selectionField',
|
|
1613
|
+
selectionState,
|
|
1614
|
+
field: fieldParts.join('.'),
|
|
1615
|
+
sourceSpan: raw.sourceSpan,
|
|
1616
|
+
};
|
|
1617
|
+
}
|
|
1618
|
+
return null;
|
|
1619
|
+
}
|
|
1620
|
+
function normalizeDashboardRowAction(raw, id) {
|
|
1621
|
+
const resourceLabel = raw.create.resource === ''
|
|
1622
|
+
? 'Resource'
|
|
1623
|
+
: raw.create.resource.charAt(0).toUpperCase() + raw.create.resource.slice(1);
|
|
1624
|
+
return {
|
|
1625
|
+
id,
|
|
1626
|
+
kind: 'dashboardRowAction',
|
|
1627
|
+
action: 'create',
|
|
1628
|
+
resource: raw.create.resource,
|
|
1629
|
+
label: normalizeMessageLike(raw.create.label ?? `Create ${resourceLabel}`, `${id}.label`, [], 'row action label'),
|
|
1630
|
+
seed: Object.fromEntries(Object.entries(raw.create.seed || {}).flatMap(([fieldName, value]) => {
|
|
1631
|
+
const normalized = normalizeDashboardRowSeedValue(value);
|
|
1632
|
+
return normalized ? [[fieldName, normalized]] : [];
|
|
1633
|
+
})),
|
|
1634
|
+
sourceSpan: raw.sourceSpan,
|
|
1635
|
+
};
|
|
1636
|
+
}
|
|
1637
|
+
function normalizeDashboardRowSeedValue(raw) {
|
|
1638
|
+
if (typeof raw === 'string' || typeof raw === 'number' || typeof raw === 'boolean') {
|
|
1639
|
+
return {
|
|
1640
|
+
kind: 'literal',
|
|
1641
|
+
value: raw,
|
|
1642
|
+
};
|
|
1643
|
+
}
|
|
1644
|
+
if (raw.row) {
|
|
1645
|
+
return {
|
|
1646
|
+
kind: 'rowField',
|
|
1647
|
+
field: raw.row,
|
|
1648
|
+
sourceSpan: raw.sourceSpan,
|
|
1649
|
+
};
|
|
1650
|
+
}
|
|
1651
|
+
if (raw.input) {
|
|
1652
|
+
return {
|
|
1653
|
+
kind: 'inputField',
|
|
1654
|
+
field: raw.input,
|
|
1655
|
+
sourceSpan: raw.sourceSpan,
|
|
1656
|
+
};
|
|
1657
|
+
}
|
|
1658
|
+
return null;
|
|
1659
|
+
}
|
|
1660
|
+
// ─── Escape Hatch Metering ───────────────────────────────────────
|
|
1661
|
+
// Counts all escape hatch usage across the IR and flags overBudget.
|
|
1662
|
+
const ESCAPE_BUDGET_PERCENT = 20; // healthy threshold
|
|
1663
|
+
function computeEscapeStats(ir) {
|
|
1664
|
+
let totalNodes = 0;
|
|
1665
|
+
let exprCount = 0;
|
|
1666
|
+
let fnCount = 0;
|
|
1667
|
+
let customCount = 0;
|
|
1668
|
+
// Count model fields
|
|
1669
|
+
for (const model of ir.models) {
|
|
1670
|
+
totalNodes += model.fields.length;
|
|
1671
|
+
}
|
|
1672
|
+
// Count resource nodes
|
|
1673
|
+
for (const resource of ir.resources) {
|
|
1674
|
+
for (const view of [resource.views.list, resource.views.edit, resource.views.create, resource.views.read]) {
|
|
1675
|
+
if (!view)
|
|
1676
|
+
continue;
|
|
1677
|
+
if ('columns' in view) {
|
|
1678
|
+
for (const col of view.columns) {
|
|
1679
|
+
totalNodes++;
|
|
1680
|
+
if (col.dynamicLabel?.source === 'escape-expr')
|
|
1681
|
+
exprCount++;
|
|
1682
|
+
if (col.displayFn)
|
|
1683
|
+
fnCount++;
|
|
1684
|
+
if (col.customRenderer)
|
|
1685
|
+
customCount++;
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
if ('fields' in view && view.kind !== 'view.read') {
|
|
1689
|
+
for (const field of view.fields) {
|
|
1690
|
+
totalNodes++;
|
|
1691
|
+
if (field.visibleWhen?.source === 'escape-expr')
|
|
1692
|
+
exprCount++;
|
|
1693
|
+
if (field.visibleWhen?.source === 'escape-fn')
|
|
1694
|
+
fnCount++;
|
|
1695
|
+
if (field.enabledWhen?.source === 'escape-expr')
|
|
1696
|
+
exprCount++;
|
|
1697
|
+
if (field.enabledWhen?.source === 'escape-fn')
|
|
1698
|
+
fnCount++;
|
|
1699
|
+
if (field.validateFn)
|
|
1700
|
+
fnCount++;
|
|
1701
|
+
if (field.customField)
|
|
1702
|
+
customCount++;
|
|
1703
|
+
}
|
|
1704
|
+
if (view.kind === 'view.create') {
|
|
1705
|
+
for (const include of view.includes) {
|
|
1706
|
+
totalNodes++;
|
|
1707
|
+
for (const field of include.fields) {
|
|
1708
|
+
totalNodes++;
|
|
1709
|
+
if (field.visibleWhen?.source === 'escape-expr')
|
|
1710
|
+
exprCount++;
|
|
1711
|
+
if (field.visibleWhen?.source === 'escape-fn')
|
|
1712
|
+
fnCount++;
|
|
1713
|
+
if (field.enabledWhen?.source === 'escape-expr')
|
|
1714
|
+
exprCount++;
|
|
1715
|
+
if (field.enabledWhen?.source === 'escape-fn')
|
|
1716
|
+
fnCount++;
|
|
1717
|
+
if (field.validateFn)
|
|
1718
|
+
fnCount++;
|
|
1719
|
+
if (field.customField)
|
|
1720
|
+
customCount++;
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
if (view.kind === 'view.read') {
|
|
1726
|
+
for (const field of view.fields) {
|
|
1727
|
+
totalNodes++;
|
|
1728
|
+
if (field.dynamicLabel?.source === 'escape-expr')
|
|
1729
|
+
exprCount++;
|
|
1730
|
+
if (field.displayFn)
|
|
1731
|
+
fnCount++;
|
|
1732
|
+
if (field.customRenderer)
|
|
1733
|
+
customCount++;
|
|
1734
|
+
}
|
|
1735
|
+
totalNodes += view.related.length;
|
|
1736
|
+
}
|
|
1737
|
+
// Count rule escape hatches
|
|
1738
|
+
if ('rules' in view && view.rules) {
|
|
1739
|
+
for (const rule of [view.rules.visibleIf, view.rules.enabledIf, view.rules.allowIf, view.rules.enforce]) {
|
|
1740
|
+
if (!rule)
|
|
1741
|
+
continue;
|
|
1742
|
+
totalNodes++;
|
|
1743
|
+
if (rule.source === 'escape-expr')
|
|
1744
|
+
exprCount++;
|
|
1745
|
+
if (rule.source === 'escape-fn')
|
|
1746
|
+
fnCount++;
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
// Count page blocks
|
|
1752
|
+
for (const page of ir.pages) {
|
|
1753
|
+
for (const block of page.blocks) {
|
|
1754
|
+
totalNodes++;
|
|
1755
|
+
if (block.customBlock)
|
|
1756
|
+
customCount++;
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
totalNodes = Math.max(totalNodes, 1); // avoid div by zero
|
|
1760
|
+
const escapeTotal = exprCount + fnCount + customCount;
|
|
1761
|
+
const escapePercent = Math.round((escapeTotal / totalNodes) * 100);
|
|
1762
|
+
return {
|
|
1763
|
+
totalNodes,
|
|
1764
|
+
exprCount,
|
|
1765
|
+
fnCount,
|
|
1766
|
+
customCount,
|
|
1767
|
+
escapePercent,
|
|
1768
|
+
overBudget: escapePercent > ESCAPE_BUDGET_PERCENT,
|
|
1769
|
+
};
|
|
1770
|
+
}
|
|
1771
|
+
//# sourceMappingURL=normalize.js.map
|