@mmapp/player-core 0.1.0-alpha.1
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/dist/index.d.mts +1436 -0
- package/dist/index.d.ts +1436 -0
- package/dist/index.js +4828 -0
- package/dist/index.mjs +4762 -0
- package/package.json +35 -0
- package/package.json.backup +35 -0
- package/src/__tests__/actions.test.ts +187 -0
- package/src/__tests__/blueprint-e2e.test.ts +706 -0
- package/src/__tests__/blueprint-test-runner.test.ts +680 -0
- package/src/__tests__/core-functions.test.ts +78 -0
- package/src/__tests__/dsl-compiler.test.ts +1382 -0
- package/src/__tests__/dsl-grammar.test.ts +1682 -0
- package/src/__tests__/events.test.ts +200 -0
- package/src/__tests__/expression.test.ts +296 -0
- package/src/__tests__/failure-policies.test.ts +110 -0
- package/src/__tests__/frontend-context.test.ts +182 -0
- package/src/__tests__/integration.test.ts +256 -0
- package/src/__tests__/security.test.ts +190 -0
- package/src/__tests__/state-machine.test.ts +450 -0
- package/src/__tests__/testing-engine.test.ts +671 -0
- package/src/actions/dispatcher.ts +80 -0
- package/src/actions/index.ts +7 -0
- package/src/actions/types.ts +25 -0
- package/src/dsl/compiler/component-mapper.ts +289 -0
- package/src/dsl/compiler/field-mapper.ts +187 -0
- package/src/dsl/compiler/index.ts +82 -0
- package/src/dsl/compiler/manifest-compiler.ts +76 -0
- package/src/dsl/compiler/symbol-table.ts +214 -0
- package/src/dsl/compiler/utils.ts +48 -0
- package/src/dsl/compiler/view-compiler.ts +286 -0
- package/src/dsl/compiler/workflow-compiler.ts +600 -0
- package/src/dsl/index.ts +66 -0
- package/src/dsl/ir-migration.ts +221 -0
- package/src/dsl/ir-types.ts +416 -0
- package/src/dsl/lexer.ts +579 -0
- package/src/dsl/parser.ts +115 -0
- package/src/dsl/types.ts +256 -0
- package/src/events/event-bus.ts +68 -0
- package/src/events/index.ts +9 -0
- package/src/events/pattern-matcher.ts +61 -0
- package/src/events/types.ts +27 -0
- package/src/expression/evaluator.ts +676 -0
- package/src/expression/functions.ts +214 -0
- package/src/expression/index.ts +13 -0
- package/src/expression/types.ts +64 -0
- package/src/index.ts +61 -0
- package/src/state-machine/index.ts +16 -0
- package/src/state-machine/interpreter.ts +319 -0
- package/src/state-machine/types.ts +89 -0
- package/src/testing/action-trace.ts +209 -0
- package/src/testing/blueprint-test-runner.ts +214 -0
- package/src/testing/graph-walker.ts +249 -0
- package/src/testing/index.ts +69 -0
- package/src/testing/nrt-comparator.ts +199 -0
- package/src/testing/nrt-types.ts +230 -0
- package/src/testing/test-actions.ts +645 -0
- package/src/testing/test-compiler.ts +278 -0
- package/src/testing/test-runner.ts +444 -0
- package/src/testing/types.ts +231 -0
- package/src/validation/definition-validator.ts +812 -0
- package/src/validation/index.ts +13 -0
- package/tsconfig.json +26 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Action Dispatcher — executes action definitions against registered handlers.
|
|
3
|
+
*
|
|
4
|
+
* The dispatcher:
|
|
5
|
+
* - Maintains a registry of handler functions by action type
|
|
6
|
+
* - Evaluates per-action conditions before execution
|
|
7
|
+
* - Collects results (success/failure) for each action
|
|
8
|
+
* - Never throws — errors are captured in ActionResult
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Evaluator, ExpressionContext } from '../expression/types';
|
|
12
|
+
import type { ActionDefinition, ActionHandlerFn, ActionResult } from './types';
|
|
13
|
+
|
|
14
|
+
export class ActionDispatcher {
|
|
15
|
+
private readonly handlers = new Map<string, ActionHandlerFn>();
|
|
16
|
+
|
|
17
|
+
/** Register a handler for an action type */
|
|
18
|
+
register(type: string, handler: ActionHandlerFn): void {
|
|
19
|
+
this.handlers.set(type, handler);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Unregister a handler */
|
|
23
|
+
unregister(type: string): void {
|
|
24
|
+
this.handlers.delete(type);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Check if a handler is registered for the given type */
|
|
28
|
+
has(type: string): boolean {
|
|
29
|
+
return this.handlers.has(type);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Execute a list of actions sequentially.
|
|
34
|
+
* Each action's condition is evaluated first (if present).
|
|
35
|
+
* Missing handlers are skipped with a warning.
|
|
36
|
+
*/
|
|
37
|
+
async execute(
|
|
38
|
+
actions: ActionDefinition[],
|
|
39
|
+
context: ExpressionContext,
|
|
40
|
+
evaluator?: Evaluator,
|
|
41
|
+
): Promise<ActionResult[]> {
|
|
42
|
+
const results: ActionResult[] = [];
|
|
43
|
+
|
|
44
|
+
for (const action of actions) {
|
|
45
|
+
// Check per-action condition
|
|
46
|
+
if (action.condition && evaluator) {
|
|
47
|
+
const condResult = evaluator.evaluate<boolean>(action.condition, context);
|
|
48
|
+
if (!condResult.value) continue; // Skip this action
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const handler = this.handlers.get(action.type);
|
|
52
|
+
if (!handler) {
|
|
53
|
+
console.warn(`[player-core] No handler registered for action type "${action.type}"`);
|
|
54
|
+
results.push({ type: action.type, success: false, error: `No handler for "${action.type}"` });
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
await handler(action.config, context);
|
|
60
|
+
results.push({ type: action.type, success: true });
|
|
61
|
+
} catch (error) {
|
|
62
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
63
|
+
console.warn(`[player-core] Action "${action.type}" failed: ${message}`);
|
|
64
|
+
results.push({ type: action.type, success: false, error: message });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return results;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Get count of registered handlers */
|
|
72
|
+
get size(): number {
|
|
73
|
+
return this.handlers.size;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Remove all handlers */
|
|
77
|
+
clear(): void {
|
|
78
|
+
this.handlers.clear();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Action System Types — handler registry and dispatch.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ExpressionContext } from '../expression/types';
|
|
6
|
+
|
|
7
|
+
/** Action definition from workflow state/transition */
|
|
8
|
+
export interface ActionDefinition {
|
|
9
|
+
type: string;
|
|
10
|
+
config: Record<string, unknown>;
|
|
11
|
+
condition?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Registered action handler */
|
|
15
|
+
export type ActionHandlerFn = (
|
|
16
|
+
config: Record<string, unknown>,
|
|
17
|
+
context: ExpressionContext,
|
|
18
|
+
) => void | Promise<void>;
|
|
19
|
+
|
|
20
|
+
/** Result of executing an action */
|
|
21
|
+
export interface ActionResult {
|
|
22
|
+
type: string;
|
|
23
|
+
success: boolean;
|
|
24
|
+
error?: string;
|
|
25
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Component Mapper — maps DSL content/iteration patterns to ExperienceNode IR.
|
|
3
|
+
*
|
|
4
|
+
* Maps to the 34 frozen component IDs from the component registry (§9).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
ContentData,
|
|
9
|
+
StringLiteralData,
|
|
10
|
+
IterationData,
|
|
11
|
+
SectionData,
|
|
12
|
+
SearchData,
|
|
13
|
+
NavigationData,
|
|
14
|
+
} from '../types';
|
|
15
|
+
import type { IRExperienceNode } from '../ir-types';
|
|
16
|
+
import { snakeCase, slugify, generateId } from './utils';
|
|
17
|
+
|
|
18
|
+
// =============================================================================
|
|
19
|
+
// Instance-level fields (NOT in state_data)
|
|
20
|
+
// =============================================================================
|
|
21
|
+
|
|
22
|
+
const INSTANCE_FIELDS = new Set([
|
|
23
|
+
'id', 'current_state', 'state', 'status',
|
|
24
|
+
'created_at', 'updated_at', 'entity_type', 'entity_id',
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
// =============================================================================
|
|
28
|
+
// Content → ExperienceNode
|
|
29
|
+
// =============================================================================
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Map a content node to an ExperienceNode.
|
|
33
|
+
* @param data - parsed content data
|
|
34
|
+
* @param insideEach - whether this content is inside an iteration (changes binding scope)
|
|
35
|
+
* @param parentId - parent ID for generating unique child IDs
|
|
36
|
+
* @param index - child index for ID uniqueness
|
|
37
|
+
*/
|
|
38
|
+
export function mapContent(
|
|
39
|
+
data: ContentData,
|
|
40
|
+
insideEach: boolean,
|
|
41
|
+
parentId: string,
|
|
42
|
+
index: number,
|
|
43
|
+
): IRExperienceNode {
|
|
44
|
+
const fieldSnake = snakeCase(data.field);
|
|
45
|
+
const nodeId = generateId(parentId, data.field, String(index));
|
|
46
|
+
const prefix = insideEach ? '$item' : '$instance';
|
|
47
|
+
|
|
48
|
+
// Determine binding path
|
|
49
|
+
const isInstanceField = INSTANCE_FIELDS.has(fieldSnake);
|
|
50
|
+
const bindingPath = isInstanceField
|
|
51
|
+
? `${prefix}.${fieldSnake === 'state' ? 'current_state' : fieldSnake}`
|
|
52
|
+
: `${prefix}.state_data.${fieldSnake}`;
|
|
53
|
+
|
|
54
|
+
// Special component based on role
|
|
55
|
+
if (data.role) {
|
|
56
|
+
return mapContentWithRole(data, nodeId, bindingPath);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Default: Text component
|
|
60
|
+
const node: IRExperienceNode = {
|
|
61
|
+
id: nodeId,
|
|
62
|
+
component: 'Text',
|
|
63
|
+
bindings: { value: bindingPath },
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
if (data.emphasis === 'big') {
|
|
67
|
+
node.config = { variant: 'heading' };
|
|
68
|
+
} else if (data.emphasis === 'small') {
|
|
69
|
+
node.config = { variant: 'caption' };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (data.label) {
|
|
73
|
+
if (!node.config) node.config = {};
|
|
74
|
+
node.config.label = data.label;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return node;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function mapContentWithRole(
|
|
81
|
+
data: ContentData,
|
|
82
|
+
nodeId: string,
|
|
83
|
+
bindingPath: string,
|
|
84
|
+
): IRExperienceNode {
|
|
85
|
+
switch (data.role) {
|
|
86
|
+
case 'tag':
|
|
87
|
+
return {
|
|
88
|
+
id: nodeId,
|
|
89
|
+
component: 'Badge',
|
|
90
|
+
bindings: { value: bindingPath },
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
case 'progress':
|
|
94
|
+
case 'meter':
|
|
95
|
+
return {
|
|
96
|
+
id: nodeId,
|
|
97
|
+
component: 'ProgressTracker',
|
|
98
|
+
bindings: { value: bindingPath },
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
case 'image':
|
|
102
|
+
return {
|
|
103
|
+
id: nodeId,
|
|
104
|
+
component: 'Image',
|
|
105
|
+
bindings: { src: bindingPath },
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
case 'number': {
|
|
109
|
+
const node: IRExperienceNode = {
|
|
110
|
+
id: nodeId,
|
|
111
|
+
component: 'Text',
|
|
112
|
+
config: { variant: 'metric' },
|
|
113
|
+
bindings: { value: bindingPath },
|
|
114
|
+
};
|
|
115
|
+
if (data.label) {
|
|
116
|
+
node.config!.label = data.label;
|
|
117
|
+
}
|
|
118
|
+
return node;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
default:
|
|
122
|
+
return {
|
|
123
|
+
id: nodeId,
|
|
124
|
+
component: 'Text',
|
|
125
|
+
bindings: { value: bindingPath },
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// =============================================================================
|
|
131
|
+
// String Literal → ExperienceNode
|
|
132
|
+
// =============================================================================
|
|
133
|
+
|
|
134
|
+
export function mapStringLiteral(
|
|
135
|
+
data: StringLiteralData,
|
|
136
|
+
parentId: string,
|
|
137
|
+
index: number,
|
|
138
|
+
): IRExperienceNode {
|
|
139
|
+
const nodeId = generateId(parentId, 'text', String(index));
|
|
140
|
+
const node: IRExperienceNode = {
|
|
141
|
+
id: nodeId,
|
|
142
|
+
component: 'Text',
|
|
143
|
+
bindings: { value: `"${data.text}"` },
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
if (data.emphasis === 'big') {
|
|
147
|
+
node.config = { variant: 'heading' };
|
|
148
|
+
} else if (data.emphasis === 'small') {
|
|
149
|
+
node.config = { variant: 'caption' };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return node;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// =============================================================================
|
|
156
|
+
// Iteration → ExperienceNode
|
|
157
|
+
// =============================================================================
|
|
158
|
+
|
|
159
|
+
export function mapIteration(
|
|
160
|
+
data: IterationData,
|
|
161
|
+
children: IRExperienceNode[],
|
|
162
|
+
parentId: string,
|
|
163
|
+
index: number,
|
|
164
|
+
): IRExperienceNode {
|
|
165
|
+
const nodeId = generateId(parentId, 'each', data.subject);
|
|
166
|
+
const eachNode: IRExperienceNode = {
|
|
167
|
+
id: nodeId,
|
|
168
|
+
component: 'Each',
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
if (data.role === 'card') {
|
|
172
|
+
// Wrap children in a Card
|
|
173
|
+
const cardNode: IRExperienceNode = {
|
|
174
|
+
id: generateId(nodeId, 'card'),
|
|
175
|
+
component: 'Card',
|
|
176
|
+
children,
|
|
177
|
+
};
|
|
178
|
+
if (data.emphasis === 'small') {
|
|
179
|
+
cardNode.config = { size: 'small' };
|
|
180
|
+
}
|
|
181
|
+
if (children.length > 1) {
|
|
182
|
+
cardNode.layout = 'stack';
|
|
183
|
+
}
|
|
184
|
+
eachNode.children = [cardNode];
|
|
185
|
+
} else {
|
|
186
|
+
eachNode.children = children;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return eachNode;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// =============================================================================
|
|
193
|
+
// Section → ExperienceNode
|
|
194
|
+
// =============================================================================
|
|
195
|
+
|
|
196
|
+
export function mapSection(
|
|
197
|
+
data: SectionData,
|
|
198
|
+
children: IRExperienceNode[],
|
|
199
|
+
parentId: string,
|
|
200
|
+
): IRExperienceNode {
|
|
201
|
+
switch (data.name) {
|
|
202
|
+
case 'numbers':
|
|
203
|
+
return {
|
|
204
|
+
id: generateId(parentId, 'numbers'),
|
|
205
|
+
component: 'MetricsGrid',
|
|
206
|
+
children,
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
case 'tabs':
|
|
210
|
+
return {
|
|
211
|
+
id: generateId(parentId, 'tabs'),
|
|
212
|
+
component: 'TabbedLayout',
|
|
213
|
+
children,
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
case 'actions':
|
|
217
|
+
return {
|
|
218
|
+
id: generateId(parentId, 'actions'),
|
|
219
|
+
component: 'TransitionActions',
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
case 'controls':
|
|
223
|
+
return {
|
|
224
|
+
id: generateId(parentId, 'controls'),
|
|
225
|
+
layout: 'row',
|
|
226
|
+
children,
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
default:
|
|
230
|
+
return {
|
|
231
|
+
id: generateId(parentId, data.name),
|
|
232
|
+
children,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// =============================================================================
|
|
238
|
+
// Search → ExperienceNode
|
|
239
|
+
// =============================================================================
|
|
240
|
+
|
|
241
|
+
export function mapSearch(
|
|
242
|
+
data: SearchData,
|
|
243
|
+
parentId: string,
|
|
244
|
+
index: number,
|
|
245
|
+
): IRExperienceNode {
|
|
246
|
+
return {
|
|
247
|
+
id: generateId(parentId, 'search', String(index)),
|
|
248
|
+
component: 'SearchInput',
|
|
249
|
+
config: { target: data.target },
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// =============================================================================
|
|
254
|
+
// Navigation → ExperienceNode binding
|
|
255
|
+
// =============================================================================
|
|
256
|
+
|
|
257
|
+
export function mapNavigation(
|
|
258
|
+
data: NavigationData,
|
|
259
|
+
insideEach: boolean,
|
|
260
|
+
parentId: string,
|
|
261
|
+
index: number,
|
|
262
|
+
): IRExperienceNode {
|
|
263
|
+
const nodeId = generateId(parentId, 'nav', String(index));
|
|
264
|
+
const prefix = insideEach ? '$item' : '$instance';
|
|
265
|
+
|
|
266
|
+
// Replace {its id} with binding template
|
|
267
|
+
const target = data.target.replace(
|
|
268
|
+
/\{its\s+id\}/g,
|
|
269
|
+
`\${${prefix}.id}`,
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
id: nodeId,
|
|
274
|
+
component: 'Link',
|
|
275
|
+
bindings: { onClick: `$action.navigate("${target}")` },
|
|
276
|
+
config: { trigger: data.trigger },
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// =============================================================================
|
|
281
|
+
// Pages → ExperienceNode
|
|
282
|
+
// =============================================================================
|
|
283
|
+
|
|
284
|
+
export function mapPages(parentId: string, index: number): IRExperienceNode {
|
|
285
|
+
return {
|
|
286
|
+
id: generateId(parentId, 'pages', String(index)),
|
|
287
|
+
component: 'Pagination',
|
|
288
|
+
};
|
|
289
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Field Mapper — converts DSL FieldDefData to IR field definitions.
|
|
3
|
+
*
|
|
4
|
+
* Handles base type mapping, adjective→property mapping,
|
|
5
|
+
* and constraint→validation mapping.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { FieldDefData } from '../types';
|
|
9
|
+
import type { IRFieldDefinition, IRFieldValidation, IRWorkflowFieldType } from '../ir-types';
|
|
10
|
+
import { snakeCase } from './utils';
|
|
11
|
+
|
|
12
|
+
// =============================================================================
|
|
13
|
+
// Base Type Mapping
|
|
14
|
+
// =============================================================================
|
|
15
|
+
|
|
16
|
+
const BASE_TYPE_MAP: Record<string, IRWorkflowFieldType> = {
|
|
17
|
+
'text': 'text',
|
|
18
|
+
'rich text': 'rich_text',
|
|
19
|
+
'number': 'number',
|
|
20
|
+
'integer': 'number',
|
|
21
|
+
'time': 'datetime',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function isTextLikeType(irType: IRWorkflowFieldType): boolean {
|
|
25
|
+
return irType === 'text' || irType === 'rich_text';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// =============================================================================
|
|
29
|
+
// Public API
|
|
30
|
+
// =============================================================================
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Map a DSL field definition to an IR field definition.
|
|
34
|
+
*/
|
|
35
|
+
export function mapField(data: FieldDefData): IRFieldDefinition {
|
|
36
|
+
const field: IRFieldDefinition = {
|
|
37
|
+
name: snakeCase(data.name),
|
|
38
|
+
type: resolveType(data.baseType),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Adjective mapping
|
|
42
|
+
for (const adj of data.adjectives) {
|
|
43
|
+
applyAdjective(field, adj);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Constraint mapping
|
|
47
|
+
const validation = buildValidation(data, field.type);
|
|
48
|
+
if (validation) {
|
|
49
|
+
field.validation = mergeValidation(field.validation, validation);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Integer → add validation rule for whole numbers
|
|
53
|
+
if (data.baseType === 'integer') {
|
|
54
|
+
if (!field.validation) field.validation = {};
|
|
55
|
+
if (!field.validation.rules) field.validation.rules = [];
|
|
56
|
+
field.validation.rules.push({
|
|
57
|
+
expression: 'eq(round($value), $value)',
|
|
58
|
+
message: 'Must be a whole number',
|
|
59
|
+
severity: 'error',
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Choice of [...] → extract options
|
|
64
|
+
if (data.baseType.startsWith('choice of')) {
|
|
65
|
+
field.type = 'select';
|
|
66
|
+
const optionsMatch = data.baseType.match(/\[(.+)\]/);
|
|
67
|
+
if (optionsMatch) {
|
|
68
|
+
if (!field.validation) field.validation = {};
|
|
69
|
+
field.validation.options = optionsMatch[1]
|
|
70
|
+
.split(',')
|
|
71
|
+
.map(o => o.trim());
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Default value from constraints
|
|
76
|
+
const defaultConstraint = data.constraints.find(c => c.kind === 'default');
|
|
77
|
+
if (defaultConstraint) {
|
|
78
|
+
field.default_value = defaultConstraint.value;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return field;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// =============================================================================
|
|
85
|
+
// Type Resolution
|
|
86
|
+
// =============================================================================
|
|
87
|
+
|
|
88
|
+
function resolveType(baseType: string): IRWorkflowFieldType {
|
|
89
|
+
if (baseType.startsWith('choice of')) return 'select';
|
|
90
|
+
// Known types map to canonical slugs; unknown types pass through as-is
|
|
91
|
+
// (custom Element-based field types use slugs like 'email', 'currency', 'my-org/address')
|
|
92
|
+
return BASE_TYPE_MAP[baseType] ?? baseType;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// =============================================================================
|
|
96
|
+
// Adjective Application
|
|
97
|
+
// =============================================================================
|
|
98
|
+
|
|
99
|
+
function applyAdjective(field: IRFieldDefinition, adjective: string): void {
|
|
100
|
+
switch (adjective) {
|
|
101
|
+
case 'required':
|
|
102
|
+
field.required = true;
|
|
103
|
+
break;
|
|
104
|
+
case 'optional':
|
|
105
|
+
field.required = false;
|
|
106
|
+
break;
|
|
107
|
+
case 'non-negative':
|
|
108
|
+
if (!field.validation) field.validation = {};
|
|
109
|
+
field.validation.min = 0;
|
|
110
|
+
break;
|
|
111
|
+
case 'positive':
|
|
112
|
+
if (!field.validation) field.validation = {};
|
|
113
|
+
field.validation.min = 1;
|
|
114
|
+
break;
|
|
115
|
+
case 'computed':
|
|
116
|
+
field.computed = '';
|
|
117
|
+
break;
|
|
118
|
+
// lowercase, uppercase, readonly — stored but no direct IR mapping
|
|
119
|
+
default:
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// =============================================================================
|
|
125
|
+
// Constraint → Validation
|
|
126
|
+
// =============================================================================
|
|
127
|
+
|
|
128
|
+
function buildValidation(
|
|
129
|
+
data: FieldDefData,
|
|
130
|
+
irType: IRWorkflowFieldType,
|
|
131
|
+
): IRFieldValidation | null {
|
|
132
|
+
if (data.constraints.length === 0) return null;
|
|
133
|
+
|
|
134
|
+
const validation: IRFieldValidation = {};
|
|
135
|
+
let hasProps = false;
|
|
136
|
+
|
|
137
|
+
for (const c of data.constraints) {
|
|
138
|
+
switch (c.kind) {
|
|
139
|
+
case 'max':
|
|
140
|
+
if (isTextLikeType(irType)) {
|
|
141
|
+
validation.maxLength = c.value as number;
|
|
142
|
+
} else {
|
|
143
|
+
validation.max = c.value as number;
|
|
144
|
+
}
|
|
145
|
+
hasProps = true;
|
|
146
|
+
break;
|
|
147
|
+
|
|
148
|
+
case 'min':
|
|
149
|
+
if (isTextLikeType(irType)) {
|
|
150
|
+
validation.minLength = c.value as number;
|
|
151
|
+
} else {
|
|
152
|
+
validation.min = c.value as number;
|
|
153
|
+
}
|
|
154
|
+
hasProps = true;
|
|
155
|
+
break;
|
|
156
|
+
|
|
157
|
+
case 'between':
|
|
158
|
+
validation.min = c.value as number;
|
|
159
|
+
validation.max = c.value2 as number;
|
|
160
|
+
hasProps = true;
|
|
161
|
+
break;
|
|
162
|
+
|
|
163
|
+
case 'default':
|
|
164
|
+
// default_value is set on the field itself, not validation
|
|
165
|
+
break;
|
|
166
|
+
|
|
167
|
+
case 'unique':
|
|
168
|
+
// metadata flag — not a validation rule
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Handle default_value separately (on the field, not in validation)
|
|
174
|
+
return hasProps ? validation : null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Extract default_value from constraints.
|
|
179
|
+
* Called internally by mapField — the default_value is set on the field.
|
|
180
|
+
*/
|
|
181
|
+
function mergeValidation(
|
|
182
|
+
existing: IRFieldValidation | undefined,
|
|
183
|
+
incoming: IRFieldValidation,
|
|
184
|
+
): IRFieldValidation {
|
|
185
|
+
if (!existing) return incoming;
|
|
186
|
+
return { ...existing, ...incoming };
|
|
187
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DSL Compiler — entry point.
|
|
3
|
+
*
|
|
4
|
+
* Pipeline: tokenize → parse → collectSymbols → compileWorkflows → compileViews → compileManifest
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { tokenize } from '../lexer';
|
|
8
|
+
import { parse } from '../parser';
|
|
9
|
+
import type { ASTNode, ParseError } from '../types';
|
|
10
|
+
import type { CompilationResult, CompilerError } from '../ir-types';
|
|
11
|
+
import { collectSymbols } from './symbol-table';
|
|
12
|
+
import { compileWorkflows } from './workflow-compiler';
|
|
13
|
+
import { compileViews } from './view-compiler';
|
|
14
|
+
import { compileManifest } from './manifest-compiler';
|
|
15
|
+
|
|
16
|
+
// =============================================================================
|
|
17
|
+
// Public API
|
|
18
|
+
// =============================================================================
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Compile DSL source text into the full IR.
|
|
22
|
+
*/
|
|
23
|
+
export function compile(source: string): CompilationResult {
|
|
24
|
+
const tokens = tokenize(source);
|
|
25
|
+
const { nodes, errors: parseErrors } = parse(tokens);
|
|
26
|
+
return compileAST(nodes, parseErrors);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Compile pre-parsed AST nodes into the full IR.
|
|
31
|
+
*/
|
|
32
|
+
export function compileAST(
|
|
33
|
+
nodes: ASTNode[],
|
|
34
|
+
parseErrors?: ParseError[],
|
|
35
|
+
): CompilationResult {
|
|
36
|
+
const allErrors: CompilerError[] = [];
|
|
37
|
+
const allWarnings: CompilerError[] = [];
|
|
38
|
+
|
|
39
|
+
// Convert parse errors
|
|
40
|
+
if (parseErrors) {
|
|
41
|
+
for (const pe of parseErrors) {
|
|
42
|
+
allErrors.push({
|
|
43
|
+
code: 'INVALID_EXPRESSION',
|
|
44
|
+
message: pe.message,
|
|
45
|
+
lineNumber: pe.lineNumber,
|
|
46
|
+
severity: 'error',
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Pass 1: Symbol collection
|
|
52
|
+
const symbols = collectSymbols(nodes);
|
|
53
|
+
|
|
54
|
+
// Pass 2: Workflow compilation
|
|
55
|
+
const { workflows, errors: wfErrors } = compileWorkflows(symbols);
|
|
56
|
+
for (const e of wfErrors) {
|
|
57
|
+
if (e.severity === 'error') allErrors.push(e);
|
|
58
|
+
else allWarnings.push(e);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Pass 3: View compilation
|
|
62
|
+
const { experiences, errors: viewErrors } = compileViews(symbols);
|
|
63
|
+
for (const e of viewErrors) {
|
|
64
|
+
if (e.severity === 'error') allErrors.push(e);
|
|
65
|
+
else allWarnings.push(e);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Pass 4: Manifest compilation
|
|
69
|
+
const { manifest, errors: mfErrors } = compileManifest(symbols);
|
|
70
|
+
for (const e of mfErrors) {
|
|
71
|
+
if (e.severity === 'error') allErrors.push(e);
|
|
72
|
+
else allWarnings.push(e);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
workflows,
|
|
77
|
+
experiences,
|
|
78
|
+
manifest,
|
|
79
|
+
errors: allErrors,
|
|
80
|
+
warnings: allWarnings,
|
|
81
|
+
};
|
|
82
|
+
}
|