@iyulab/m3l 0.1.2 → 0.1.4
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 +67 -11
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -2
- package/dist/lexer.js +13 -4
- package/dist/parser.d.ts +5 -0
- package/dist/parser.js +187 -2
- package/dist/reader.d.ts +1 -1
- package/dist/reader.js +1 -1
- package/dist/resolver.d.ts +1 -1
- package/dist/resolver.js +28 -1
- package/dist/types.d.ts +30 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# @iyulab/m3l
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@iyulab/m3l)
|
|
4
|
+
[](https://github.com/iyulab/m3l/actions/workflows/parser-publish.yml)
|
|
5
|
+
[](../../LICENSE)
|
|
6
|
+
|
|
7
|
+
M3L (Meta Model Markup Language) parser and CLI — parse `.m3l.md` / `.m3l` files into a structured JSON AST.
|
|
4
8
|
|
|
5
9
|
M3L is a Markdown-based data modeling language. You write data models in readable Markdown, and this parser converts them into a machine-processable AST with full validation.
|
|
6
10
|
|
|
@@ -107,6 +111,7 @@ npx m3l validate ./models --strict --format json
|
|
|
107
111
|
| `## Name ::enum` | Enum definition |
|
|
108
112
|
| `## Name ::interface` | Interface definition |
|
|
109
113
|
| `## Name ::view` | Derived view |
|
|
114
|
+
| `## @name ::attribute` | Attribute registry entry |
|
|
110
115
|
| `- field: type` | Field definition |
|
|
111
116
|
| `- field: type?` | Nullable field |
|
|
112
117
|
| `- field: type[]` | Array field |
|
|
@@ -133,6 +138,7 @@ interface M3LAST {
|
|
|
133
138
|
enums: EnumNode[];
|
|
134
139
|
interfaces: ModelNode[];
|
|
135
140
|
views: ModelNode[];
|
|
141
|
+
attributeRegistry: AttributeRegistryEntry[];
|
|
136
142
|
errors: Diagnostic[];
|
|
137
143
|
warnings: Diagnostic[];
|
|
138
144
|
}
|
|
@@ -146,10 +152,10 @@ interface FieldNode {
|
|
|
146
152
|
label?: string;
|
|
147
153
|
type?: string;
|
|
148
154
|
params?: (string | number)[];
|
|
149
|
-
generic_params?: string[]; // map<K,V>
|
|
155
|
+
generic_params?: string[]; // map<K,V> -> ["K", "V"]
|
|
150
156
|
nullable: boolean;
|
|
151
157
|
array: boolean;
|
|
152
|
-
arrayItemNullable: boolean; // string?[]
|
|
158
|
+
arrayItemNullable: boolean; // string?[] -> true
|
|
153
159
|
kind: 'stored' | 'computed' | 'lookup' | 'rollup';
|
|
154
160
|
default_value?: string;
|
|
155
161
|
description?: string;
|
|
@@ -163,17 +169,61 @@ interface FieldNode {
|
|
|
163
169
|
loc: SourceLocation;
|
|
164
170
|
}
|
|
165
171
|
|
|
166
|
-
interface
|
|
172
|
+
interface FieldAttribute {
|
|
173
|
+
name: string;
|
|
174
|
+
args?: (string | number | boolean)[];
|
|
175
|
+
cascade?: string;
|
|
176
|
+
isStandard?: boolean; // true for M3L standard attributes
|
|
177
|
+
isRegistered?: boolean; // true for attributes in the registry
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
interface CustomAttribute {
|
|
181
|
+
content: string; // e.g. "MaxLength(100)"
|
|
182
|
+
raw: string; // e.g. "[MaxLength(100)]"
|
|
183
|
+
parsed?: { // structured parse result
|
|
184
|
+
name: string;
|
|
185
|
+
arguments: (string | number | boolean)[];
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
interface AttributeRegistryEntry {
|
|
167
190
|
name: string;
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
191
|
+
description?: string;
|
|
192
|
+
target: ('field' | 'model')[];
|
|
193
|
+
type: string;
|
|
194
|
+
range?: [number, number];
|
|
195
|
+
required: boolean;
|
|
196
|
+
defaultValue?: string | number | boolean;
|
|
174
197
|
}
|
|
175
198
|
```
|
|
176
199
|
|
|
200
|
+
## Attribute Registry
|
|
201
|
+
|
|
202
|
+
Define custom attributes with validation metadata using `::attribute`:
|
|
203
|
+
|
|
204
|
+
```markdown
|
|
205
|
+
## @pii ::attribute
|
|
206
|
+
> Personal identifiable information marker
|
|
207
|
+
- target: [field]
|
|
208
|
+
- type: boolean
|
|
209
|
+
- default: false
|
|
210
|
+
|
|
211
|
+
## @audit_level ::attribute
|
|
212
|
+
> Audit compliance level
|
|
213
|
+
- target: [field, model]
|
|
214
|
+
- type: integer
|
|
215
|
+
- range: [1, 5]
|
|
216
|
+
- default: 1
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Attributes are classified into 3 tiers:
|
|
220
|
+
|
|
221
|
+
| Tier | `isStandard` | `isRegistered` | Example |
|
|
222
|
+
|------|-------------|----------------|---------|
|
|
223
|
+
| Standard | `true` | — | `@primary`, `@unique`, `@reference` |
|
|
224
|
+
| Registered | — | `true` | `@pii`, `@audit_level` (defined via `::attribute`) |
|
|
225
|
+
| Unregistered | — | — | `@some_unknown_attr` |
|
|
226
|
+
|
|
177
227
|
## Validation
|
|
178
228
|
|
|
179
229
|
The validator checks for semantic errors and style warnings:
|
|
@@ -184,7 +234,9 @@ The validator checks for semantic errors and style warnings:
|
|
|
184
234
|
| M3L-E001 | Rollup FK field missing `@reference` |
|
|
185
235
|
| M3L-E002 | Lookup FK field missing `@reference` |
|
|
186
236
|
| M3L-E004 | View references non-existent model |
|
|
237
|
+
| M3L-E005 | Duplicate model/enum name |
|
|
187
238
|
| M3L-E006 | Duplicate field name within model |
|
|
239
|
+
| M3L-E007 | Unresolved parent in inheritance |
|
|
188
240
|
|
|
189
241
|
**Warnings (--strict):**
|
|
190
242
|
| Code | Description |
|
|
@@ -221,6 +273,10 @@ Parse an M3L content string into an AST.
|
|
|
221
273
|
|
|
222
274
|
Parse and validate M3L files. Options: `{ strict?: boolean }`.
|
|
223
275
|
|
|
276
|
+
### `STANDARD_ATTRIBUTES: Set<string>`
|
|
277
|
+
|
|
278
|
+
The set of 27 M3L standard attribute names (e.g. `primary`, `unique`, `reference`, `computed`, ...).
|
|
279
|
+
|
|
224
280
|
### Lower-level API
|
|
225
281
|
|
|
226
282
|
```typescript
|
|
@@ -238,7 +294,7 @@ This TypeScript parser and the [C# parser](../csharp/) produce equivalent AST st
|
|
|
238
294
|
|
|
239
295
|
| | TypeScript | C# |
|
|
240
296
|
|---|---|---|
|
|
241
|
-
| Package | `@iyulab/m3l` | `
|
|
297
|
+
| Package | `@iyulab/m3l` | `M3LParser` |
|
|
242
298
|
| Runtime | Node.js 20+ | .NET 8.0+ |
|
|
243
299
|
| AST Version | 1.0 | 1.0 |
|
|
244
300
|
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { lex } from './lexer.js';
|
|
2
|
-
export { parseTokens, parseString as parseFileString } from './parser.js';
|
|
2
|
+
export { parseTokens, parseString as parseFileString, STANDARD_ATTRIBUTES } from './parser.js';
|
|
3
3
|
export { resolve, AST_VERSION, PARSER_VERSION } from './resolver.js';
|
|
4
4
|
export { validate } from './validator.js';
|
|
5
5
|
export { readM3LFiles, readM3LString, readProjectConfig } from './reader.js';
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { lex } from './lexer.js';
|
|
2
|
-
export { parseTokens, parseString as parseFileString } from './parser.js';
|
|
2
|
+
export { parseTokens, parseString as parseFileString, STANDARD_ATTRIBUTES } from './parser.js';
|
|
3
3
|
export { resolve, AST_VERSION, PARSER_VERSION } from './resolver.js';
|
|
4
4
|
export { validate } from './validator.js';
|
|
5
5
|
export { readM3LFiles, readM3LString, readProjectConfig } from './reader.js';
|
|
@@ -15,7 +15,7 @@ export async function parse(inputPath) {
|
|
|
15
15
|
const resolved = resolvePath(inputPath);
|
|
16
16
|
const files = await readM3LFiles(resolved);
|
|
17
17
|
if (files.length === 0) {
|
|
18
|
-
throw new Error(`No .m3l.md files found at: ${inputPath}`);
|
|
18
|
+
throw new Error(`No .m3l.md or .m3l files found at: ${inputPath}`);
|
|
19
19
|
}
|
|
20
20
|
const parsedFiles = files.map(f => parseFileContent(f.content, f.path));
|
|
21
21
|
let projectInfo;
|
package/dist/lexer.js
CHANGED
|
@@ -3,11 +3,11 @@ const RE_H1 = /^# (.+)$/;
|
|
|
3
3
|
const RE_H2 = /^## (.+)$/;
|
|
4
4
|
const RE_H3 = /^### (.+)$/;
|
|
5
5
|
const RE_HR = /^-{3,}$/;
|
|
6
|
-
const RE_BLOCKQUOTE =
|
|
6
|
+
const RE_BLOCKQUOTE = /^\s*> (.+)$/;
|
|
7
7
|
const RE_LIST_ITEM = /^(\s*)- (.+)$/;
|
|
8
8
|
const RE_BLANK = /^\s*$/;
|
|
9
9
|
// H2 sub-patterns
|
|
10
|
-
const RE_TYPE_INDICATOR = /^([\w][\w.]*(?:\([^)]*\))?)\s*::(\w+)(.*)$/;
|
|
10
|
+
const RE_TYPE_INDICATOR = /^(@?[\w][\w.]*(?:\([^)]*\))?)\s*::(\w+)(.*)$/;
|
|
11
11
|
const RE_MODEL_DEF = /^([\w][\w.]*(?:\([^)]*\))?)\s*(?::\s*(.+?))?(\s+@.+)?$/;
|
|
12
12
|
// Field line patterns
|
|
13
13
|
const RE_FIELD_NAME = /^([\w]+)(?:\(([^)]*)\))?\s*(?::\s*(.+))?$/;
|
|
@@ -135,7 +135,7 @@ export function lex(content, file) {
|
|
|
135
135
|
return tokens;
|
|
136
136
|
}
|
|
137
137
|
function tokenizeH2(content, raw, line) {
|
|
138
|
-
// Check for type indicator: ## Name ::enum, ::interface, ::view
|
|
138
|
+
// Check for type indicator: ## Name ::enum, ::interface, ::view, ::attribute
|
|
139
139
|
const typeMatch = content.match(RE_TYPE_INDICATOR);
|
|
140
140
|
if (typeMatch) {
|
|
141
141
|
const namepart = typeMatch[1];
|
|
@@ -143,6 +143,14 @@ function tokenizeH2(content, raw, line) {
|
|
|
143
143
|
const rest = typeMatch[3]?.trim() || '';
|
|
144
144
|
const { name, label } = parseNameLabel(namepart);
|
|
145
145
|
const data = { name, label };
|
|
146
|
+
// Parse inheritance: ::enum : Base1, Base2
|
|
147
|
+
const inheritMatch = rest.match(/^:\s*(.+?)(?:\s+@|\s*"|\s*$)/);
|
|
148
|
+
if (inheritMatch) {
|
|
149
|
+
data.inherits = inheritMatch[1].split(',').map(s => s.trim()).filter(Boolean);
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
data.inherits = [];
|
|
153
|
+
}
|
|
146
154
|
if (typeIndicator === 'view') {
|
|
147
155
|
data.materialized = rest.includes('@materialized');
|
|
148
156
|
}
|
|
@@ -151,7 +159,8 @@ function tokenizeH2(content, raw, line) {
|
|
|
151
159
|
if (descMatch) {
|
|
152
160
|
data.description = descMatch[1];
|
|
153
161
|
}
|
|
154
|
-
|
|
162
|
+
const tokenType = typeIndicator === 'attribute' ? 'attribute_def' : typeIndicator;
|
|
163
|
+
return { type: tokenType, raw, line, indent: 0, data };
|
|
155
164
|
}
|
|
156
165
|
// Regular model: ## Name : Parent1, Parent2
|
|
157
166
|
const modelMatch = content.match(RE_MODEL_DEF);
|
package/dist/parser.d.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import type { Token, ParsedFile } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Standard M3L attribute catalog.
|
|
4
|
+
* These are the officially defined attributes in the M3L specification.
|
|
5
|
+
*/
|
|
6
|
+
export declare const STANDARD_ATTRIBUTES: Set<string>;
|
|
2
7
|
/**
|
|
3
8
|
* Parse M3L content string into a ParsedFile AST.
|
|
4
9
|
*/
|
package/dist/parser.js
CHANGED
|
@@ -1,4 +1,22 @@
|
|
|
1
1
|
import { lex, parseTypeAndAttrs } from './lexer.js';
|
|
2
|
+
/**
|
|
3
|
+
* Standard M3L attribute catalog.
|
|
4
|
+
* These are the officially defined attributes in the M3L specification.
|
|
5
|
+
*/
|
|
6
|
+
export const STANDARD_ATTRIBUTES = new Set([
|
|
7
|
+
// Field constraints
|
|
8
|
+
'primary', 'unique', 'required', 'index', 'generated', 'immutable',
|
|
9
|
+
// References / relations
|
|
10
|
+
'reference', 'fk', 'relation', 'on_update', 'on_delete',
|
|
11
|
+
// Search / display
|
|
12
|
+
'searchable', 'description', 'visibility',
|
|
13
|
+
// Validation
|
|
14
|
+
'min', 'max', 'validate', 'not_null',
|
|
15
|
+
// Derived fields
|
|
16
|
+
'computed', 'computed_raw', 'lookup', 'rollup', 'from', 'persisted',
|
|
17
|
+
// Model-level
|
|
18
|
+
'public', 'private', 'materialized', 'meta', 'behavior', 'override', 'default_attribute',
|
|
19
|
+
]);
|
|
2
20
|
/**
|
|
3
21
|
* Parse M3L content string into a ParsedFile AST.
|
|
4
22
|
*/
|
|
@@ -21,6 +39,8 @@ export function parseTokens(tokens, file) {
|
|
|
21
39
|
enums: [],
|
|
22
40
|
interfaces: [],
|
|
23
41
|
views: [],
|
|
42
|
+
attributeRegistry: [],
|
|
43
|
+
currentAttrDef: null,
|
|
24
44
|
sourceDirectivesDone: false,
|
|
25
45
|
};
|
|
26
46
|
for (const token of tokens) {
|
|
@@ -35,6 +55,7 @@ export function parseTokens(tokens, file) {
|
|
|
35
55
|
enums: state.enums,
|
|
36
56
|
interfaces: state.interfaces,
|
|
37
57
|
views: state.views,
|
|
58
|
+
attributeRegistry: state.attributeRegistry,
|
|
38
59
|
};
|
|
39
60
|
}
|
|
40
61
|
function processToken(token, state) {
|
|
@@ -52,6 +73,9 @@ function processToken(token, state) {
|
|
|
52
73
|
case 'view':
|
|
53
74
|
handleViewStart(token, state);
|
|
54
75
|
break;
|
|
76
|
+
case 'attribute_def':
|
|
77
|
+
handleAttributeDefStart(token, state);
|
|
78
|
+
break;
|
|
55
79
|
case 'section':
|
|
56
80
|
handleSection(token, state);
|
|
57
81
|
break;
|
|
@@ -115,6 +139,7 @@ function handleEnumStart(token, state) {
|
|
|
115
139
|
type: 'enum',
|
|
116
140
|
source: state.file,
|
|
117
141
|
line: token.line,
|
|
142
|
+
inherits: data.inherits || [],
|
|
118
143
|
description: data.description,
|
|
119
144
|
values: [],
|
|
120
145
|
loc: { file: state.file, line: token.line, col: 1 },
|
|
@@ -181,6 +206,18 @@ function handleSection(token, state) {
|
|
|
181
206
|
}
|
|
182
207
|
}
|
|
183
208
|
function handleField(token, state) {
|
|
209
|
+
// Handle attribute definition fields (- target: [field, model])
|
|
210
|
+
if (state.currentAttrDef) {
|
|
211
|
+
const data = token.data;
|
|
212
|
+
const name = data.name;
|
|
213
|
+
const raw = token.raw.trim().replace(/^-\s*/, '');
|
|
214
|
+
const colonIdx = raw.indexOf(':');
|
|
215
|
+
if (colonIdx >= 0) {
|
|
216
|
+
const value = raw.substring(colonIdx + 1).trim();
|
|
217
|
+
state.currentAttrDef.fields.set(name, value);
|
|
218
|
+
}
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
184
221
|
if (!state.currentElement)
|
|
185
222
|
return;
|
|
186
223
|
const data = token.data;
|
|
@@ -229,11 +266,12 @@ function handleDirective(data, model, token, state) {
|
|
|
229
266
|
if (!attrs || attrs.length === 0)
|
|
230
267
|
return;
|
|
231
268
|
const attr = attrs[0];
|
|
232
|
-
if (attr.name === 'index') {
|
|
269
|
+
if (attr.name === 'index' || attr.name === 'unique') {
|
|
233
270
|
model.sections.indexes.push({
|
|
234
271
|
type: 'directive',
|
|
235
272
|
raw: data.raw_content,
|
|
236
273
|
args: attr.args,
|
|
274
|
+
unique: attr.name === 'unique',
|
|
237
275
|
loc: { file: state.file, line: token.line, col: 1 },
|
|
238
276
|
});
|
|
239
277
|
}
|
|
@@ -322,6 +360,7 @@ function handleSectionItem(data, model, token, state) {
|
|
|
322
360
|
raw: token.raw.trim().replace(/^- /, ''),
|
|
323
361
|
loc,
|
|
324
362
|
});
|
|
363
|
+
state.lastField = { name: token.raw.trim().replace(/^- /, '') };
|
|
325
364
|
return;
|
|
326
365
|
}
|
|
327
366
|
// Metadata section
|
|
@@ -384,6 +423,16 @@ function handleNestedItem(token, state) {
|
|
|
384
423
|
}
|
|
385
424
|
return;
|
|
386
425
|
}
|
|
426
|
+
// Nested items under relation in Relations section
|
|
427
|
+
if (state.currentSection === 'Relations' && state.lastField) {
|
|
428
|
+
const lastRelation = model.sections.relations[model.sections.relations.length - 1];
|
|
429
|
+
if (lastRelation && typeof lastRelation === 'object') {
|
|
430
|
+
if (key) {
|
|
431
|
+
lastRelation[key] = parseNestedValue(value || '');
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
387
436
|
// Nested items under a field
|
|
388
437
|
if (state.lastField) {
|
|
389
438
|
const field = state.lastField;
|
|
@@ -455,9 +504,25 @@ function handleNestedItem(token, state) {
|
|
|
455
504
|
}
|
|
456
505
|
}
|
|
457
506
|
function handleBlockquote(token, state) {
|
|
507
|
+
// Handle attribute definition description
|
|
508
|
+
if (state.currentAttrDef) {
|
|
509
|
+
const text = token.data.text;
|
|
510
|
+
state.currentAttrDef.description = text;
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
458
513
|
if (!state.currentElement)
|
|
459
514
|
return;
|
|
460
515
|
const text = token.data.text;
|
|
516
|
+
// Field-level blockquote: apply to lastField if available (not for enums)
|
|
517
|
+
if (state.lastField && !isEnumNode(state.currentElement)) {
|
|
518
|
+
if (state.lastField.description) {
|
|
519
|
+
state.lastField.description += '\n' + text;
|
|
520
|
+
}
|
|
521
|
+
else {
|
|
522
|
+
state.lastField.description = text;
|
|
523
|
+
}
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
461
526
|
if (state.currentElement.description) {
|
|
462
527
|
state.currentElement.description += '\n' + text;
|
|
463
528
|
}
|
|
@@ -478,6 +543,8 @@ function handleText(token, state) {
|
|
|
478
543
|
}
|
|
479
544
|
}
|
|
480
545
|
function finalizeElement(state) {
|
|
546
|
+
// Finalize pending attribute definition
|
|
547
|
+
finalizeAttrDef(state);
|
|
481
548
|
if (!state.currentElement)
|
|
482
549
|
return;
|
|
483
550
|
if (isEnumNode(state.currentElement)) {
|
|
@@ -502,6 +569,70 @@ function finalizeElement(state) {
|
|
|
502
569
|
state.currentKind = 'stored';
|
|
503
570
|
state.lastField = null;
|
|
504
571
|
}
|
|
572
|
+
function handleAttributeDefStart(token, state) {
|
|
573
|
+
finalizeElement(state);
|
|
574
|
+
const data = token.data || {};
|
|
575
|
+
const name = (data.name || '').replace(/^@/, '');
|
|
576
|
+
state.currentAttrDef = {
|
|
577
|
+
name,
|
|
578
|
+
description: data.description,
|
|
579
|
+
fields: new Map(),
|
|
580
|
+
};
|
|
581
|
+
state.currentElement = null;
|
|
582
|
+
}
|
|
583
|
+
function finalizeAttrDef(state) {
|
|
584
|
+
if (!state.currentAttrDef)
|
|
585
|
+
return;
|
|
586
|
+
const def = state.currentAttrDef;
|
|
587
|
+
const fields = def.fields;
|
|
588
|
+
const targetRaw = fields.get('target');
|
|
589
|
+
const target = [];
|
|
590
|
+
if (targetRaw) {
|
|
591
|
+
const cleaned = targetRaw.replace(/^\[|\]$/g, '').split(',').map(s => s.trim());
|
|
592
|
+
for (const t of cleaned) {
|
|
593
|
+
if (t === 'field' || t === 'model')
|
|
594
|
+
target.push(t);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
const rangeRaw = fields.get('range');
|
|
598
|
+
let range;
|
|
599
|
+
if (rangeRaw) {
|
|
600
|
+
const nums = rangeRaw.replace(/^\[|\]$/g, '').split(',').map(s => Number(s.trim()));
|
|
601
|
+
if (nums.length === 2 && !isNaN(nums[0]) && !isNaN(nums[1])) {
|
|
602
|
+
range = [nums[0], nums[1]];
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
const requiredRaw = fields.get('required');
|
|
606
|
+
const required = requiredRaw === 'true' || requiredRaw === true;
|
|
607
|
+
const defaultRaw = fields.get('default');
|
|
608
|
+
let defaultValue;
|
|
609
|
+
if (defaultRaw !== undefined) {
|
|
610
|
+
if (defaultRaw === 'true')
|
|
611
|
+
defaultValue = true;
|
|
612
|
+
else if (defaultRaw === 'false')
|
|
613
|
+
defaultValue = false;
|
|
614
|
+
else if (typeof defaultRaw === 'string' && !isNaN(Number(defaultRaw)))
|
|
615
|
+
defaultValue = Number(defaultRaw);
|
|
616
|
+
else if (typeof defaultRaw === 'string')
|
|
617
|
+
defaultValue = defaultRaw;
|
|
618
|
+
else if (typeof defaultRaw === 'number' || typeof defaultRaw === 'boolean')
|
|
619
|
+
defaultValue = defaultRaw;
|
|
620
|
+
}
|
|
621
|
+
const entry = {
|
|
622
|
+
name: def.name,
|
|
623
|
+
target: target.length > 0 ? target : ['field'],
|
|
624
|
+
type: fields.get('type') || 'boolean',
|
|
625
|
+
required,
|
|
626
|
+
};
|
|
627
|
+
if (def.description)
|
|
628
|
+
entry.description = def.description;
|
|
629
|
+
if (range)
|
|
630
|
+
entry.range = range;
|
|
631
|
+
if (defaultValue !== undefined)
|
|
632
|
+
entry.defaultValue = defaultValue;
|
|
633
|
+
state.attributeRegistry.push(entry);
|
|
634
|
+
state.currentAttrDef = null;
|
|
635
|
+
}
|
|
505
636
|
// --- Helpers ---
|
|
506
637
|
function buildFieldNode(data, token, state) {
|
|
507
638
|
const attrs = parseAttributes(data.attributes);
|
|
@@ -559,6 +690,10 @@ function buildFieldNode(data, token, state) {
|
|
|
559
690
|
field.computed.platform = parts.platform;
|
|
560
691
|
}
|
|
561
692
|
}
|
|
693
|
+
// Inline comment as field description
|
|
694
|
+
if (!field.description && data.comment) {
|
|
695
|
+
field.description = data.comment;
|
|
696
|
+
}
|
|
562
697
|
return field;
|
|
563
698
|
}
|
|
564
699
|
/**
|
|
@@ -602,6 +737,8 @@ function parseAttributes(rawAttrs) {
|
|
|
602
737
|
attr.args = [a.args];
|
|
603
738
|
if (a.cascade)
|
|
604
739
|
attr.cascade = a.cascade;
|
|
740
|
+
if (STANDARD_ATTRIBUTES.has(a.name))
|
|
741
|
+
attr.isStandard = true;
|
|
605
742
|
return attr;
|
|
606
743
|
});
|
|
607
744
|
}
|
|
@@ -798,9 +935,57 @@ function parseCustomAttributes(rawAttrs) {
|
|
|
798
935
|
return rawAttrs.map(raw => {
|
|
799
936
|
// raw is "[MaxLength(100)]" — strip brackets to get content
|
|
800
937
|
const content = raw.replace(/^\[|\]$/g, '');
|
|
801
|
-
|
|
938
|
+
const attr = { content, raw };
|
|
939
|
+
// Try to parse "Name(arg1, arg2)" or "Name" pattern
|
|
940
|
+
const match = content.match(/^([A-Za-z_][\w.]*)(?:\((.+)\))?$/);
|
|
941
|
+
if (match) {
|
|
942
|
+
const name = match[1];
|
|
943
|
+
const argsStr = match[2];
|
|
944
|
+
const args = [];
|
|
945
|
+
if (argsStr) {
|
|
946
|
+
for (const part of splitBalanced(argsStr)) {
|
|
947
|
+
const trimmed = part.trim();
|
|
948
|
+
args.push(parseArgValue(trimmed));
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
attr.parsed = { name, arguments: args };
|
|
952
|
+
}
|
|
953
|
+
return attr;
|
|
802
954
|
});
|
|
803
955
|
}
|
|
956
|
+
/** Split a string by commas, respecting balanced parentheses */
|
|
957
|
+
function splitBalanced(s) {
|
|
958
|
+
const parts = [];
|
|
959
|
+
let depth = 0;
|
|
960
|
+
let start = 0;
|
|
961
|
+
for (let i = 0; i < s.length; i++) {
|
|
962
|
+
if (s[i] === '(')
|
|
963
|
+
depth++;
|
|
964
|
+
else if (s[i] === ')')
|
|
965
|
+
depth--;
|
|
966
|
+
else if (s[i] === ',' && depth === 0) {
|
|
967
|
+
parts.push(s.substring(start, i));
|
|
968
|
+
start = i + 1;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
parts.push(s.substring(start));
|
|
972
|
+
return parts;
|
|
973
|
+
}
|
|
974
|
+
/** Parse a single argument value: numbers, booleans, or strings */
|
|
975
|
+
function parseArgValue(s) {
|
|
976
|
+
if (s === 'true')
|
|
977
|
+
return true;
|
|
978
|
+
if (s === 'false')
|
|
979
|
+
return false;
|
|
980
|
+
const n = Number(s);
|
|
981
|
+
if (!isNaN(n) && s.length > 0)
|
|
982
|
+
return n;
|
|
983
|
+
// Strip surrounding quotes if present
|
|
984
|
+
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
|
985
|
+
return s.slice(1, -1);
|
|
986
|
+
}
|
|
987
|
+
return s;
|
|
988
|
+
}
|
|
804
989
|
function isEnumNode(el) {
|
|
805
990
|
return el.type === 'enum' && 'values' in el;
|
|
806
991
|
}
|
package/dist/reader.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ export interface M3LFile {
|
|
|
4
4
|
}
|
|
5
5
|
/**
|
|
6
6
|
* Read M3L files from a path (file or directory).
|
|
7
|
-
* If path is a directory, scans for **\/*.m3l.md files.
|
|
7
|
+
* If path is a directory, scans for **\/*.m3l.md and **\/*.m3l files.
|
|
8
8
|
* If an m3l.config.yaml exists in the directory, uses its sources patterns.
|
|
9
9
|
*/
|
|
10
10
|
export declare function readM3LFiles(inputPath: string): Promise<M3LFile[]>;
|
package/dist/reader.js
CHANGED
|
@@ -3,7 +3,7 @@ import { join, resolve as resolvePath } from 'path';
|
|
|
3
3
|
import fg from 'fast-glob';
|
|
4
4
|
/**
|
|
5
5
|
* Read M3L files from a path (file or directory).
|
|
6
|
-
* If path is a directory, scans for **\/*.m3l.md files.
|
|
6
|
+
* If path is a directory, scans for **\/*.m3l.md and **\/*.m3l files.
|
|
7
7
|
* If an m3l.config.yaml exists in the directory, uses its sources patterns.
|
|
8
8
|
*/
|
|
9
9
|
export async function readM3LFiles(inputPath) {
|
package/dist/resolver.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { ParsedFile, M3LAST, ProjectInfo } from './types.js';
|
|
|
2
2
|
/** AST schema version — bump major on breaking structure changes */
|
|
3
3
|
export declare const AST_VERSION = "1.0";
|
|
4
4
|
/** Parser package version — kept in sync with package.json */
|
|
5
|
-
export declare const PARSER_VERSION = "0.1.
|
|
5
|
+
export declare const PARSER_VERSION = "0.1.4";
|
|
6
6
|
/**
|
|
7
7
|
* Resolve and merge multiple parsed file ASTs into a single M3LAST.
|
|
8
8
|
* Handles: inheritance resolution, duplicate detection, reference validation.
|
package/dist/resolver.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** AST schema version — bump major on breaking structure changes */
|
|
2
2
|
export const AST_VERSION = '1.0';
|
|
3
3
|
/** Parser package version — kept in sync with package.json */
|
|
4
|
-
export const PARSER_VERSION = '0.1.
|
|
4
|
+
export const PARSER_VERSION = '0.1.4';
|
|
5
5
|
/**
|
|
6
6
|
* Resolve and merge multiple parsed file ASTs into a single M3LAST.
|
|
7
7
|
* Handles: inheritance resolution, duplicate detection, reference validation.
|
|
@@ -14,6 +14,7 @@ export function resolve(files, project) {
|
|
|
14
14
|
const allEnums = [];
|
|
15
15
|
const allInterfaces = [];
|
|
16
16
|
const allViews = [];
|
|
17
|
+
const allAttrRegistry = [];
|
|
17
18
|
const sources = [];
|
|
18
19
|
for (const file of files) {
|
|
19
20
|
sources.push(file.source);
|
|
@@ -21,6 +22,8 @@ export function resolve(files, project) {
|
|
|
21
22
|
allEnums.push(...file.enums);
|
|
22
23
|
allInterfaces.push(...file.interfaces);
|
|
23
24
|
allViews.push(...file.views);
|
|
25
|
+
if (file.attributeRegistry)
|
|
26
|
+
allAttrRegistry.push(...file.attributeRegistry);
|
|
24
27
|
}
|
|
25
28
|
// Build name maps
|
|
26
29
|
const modelMap = new Map();
|
|
@@ -54,6 +57,29 @@ export function resolve(files, project) {
|
|
|
54
57
|
for (const model of [...allModels, ...allViews]) {
|
|
55
58
|
checkDuplicateFields(model, errors);
|
|
56
59
|
}
|
|
60
|
+
// Tag isRegistered on attributes matching the registry
|
|
61
|
+
if (allAttrRegistry.length > 0) {
|
|
62
|
+
const registeredNames = new Set(allAttrRegistry.map(r => r.name));
|
|
63
|
+
const tagAttrs = (attrs) => {
|
|
64
|
+
for (const a of attrs) {
|
|
65
|
+
if (registeredNames.has(a.name)) {
|
|
66
|
+
a.isRegistered = true;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
const tagModel = (m) => {
|
|
71
|
+
tagAttrs(m.attributes);
|
|
72
|
+
for (const f of m.fields) {
|
|
73
|
+
tagAttrs(f.attributes);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
for (const m of allModels)
|
|
77
|
+
tagModel(m);
|
|
78
|
+
for (const v of allViews)
|
|
79
|
+
tagModel(v);
|
|
80
|
+
for (const i of allInterfaces)
|
|
81
|
+
tagModel(i);
|
|
82
|
+
}
|
|
57
83
|
// Detect namespace from first file if available
|
|
58
84
|
const projectInfo = project || {};
|
|
59
85
|
if (!projectInfo.name) {
|
|
@@ -70,6 +96,7 @@ export function resolve(files, project) {
|
|
|
70
96
|
enums: allEnums,
|
|
71
97
|
interfaces: allInterfaces,
|
|
72
98
|
views: allViews,
|
|
99
|
+
attributeRegistry: allAttrRegistry,
|
|
73
100
|
errors,
|
|
74
101
|
warnings,
|
|
75
102
|
};
|
package/dist/types.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ export interface SourceLocation {
|
|
|
4
4
|
line: number;
|
|
5
5
|
col: number;
|
|
6
6
|
}
|
|
7
|
-
export type TokenType = 'namespace' | 'model' | 'enum' | 'interface' | 'view' | 'section' | 'field' | 'nested_item' | 'blockquote' | 'horizontal_rule' | 'blank' | 'text';
|
|
7
|
+
export type TokenType = 'namespace' | 'model' | 'enum' | 'interface' | 'view' | 'attribute_def' | 'section' | 'field' | 'nested_item' | 'blockquote' | 'horizontal_rule' | 'blank' | 'text';
|
|
8
8
|
export interface Token {
|
|
9
9
|
type: TokenType;
|
|
10
10
|
raw: string;
|
|
@@ -17,6 +17,10 @@ export interface FieldAttribute {
|
|
|
17
17
|
name: string;
|
|
18
18
|
args?: (string | number | boolean)[];
|
|
19
19
|
cascade?: string;
|
|
20
|
+
/** Whether this is a standard M3L attribute (from the official catalog) */
|
|
21
|
+
isStandard?: boolean;
|
|
22
|
+
/** Whether this attribute is registered in an Attribute Registry (::attribute definition) */
|
|
23
|
+
isRegistered?: boolean;
|
|
20
24
|
}
|
|
21
25
|
/** Structured representation of a backtick-wrapped framework attribute like `[MaxLength(100)]` */
|
|
22
26
|
export interface CustomAttribute {
|
|
@@ -24,6 +28,11 @@ export interface CustomAttribute {
|
|
|
24
28
|
content: string;
|
|
25
29
|
/** Original text including brackets, e.g., "[MaxLength(100)]" */
|
|
26
30
|
raw: string;
|
|
31
|
+
/** Parsed structure — name and arguments extracted from the content */
|
|
32
|
+
parsed?: {
|
|
33
|
+
name: string;
|
|
34
|
+
arguments: (string | number | boolean)[];
|
|
35
|
+
};
|
|
27
36
|
}
|
|
28
37
|
export interface EnumValue {
|
|
29
38
|
name: string;
|
|
@@ -104,6 +113,7 @@ export interface EnumNode {
|
|
|
104
113
|
type: 'enum';
|
|
105
114
|
source: string;
|
|
106
115
|
line: number;
|
|
116
|
+
inherits: string[];
|
|
107
117
|
description?: string;
|
|
108
118
|
values: EnumValue[];
|
|
109
119
|
loc: SourceLocation;
|
|
@@ -127,6 +137,23 @@ export interface ParsedFile {
|
|
|
127
137
|
enums: EnumNode[];
|
|
128
138
|
interfaces: ModelNode[];
|
|
129
139
|
views: ModelNode[];
|
|
140
|
+
attributeRegistry: AttributeRegistryEntry[];
|
|
141
|
+
}
|
|
142
|
+
export interface AttributeRegistryEntry {
|
|
143
|
+
/** Attribute name (without @) */
|
|
144
|
+
name: string;
|
|
145
|
+
/** Description */
|
|
146
|
+
description?: string;
|
|
147
|
+
/** Valid targets: 'field', 'model' */
|
|
148
|
+
target: ('field' | 'model')[];
|
|
149
|
+
/** Value type: 'boolean', 'integer', 'string', etc. */
|
|
150
|
+
type: string;
|
|
151
|
+
/** Valid range for numeric types */
|
|
152
|
+
range?: [number, number];
|
|
153
|
+
/** Whether the attribute is required */
|
|
154
|
+
required: boolean;
|
|
155
|
+
/** Default value */
|
|
156
|
+
defaultValue?: string | number | boolean;
|
|
130
157
|
}
|
|
131
158
|
export interface M3LAST {
|
|
132
159
|
/** Parser package version (semver) */
|
|
@@ -139,6 +166,8 @@ export interface M3LAST {
|
|
|
139
166
|
enums: EnumNode[];
|
|
140
167
|
interfaces: ModelNode[];
|
|
141
168
|
views: ModelNode[];
|
|
169
|
+
/** Attribute registry entries parsed from ::attribute definitions */
|
|
170
|
+
attributeRegistry: AttributeRegistryEntry[];
|
|
142
171
|
errors: Diagnostic[];
|
|
143
172
|
warnings: Diagnostic[];
|
|
144
173
|
}
|